diff --git a/.claude/skills/axiom-accessibility-diag/.openskills.json b/.claude/skills/axiom-accessibility-diag/.openskills.json
new file mode 100644
index 0000000..df63d03
--- /dev/null
+++ b/.claude/skills/axiom-accessibility-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-accessibility-diag",
+ "installedAt": "2026-04-12T08:05:39.269Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-accessibility-diag/SKILL.md b/.claude/skills/axiom-accessibility-diag/SKILL.md
new file mode 100644
index 0000000..477ad29
--- /dev/null
+++ b/.claude/skills/axiom-accessibility-diag/SKILL.md
@@ -0,0 +1,989 @@
+---
+name: axiom-accessibility-diag
+description: Use when fixing VoiceOver issues, Dynamic Type violations, color contrast failures, touch target problems, keyboard navigation gaps, or Reduce Motion support - comprehensive accessibility diagnostics with WCAG compliance, Accessibility Inspector workflows, and App Store Review preparation for iOS/macOS
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Accessibility Diagnostics
+
+## Overview
+
+Systematic accessibility diagnosis and remediation for iOS/macOS apps. Covers the 7 most common accessibility issues that cause App Store rejections and user complaints.
+
+**Core principle** Accessibility is not optional. iOS apps must support VoiceOver, Dynamic Type, and sufficient color contrast to pass App Store Review. Users with disabilities depend on these features.
+
+## When to Use This Skill
+
+- Fixing VoiceOver navigation issues (missing labels, wrong element order)
+- Supporting Dynamic Type (text scaling for vision disabilities)
+- Meeting color contrast requirements (WCAG AA/AAA)
+- Fixing touch target size violations (< 44x44pt)
+- Adding keyboard navigation (iPadOS/macOS)
+- Supporting Reduce Motion (vestibular disorders)
+- Preparing for App Store Review accessibility requirements
+- Responding to user complaints about accessibility
+
+## The 7 Critical Accessibility Issues
+
+### 1. VoiceOver Labels & Hints (CRITICAL - App Store Rejection)
+
+**Problem** Missing or generic accessibility labels prevent VoiceOver users from understanding UI purpose.
+
+**WCAG** 4.1.2 Name, Role, Value (Level A)
+
+#### Common violations
+```swift
+// ❌ WRONG - No label (VoiceOver says "Button")
+Button(action: addToCart) {
+ Image(systemName: "cart.badge.plus")
+}
+
+// ❌ WRONG - Generic label
+.accessibilityLabel("Button")
+
+// ❌ WRONG - Reads implementation details
+.accessibilityLabel("cart.badge.plus") // VoiceOver: "cart dot badge dot plus"
+
+// ✅ CORRECT - Descriptive label
+Button(action: addToCart) {
+ Image(systemName: "cart.badge.plus")
+}
+.accessibilityLabel("Add to cart")
+
+// ✅ CORRECT - With hint for complex actions
+.accessibilityLabel("Add to cart")
+.accessibilityHint("Double-tap to add this item to your shopping cart")
+```
+
+#### When to use hints
+- Action is not obvious from label ("Add to cart" is obvious, no hint needed)
+- Multi-step interaction ("Swipe right to confirm, left to cancel")
+- State change ("Double-tap to toggle notifications on or off")
+
+#### Decorative elements
+```swift
+// ✅ CORRECT - Hide decorative images from VoiceOver
+Image("decorative-pattern")
+ .accessibilityHidden(true)
+
+// ✅ CORRECT - Combine multiple elements into one label
+HStack {
+ Image(systemName: "star.fill")
+ Text("4.5")
+ Text("(234 reviews)")
+}
+.accessibilityElement(children: .combine)
+.accessibilityLabel("Rating: 4.5 stars from 234 reviews")
+```
+
+#### Testing
+- Enable VoiceOver: Cmd+F5 (simulator) or triple-click side button (device)
+- Navigate: Swipe right/left to move between elements
+- Listen: Does VoiceOver announce purpose clearly?
+- Check order: Does navigation order match visual layout?
+
+---
+
+### 2. Dynamic Type Support (HIGH - User Experience)
+
+**Problem** Fixed font sizes prevent users with vision disabilities from reading text.
+
+**WCAG** 1.4.4 Resize Text (Level AA - support 200% scaling without loss of content/functionality)
+
+#### Common violations
+```swift
+// ❌ WRONG - Fixed size, won't scale
+Text("Price: $19.99")
+ .font(.system(size: 17))
+
+UILabel().font = UIFont.systemFont(ofSize: 17)
+
+// ❌ WRONG - Custom font without scaling
+Text("Headline")
+ .font(Font.custom("CustomFont", size: 24))
+
+// ✅ CORRECT - SwiftUI semantic styles (auto-scales)
+Text("Price: $19.99")
+ .font(.body)
+
+Text("Headline")
+ .font(.headline)
+
+// ✅ CORRECT - UIKit semantic styles
+label.font = UIFont.preferredFont(forTextStyle: .body)
+
+// ✅ CORRECT - Custom font with scaling
+let customFont = UIFont(name: "CustomFont", size: 24)!
+label.font = UIFontMetrics.default.scaledFont(for: customFont)
+label.adjustsFontForContentSizeCategory = true
+```
+
+#### Custom sizes that scale with Dynamic Type
+```swift
+// ❌ WRONG - Fixed size, won't scale
+Text("Price: $19.99")
+ .font(.system(size: 17))
+
+// ⚠️ ACCEPTABLE - Custom font without scaling (accessibility violation)
+Text("Headline")
+ .font(Font.custom("CustomFont", size: 24))
+
+// ✅ GOOD - Custom size that scales with Dynamic Type
+Text("Large Title")
+ .font(.system(size: 60).relativeTo(.largeTitle))
+
+Text("Custom Headline")
+ .font(.system(size: 24).relativeTo(.title2))
+
+// ✅ BEST - Use semantic styles when possible
+Text("Headline")
+ .font(.headline)
+```
+
+**How `relativeTo:` works**
+- Base size: Your exact pixel size (24pt, 60pt, etc.)
+- Scales with: The text style you specify (`.title2`, `.largeTitle`, etc.)
+- Result: When user increases text size in Settings, your custom size grows proportionally
+
+**Example**
+- `.title2` base: ~22pt → Your custom: 24pt (1.09x larger)
+- User increases to "Extra Large" text
+- `.title2` grows to ~28pt → Your custom grows to ~30.5pt (maintains 1.09x ratio)
+
+**Fix hierarchy (best to worst)**
+1. **Best**: Use semantic styles (`.title`, `.body`, `.caption`)
+2. **Good**: Use `.system(size:).relativeTo()` for required custom sizes
+3. **Acceptable**: Custom font with `.dynamicTypeSize()` modifier
+4. **Unacceptable**: Fixed sizes that never scale
+
+#### SwiftUI text styles
+- `.largeTitle` - 34pt (scales to 44pt at accessibility sizes)
+- `.title` - 28pt
+- `.title2` - 22pt
+- `.title3` - 20pt
+- `.headline` - 17pt semibold
+- `.body` - 17pt (default)
+- `.callout` - 16pt
+- `.subheadline` - 15pt
+- `.footnote` - 13pt
+- `.caption` - 12pt
+- `.caption2` - 11pt
+
+#### Layout considerations
+```swift
+// ❌ WRONG - Fixed frame breaks with large text
+Text("Long product description...")
+ .font(.body)
+ .frame(height: 50) // Clips at large text sizes
+
+// ✅ CORRECT - Flexible frame
+Text("Long product description...")
+ .font(.body)
+ .lineLimit(nil) // Allow multiple lines
+ .fixedSize(horizontal: false, vertical: true)
+
+// ✅ CORRECT - Stack rearranges at large sizes
+HStack {
+ Text("Label:")
+ Text("Value")
+}
+.dynamicTypeSize(...DynamicTypeSize.xxxLarge) // Limit maximum size if needed
+```
+
+#### Testing
+1. Xcode Preview: Environment override
+ ```swift
+ .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
+ ```
+
+2. Simulator: Settings → Accessibility → Display & Text Size → Larger Text → Drag to maximum
+
+3. Device: Settings → Accessibility → Display & Text Size → Larger Text
+
+4. Check: Does text remain readable? Does layout adapt? Is any text clipped?
+
+---
+
+### 3. Color Contrast (HIGH - Vision Disabilities)
+
+**Problem** Low contrast text is unreadable for users with vision disabilities or in bright sunlight.
+
+#### WCAG
+- **1.4.3 Contrast (Minimum)** — Level AA
+ - Normal text (< 18pt): 4.5:1 contrast ratio
+ - Large text (≥ 18pt or ≥ 14pt bold): 3:1 contrast ratio
+- **1.4.6 Contrast (Enhanced)** — Level AAA
+ - Normal text: 7:1 contrast ratio
+ - Large text: 4.5:1 contrast ratio
+
+#### Common violations
+```swift
+// ❌ WRONG - Low contrast (1.8:1 - fails WCAG)
+Text("Warning")
+ .foregroundColor(.yellow) // on white background
+
+// ❌ WRONG - Low contrast in dark mode
+Text("Info")
+ .foregroundColor(.gray) // on black background
+
+// ✅ CORRECT - High contrast (7:1+ passes AAA)
+Text("Warning")
+ .foregroundColor(.orange) // or .red
+
+// ✅ CORRECT - System colors adapt to light/dark mode
+Text("Info")
+ .foregroundColor(.primary) // Black in light mode, white in dark
+
+Text("Secondary")
+ .foregroundColor(.secondary) // Automatic high contrast
+```
+
+#### Differentiate Without Color
+```swift
+// ❌ WRONG - Color alone indicates status
+Circle()
+ .fill(isAvailable ? .green : .red)
+
+// ✅ CORRECT - Color + icon/text
+HStack {
+ Image(systemName: isAvailable ? "checkmark.circle.fill" : "xmark.circle.fill")
+ Text(isAvailable ? "Available" : "Unavailable")
+}
+.foregroundColor(isAvailable ? .green : .red)
+
+// ✅ CORRECT - Respect system preference
+if UIAccessibility.shouldDifferentiateWithoutColor {
+ // Use patterns, icons, or text instead of color alone
+}
+```
+
+#### Testing
+1. Use Color Contrast Analyzer tool (free download)
+2. Screenshot your UI, measure text vs background
+3. Check both light and dark mode
+4. Settings → Accessibility → Display & Text Size → Increase Contrast (test with this ON)
+
+#### Quick reference
+- Black (#000000) on White (#FFFFFF): 21:1 ✅ AAA
+- Dark Gray (#595959) on White: 7:1 ✅ AAA
+- Medium Gray (#767676) on White: 4.5:1 ✅ AA
+- Light Gray (#959595) on White: 2.8:1 ❌ Fails
+
+---
+
+### 4. Touch Target Sizes (MEDIUM - Motor Disabilities)
+
+**Problem** Small tap targets are difficult or impossible for users with motor disabilities.
+
+**WCAG** 2.5.5 Target Size (Level AAA - 44x44pt minimum)
+
+**Apple HIG** 44x44pt minimum for all tappable elements
+
+#### Common violations
+```swift
+// ❌ WRONG - Too small (24x24pt)
+Button("×") {
+ dismiss()
+}
+.frame(width: 24, height: 24)
+
+// ❌ WRONG - Small icon without padding
+Image(systemName: "heart")
+ .font(.system(size: 16))
+ .onTapGesture { }
+
+// ✅ CORRECT - Minimum 44x44pt
+Button("×") {
+ dismiss()
+}
+.frame(minWidth: 44, minHeight: 44)
+
+// ✅ CORRECT - Larger icon or padding
+Image(systemName: "heart")
+ .font(.system(size: 24))
+ .frame(minWidth: 44, minHeight: 44)
+ .contentShape(Rectangle()) // Expand tap area
+ .onTapGesture { }
+
+// ✅ CORRECT - UIKit button with edge insets
+button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
+// Total size: icon size + insets ≥ 44x44pt
+```
+
+#### Spacing between targets
+```swift
+// ❌ WRONG - Targets too close (hard to tap accurately)
+HStack(spacing: 4) {
+ Button("Edit") { }
+ Button("Delete") { }
+}
+
+// ✅ CORRECT - Adequate spacing (8pt minimum, 12pt better)
+HStack(spacing: 12) {
+ Button("Edit") { }
+ Button("Delete") { }
+}
+```
+
+#### Testing
+1. Accessibility Inspector: Xcode → Open Developer Tool → Accessibility Inspector
+2. Select "Audit" tab → Run audit → Check for "Small Text" and "Hit Region" warnings
+3. Manual: Tap with one finger (not stylus) — can you hit it reliably without mistakes?
+
+---
+
+### 5. Keyboard Navigation (MEDIUM - iPadOS/macOS)
+
+**Problem** Users who cannot use touch/mouse cannot navigate app.
+
+**WCAG** 2.1.1 Keyboard (Level A - all functionality available via keyboard)
+
+#### Common violations
+```swift
+// ❌ WRONG - Custom gesture without keyboard alternative
+.onTapGesture {
+ showDetails()
+}
+// No way to trigger with keyboard
+
+// ✅ CORRECT - Button provides keyboard support automatically
+Button("Show Details") {
+ showDetails()
+}
+.keyboardShortcut("d", modifiers: .command) // Optional shortcut
+
+// ✅ CORRECT - Custom control with focus support
+struct CustomButton: View {
+ @FocusState private var isFocused: Bool
+
+ var body: some View {
+ Text("Custom")
+ .focusable()
+ .focused($isFocused)
+ .onKeyPress(.return) {
+ action()
+ return .handled
+ }
+ }
+}
+```
+
+#### Focus management
+```swift
+// ✅ CORRECT - Set initial focus
+.focusSection() // Group related controls
+.defaultFocus($focus, .constant(true)) // Set default
+
+// ✅ CORRECT - Move focus after action
+@FocusState private var focusedField: Field?
+
+Button("Next") {
+ focusedField = .next
+}
+```
+
+#### Testing (iPadOS/macOS)
+1. Connect keyboard to iPad or use Mac
+2. Press Tab - does focus move to interactive elements?
+3. Press Space/Return - does focused element activate?
+4. Check custom controls have visible focus indicator
+5. Can you reach all functionality without mouse/touch?
+
+---
+
+### 6. Reduce Motion Support (MEDIUM - Vestibular Disorders)
+
+**Problem** Animations cause discomfort, nausea, or seizures for users with vestibular disorders.
+
+**WCAG** 2.3.3 Animation from Interactions (Level AAA - motion animation can be disabled)
+
+#### Common violations
+```swift
+// ❌ WRONG - Always animates (can cause nausea)
+.onAppear {
+ withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
+ scale = 1.0
+ }
+}
+
+// ❌ WRONG - Parallax scrolling without opt-out
+ScrollView {
+ GeometryReader { geo in
+ Image("hero")
+ .offset(y: geo.frame(in: .global).minY * 0.5) // Parallax
+ }
+}
+
+// ✅ CORRECT - Respect Reduce Motion preference
+.onAppear {
+ if UIAccessibility.isReduceMotionEnabled {
+ scale = 1.0 // Instant
+ } else {
+ withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
+ scale = 1.0
+ }
+ }
+}
+
+// ✅ CORRECT - Simpler animation or cross-fade
+if UIAccessibility.isReduceMotionEnabled {
+ // Cross-fade or instant change
+ withAnimation(.linear(duration: 0.2)) {
+ showView = true
+ }
+} else {
+ // Complex spring animation
+ withAnimation(.spring()) {
+ showView = true
+ }
+}
+```
+
+#### SwiftUI modifier
+```swift
+// ✅ CORRECT - Automatic support
+.animation(.spring(), value: isExpanded)
+.transaction { transaction in
+ if UIAccessibility.isReduceMotionEnabled {
+ transaction.animation = nil // Disable animation
+ }
+}
+```
+
+#### Testing
+1. Settings → Accessibility → Motion → Reduce Motion (toggle ON)
+2. Navigate app - are animations reduced or eliminated?
+3. Test: Transitions, scrolling effects, parallax, particle effects
+4. Video autoplay should also respect this preference
+
+---
+
+### 7. Common Violations (HIGH - App Store Review)
+
+#### Images Without Labels
+
+```swift
+// ❌ WRONG - Informative image without label
+Image("product-photo")
+
+// ✅ CORRECT - Informative image with label
+Image("product-photo")
+ .accessibilityLabel("Red sneakers with white laces")
+
+// ✅ CORRECT - Decorative image hidden
+Image("background-pattern")
+ .accessibilityHidden(true)
+```
+
+#### Buttons With Wrong Traits
+
+```swift
+// ❌ WRONG - Custom button without button trait
+Text("Submit")
+ .onTapGesture {
+ submit()
+ }
+// VoiceOver announces as "Submit, text" not "Submit, button"
+
+// ✅ CORRECT - Use Button for button-like controls
+Button("Submit") {
+ submit()
+}
+// VoiceOver announces as "Submit, button"
+
+// ✅ CORRECT - Custom control with correct trait
+Text("Submit")
+ .accessibilityAddTraits(.isButton)
+ .onTapGesture {
+ submit()
+ }
+```
+
+#### Inaccessible Custom Controls
+
+```swift
+// ❌ WRONG - Custom slider without accessibility support
+struct CustomSlider: View {
+ @Binding var value: Double
+
+ var body: some View {
+ // Drag gesture only, no VoiceOver support
+ GeometryReader { geo in
+ // ...
+ }
+ .gesture(DragGesture()...)
+ }
+}
+
+// ✅ CORRECT - Custom slider with accessibility actions
+struct CustomSlider: View {
+ @Binding var value: Double
+
+ var body: some View {
+ GeometryReader { geo in
+ // ...
+ }
+ .gesture(DragGesture()...)
+ .accessibilityElement()
+ .accessibilityLabel("Volume")
+ .accessibilityValue("\(Int(value))%")
+ .accessibilityAdjustableAction { direction in
+ switch direction {
+ case .increment:
+ value = min(value + 10, 100)
+ case .decrement:
+ value = max(value - 10, 0)
+ @unknown default:
+ break
+ }
+ }
+ }
+}
+```
+
+#### Missing State Announcements
+
+```swift
+// ❌ WRONG - State change without announcement
+Button("Toggle") {
+ isOn.toggle()
+}
+
+// ✅ CORRECT - State change with announcement
+Button("Toggle") {
+ isOn.toggle()
+ UIAccessibility.post(
+ notification: .announcement,
+ argument: isOn ? "Enabled" : "Disabled"
+ )
+}
+
+// ✅ CORRECT - Automatic state with accessibilityValue
+Button("Toggle") {
+ isOn.toggle()
+}
+.accessibilityValue(isOn ? "Enabled" : "Disabled")
+```
+
+## 8. Assistive Access Support (iOS 17+ — Cognitive Disabilities)
+
+**Problem** App is unavailable or broken in Assistive Access mode, excluding users with cognitive disabilities who rely on a simplified system experience.
+
+Assistive Access is a system-wide mode (Settings > Accessibility > Assistive Access) that replaces the standard iOS UI with large controls, simplified navigation, and reduced cognitive load. Apps that don't opt in are hidden from users in this mode.
+
+#### Symptom: App missing from Assistive Access home screen
+
+Your app doesn't appear under "Optimized Apps" in Assistive Access settings.
+
+```xml
+
+UISupportsAssistiveAccess
+
+```
+
+This makes the app available and launches it full screen in Assistive Access mode. Without this key, users in Assistive Access mode cannot access your app at all.
+
+#### Symptom: Standard UI too complex for Assistive Access users
+
+Your app launches in Assistive Access but shows the full standard interface, overwhelming users who need simplified controls.
+
+```swift
+// ✅ FIX - Provide a dedicated Assistive Access scene
+@main
+struct MyApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView() // Standard UI
+ }
+
+ AssistiveAccess {
+ AssistiveAccessContentView() // Simplified UI
+ }
+ }
+}
+```
+
+The `AssistiveAccess` scene type provides a separate entry point. When the system is in Assistive Access mode, it uses this scene instead of the standard `WindowGroup`. Native SwiftUI controls inside this scene automatically adopt the Assistive Access visual style (large buttons, prominent navigation, grid/row layout).
+
+#### Symptom: App already designed for cognitive accessibility but displays in reduced frame
+
+If your app is already purpose-built for users with cognitive disabilities (e.g., AAC apps), it may appear in a reduced frame rather than full screen.
+
+```xml
+
+UISupportsFullScreenInAssistiveAccess
+
+```
+
+This displays your app identically to its standard appearance, bypassing the Assistive Access frame.
+
+#### Detecting Assistive Access at runtime
+
+```swift
+struct MyView: View {
+ @Environment(\.accessibilityAssistiveAccessEnabled) var assistiveAccessEnabled
+
+ var body: some View {
+ if assistiveAccessEnabled {
+ // Simplified content
+ } else {
+ // Standard content
+ }
+ }
+}
+```
+
+#### UIKit implementation
+
+For UIKit apps, use the `.windowAssistiveAccessApplication` scene session role in your `UISceneConfiguration` to route to a dedicated scene delegate for the Assistive Access experience.
+
+#### Design principles for Assistive Access scenes
+
+- **Distill to core functionality** — One or two essential features, not the full app
+- **Large, prominent controls** — Ample spacing, no hidden gestures or timed interactions
+- **Multiple representations** — Pair text with icons; use visual alternatives
+- **Step-by-step navigation** — Clear back buttons, consistent patterns
+- **Safe interactions** — Remove irreversible actions; confirm destructive ones
+
+#### Adding navigation icons
+
+```swift
+NavigationStack {
+ MyView()
+ .navigationTitle("My Feature")
+ .assistiveAccessNavigationIcon(systemImage: "star.fill")
+}
+```
+
+#### Testing
+
+1. **Device** — Enable Assistive Access in Settings > Accessibility > Assistive Access, verify app appears in "Optimized Apps", test the full user flow
+2. **Accessibility Inspector** — Run audit on the Assistive Access scene for label, contrast, and hit region issues
+
+---
+
+## Accessibility Inspector Workflow
+
+### 1. Launch Accessibility Inspector
+
+Xcode → Open Developer Tool → Accessibility Inspector
+
+### 2. Select Target
+
+- Dropdown: Choose running simulator or connected device
+- Target: Select your app
+
+### 3. Inspection Mode
+
+- Click "Inspection Pointer" button (crosshair icon)
+- Hover over UI elements to see:
+ - Label, Value, Hint, Traits
+ - Frame, Path
+ - Actions available
+ - Parent/child hierarchy
+
+### 4. Run Audit
+
+- Click "Audit" tab
+- Click "Run Audit" button
+- Review findings:
+ - **Contrast** — Color contrast issues
+ - **Hit Region** — Touch target size issues
+ - **Clipped Text** — Text truncation with Dynamic Type
+ - **Element Description** — Missing labels/hints
+ - **Traits** — Wrong accessibility traits
+
+### 5. Fix and Re-Test
+
+- Click each finding for details
+- Fix in code
+- Re-run audit to verify
+
+## VoiceOver Testing Checklist
+
+### Enable VoiceOver
+- **Simulator** Cmd+F5 or Settings → Accessibility → VoiceOver
+- **Device** Triple-click side button (if enabled in Settings)
+
+### Navigation Testing
+1. ☐ Swipe right/left - moves logically through UI elements
+2. ☐ Each element announces purpose clearly
+3. ☐ No unlabeled elements (except decorative)
+4. ☐ Heading navigation works (swipe up/down with 2 fingers)
+5. ☐ Container navigation works (swipe left/right with 3 fingers)
+
+### Interaction Testing
+1. ☐ Double-tap activates buttons
+2. ☐ Swipe up/down adjusts sliders/pickers (with `.accessibilityAdjustableAction`)
+3. ☐ Custom gestures have VoiceOver equivalents
+4. ☐ Text fields announce keyboard type
+5. ☐ State changes are announced
+
+### Content Testing
+1. ☐ Images have descriptive labels or are hidden
+2. ☐ Error messages are announced
+3. ☐ Loading states are announced
+4. ☐ Modal sheets announce role
+5. ☐ Alerts announce automatically
+
+## App Store Review Preparation
+
+### Required Accessibility Features (iOS)
+
+1. **VoiceOver Support**
+ - All UI elements must have labels
+ - Navigation must be logical
+ - All actions must be performable
+
+2. **Dynamic Type**
+ - Text must scale from -3 to +12 sizes
+ - Layout must adapt without clipping
+
+3. **Sufficient Contrast**
+ - Minimum 4.5:1 for normal text
+ - Minimum 3:1 for large text (≥18pt)
+
+### App Store Connect Metadata
+
+When submitting:
+1. Accessibility → Select features your app supports:
+ - ☑ VoiceOver
+ - ☑ Dynamic Type
+ - ☑ Increased Contrast
+ - ☑ Reduce Motion (if supported)
+
+2. Test Notes: Document accessibility testing
+ ```
+ Accessibility Testing Completed:
+ - VoiceOver: All screens tested with VoiceOver enabled
+ - Dynamic Type: Tested at all size categories
+ - Color Contrast: Verified 4.5:1 minimum contrast
+ - Touch Targets: All buttons minimum 44x44pt
+ - Reduce Motion: Animations respect user preference
+ ```
+
+### Common Rejection Reasons
+
+1. **"App is not fully functional with VoiceOver"**
+ - Missing labels on images/buttons
+ - Unlabeled custom controls
+ - Actions not performable with VoiceOver
+
+2. **"Text is not readable at all Dynamic Type sizes"**
+ - Fixed font sizes
+ - Text clipping at large sizes
+ - Layout breaks at accessibility sizes
+
+3. **"Insufficient color contrast"**
+ - Text fails 4.5:1 ratio
+ - UI elements fail 3:1 ratio
+ - Color-only indicators
+
+---
+
+## Design Review Pressure: Defending Accessibility Requirements
+
+### The Problem
+
+Under design review pressure, you'll face requests to:
+- "Those VoiceOver labels make the code messy - can we skip them?"
+- "Dynamic Type breaks our carefully designed layout - let's lock font sizes"
+- "The high contrast requirement ruins our brand aesthetic"
+- "44pt touch targets are too big - make them smaller for a cleaner look"
+
+These sound like reasonable design preferences. **But they violate App Store requirements and exclude 15% of users.** Your job: defend using App Store guidelines and legal requirements, not opinion.
+
+### Red Flags — Designer Requests That Violate Accessibility
+
+If you hear ANY of these, **STOP and reference this skill**:
+
+- ❌ **"Skip VoiceOver labels on icon-only buttons"** – App Store rejection (Guideline 2.5.1)
+- ❌ **"Use fixed 14pt font for compact design"** – Excludes users with vision disabilities
+- ❌ **"3:1 contrast ratio is fine"** – Fails WCAG AA for text (needs 4.5:1)
+- ❌ **"Make buttons 36x36pt for clean aesthetic"** – Fails touch target requirement (44x44pt minimum)
+- ❌ **"Disable Dynamic Type in this screen"** – App Store rejection risk
+- ❌ **"Color-code without labels (red=error, green=success)"** – Excludes colorblind users (8% of men)
+
+### How to Push Back Professionally
+
+#### Step 1: Show the Guideline
+
+```
+"I want to support this design direction, but let me show you Apple's App Store
+Review Guideline 2.5.1:
+
+'Apps should support accessibility features such as VoiceOver and Dynamic Type.
+Failure to include sufficient accessibility features may result in rejection.'
+
+Here's what we need for approval:
+1. VoiceOver labels on all interactive elements
+2. Dynamic Type support (can't lock font sizes)
+3. 4.5:1 contrast ratio for text, 3:1 for UI
+4. 44x44pt minimum touch targets
+
+Let me show where our design currently falls short..."
+```
+
+#### Step 2: Demonstrate the Risk
+
+Open the app with accessibility features enabled:
+- **VoiceOver** (Cmd+F5): Show buttons announcing "Button" instead of purpose
+- **Largest Text Size**: Show layout breaking or text clipping
+- **Color Contrast Analyzer**: Show failing contrast ratios
+- **Touch target overlay**: Show targets < 44pt
+
+#### Reference
+- App Store Review Guideline 2.5.1
+- WCAG 2.1 Level AA (industry standard)
+- ADA compliance requirements (legal risk in US)
+
+#### Step 3: Offer Compromise
+
+```
+"I can achieve your aesthetic goals while meeting accessibility requirements:
+
+1. VoiceOver labels: Add them programmatically (invisible in UI, required for approval)
+2. Dynamic Type: Use layout techniques that adapt (examples from Apple HIG)
+3. Contrast: Adjust colors slightly to meet 4.5:1 (I'll show options that preserve brand)
+4. Touch targets: Expand hit areas programmatically (visual size stays the same)
+
+These changes won't affect the visual design you're seeing, but they're required
+for App Store approval and legal compliance."
+```
+
+#### Step 4: Document the Decision
+
+If overruled (designer insists on violations):
+
+```
+Slack message to PM + designer:
+
+"Design review decided to proceed with:
+- Fixed font sizes (disabling Dynamic Type)
+- 38x38pt buttons (below 44pt requirement)
+- 3.8:1 text contrast (below 4.5:1 requirement)
+
+Important: These changes violate App Store Review Guideline 2.5.1 and WCAG AA.
+This creates three risks:
+
+1. App Store rejection during review (adds 1-2 week delay)
+2. ADA compliance issues if user files complaint (legal risk)
+3. 15% of potential users unable to use app effectively
+
+I'm flagging this proactively so we can prepare a response plan if rejected."
+```
+
+#### Why this works
+- You're not questioning their design taste
+- You're raising App Store rejection risk (business impact)
+- You're citing specific guidelines (not opinion)
+- You're offering solutions that preserve visual design
+- You're documenting the decision (protects you post-rejection)
+
+### Real-World Example: App Store Rejection (48-Hour Resubmit Window)
+
+#### Scenario
+- 48 hours until resubmit deadline after rejection
+- Apple cited: "2.5.1 - Insufficient VoiceOver support"
+- Designer says: "Just add generic labels quickly"
+- PM watching the meeting, wants fastest fix
+
+#### What to do
+
+```swift
+// ❌ WRONG - Generic labels (will fail re-review)
+Button(action: addToCart) {
+ Image(systemName: "cart.badge.plus")
+}
+.accessibilityLabel("Button") // Apple will reject again
+
+// ✅ CORRECT - Descriptive labels (passes review)
+Button(action: addToCart) {
+ Image(systemName: "cart.badge.plus")
+}
+.accessibilityLabel("Add to cart")
+.accessibilityHint("Double-tap to add this item to your shopping cart")
+```
+
+#### In the meeting, demonstrate
+1. Enable VoiceOver (Cmd+F5)
+2. Show "Button" announcement (generic - fails)
+3. Show "Add to cart" announcement (descriptive - passes)
+4. Reference Apple's rejection message: "Elements must have descriptive labels"
+
+**Time estimate** 2-4 hours to audit all interactive elements and add proper labels.
+
+#### Result
+- Honest time estimate prevents second rejection
+- Proper labels pass Apple review
+- Resubmit accepted within 48 hours
+
+### When to Accept the Design Decision (Even If You Disagree)
+
+Sometimes designers have valid reasons to override accessibility guidelines. Accept if:
+
+- [ ] They understand the App Store rejection risk
+- [ ] They're willing to delay launch if rejected
+- [ ] You document the decision in writing
+- [ ] They commit to fixing if rejected
+
+#### Document in Slack
+
+```
+"Design review decided to proceed with [specific violations].
+
+We understand this creates:
+- App Store rejection risk (Guideline 2.5.1)
+- Potential 1-2 week delay if rejected
+- Need to audit and fix all instances if rejected
+
+Monitoring plan:
+- Submit for review with current design
+- If rejected, implement proper accessibility (estimated 2-4 hours)
+- Have accessibility-compliant version ready as backup"
+```
+
+This protects both of you and shows you're not blocking - just de-risking.
+
+---
+
+## WCAG Compliance Levels
+
+### Level A (Minimum — Required for App Store)
+- 1.1.1 Non-text Content — Images have text alternatives
+- 2.1.1 Keyboard — All functionality via keyboard (iPadOS/macOS)
+- 4.1.2 Name, Role, Value — Elements have accessible names
+
+### Level AA (Standard — Recommended)
+- 1.4.3 Contrast (Minimum) — 4.5:1 text, 3:1 UI
+- 1.4.4 Resize Text — Support 200% text scaling
+- 1.4.5 Images of Text — Use real text when possible
+
+### Level AAA (Enhanced — Best Practice)
+- 1.4.6 Contrast (Enhanced) — 7:1 text, 4.5:1 UI
+- 2.3.3 Animation from Interactions — Reduce Motion support
+- 2.5.5 Target Size - 44x44pt minimum targets
+
+**Goal** Meet Level AA for all content, Level AAA where feasible.
+
+## Quick Command Reference
+
+After making fixes:
+
+```bash
+# Quick scan for new issues
+/axiom:audit-accessibility
+
+# Deep diagnosis for specific issues
+/skill axiom:accessibility-diag
+```
+
+## Resources
+
+**Docs**: /accessibility/voiceover, /uikit/uifont/scaling_fonts_automatically
+
+---
+
+**Remember** Accessibility is not a feature, it's a requirement. 15% of users have some form of disability. Making your app accessible isn't just the right thing to do - it expands your user base and improves the experience for everyone.
diff --git a/.claude/skills/axiom-accessibility-diag/agents/openai.yaml b/.claude/skills/axiom-accessibility-diag/agents/openai.yaml
new file mode 100644
index 0000000..7776dbb
--- /dev/null
+++ b/.claude/skills/axiom-accessibility-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Accessibility Diagnostics"
+ short_description: "Fixing VoiceOver issues, Dynamic Type violations, color contrast failures, touch target problems, keyboard navigation..."
diff --git a/.claude/skills/axiom-alarmkit-ref/.openskills.json b/.claude/skills/axiom-alarmkit-ref/.openskills.json
new file mode 100644
index 0000000..6b30d61
--- /dev/null
+++ b/.claude/skills/axiom-alarmkit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-alarmkit-ref",
+ "installedAt": "2026-04-12T08:05:41.287Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-alarmkit-ref/SKILL.md b/.claude/skills/axiom-alarmkit-ref/SKILL.md
new file mode 100644
index 0000000..7bb91ed
--- /dev/null
+++ b/.claude/skills/axiom-alarmkit-ref/SKILL.md
@@ -0,0 +1,502 @@
+---
+name: axiom-alarmkit-ref
+description: Use when implementing alarm functionality, scheduling wake alarms, or integrating AlarmKit with Live Activities. Covers AlarmKit authorization, alarm configuration, SwiftUI views, and Live Activity integration.
+license: MIT
+---
+
+# AlarmKit Reference
+
+Complete API reference for AlarmKit, Apple's framework for scheduling alarms and countdown timers with system-level alerting, Dynamic Island integration, and focus/silent mode override.
+
+## Overview
+
+AlarmKit lets apps create alarms and timers that behave like the built-in Clock app -- they override Do Not Disturb, appear in the Dynamic Island, and show on the Lock Screen. The framework handles scheduling, snooze, pause/resume, and UI presentation through a small set of types centered on `AlarmManager`.
+
+## System Requirements
+
+- **iOS 26+** (AlarmKit introduced in iOS 26)
+- **Widget Extension** required for Live Activity / Dynamic Island presentation
+- **Physical device** recommended for alarm sound and notification testing
+
+---
+
+## Part 1: Key Components
+
+### AlarmManager
+
+Singleton entry point for all alarm operations.
+
+```swift
+import AlarmKit
+
+let manager = AlarmManager.shared
+```
+
+All scheduling, cancellation, and observation flows through this shared instance.
+
+### Alarm
+
+Describes an alarm that can alert once or on a repeating schedule.
+
+```swift
+struct Alarm {
+ var id: UUID
+ var schedule: Schedule?
+ var countdownDuration: CountdownDuration?
+ var state: AlarmState
+}
+```
+
+### AlarmPresentation
+
+Content for the alarm UI across three states -- alerting, counting down, and paused.
+
+```swift
+struct AlarmPresentation {
+ var alert: Alert // Required: shown when alarm fires
+ var countdown: Countdown? // Optional: shown during countdown
+ var paused: Paused? // Optional: shown when paused
+}
+```
+
+### AlarmAttributes
+
+Generic container pairing presentation with app-specific metadata and tint color. Used to configure the Live Activity widget.
+
+```swift
+struct AlarmAttributes {
+ var presentation: AlarmPresentation
+ var metadata: Metadata
+ var tintColor: Color
+}
+```
+
+### AlarmMetadata
+
+Protocol for app-specific data attached to an alarm. Conform an empty struct for minimal usage, or add properties for richer UI.
+
+```swift
+struct RecipeMetadata: AlarmMetadata {
+ let recipeName: String
+ let cookingStep: String
+}
+```
+
+---
+
+## Part 2: Authorization
+
+Apps must request permission before scheduling alarms. Add `NSAlarmKitUsageDescription` to Info.plist.
+
+### Requesting Authorization
+
+```swift
+func requestAlarmAuthorization() async -> Bool {
+ do {
+ let state = try await AlarmManager.shared.requestAuthorization()
+ return state == .authorized
+ } catch {
+ print("Authorization error: \(error)")
+ return false
+ }
+}
+```
+
+### Checking Current State
+
+Use `authorizationState` (not `authorizationStatus`) to read the current value:
+
+```swift
+let state = await AlarmManager.shared.authorizationState
+// .authorized | .denied | .notDetermined
+```
+
+### Observing Authorization Changes
+
+```swift
+for await authState in AlarmManager.shared.authorizationUpdates {
+ switch authState {
+ case .authorized: enableAlarmUI()
+ case .denied: showPermissionPrompt()
+ case .notDetermined: break
+ @unknown default: break
+ }
+}
+```
+
+---
+
+## Part 3: Scheduling Alarms
+
+Every alarm requires a `UUID`, an `AlarmManager.AlarmConfiguration`, and a call to `schedule(id:configuration:)`.
+
+### One-Time Alarm
+
+```swift
+let id = UUID()
+let time = Alarm.Schedule.Relative.Time(hour: 7, minute: 30)
+let schedule = Alarm.Schedule.relative(.init(
+ time: time,
+ repeats: .never
+))
+
+let alert = AlarmPresentation.Alert(
+ title: "Wake Up",
+ stopButton: .stopButton,
+ secondaryButton: .snoozeButton,
+ secondaryButtonBehavior: .countdown
+)
+
+struct EmptyMetadata: AlarmMetadata {}
+let config = AlarmManager.AlarmConfiguration(
+ countdownDuration: nil,
+ schedule: schedule,
+ attributes: AlarmAttributes(
+ presentation: AlarmPresentation(alert: alert),
+ metadata: EmptyMetadata(),
+ tintColor: .blue
+ ),
+ sound: .default
+)
+
+let alarm = try await AlarmManager.shared.schedule(id: id, configuration: config)
+```
+
+### Repeating Alarm
+
+Use `.weekly(Array(weekdays))` for specific days:
+
+```swift
+let time = Alarm.Schedule.Relative.Time(hour: 6, minute: 0)
+let schedule = Alarm.Schedule.relative(.init(
+ time: time,
+ repeats: .weekly([.monday, .tuesday, .wednesday, .thursday, .friday])
+))
+```
+
+### Countdown Timer
+
+Set `schedule: nil` and provide `countdownDuration` with a `preAlert` interval:
+
+```swift
+let countdown = Alarm.CountdownDuration(
+ preAlert: 300, // 5 minutes
+ postAlert: 10 // Optional post-alert snooze window
+)
+
+let config = AlarmManager.AlarmConfiguration(
+ countdownDuration: countdown,
+ schedule: nil,
+ attributes: attributes,
+ sound: .default
+)
+```
+
+Timers support pause/resume and show a countdown presentation when `AlarmPresentation.countdown` is provided.
+
+### Snooze Configuration
+
+Snooze uses `CountdownDuration.postAlert` combined with a `.snoozeButton` secondary action:
+
+```swift
+let alert = AlarmPresentation.Alert(
+ title: "Alarm",
+ stopButton: .stopButton,
+ secondaryButton: .snoozeButton,
+ secondaryButtonBehavior: .countdown // Starts post-alert countdown
+)
+
+let countdownDuration = Alarm.CountdownDuration(
+ preAlert: nil,
+ postAlert: 9 * 60 // 9-minute snooze
+)
+```
+
+---
+
+## Part 4: Customizing Alarm UI
+
+### Alert Presentation
+
+The alert state is shown when the alarm fires. The stop button is required; secondary button is optional.
+
+```swift
+// Minimal
+let basic = AlarmPresentation.Alert(
+ title: "Alarm",
+ stopButton: .stopButton
+)
+
+// With custom button labels
+let custom = AlarmPresentation.Alert(
+ title: "Medication Reminder",
+ stopButton: AlarmButton(label: "Taken"),
+ secondaryButton: AlarmButton(label: "Remind Later"),
+ secondaryButtonBehavior: .countdown
+)
+
+// With open-app action
+let openApp = AlarmPresentation.Alert(
+ title: "Workout Time",
+ stopButton: .stopButton,
+ secondaryButton: .openAppButton,
+ secondaryButtonBehavior: .custom
+)
+```
+
+### Countdown Presentation
+
+Shown while a timer counts down. Only relevant for alarms with `countdownDuration.preAlert`.
+
+```swift
+let countdown = AlarmPresentation.Countdown(
+ title: "Timer Running",
+ pauseButton: .pauseButton
+)
+```
+
+### Paused Presentation
+
+Shown when a countdown timer is paused.
+
+```swift
+let paused = AlarmPresentation.Paused(
+ title: "Timer Paused",
+ resumeButton: .resumeButton
+)
+```
+
+### Full Three-State Presentation
+
+Combine all three for a complete timer experience:
+
+```swift
+let presentation = AlarmPresentation(
+ alert: AlarmPresentation.Alert(
+ title: "Timer Complete",
+ stopButton: .stopButton,
+ secondaryButton: .repeatButton,
+ secondaryButtonBehavior: .countdown
+ ),
+ countdown: AlarmPresentation.Countdown(
+ title: "Cooking Timer",
+ pauseButton: .pauseButton
+ ),
+ paused: AlarmPresentation.Paused(
+ title: "Timer Paused",
+ resumeButton: .resumeButton
+ )
+)
+```
+
+---
+
+## Part 5: Managing Alarms
+
+### Retrieve All Alarms
+
+```swift
+let alarms = try AlarmManager.shared.alarms
+```
+
+### Pause / Resume
+
+```swift
+try await AlarmManager.shared.pause(id: alarmID)
+try await AlarmManager.shared.resume(id: alarmID)
+```
+
+### Cancel
+
+```swift
+try await AlarmManager.shared.cancel(id: alarmID)
+```
+
+### Observe Alarm Updates
+
+Use `alarmUpdates` to keep UI in sync. An alarm absent from the emitted array is no longer scheduled.
+
+```swift
+for await alarms in AlarmManager.shared.alarmUpdates {
+ self.alarms = alarms
+}
+```
+
+---
+
+## Part 6: Live Activity Integration
+
+AlarmKit alarms appear in the Dynamic Island and Lock Screen through `ActivityConfiguration`. Add a Widget Extension target and implement the widget using `AlarmAttributes`.
+
+```swift
+struct AlarmWidgetView: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: AlarmAttributes.self) { context in
+ // Lock Screen presentation
+ VStack {
+ Text(context.attributes.presentation.alert.title)
+ if context.state.mode == .countdown {
+ Text(
+ timerInterval: context.state.countdownEndDate
+ .timeIntervalSinceNow,
+ countsDown: true
+ )
+ .bold()
+ }
+ }
+ .padding()
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Text(context.attributes.presentation.alert.title)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ if context.state.mode == .countdown {
+ Text(
+ timerInterval: context.state.countdownEndDate
+ .timeIntervalSinceNow,
+ countsDown: true
+ )
+ }
+ }
+ } compactLeading: {
+ Image(systemName: "alarm")
+ } compactTrailing: {
+ if context.state.mode == .countdown {
+ Text(
+ timerInterval: context.state.countdownEndDate
+ .timeIntervalSinceNow,
+ countsDown: true
+ )
+ }
+ } minimal: {
+ Image(systemName: "alarm")
+ }
+ }
+ }
+}
+```
+
+---
+
+## Part 7: SwiftUI Integration
+
+### ViewModel Pattern with @Observable
+
+```swift
+import AlarmKit
+
+@Observable
+class AlarmViewModel {
+ var alarms: [Alarm] = []
+ private let manager = AlarmManager.shared
+
+ func requestAuthorization() {
+ Task {
+ _ = try? await manager.requestAuthorization()
+ }
+ }
+
+ func loadAndObserve() {
+ Task {
+ alarms = (try? manager.alarms) ?? []
+ for await updated in manager.alarmUpdates {
+ alarms = updated
+ }
+ }
+ }
+
+ func addAlarm(hour: Int, minute: Int, weekdays: Set) {
+ Task {
+ let time = Alarm.Schedule.Relative.Time(hour: hour, minute: minute)
+ let schedule = Alarm.Schedule.relative(.init(
+ time: time,
+ repeats: weekdays.isEmpty ? .never : .weekly(Array(weekdays))
+ ))
+
+ let alert = AlarmPresentation.Alert(
+ title: "Alarm",
+ stopButton: .stopButton,
+ secondaryButton: .snoozeButton,
+ secondaryButtonBehavior: .countdown
+ )
+
+ struct EmptyMetadata: AlarmMetadata {}
+ let config = AlarmManager.AlarmConfiguration(
+ countdownDuration: Alarm.CountdownDuration(
+ preAlert: nil, postAlert: 9 * 60
+ ),
+ schedule: schedule,
+ attributes: AlarmAttributes(
+ presentation: AlarmPresentation(alert: alert),
+ metadata: EmptyMetadata(),
+ tintColor: .blue
+ ),
+ sound: .default
+ )
+
+ _ = try? await manager.schedule(id: UUID(), configuration: config)
+ }
+ }
+
+ func cancel(id: UUID) {
+ Task { try? await manager.cancel(id: id) }
+ }
+
+ func togglePause(id: UUID, isPaused: Bool) {
+ Task {
+ if isPaused {
+ try? await manager.resume(id: id)
+ } else {
+ try? await manager.pause(id: id)
+ }
+ }
+ }
+}
+```
+
+### Alarm List View
+
+```swift
+struct AlarmListView: View {
+ @State private var viewModel = AlarmViewModel()
+
+ var body: some View {
+ NavigationStack {
+ List(viewModel.alarms, id: \.id) { alarm in
+ AlarmRow(alarm: alarm, viewModel: viewModel)
+ }
+ .navigationTitle("Alarms")
+ .onAppear {
+ viewModel.requestAuthorization()
+ viewModel.loadAndObserve()
+ }
+ }
+ }
+}
+```
+
+---
+
+## Part 8: Best Practices
+
+| Practice | Detail |
+|----------|--------|
+| Request authorization early | On first launch or first alarm creation attempt |
+| Handle denial gracefully | Guide users to Settings if permission was denied |
+| Persist alarm UUIDs | Store IDs to manage alarms across app launches |
+| Implement widget extension | Required for countdown/Dynamic Island presentation |
+| Use `alarmUpdates` | Keep UI in sync; don't poll or cache stale state |
+| Test on physical device | Alarm sounds, notifications, and Live Activities require real hardware |
+| Respect system limits | There is a system-imposed cap on alarms per app |
+| Use `authorizationState` | Not `authorizationStatus` -- the correct property name is `authorizationState` |
+
+---
+
+## Resources
+
+**WWDC**: 2025-230
+
+**Docs**: /alarmkit, /alarmkit/alarmmanager, /alarmkit/alarm, /alarmkit/alarmpresentation, /alarmkit/alarmattributes
+
+**Skills**: axiom-extensions-widgets-ref, axiom-swiftui-26-ref
diff --git a/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml b/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml
new file mode 100644
index 0000000..17dd502
--- /dev/null
+++ b/.claude/skills/axiom-alarmkit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "AlarmKit Reference"
+ short_description: "Implementing alarm functionality, scheduling wake alarms, or integrating AlarmKit with Live Activities"
diff --git a/.claude/skills/axiom-analyze-crash/.openskills.json b/.claude/skills/axiom-analyze-crash/.openskills.json
new file mode 100644
index 0000000..9b892e0
--- /dev/null
+++ b/.claude/skills/axiom-analyze-crash/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-analyze-crash",
+ "installedAt": "2026-04-12T08:05:41.288Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-analyze-crash/SKILL.md b/.claude/skills/axiom-analyze-crash/SKILL.md
new file mode 100644
index 0000000..c0c79b6
--- /dev/null
+++ b/.claude/skills/axiom-analyze-crash/SKILL.md
@@ -0,0 +1,343 @@
+---
+name: axiom-analyze-crash
+description: Use when the user has a crash log (.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# Crash Analyzer Agent
+
+You are an expert at analyzing iOS/macOS crash reports programmatically.
+
+## Core Principle
+
+**Understand the crash before writing any fix.** 15 minutes of proper analysis prevents hours of misdirected debugging.
+
+## Your Mission
+
+When the user provides a crash log:
+1. Parse the crash report (JSON .ips or text format)
+2. Extract key fields (exception, crashed thread, frames)
+3. Check symbolication status
+4. Categorize by crash pattern
+5. Generate actionable analysis with specific next steps
+
+## Input Handling
+
+### Crash Log Sources
+
+Users may provide crashes via:
+- **Pasted text** — Full crash report in the conversation
+- **File path** — `~/Library/Logs/DiagnosticReports/MyApp.ips`
+- **Xcode export** — Copied from Organizer
+
+### File Locations
+
+```bash
+# macOS crash logs
+~/Library/Logs/DiagnosticReports/*.ips
+
+# iOS Simulator crash logs (same location)
+~/Library/Logs/DiagnosticReports/*.ips
+
+# Device crash logs (after sync)
+~/Library/Logs/CrashReporter/MobileDevice//
+```
+
+## Crash Report Formats
+
+### Modern Format (.ips - JSON)
+
+```json
+{"app_name":"MyApp","timestamp":"2026-01-09 06:55:45.00 -0800",...}
+{
+ "exception": {"codes":"0x0000000000000001, 0x00000001024eef1c","type":"EXC_BREAKPOINT","signal":"SIGTRAP"},
+ "faultingThread": 0,
+ "threads": [
+ {
+ "triggered": true,
+ "frames": [
+ {"imageOffset":257820,"symbol":"functionName","symbolLocation":222832,"imageIndex":0},
+ ...
+ ]
+ }
+ ],
+ "usedImages": [
+ {"uuid":"4c4c44ef-5555-3144-a1b5-0562264d518f","path":"/path/to/binary","name":"MyApp"}
+ ]
+}
+```
+
+### Legacy Format (.crash - Text)
+
+```
+Exception Type: EXC_BAD_ACCESS (SIGSEGV)
+Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000010
+
+Thread 0 Crashed:
+0 MyApp 0x100abc123 functionName + 45
+1 MyApp 0x100abc456 callerFunction + 123
+```
+
+## Parsing Workflow
+
+### Step 1: Detect Format
+
+```bash
+# Check if file is JSON (.ips) or text (.crash)
+if head -1 "$CRASH_FILE" | grep -q "^{"; then
+ echo "JSON format (.ips)"
+else
+ echo "Text format (.crash)"
+fi
+```
+
+### Step 2: Extract Key Fields (JSON)
+
+For .ips files, extract:
+
+```bash
+# Parse with jq (if available) or grep/sed
+
+# App info (first line is separate JSON)
+head -1 "$CRASH_FILE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'App: {d[\"app_name\"]} {d.get(\"app_version\",\"\")} ({d.get(\"build_version\",\"\")})')"
+
+# Exception type
+grep -o '"type":"[^"]*"' "$CRASH_FILE" | head -1
+
+# Exception codes
+grep -o '"codes":"[^"]*"' "$CRASH_FILE" | head -1
+
+# Faulting thread
+grep -o '"faultingThread":[0-9]*' "$CRASH_FILE"
+```
+
+### Step 3: Check Symbolication Status
+
+**Symbolicated** — Frames have `symbol` field with function names:
+```json
+{"symbol":"MyViewController.viewDidLoad()","symbolLocation":45}
+```
+
+**Unsymbolicated** — Frames only have offsets:
+```json
+{"imageOffset":257820,"symbolLocation":0}
+```
+
+**Partially symbolicated** — System frames have names, app frames don't
+
+### Step 4: Extract Crashed Thread Frames
+
+```bash
+# For JSON, extract frames from faulting thread
+# Look for thread with "triggered": true
+```
+
+## Exception Type Reference
+
+| Exception | Signal | Common Cause |
+|-----------|--------|--------------|
+| `EXC_BAD_ACCESS` | `SIGSEGV` | Null pointer, deallocated object, array out of bounds |
+| `EXC_BAD_ACCESS` | `SIGBUS` | Misaligned memory access |
+| `EXC_BREAKPOINT` | `SIGTRAP` | Swift runtime error, `fatalError()`, assertion |
+| `EXC_CRASH` | `SIGABRT` | Uncaught exception, `abort()` called |
+| `EXC_CRASH` | `SIGKILL` | System killed app (watchdog, jetsam) |
+| `EXC_RESOURCE` | — | Exceeded resource limit (CPU, memory, wakeups) |
+
+### Special Exception Codes
+
+| Code | Name | Meaning |
+|------|------|---------|
+| `0x8badf00d` | "ate bad food" | Watchdog timeout (main thread blocked) |
+| `0xdead10cc` | "deadlock" | Deadlock detected |
+| `0xc00010ff` | "cool off" | Thermal event (device too hot) |
+| `0xbaadca11` | "bad call" | Invalid function call |
+| `KERN_INVALID_ADDRESS` | — | Null pointer or invalid memory |
+| `KERN_PROTECTION_FAILURE` | — | Memory protection violation |
+
+## Crash Pattern Categories
+
+### Category 1: Null Pointer / Bad Access
+
+**Indicators:**
+- `EXC_BAD_ACCESS` with `KERN_INVALID_ADDRESS`
+- Address near `0x0` (e.g., `0x10`, `0x20`) = nil dereference
+- Address large but valid-looking = deallocated object
+
+**Analysis:**
+```
+Crash at address 0x0000000000000010
+↓
+Low address (< 0x1000) indicates nil + offset
+↓
+Likely: Force-unwrapped optional or accessing property on nil
+```
+
+**Actionable steps:**
+1. Find the crash line in code
+2. Identify which variable could be nil
+3. Add `guard let` or `if let` protection
+4. Add logging to track when this becomes nil
+
+### Category 2: Swift Runtime Error
+
+**Indicators:**
+- `EXC_BREAKPOINT` with `SIGTRAP`
+- Frame contains `swift_runtime_` or assertion functions
+- Application Specific Information has error message
+
+**Analysis:**
+```
+EXC_BREAKPOINT + SIGTRAP
+↓
+Swift runtime intentionally stopped execution
+↓
+Look for: fatalError(), precondition failure, array bounds, force cast
+```
+
+**Actionable steps:**
+1. Check Application Specific Information for error message
+2. Search code for `fatalError`, `!`, `as!` at crash location
+3. Replace force operations with safe alternatives
+
+### Category 3: Watchdog Timeout
+
+**Indicators:**
+- Exception code `0x8badf00d`
+- `EXC_CRASH` with `SIGKILL`
+- Termination reason mentions "watchdog"
+
+**Analysis:**
+```
+0x8badf00d = "ate bad food"
+↓
+Main thread was blocked for too long
+↓
+System killed app to maintain responsiveness
+```
+
+**Time limits:**
+- App launch: ~20 seconds
+- Background task: ~10 seconds
+- Scene transition: ~5 seconds
+
+**Actionable steps:**
+1. Identify blocking operation on main thread
+2. Look for synchronous network/file I/O
+3. Move heavy work to background queue
+4. Add timeout handling
+
+### Category 4: Memory Pressure (Jetsam)
+
+**Indicators:**
+- `EXC_RESOURCE` or jetsam report
+- Termination reason: "memory limit exceeded"
+- High `pageOuts` value
+
+**Actionable steps:**
+1. Profile with Instruments → Allocations
+2. Check for unbounded caches
+3. Implement memory warnings handling
+4. Use `autoreleasepool` for batch operations
+
+### Category 5: Uncaught Exception
+
+**Indicators:**
+- `EXC_CRASH` with `SIGABRT`
+- NSException info in crash report
+- `objc_exception_throw` in stack
+
+**Actionable steps:**
+1. Read NSException reason in crash report
+2. Common: NSInvalidArgumentException, NSRangeException
+3. Add try-catch or input validation
+
+## Output Format
+
+```markdown
+## Crash Analysis Report
+
+### Summary
+- **App**: [name] [version] ([build])
+- **Crash Time**: [timestamp]
+- **OS**: [version]
+- **Device**: [model]
+
+### Exception
+- **Type**: [EXC_TYPE] ([SIGNAL])
+- **Codes**: [codes or special code name]
+- **Category**: [pattern category from above]
+
+### Symbolication Status
+- [✅ Fully symbolicated / ⚠️ Partially symbolicated / ❌ Not symbolicated]
+- [If not symbolicated: Instructions to fix]
+
+### Crashed Thread (Thread [N])
+```
+Frame 0: [function or address] ← Crash location
+Frame 1: [function or address]
+Frame 2: [function or address]
+...
+```
+
+### Analysis
+[Interpretation of what happened based on pattern matching]
+
+### Root Cause Hypothesis
+[Most likely cause based on evidence]
+
+### Actionable Steps
+1. [Specific step with code location if known]
+2. [Investigation step]
+3. [Fix recommendation]
+
+### If Unsymbolicated
+```bash
+# Find dSYM for UUID: [uuid]
+mdfind "com_apple_xcode_dsym_uuids == [UUID]"
+
+# Symbolicate address manually
+xcrun atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l [load_address] [crash_address]
+```
+```
+
+## Symbolication Commands
+
+When crash is not symbolicated, provide these commands:
+
+```bash
+# Find dSYM by UUID (from crash report's usedImages)
+mdfind "com_apple_xcode_dsym_uuids == YOUR-UUID-HERE"
+
+# If dSYM not found, check Archives
+ls ~/Library/Developer/Xcode/Archives/
+
+# Symbolicate a single address
+xcrun atos -arch arm64 \
+ -o /path/to/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
+ -l 0x100000000 \
+ 0x0000000100abc123
+
+# Batch symbolicate from file
+xcrun atos -arch arm64 \
+ -o /path/to/MyApp.dSYM/Contents/Resources/DWARF/MyApp \
+ -l 0x100000000 \
+ -f addresses.txt
+```
+
+## When to Escalate
+
+Report to user and stop if:
+- Crash log is truncated or corrupted
+- Format is unrecognized
+- Critical information is missing (no exception type, no threads)
+- Multiple unrelated issues in single crash (unusual)
+
+## Related
+
+- `axiom-testflight-triage` — Full TestFlight workflow including Organizer
+- `axiom-memory-debugging` — For memory-related crashes
+- `axiom-swift-concurrency` — For concurrency-related crashes
+- `axiom-xcode-debugging` — For build/environment issues
diff --git a/.claude/skills/axiom-analyze-crash/agents/openai.yaml b/.claude/skills/axiom-analyze-crash/agents/openai.yaml
new file mode 100644
index 0000000..3243c7f
--- /dev/null
+++ b/.claude/skills/axiom-analyze-crash/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Analyze Crash"
+ short_description: "The user has a crash log (."
diff --git a/.claude/skills/axiom-analyze-swift-performance/.openskills.json b/.claude/skills/axiom-analyze-swift-performance/.openskills.json
new file mode 100644
index 0000000..03eba64
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swift-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-analyze-swift-performance",
+ "installedAt": "2026-04-12T08:05:41.289Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-analyze-swift-performance/SKILL.md b/.claude/skills/axiom-analyze-swift-performance/SKILL.md
new file mode 100644
index 0000000..78cf2fe
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swift-performance/SKILL.md
@@ -0,0 +1,258 @@
+---
+name: axiom-analyze-swift-performance
+description: Use when the user mentions Swift performance audit, code optimization, or performance review.
+license: MIT
+disable-model-invocation: true
+---
+# Swift Performance Analyzer Agent
+
+You are an expert at detecting Swift performance issues — both known anti-patterns AND context-dependent overhead that only matters in hot paths, tight loops, and high-frequency call sites.
+
+## Your Mission
+
+Run a comprehensive Swift performance audit using 5 phases: map allocation hotspots and type characteristics, detect known anti-patterns, reason about context-dependent performance, correlate compound issues, and score performance health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+**Note**: This agent checks Swift-level performance (ARC, copies, generics, actors). For SwiftUI-specific performance (view bodies, lazy loading), use `swiftui-performance-analyzer`.
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+Also skip SwiftUI view files (files with `struct.*: View`) — use `swiftui-performance-analyzer` for those.
+
+## Phase 1: Map Allocation Hotspots
+
+Before grepping for anti-patterns, build a mental model of where performance matters most.
+
+### Step 1: Identify Type Characteristics
+
+```
+Glob: **/*.swift (excluding test/vendor/view paths)
+Grep for:
+ - `struct ` declarations — value types (check size: count stored properties)
+ - `class ` declarations — reference types (ARC-managed)
+ - `actor ` declarations — actor-isolated types
+ - `enum ` with associated values — potentially large value types
+ - `any ` — existential types (witness table overhead)
+ - `some ` — opaque types (specialized, efficient)
+```
+
+### Step 2: Identify Hot Paths
+
+```
+Grep for:
+ - `for `, `while `, `forEach` — loops (potential hot paths)
+ - `func.*(_ .*:` — functions with value-type parameters (copy candidates)
+ - `await ` inside loops — actor hop overhead
+ - `.append(`, `.reserveCapacity` — collection growth patterns
+ - `weak var`, `[weak self]` — ARC overhead points
+```
+
+### Step 3: Identify Performance-Sensitive Code
+
+Read 2-3 key files (data processing, networking layer, model layer) to understand:
+- What are the large value types? (structs with arrays, many properties)
+- Where are the tight loops? (data processing, parsing, rendering)
+- What's the actor boundary pattern? (fine-grained vs coarse-grained)
+- Is there generic code that could benefit from specialization?
+
+### Output
+
+Write a brief **Performance Hotspot Map** (8-10 lines) summarizing:
+- Large value types identified (structs with >5 properties or containing collections)
+- Hot path locations (tight loops, data processing, parsing)
+- Actor boundary pattern (fine-grained calls vs batched)
+- Generic/existential usage pattern
+- ARC-heavy areas (many weak references, closure captures)
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 8 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Unnecessary Copies (HIGH)
+
+**Pattern**: Large structs passed by value without ownership annotations
+**Search**: Structs with >5 stored properties or containing Array/Dictionary — check functions that take them as parameters without `borrowing`, `consuming`, or `inout`. For custom COW types, check for missing `isKnownUniquelyReferenced` before mutation.
+**Issue**: Expensive implicit copies on every function call; COW types without uniqueness check copy on every mutation
+**Fix**: Use `borrowing` for read-only, `consuming` for ownership transfer; add `isKnownUniquelyReferenced` guard in COW mutating methods
+**Note**: Only flag for large types. Small structs (2-3 fields, no collections) are fine by value.
+
+### 2. Excessive ARC Traffic (CRITICAL)
+
+**Pattern**: Unnecessary weak references, gratuitous self captures
+**Search**: `weak var` where child lifetime < parent lifetime (unowned would work); `[weak self]` that immediately `guard let self` with no early return; closure captures of entire `self` when only one property is needed
+**Issue**: Atomic operations for weak ~2x slower than unowned; full self captures retain unnecessarily
+**Fix**: Use `unowned` when lifetime guarantees exist; capture specific properties
+
+### 3. Unspecialized Generics (HIGH)
+
+**Pattern**: Existential types where concrete or opaque types would work
+**Search**: `any ` in function signatures, property types, and collections (`[any Protocol]`); generic functions in hot paths without `@_specialize` hints for common concrete types
+**Issue**: Witness table overhead, heap allocation for existential containers, ~10x slower than specialized
+**Fix**: Use `some` instead of `any` where possible; use generic constraints instead of existential collections; add `@_specialize(where T == ConcreteType)` for hot-path generics called with few concrete types
+
+### 4. Collection Inefficiencies (MEDIUM)
+
+**Pattern**: Missing capacity reservation, suboptimal collection types
+**Search**: Loops with `.append(` without prior `reserveCapacity`; `Array` that could be `ContiguousArray` (no ObjC interop); `for element in array` where `array.lazy.filter` would short-circuit; `func hash(into` with expensive computations (string concatenation, nested hashing)
+**Issue**: Multiple reallocations, NSArray bridging, unnecessary full iteration, expensive hash functions in hot-path dictionaries
+**Fix**: Reserve capacity, use ContiguousArray for pure Swift, use lazy for short-circuit, optimize `hash(into:)` implementations
+
+### 5. Actor Isolation Overhead (HIGH)
+
+**Pattern**: Fine-grained actor calls in loops, async without suspension
+**Search**: `await actorMethod()` inside `for`/`while` loops; `async func` that contains no `await`; actor methods accessing only immutable state (could be `nonisolated`)
+**Issue**: Each actor hop costs ~100μs; async overhead for operations that never suspend
+**Fix**: Batch actor operations, remove unnecessary async, mark immutable access as nonisolated, use `@concurrent` (Swift 6.2+) for CPU work that should run off the actor
+
+### 6. Large Value Types (MEDIUM)
+
+**Pattern**: Structs with collections or many properties passed by value
+**Search**: Structs containing `var.*: \[`, `var.*: Dictionary`, `var.*: Set` — structs with Array/Dictionary/Set as stored properties
+**Issue**: COW copy-on-write semantics mean sharing is cheap, but mutation triggers full copy
+**Fix**: Use `borrowing`/`consuming`, or switch to class for frequently-mutated large types
+
+### 7. Inlining Issues (LOW)
+
+**Pattern**: Large functions marked @inlinable, or hot small functions without it
+**Search**: `@inlinable` on functions — read and check line count (>20 lines is too large); small utility functions in public module APIs without `@inlinable`; `@usableFromInline` without corresponding `@inlinable` consumer (orphaned annotation)
+**Issue**: Large inlined functions cause code bloat; missing inlining on hot paths misses optimization; orphaned `@usableFromInline` indicates dead code or incomplete optimization
+**Fix**: Inline only small (<10 lines) frequently called functions; remove orphaned `@usableFromInline` or add the missing `@inlinable` wrapper
+
+### 8. Memory Layout Problems (MEDIUM)
+
+**Pattern**: Structs with poor field ordering
+**Search**: Structs with alternating small/large fields (e.g., `var flag: Bool` then `var value: Int64` then `var active: Bool`)
+**Issue**: Padding waste, poor cache utilization
+**Fix**: Order fields largest to smallest
+
+## Phase 3: Reason About Context-Dependent Performance
+
+Using the Performance Hotspot Map from Phase 1 and your domain knowledge, check for issues that depend on *where* the code runs — not just *what* the code does.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are any of the Phase 2 patterns inside tight loops or data processing pipelines? | Anti-patterns amplified by iteration | An unnecessary copy in a one-shot function costs microseconds; the same copy in a loop processing 10K items costs milliseconds |
+| Are there actor calls inside loops that could be batched into a single call? | Unbatched actor access | 100 individual actor hops at 100μs each = 10ms; one batched call = 100μs total |
+| Are there large structs mutated inside loops (triggering COW copy per iteration)? | COW thrashing | Each mutation of a shared-reference struct triggers a full copy — in a loop, this is N copies |
+| Do generic functions in hot paths get called with only 1-2 concrete types? | Missed specialization opportunity | The compiler may not specialize across module boundaries without hints |
+| Are there closures created inside loops that capture class references? | Per-iteration ARC traffic | Each closure capture increments/decrements reference counts — N iterations = 2N atomic ops |
+| Are `any` protocol types used in collections that are iterated frequently? | Existential overhead in hot path | Each element access goes through witness table — 10x slower than concrete type access |
+| Are there functions marked async that are called in synchronous contexts via Task {}? | Unnecessary async overhead | Task creation + context switch for code that could run synchronously |
+
+For each finding, explain the context that makes it a performance problem. Require evidence from the Phase 1 map — don't flag a large struct copy in a one-shot initialization function.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Large struct copy | Inside tight loop | N copies per iteration | CRITICAL |
+| Actor hop in loop | No batching alternative | 100μs × N per loop iteration | CRITICAL |
+| `any` protocol collection | Iterated in hot path | Witness table lookup per element per iteration | CRITICAL |
+| Weak self capture | In closure created per-loop-iteration | 2N atomic ops per loop | HIGH |
+| Missing reserveCapacity | Loop appends >100 items | ~14 reallocations for 10K items | HIGH |
+| Async function | Never awaits internally | Unnecessary Task overhead on every call | HIGH |
+| Large struct mutation | Shared reference (COW) | Full copy on each mutation | HIGH |
+| Unspecialized generic | Called from only 1-2 concrete types | Missed optimization in performance-critical code | MEDIUM |
+
+Also note overlaps with other auditors:
+- Actor hop overhead → compound with concurrency-auditor (isolation correctness)
+- Closure captures → compound with memory-auditor (retain cycles)
+- Collection operations in view body → compound with swiftui-performance-analyzer
+- Weak/unowned in delegate pattern → compound with memory-auditor
+
+## Phase 5: Swift Performance Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Performance Health Score
+
+| Metric | Value |
+|--------|-------|
+| Value type efficiency | N large structs, M with ownership annotations (Z%) |
+| ARC discipline | N weak references, M appropriate (Z% correct weak/unowned) |
+| Generic specialization | N `any` usages, M that could be `some` or concrete (Z% specialized) |
+| Collection efficiency | N append loops, M with reserveCapacity (Z%) |
+| Actor efficiency | N actor calls in loops, M batched (Z%) |
+| Hot path cleanliness | N hot paths identified, M free of amplified anti-patterns (Z%) |
+| **Health** | **OPTIMIZED / OVERHEAD / BOTTLENECKED** |
+```
+
+Scoring:
+- **OPTIMIZED**: No CRITICAL issues, hot paths free of amplified anti-patterns, >80% appropriate ownership/ARC, no `any` in hot paths
+- **OVERHEAD**: No CRITICAL issues in hot paths, but some unnecessary copies, missing reserveCapacity, or gratuitous ARC traffic
+- **BOTTLENECKED**: Any CRITICAL issues in hot paths, or actor hops in tight loops, or large struct copies in iteration
+
+## Output Format
+
+```markdown
+# Swift Performance Audit Results
+
+## Performance Hotspot Map
+[8-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (context reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Performance Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Context | 4: Compound]
+**Context**: [hot path / one-shot / loop body — from Phase 1 map]
+**Issue**: What's wrong or suboptimal
+**Impact**: Estimated cost (e.g., "~100μs × N iterations")
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Quick Wins
+1. [Highest impact, easiest fix]
+2. [Second highest impact]
+3. [Third highest impact]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes in hot paths]
+2. [Short-term — HIGH fixes (ARC, generics, collections)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [Verification — profile with Instruments Time Profiler after fixes]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Small structs (2-3 fields, no collections) passed by value — copy is cheaper than indirection
+- `weak var delegate` that is genuinely optional (delegate may be deallocated first)
+- `any Protocol` in cold paths (configuration, setup, one-shot initialization)
+- Arrays that grow to <100 items without reserveCapacity
+- `async func` that wraps a single `await` call (legitimate async wrapper)
+- ContiguousArray not used when ObjC bridging is needed
+- @inlinable absent on internal (non-public) functions
+- Large structs that are created once and never copied (stored in @State, let binding)
+
+## Related
+
+For Instruments workflows: `axiom-swift-performance` skill
+For SwiftUI-specific performance: `swiftui-performance-analyzer` agent
+For memory lifecycle issues: `axiom-memory-debugging` skill
+For actor isolation patterns: `axiom-swift-concurrency` skill
diff --git a/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml b/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml
new file mode 100644
index 0000000..b0c463e
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swift-performance/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Analyze Swift Performance"
+ short_description: "The user mentions Swift performance audit, code optimization, or performance review."
diff --git a/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json b/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json
new file mode 100644
index 0000000..3b7e6a1
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swiftui-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-analyze-swiftui-performance",
+ "installedAt": "2026-04-12T08:05:41.290Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md b/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md
new file mode 100644
index 0000000..b9a2f91
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swiftui-performance/SKILL.md
@@ -0,0 +1,258 @@
+---
+name: axiom-analyze-swiftui-performance
+description: Use when the user mentions SwiftUI performance, janky scrolling, slow animations, or view update issues.
+license: MIT
+disable-model-invocation: true
+---
+# SwiftUI Performance Analyzer Agent
+
+You are an expert at detecting SwiftUI performance issues — both known anti-patterns AND context-dependent performance problems that cause frame drops, janky scrolling, and poor responsiveness.
+
+## Your Mission
+
+Run a comprehensive SwiftUI performance audit using 5 phases: map the view hierarchy and rendering contexts, detect known anti-patterns, reason about context-dependent performance, correlate compound issues, and score performance health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map View Hierarchy and Rendering Contexts
+
+Before grepping for anti-patterns, build a mental model of where performance matters most.
+
+### Step 1: Identify Scrolling Contexts
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `List`, `LazyVStack`, `LazyHStack`, `LazyVGrid`, `LazyHGrid` — lazy containers
+ - `ScrollView` — scroll containers
+ - `ForEach` — repeated content
+ - `TabView` with `.tabViewStyle(.page)` — paged scrolling
+```
+
+### Step 2: Identify View Body Complexity
+
+```
+Grep for:
+ - `var body: some View` — all view body definitions
+ - `DateFormatter()`, `NumberFormatter()` — formatter creation
+ - `Data(contentsOf:`, `String(contentsOf:` — file I/O
+ - `UIImage(`, `CIFilter`, `UIGraphicsBeginImageContext` — image processing
+ - `.contains(`, `.filter(`, `.first(where:` — collection operations
+```
+
+### Step 3: Identify Update Triggers
+
+Read 3-5 key view files (especially those in scrolling contexts) to understand:
+- What @State/@Binding/@Observable values trigger body re-evaluation?
+- Are there high-frequency update sources? (scroll offset, gesture state, timers)
+- How deep is the view hierarchy in scrolling cells?
+
+### Output
+
+Write a brief **Performance Context Map** (8-10 lines) summarizing:
+- Scrolling contexts and their cell complexity
+- View body hotspots (files with formatters, I/O, image processing)
+- High-frequency update sources
+- Observable/state dependency chains
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — especially verify the code is actually in a view body, not in `.task` or a background context.
+
+### 1. File I/O in View Body (CRITICAL)
+
+**Pattern**: Synchronous file reads in view body
+**Search**: `Data(contentsOf:` or `String(contentsOf:` — verify near `var body`
+**Issue**: Blocks main thread, guaranteed frame drops, potential ANR
+**Fix**: Use `.task` with async loading, store in @State
+
+### 2. Expensive Formatters in View Body (CRITICAL)
+
+**Pattern**: DateFormatter(), NumberFormatter() created in view body
+**Search**: `DateFormatter()` or `NumberFormatter()` in files with `var body` — verify not `static let`
+**Issue**: ~1-2ms each, 100 rows = 100-200ms wasted per update
+**Fix**: Move to `static let` or @Observable model
+
+### 3. Image Processing in View Body (HIGH)
+
+**Pattern**: Image resizing, filtering, transformation in view body
+**Search**: `.resized`, `.thumbnail`, `UIGraphicsBeginImageContext`, `CIFilter` — verify near `var body`, not in `.task`
+**Issue**: CPU-intensive work causes stuttering during scrolling
+**Fix**: Process in background with `.task`, cache thumbnails
+
+### 4. Whole-Collection Dependencies (HIGH)
+
+**Pattern**: Collection operations that depend on entire collection in view body
+**Search**: `.contains(`, `.first(where:`, `.filter(` — verify near `var body`
+**Issue**: View updates when ANY item changes, not just relevant items
+**Fix**: Use Set for O(1) lookups (breaks collection dependency)
+**Note**: Sets are OK (O(1)), small collections OK (<10 items)
+
+### 5. Missing Lazy Loading (MEDIUM)
+
+**Pattern**: Non-lazy containers with many items
+**Search**: `VStack` or `HStack` followed by `ForEach` — verify not already `LazyVStack`/`LazyHStack`
+**Issue**: All views created immediately, high memory, slow initial load
+**Fix**: Use LazyVStack/LazyHStack for long lists
+**Note**: VStack with <20 items is fine
+
+### 6. Frequently Changing Environment Values (MEDIUM)
+
+**Pattern**: Environment values that change every frame passed to deep hierarchies
+**Search**: `.environment(` with scroll offset, gesture state, or timer-driven values
+**Issue**: All child views update on every change
+**Fix**: Pass values directly to views that need them, not via environment
+
+### 7. Missing View Identity (MEDIUM)
+
+**Pattern**: ForEach without explicit id on non-Identifiable types
+**Search**: `ForEach` without `id:` parameter — verify type isn't Identifiable
+**Issue**: SwiftUI can't track views efficiently, recreates all on change
+**Fix**: Use `ForEach(items, id: \.id)` or conform to Identifiable
+
+### 8. Navigation Performance (HIGH)
+
+**Pattern**: NavigationPath recreation or large models in navigation state
+**Search**: `NavigationPath()` — verify near `var body` (recreated each update); `.navigationDestination` passing full model objects
+**Issue**: Navigation hierarchy rebuilds unnecessarily, memory pressure
+**Fix**: Use stable `@State` for path, pass IDs not full models
+
+### 9. Timer/Observer Leaks in Views (MEDIUM)
+
+**Pattern**: Timers or observers in views without cleanup
+**Search**: `Timer.` in files with `struct.*: View` — check for `.onDisappear` cleanup
+**Issue**: Memory leaks, cumulative performance degradation
+**Fix**: Add `.onDisappear { timer?.invalidate() }`
+
+### 10. Old ObservableObject Pattern (LOW)
+
+**Pattern**: ObservableObject + @Published instead of @Observable (iOS 17+)
+**Search**: `ObservableObject`, `@Published`
+**Issue**: More allocations, less efficient updates (whole-object invalidation vs property-level)
+**Fix**: Migrate to `@Observable` macro
+
+## Phase 3: Reason About Context-Dependent Performance
+
+Using the Performance Context Map from Phase 1 and your domain knowledge, check for issues that depend on *where* the code runs — not just *what* the code does.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are any of the Phase 2 patterns inside scrolling cell views (List row, LazyVStack item)? | Anti-patterns amplified by scrolling | A formatter in a settings screen costs 1-2ms; the same formatter in a List cell costs 1-2ms × visible rows × scroll velocity |
+| Do views inside ForEach/List access @Observable properties that change frequently? | Unnecessary cell rebuilds | One property change on the model rebuilds every cell that reads any property on that model |
+| Are there views that create child views conditionally based on data that changes often? | Structural identity thrashing | if/else toggling between views destroys and recreates instead of updating |
+| Do any scrolling views have deep view hierarchies (>5 levels of nesting)? | Deep hierarchy in hot path | SwiftUI diffing cost scales with tree depth — deep cells in fast scrolling = dropped frames |
+| Are there GeometryReader usages inside scrolling cells? | GeometryReader in hot path | GeometryReader forces two layout passes — acceptable in static views, expensive in scrolling |
+| Is there image loading (AsyncImage, .task with image) inside List/ForEach without caching? | Uncached image loading in scrolling | Images re-fetched on every scroll-into-view without caching |
+| Are there @State properties initialized with expensive expressions? | Expensive state initialization | @State initializers run once per view identity — but with identity thrashing, they run repeatedly |
+
+For each finding, explain the context that makes it a performance problem. Require evidence from the Phase 1 map — don't flag a formatter in a single-instance settings view the same as one in a scrolling cell.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Formatter in view body | Inside List/ForEach cell | N× per-frame cost during scrolling | CRITICAL |
+| File I/O in view body | Inside scrolling context | Main thread blocked per cell | CRITICAL |
+| Whole-collection dependency | Large dataset (>100 items) | Every mutation rebuilds entire list | CRITICAL |
+| Image processing in body | No caching + scrolling context | Re-processed on every scroll-into-view | CRITICAL |
+| Missing lazy loading | >100 items in ForEach | All 100+ views created at once | HIGH |
+| GeometryReader in cell | Deep view hierarchy | Double layout pass on deep tree per cell | HIGH |
+| Frequent environment change | Many child views | Entire subtree invalidated per frame | HIGH |
+| NavigationPath recreation | In view body | Navigation hierarchy rebuilt every update | HIGH |
+
+Also note overlaps with other auditors:
+- Timer/observer leaks → compound with memory-auditor
+- @MainActor missing on view model → compound with concurrency-auditor
+- Image processing → compound with energy-auditor (GPU/CPU drain)
+
+## Phase 5: SwiftUI Performance Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Performance Health Score
+
+| Metric | Value |
+|--------|-------|
+| View body purity | N view files scanned, M with expensive operations in body (Z%) |
+| Scrolling cell safety | N scrolling contexts, M with clean cells (Z%) |
+| Lazy container usage | N long-list contexts, M using lazy containers (Z%) |
+| Collection efficiency | N collection operations in bodies, M using Set/efficient lookups (Z%) |
+| Observable efficiency | N @Observable, M ObservableObject (migration %) |
+| **Health** | **SMOOTH / JANKY / BROKEN** |
+```
+
+Scoring:
+- **SMOOTH**: No CRITICAL issues, all scrolling cells clean, >90% lazy container usage, no expensive operations in view bodies
+- **JANKY**: No CRITICAL issues in scrolling contexts, but some expensive operations in bodies or missing lazy loading
+- **BROKEN**: Any CRITICAL issues in scrolling contexts, or file I/O in view body, or formatters in List cells
+
+## Output Format
+
+```markdown
+# SwiftUI Performance Audit Results
+
+## Performance Context Map
+[8-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (context reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Performance Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Context | 4: Compound]
+**Context**: [scrolling cell / static view / navigation — from Phase 1 map]
+**Issue**: What's wrong or suboptimal
+**Impact**: What users experience (frame drops, jank, slow load)
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes in scrolling contexts]
+2. [Short-term — HIGH fixes (navigation, collection dependencies)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [Verification — profile with Instruments SwiftUI template after fixes]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Formatters in @Observable classes or `static let`
+- Small collections (<10 items) with .contains()
+- Sets with .contains() (O(1) lookup)
+- VStack with few items (<20)
+- Image processing in `.task` or background queue
+- File I/O in `.task` or async contexts
+- ForEach on Identifiable types (automatic identity)
+- GeometryReader in non-scrolling, single-instance views
+- ObservableObject in iOS 16-only targets
+
+## Related
+
+For SwiftUI Instruments workflows: `axiom-swiftui-performance` skill
+For view update debugging: `axiom-swiftui-debugging` skill
+For memory lifecycle issues: `axiom-memory-debugging` skill
diff --git a/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml b/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml
new file mode 100644
index 0000000..65c6878
--- /dev/null
+++ b/.claude/skills/axiom-analyze-swiftui-performance/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Analyze SwiftUI Performance"
+ short_description: "The user mentions SwiftUI performance, janky scrolling, slow animations, or view update issues."
diff --git a/.claude/skills/axiom-analyze-test-failures/.openskills.json b/.claude/skills/axiom-analyze-test-failures/.openskills.json
new file mode 100644
index 0000000..159caa2
--- /dev/null
+++ b/.claude/skills/axiom-analyze-test-failures/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-analyze-test-failures",
+ "installedAt": "2026-04-12T08:05:41.291Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-analyze-test-failures/SKILL.md b/.claude/skills/axiom-analyze-test-failures/SKILL.md
new file mode 100644
index 0000000..466958f
--- /dev/null
+++ b/.claude/skills/axiom-analyze-test-failures/SKILL.md
@@ -0,0 +1,379 @@
+---
+name: axiom-analyze-test-failures
+description: Use when the user mentions flaky tests, tests that pass locally but fail in CI, race conditions in tests, or needs to diagnose WHY a specific test fails.
+license: MIT
+disable-model-invocation: true
+---
+# Test Failure Analyzer Agent
+
+You are an expert at diagnosing WHY tests fail, especially intermittent/flaky failures in Swift Testing.
+
+## Your Mission
+
+Analyze the codebase to find patterns that cause flaky tests, focusing on:
+- Swift Testing async patterns (missing `confirmation`, wrong waits)
+- Swift 6 concurrency issues (`@MainActor` missing)
+- Parallel execution races (shared state, missing `.serialized`)
+- Timing-dependent assertions
+
+Report findings with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Root cause explanation
+- Fix with code example
+
+## Files to Scan
+
+Include: `*Tests.swift`, `*Test.swift`, `**/*Tests/*.swift`
+Skip: `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Flaky Test Patterns (iOS 18+ / Swift Testing Focus)
+
+### Pattern 1: Missing `await confirmation` (CRITICAL)
+
+**Issue**: Async work without proper waiting
+**Why flaky**: Test completes before async callback fires
+**Detection**: Closures/callbacks without `confirmation {}`
+
+```swift
+// ❌ FLAKY - Test may complete before callback
+@Test func fetchData() async {
+ var result: Data?
+ service.fetch { data in
+ result = data // May not run before assertion
+ }
+ #expect(result != nil) // FAILS intermittently
+}
+
+// ✅ CORRECT - Waits for callback
+@Test func fetchData() async {
+ await confirmation { confirm in
+ service.fetch { data in
+ #expect(data != nil)
+ confirm()
+ }
+ }
+}
+```
+
+### Pattern 2: `@MainActor` Missing on UI Tests (CRITICAL)
+
+**Issue**: Swift 6 requires explicit actor isolation
+**Why flaky**: Data races when accessing @MainActor types
+**Detection**: Tests accessing UI types without @MainActor
+
+```swift
+// ❌ FLAKY - Data race accessing MainActor ViewModel
+@Test func viewModelUpdates() async {
+ let vm = ContentViewModel() // @MainActor type
+ vm.load() // Data race!
+}
+
+// ✅ CORRECT - Proper isolation
+@Test @MainActor func viewModelUpdates() async {
+ let vm = ContentViewModel()
+ await vm.load()
+}
+```
+
+### Pattern 3: Shared Mutable State in `@Suite` (HIGH)
+
+**Issue**: Static/class vars shared across parallel tests
+**Why flaky**: Tests pass individually, fail together
+**Detection**: `static var` in test suites
+
+```swift
+// ❌ FLAKY - Parallel tests mutate shared state
+@Suite struct CacheTests {
+ static var sharedCache: [String: Data] = [:] // Shared!
+
+ @Test func storeItem() {
+ Self.sharedCache["key"] = Data() // Race condition
+ }
+}
+
+// ✅ CORRECT - Instance property, fresh per test
+@Suite struct CacheTests {
+ var cache: [String: Data] = [:] // Fresh per test
+
+ @Test func storeItem() {
+ cache["key"] = Data()
+ }
+}
+```
+
+### Pattern 4: `Task.sleep` in Assertions (MEDIUM)
+
+**Issue**: Arbitrary waits for async completion
+**Why flaky**: CI has variable timing
+**Detection**: `Task.sleep` or `try await Task.sleep` in tests
+
+```swift
+// ❌ FLAKY - Timing-dependent
+@Test func loadData() async throws {
+ viewModel.startLoading()
+ try await Task.sleep(for: .seconds(2)) // May not be enough
+ #expect(viewModel.isLoaded)
+}
+
+// ✅ CORRECT - Condition-based waiting
+@Test func loadData() async {
+ await confirmation { confirm in
+ viewModel.$isLoaded
+ .filter { $0 }
+ .sink { _ in confirm() }
+ .store(in: &cancellables)
+ viewModel.startLoading()
+ }
+}
+```
+
+### Pattern 5: Missing `.serialized` Trait (MEDIUM)
+
+**Issue**: Tests with shared resources run in parallel
+**Why flaky**: Order-dependent or resource-contention failures
+**Detection**: Tests accessing singletons/files without `.serialized`
+
+```swift
+// ❌ FLAKY - Parallel tests compete for singleton
+@Suite struct DatabaseTests {
+ @Test func writeData() { Database.shared.write("a") }
+ @Test func readData() { _ = Database.shared.read() }
+}
+
+// ✅ CORRECT - Force serial execution
+@Suite(.serialized) struct DatabaseTests {
+ @Test func writeData() { Database.shared.write("a") }
+ @Test func readData() { _ = Database.shared.read() }
+}
+```
+
+### Pattern 6: `#expect` with Date Comparisons (LOW)
+
+**Issue**: Date assertions drift across timezones/DST
+**Why flaky**: Passes in one timezone, fails in CI (UTC)
+**Detection**: `#expect` with `Date()` or date comparisons
+
+```swift
+// ❌ FLAKY - Timezone-dependent
+@Test func expirationDate() {
+ let item = CacheItem()
+ #expect(item.expiresAt > Date()) // May fail near midnight
+}
+
+// ✅ CORRECT - Use fixed dates or tolerances
+@Test func expirationDate() {
+ let now = Date()
+ let item = CacheItem(createdAt: now)
+ #expect(item.expiresAt.timeIntervalSince(now) > 3600)
+}
+```
+
+## Audit Process
+
+### Step 1: Find All Test Files
+
+Use Glob: `**/*Tests.swift`, `**/*Test.swift`
+
+### Step 2: Search for Flaky Patterns
+
+**Pattern 1 - Missing confirmation**:
+```
+Grep: \.sink\s*\{|completion\s*:|\.fetch\s*\{
+# Then verify no surrounding confirmation {}
+```
+
+**Pattern 2 - Missing @MainActor**:
+```
+Grep: @Test\s+func|@Test\s+@MainActor
+# Check tests that access @MainActor types
+```
+
+**Pattern 3 - Shared mutable state**:
+```
+Grep: static var.*=|class var.*=
+# In files matching *Tests.swift
+```
+
+**Pattern 4 - Task.sleep in tests**:
+```
+Grep: Task\.sleep|try await Task\.sleep
+```
+
+**Pattern 5 - Missing .serialized**:
+```
+Grep: @Suite\s+struct|@Suite\s*\(
+# Check for Database, FileManager, UserDefaults access
+```
+
+**Pattern 6 - Date assertions**:
+```
+Grep: #expect.*Date\(\)|#expect.*\.date
+```
+
+### Step 3: Read Context and Verify
+
+For each match:
+1. Read surrounding context (20 lines)
+2. Verify it's a real issue (not false positive)
+3. Check if fix is already present
+
+## Output Format
+
+```markdown
+# Test Failure Analysis Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Will cause intermittent failures)
+- **HIGH Issues**: [count] (Likely flaky in parallel execution)
+- **MEDIUM Issues**: [count] (May cause timing issues)
+- **LOW Issues**: [count] (Edge case failures)
+
+## Flakiness Risk Score: HIGH / MEDIUM / LOW
+
+## CRITICAL Issues
+
+### Missing `await confirmation`
+- `Tests/NetworkTests.swift:45`
+ ```swift
+ @Test func fetchUser() async {
+ var user: User?
+ api.fetchUser { user = $0 }
+ #expect(user != nil) // FLAKY!
+ }
+ ```
+ - **Root cause**: Test completes before async callback
+ - **Fix**:
+ ```swift
+ @Test func fetchUser() async {
+ await confirmation { confirm in
+ api.fetchUser { user in
+ #expect(user != nil)
+ confirm()
+ }
+ }
+ }
+ ```
+
+### Missing `@MainActor`
+- `Tests/ViewModelTests.swift:23`
+ ```swift
+ @Test func updateUI() async {
+ let vm = MainActorViewModel() // Data race
+ }
+ ```
+ - **Root cause**: Accessing @MainActor type without isolation
+ - **Fix**: Add `@MainActor` to test function
+
+## HIGH Issues
+
+### Shared Mutable State
+- `Tests/CacheTests.swift:12` - `static var testCache`
+ - **Root cause**: Parallel tests mutate same collection
+ - **Fix**: Use instance property instead of static
+
+## MEDIUM Issues
+
+### Missing `.serialized` Trait
+- `Tests/DatabaseTests.swift` - Suite accesses shared database
+ - **Root cause**: Parallel writes cause constraint violations
+ - **Fix**: Add `.serialized` trait to `@Suite`
+
+## Verification Steps
+
+After fixes, verify with:
+
+```bash
+# Run tests multiple times to detect flakiness
+swift test --parallel --num-workers 8
+
+# Run specific test repeatedly
+swift test --filter "TestName" --iterations 100
+
+# Xcode: Edit Scheme → Test → Options → "Repeat Until Failure"
+```
+
+## Swift Testing Best Practices
+
+| Pattern | Use When |
+|---------|----------|
+| `confirmation {}` | Any callback/closure-based async |
+| `@MainActor` | Test accesses UI types |
+| `.serialized` | Tests share singleton/file/database |
+| Instance properties | Any test data that changes |
+```
+
+## Severity Definitions
+
+**CRITICAL**: Will definitely cause intermittent failures
+- Missing `confirmation` for async callbacks
+- Missing `@MainActor` for UI tests
+
+**HIGH**: Likely to cause parallel execution failures
+- Shared mutable state (`static var`)
+- Order-dependent tests
+
+**MEDIUM**: May cause timing-related failures
+- `Task.sleep` for waiting
+- Missing `.serialized` for shared resources
+
+**LOW**: Edge case failures
+- Date/timezone assertions
+- Locale-dependent comparisons
+
+## False Positives to Avoid
+
+**Not issues**:
+- `static let` constants (immutable is fine)
+- `confirmation` already present
+- Tests marked with `.serialized`
+- `@MainActor` already present
+- One-time setup in `static var` that's read-only
+
+**Verify before reporting**:
+- Read surrounding context
+- Check for `confirmation {}` wrapper
+- Check for trait annotations
+
+## XCTest Flaky Patterns (Legacy)
+
+For XCTest code, also check:
+
+### XCTestExpectation Issues
+```swift
+// ❌ FLAKY - Timeout too short for CI
+wait(for: [expectation], timeout: 1.0)
+
+// ✅ BETTER - Generous timeout
+wait(for: [expectation], timeout: 10.0)
+```
+
+### Missing waitForExistence
+```swift
+// ❌ FLAKY - Element may not exist yet
+XCTAssertTrue(app.buttons["Submit"].exists)
+
+// ✅ CORRECT - Wait for element
+XCTAssertTrue(app.buttons["Submit"].waitForExistence(timeout: 5))
+```
+
+## When No Issues Found
+
+Report:
+```markdown
+# Test Failure Analysis Results
+
+## Summary
+No flaky test patterns detected.
+
+## Verified
+- ✅ Async tests use `confirmation` properly
+- ✅ UI tests have `@MainActor` isolation
+- ✅ No shared mutable state in suites
+- ✅ No timing-dependent assertions
+
+## Recommendations
+- Run tests with `--iterations 100` to verify stability
+- Enable parallel testing to expose hidden races
+- Use Xcode's "Repeat Until Failure" for suspect tests
+```
diff --git a/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml b/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml
new file mode 100644
index 0000000..63ae37b
--- /dev/null
+++ b/.claude/skills/axiom-analyze-test-failures/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Analyze Test Failures"
+ short_description: "The user mentions flaky tests, tests that pass locally but fail in CI, race conditions in tests, or needs to diagnose..."
diff --git a/.claude/skills/axiom-app-attest/.openskills.json b/.claude/skills/axiom-app-attest/.openskills.json
new file mode 100644
index 0000000..6119785
--- /dev/null
+++ b/.claude/skills/axiom-app-attest/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-attest",
+ "installedAt": "2026-04-12T08:05:42.610Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-attest/SKILL.md b/.claude/skills/axiom-app-attest/SKILL.md
new file mode 100644
index 0000000..1c8ac50
--- /dev/null
+++ b/.claude/skills/axiom-app-attest/SKILL.md
@@ -0,0 +1,335 @@
+---
+name: axiom-app-attest
+description: Use when implementing app integrity verification, preventing fraud with DCAppAttestService, validating requests from legitimate app instances, using DeviceCheck for promotional abuse prevention, or needing server-side attestation/assertion validation. Covers key generation, attestation, assertion, rollout strategy, and risk metrics.
+license: MIT
+---
+
+# App Attest
+
+Device-backed app integrity verification for fraud prevention. Proves three things to your server: the request came from a genuine Apple device, running your genuine app, with an untampered payload.
+
+## When to Use This Skill
+
+Use when you need to:
+- Verify requests come from legitimate app instances (not modified/cloned apps)
+- Prevent fraud in purchases, promotions, or competitive features
+- Implement DCAppAttestService attestation or assertion flows
+- Handle DeviceCheck 2-bit per-device state for promotional abuse
+- Build server-side validation for attestation objects or assertion signatures
+- Plan a gradual App Attest rollout for a large install base
+
+## Example Prompts
+
+"How do I verify my app hasn't been tampered with?"
+"DCAppAttestService attestKey keeps failing with serverUnavailable"
+"How do I prevent users from claiming a free trial multiple times?"
+"What's the difference between attestation and assertion?"
+"How do I validate an attestation object on my server?"
+"isSupported returns false — should I block the user?"
+"We have 2M DAU, how do I roll out App Attest safely?"
+"How do I detect if someone is creating fake app instances?"
+
+## Red Flags
+
+Signs you're headed for trouble:
+
+- **Validating app integrity on-device** — Modified apps control the runtime. Any local check can be patched out. Verification MUST happen server-side.
+- **Not guarding with isSupported** — DCAppAttestService crashes on unsupported devices. Always check before calling any API.
+- **Blocking users when isSupported returns false** — Some legitimate devices return false. Treat as risk signal, not hard block.
+- **Reusing keys across multiple users on same device** — One key per user per device. Shared keys break account-level trust association.
+- **Enabling App Attest for all users at once** — `attestKey` calls Apple's servers. At scale, rate limiting causes failures. Gradual rollout required (WWDC 2021-10244).
+- **Using assertions for every API call** — Cryptographic cost per call. Reserve for sensitive operations (purchases, account changes), not routine fetches.
+- **Discarding key on serverUnavailable error** — Transient Apple server issue. Retry with same key. Only discard on other errors.
+- **Skipping counter validation on server** — Counter must be ever-increasing. Without this, replay attacks succeed.
+
+## Three Properties Verified
+
+App Attest proves three things about each request:
+
+| Property | What It Proves | How |
+|----------|---------------|-----|
+| Genuine device | Request comes from real Apple hardware | Hardware-backed key in Secure Enclave |
+| Genuine app | Your app binary, unmodified | App identity hash in attestation |
+| Untampered payload | Request data hasn't been altered | Digest signing in assertions |
+
+**Privacy design**: Anonymous. No hardware identifiers. Keys don't survive reinstall/migration/restore. Apple can't correlate across apps or users.
+
+## Key Generation
+
+```swift
+import DeviceCheck
+
+func generateAppAttestKey(for userId: String) async throws -> String {
+ let service = DCAppAttestService.shared
+
+ guard service.isSupported else {
+ // NOT an error — use as risk signal, not blocker
+ reportUnattestedDevice()
+ throw AppAttestError.unsupported
+ }
+
+ let keyId = try await service.generateKey()
+ // Cache persistently — one key per user per device
+ UserDefaults.standard.set(keyId, forKey: "appAttestKeyId_\(userId)")
+ return keyId
+}
+```
+
+**Key lifecycle**: One key per user per device. Cache `keyId` persistently (Keychain or UserDefaults). Keys don't survive reinstall, migration, or restore. App Clips share identity with full app. Generate new key on sign-out.
+
+## Attestation Flow
+
+Attestation registers the key with Apple and your server. Happens once per key.
+
+```dot
+digraph attestation {
+ "Server issues\nchallenge" [shape=ellipse];
+ "SHA256 hash\nchallenge" [shape=box];
+ "attestKey API\n(Apple servers)" [shape=box];
+ "Send attestation\nto your server" [shape=box];
+ "Server validates\ncertificate chain" [shape=box];
+ "Store public key\n+ key association" [shape=doublecircle];
+
+ "Error?" [shape=diamond];
+ "serverUnavailable?" [shape=diamond];
+ "Retry same key" [shape=box];
+ "Discard key\ngenerate new" [shape=box];
+
+ "Server issues\nchallenge" -> "SHA256 hash\nchallenge";
+ "SHA256 hash\nchallenge" -> "attestKey API\n(Apple servers)";
+ "attestKey API\n(Apple servers)" -> "Error?" ;
+ "Error?" -> "Send attestation\nto your server" [label="success"];
+ "Error?" -> "serverUnavailable?" [label="error"];
+ "serverUnavailable?" -> "Retry same key" [label="yes"];
+ "serverUnavailable?" -> "Discard key\ngenerate new" [label="no"];
+ "Send attestation\nto your server" -> "Server validates\ncertificate chain";
+ "Server validates\ncertificate chain" -> "Store public key\n+ key association";
+}
+```
+
+```swift
+func attestKey(userId: String) async throws {
+ guard let keyId = storedKeyId(for: userId) else {
+ throw AppAttestError.noKey
+ }
+
+ // 1. Get one-time challenge from YOUR server (minimum 16 bytes)
+ let challenge = try await server.fetchAttestationChallenge()
+
+ // 2. Hash the challenge
+ let hash = Data(SHA256.hash(data: challenge))
+
+ // 3. Request attestation from Apple
+ do {
+ let attestation = try await service.attestKey(keyId, clientDataHash: hash)
+ // 4. Send attestation object to YOUR server for validation
+ try await server.verifyAttestation(attestation, keyId: keyId, challenge: challenge)
+ } catch DCError.serverUnavailable {
+ // Transient — retry with SAME key later
+ scheduleAttestationRetry(keyId: keyId, userId: userId)
+ } catch {
+ // Other error — key is compromised or invalid
+ // Discard and generate a new key
+ clearStoredKey(for: userId)
+ try await generateAndAttestNewKey(userId: userId)
+ }
+}
+```
+
+**Challenge requirements**: Server-generated, single-use, minimum 16 bytes, short-lived (expire after minutes, not hours).
+
+## Assertion Flow
+
+Assertions prove ongoing request integrity. No Apple server involvement — on-device only.
+
+```swift
+func assertRequest(payload: Data, userId: String) async throws -> Data {
+ guard let keyId = storedKeyId(for: userId) else {
+ throw AppAttestError.noKey
+ }
+
+ // Hash the payload you want to protect
+ let hash = Data(SHA256.hash(data: payload))
+
+ // Generate assertion (on-device, no network)
+ let assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
+
+ // Send assertion + original payload to server
+ // Server verifies signature and checks counter
+ return assertion
+}
+```
+
+**When to assert**: Reserve for moments that cost you money or trust if faked.
+
+| Assert | Don't Assert |
+|--------|-------------|
+| In-app purchases | Content fetches |
+| Account changes (email, password) | Read-only API calls |
+| Competitive actions (leaderboard scores) | Analytics events |
+| Promotional claims (free trial) | UI configuration |
+| Reward redemptions | Search queries |
+
+**Performance**: Secure Enclave operations. Fast enough for individual actions, expensive on every request.
+
+## Server-Side Validation
+
+Your server does the actual trust verification. The app only generates cryptographic material.
+
+### Attestation Validation (once per key)
+
+1. **Certificate chain** — Verify roots to Apple's App Attest root CA (Apple Private PKI)
+2. **Nonce** — Recompute SHA256(challenge || clientDataHash), match against credential certificate
+3. **App identity hash** — SHA256(teamId + "." + bundleId) must match your app
+4. **Counter** — Store initial value (assertions increment from here)
+5. **Key association** — Extract and store public key, associate with user account
+
+### Assertion Validation (per sensitive request)
+
+1. **Signature** — Verify using stored public key from attestation
+2. **App identity hash** — Must match attestation's hash (prevents cross-app replay)
+3. **Counter** — Must be strictly greater than last seen value (replay protection)
+4. **Client data hash** — Recompute from request payload, must match what was signed
+
+**Counter is critical**: Without strictly-increasing counter validation, replay attacks succeed indefinitely.
+
+## Rollout Strategy
+
+From WWDC 2021-10244: `attestKey` makes a network call to Apple's servers. Apple rate-limits these calls per app.
+
+| Install Base | Recommended Ramp Time |
+|-------------|----------------------|
+| <100K DAU | Days |
+| ~1M DAU | ~1 day gradual ramp |
+| ~100M DAU | Weeks |
+| ~1B DAU | 1+ month gradual ramp |
+
+### Gradual Enablement Pattern
+
+```swift
+func shouldEnableAppAttest(userId: String) -> Bool {
+ guard DCAppAttestService.shared.isSupported else { return false }
+ // Server controls rollout percentage — start at 1%, ramp daily
+ return server.isAppAttestEnabled(for: userId)
+}
+```
+
+**Rollout process**: Start at 1%. Monitor attestation success rate. If above 95%, double daily. If rate limiting errors spike, pause. Treat unattested requests as lower-trust during rollout (additional fraud signals), not blocked.
+
+## DeviceCheck Integration
+
+DeviceCheck stores 2 bits of state per device on Apple's servers. Different purpose from App Attest.
+
+| Feature | App Attest | DeviceCheck |
+|---------|-----------|-------------|
+| Purpose | Verify app integrity | Track per-device state |
+| Survives reinstall | No | Yes (tied to hardware) |
+| Apple servers | Attestation only | Every query |
+
+### Promotional Fraud Prevention
+
+```swift
+import DeviceCheck
+
+func checkTrialEligibility() async throws -> Bool {
+ guard DCDevice.current.isSupported else { return true }
+
+ let token = try await DCDevice.current.generateToken()
+ // Server calls Apple: POST https://api.devicecheck.apple.com/v1/query_two_bits
+ let state = try await server.queryDeviceState(token: token)
+ return !state.bit0 // bit0 = has claimed trial
+}
+
+func markTrialClaimed() async throws {
+ let token = try await DCDevice.current.generateToken()
+ // Server calls Apple: POST https://api.devicecheck.apple.com/v1/update_two_bits
+ try await server.updateDeviceState(token: token, bit0: true)
+}
+```
+
+**2 bits, your rules**: Apple stores bits + timestamp. Semantics are yours (e.g., bit0=trial claimed, bit1=abuse flagged). Reset on your schedule. Shared across all apps from the same developer team — coordinate meaning across your portfolio.
+
+## Risk Metric Service
+
+After attestation, redeem the receipt with Apple to get risk metrics:
+
+**Server-side**: POST receipt to `https://data.appattest.apple.com/v1/attestationData` (use `data-development.appattest.apple.com` for sandbox). Response includes approximate key count for the device.
+
+**How to use**: Most devices have 1-3 keys. High key counts signal an attacker creating many fake identities. Redeem periodically (Apple rate-limits), establish a baseline for your app, and combine with other fraud signals (velocity, behavioral analysis).
+
+## Anti-Rationalization Table
+
+| Rationalization | Why It Fails | What To Do Instead |
+|----------------|-------------|-------------------|
+| "We'll validate integrity on-device" | Modified apps control the runtime and can patch out any local check | All validation on your server. Device only generates crypto material. |
+| "isSupported is always true on modern devices" | Some configurations and enterprise MDM setups return false | Always guard. Handle false as risk signal, not crash. |
+| "One key per device is enough" | Multi-user devices need per-user keys for accurate account association | One key per user per device. New key on sign-out. |
+| "We'll enable App Attest for everyone on launch day" | Apple rate-limits attestKey calls. Large install bases will see widespread failures. | Server-controlled gradual rollout. Monitor success rate. |
+| "Assert every API call for maximum security" | Secure Enclave operations have real cost. Assertion latency on every request degrades UX. | Assert sensitive operations only. Use session tokens for routine calls. |
+| "serverUnavailable means the key is bad" | It's a transient Apple server issue. Discarding the key forces re-attestation unnecessarily. | Retry with same key. Only discard on non-transient errors. |
+| "We don't need counter validation" | Without strictly-increasing counters, replay attacks succeed indefinitely. | Store counter server-side. Reject assertions with counter <= last seen. |
+| "DeviceCheck replaces App Attest" | DeviceCheck is 2-bit state storage, not integrity verification. Different threat models. | Use both: App Attest for integrity, DeviceCheck for per-device flags. |
+
+## Pressure Scenarios
+
+### Scenario 1: "Block users who fail attestation"
+
+**Pressure**: "If they can't attest, they're probably running a modified app. Block them."
+
+**Reality**: `isSupported` returns false on legitimate devices (older hardware, enterprise MDM, simulator). During rollout, most users simply haven't been enrolled yet. Blocking = blocking real customers.
+
+**Correct action**: Trust tiers on server. Attested = high trust. Unattested = lower trust with additional fraud signals. Never hard-block on attestation failure alone.
+
+**Push-back template**: "Some legitimate devices return isSupported=false. Let's use attestation as one signal in a risk score — high trust for attested, additional checks for unattested."
+
+### Scenario 2: "Enable App Attest for everyone at once"
+
+**Pressure**: "We've been building this for weeks. Ship it to everyone."
+
+**Reality**: `attestKey` calls Apple's servers. Apple rate-limits per app. At 5M DAU, flipping the switch causes a thundering herd — mass failures, error floods, confused users. WWDC 2021-10244 explicitly recommends gradual rollout.
+
+**Correct action**: Server-controlled rollout starting at 1%. At 5M DAU, expect ~1 week to full rollout.
+
+**Push-back template**: "Apple rate-limits attestKey calls — their WWDC session recommends gradual rollout. I'll set up server-side percentage control starting at 1%, ramping to 100% over about a week."
+
+## Checklist
+
+Before shipping App Attest:
+
+**Key Generation**:
+- [ ] `isSupported` checked before any DCAppAttestService call
+- [ ] Graceful handling when `isSupported` returns false (risk signal, not block)
+- [ ] Key ID cached persistently per user
+- [ ] One key per user per device (not shared)
+
+**Attestation**:
+- [ ] Challenge from server is single-use, minimum 16 bytes, short-lived
+- [ ] `serverUnavailable` retries with same key
+- [ ] Other errors discard key and generate new
+- [ ] Attestation object sent to server for validation (not validated on-device)
+
+**Assertion**:
+- [ ] Used only for sensitive operations (not every API call)
+- [ ] Payload hash covers the actual request data being protected
+- [ ] Server validates signature with stored public key
+- [ ] Server validates counter is strictly increasing
+
+**Server**:
+- [ ] Certificate chain validated against Apple's App Attest root CA
+- [ ] App identity hash (teamId + bundleId) verified
+- [ ] Counter stored and checked for strict increase
+- [ ] Public key associated with user account
+
+**Rollout**:
+- [ ] Server-controlled percentage (not client-side)
+- [ ] Gradual ramp with monitoring
+- [ ] Unattested users handled gracefully (lower trust, not blocked)
+- [ ] Rollback plan if attestation success rate drops
+
+## Resources
+
+**WWDC**: 2021-10244
+
+**Docs**: /devicecheck, /devicecheck/establishing-your-app-s-integrity, /devicecheck/validating-apps-that-connect-to-your-server
+
+**Skills**: axiom-cryptokit
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-attest/agents/openai.yaml b/.claude/skills/axiom-app-attest/agents/openai.yaml
new file mode 100644
index 0000000..8c93230
--- /dev/null
+++ b/.claude/skills/axiom-app-attest/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Attest"
+ short_description: "Implementing app integrity verification, preventing fraud with DCAppAttestService, validating requests from legitimat..."
diff --git a/.claude/skills/axiom-app-composition/.openskills.json b/.claude/skills/axiom-app-composition/.openskills.json
new file mode 100644
index 0000000..d09e194
--- /dev/null
+++ b/.claude/skills/axiom-app-composition/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-composition",
+ "installedAt": "2026-04-12T08:05:43.422Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-composition/SKILL.md b/.claude/skills/axiom-app-composition/SKILL.md
new file mode 100644
index 0000000..fef8500
--- /dev/null
+++ b/.claude/skills/axiom-app-composition/SKILL.md
@@ -0,0 +1,1462 @@
+---
+name: axiom-app-composition
+description: Use when structuring app entry points, managing authentication flows, switching root views, handling scene lifecycle, or asking 'how do I structure my @main', 'where does auth state live', 'how do I prevent screen flicker on launch', 'when should I modularize' - app-level composition patterns for iOS 26+
+license: MIT
+compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+. Xcode 26+
+metadata:
+ version: "1.0"
+---
+
+# App Composition
+
+## When to Use This Skill
+
+Use this skill when:
+- Structuring your @main entry point and root view
+- Managing authentication state (login → onboarding → main)
+- Switching between app-level states without flicker
+- Handling scene lifecycle events (scenePhase)
+- Restoring app state after termination
+- Deciding when to split into feature modules
+- Coordinating between multiple windows (iPad, axiom-visionOS)
+
+## Example Prompts
+
+| What You Might Ask | Why This Skill Helps |
+|--------------------|----------------------|
+| "How do I switch between login and main screens?" | AppStateController pattern with validated transitions |
+| "My app flickers when switching from splash to main" | Flicker prevention with animation coordination |
+| "Where should auth state live?" | App-level state machine, not scattered booleans |
+| "How do I handle app going to background?" | scenePhase lifecycle patterns |
+| "When should I split my app into modules?" | Decision tree based on codebase size and team |
+| "How do I restore state after app is killed?" | SceneStorage and state validation patterns |
+
+## Quick Decision Tree
+
+```
+What app-level architecture question are you solving?
+│
+├─ How do I manage app states (loading, auth, main)?
+│ └─ Part 1: App-Level State Machines
+│ - Enum-based state with validated transitions
+│ - AppStateController pattern
+│ - Prevents "boolean soup" anti-pattern
+│
+├─ How do I structure @main and root view switching?
+│ └─ Part 2: Root View Switching Patterns
+│ - Delegate to AppStateController (no logic in @main)
+│ - Flicker prevention with animation
+│ - Coordinator integration
+│
+├─ How do I handle scene lifecycle?
+│ └─ Part 3: Scene Lifecycle Integration
+│ - scenePhase for session validation
+│ - SceneStorage for restoration
+│ - Multi-window coordination
+│
+├─ When should I modularize?
+│ └─ Part 4: Feature Module Basics
+│ - Decision tree by size/team
+│ - Module boundaries and DI
+│ - Navigation coordination
+│
+└─ What mistakes should I avoid?
+ └─ Part 5: Anti-Patterns + Part 6: Pressure Scenarios
+ - Boolean-based state
+ - Logic in @main
+ - Missing restoration validation
+```
+
+---
+
+# Part 1: App-Level State Machines
+
+## Core Principle
+
+> "Apps have discrete states. Model them explicitly with enums, not scattered booleans."
+
+Every non-trivial app has distinct states: loading, unauthenticated, onboarding, authenticated, error recovery. These states should be:
+1. **Explicit** — An enum, not multiple booleans
+2. **Validated** — Transitions are checked and logged
+3. **Centralized** — One source of truth
+4. **Observable** — Views react to state changes
+
+## The Boolean Soup Problem
+
+```swift
+// ❌ Boolean soup — impossible to validate, prone to invalid states
+class AppState {
+ var isLoading = true
+ var isLoggedIn = false
+ var hasCompletedOnboarding = false
+ var hasError = false
+ var user: User?
+
+ // What if isLoading && isLoggedIn && hasError are all true?
+ // Invalid state, but nothing prevents it
+}
+```
+
+**Problems**
+- No compile-time guarantee of valid states
+- Easy to forget to update one boolean
+- Testing requires checking all combinations
+- Race conditions create impossible states
+
+## The AppStateController Pattern
+
+### Step 1: Define Explicit States
+
+```swift
+enum AppState: Equatable {
+ case loading
+ case unauthenticated
+ case onboarding(OnboardingStep)
+ case authenticated(User)
+ case error(AppError)
+}
+
+enum OnboardingStep: Equatable {
+ case welcome
+ case permissions
+ case profileSetup
+ case complete
+}
+
+enum AppError: Equatable {
+ case networkUnavailable
+ case sessionExpired
+ case maintenanceMode
+}
+```
+
+### Step 2: Create the Controller
+
+```swift
+@Observable
+@MainActor
+class AppStateController {
+ private(set) var state: AppState = .loading
+
+ // MARK: - State Transitions
+
+ func transition(to newState: AppState) {
+ guard isValidTransition(from: state, to: newState) else {
+ assertionFailure("Invalid transition: \(state) → \(newState)")
+ logInvalidTransition(from: state, to: newState)
+ return
+ }
+
+ let oldState = state
+ state = newState
+ logTransition(from: oldState, to: newState)
+ }
+
+ // MARK: - Validation
+
+ private func isValidTransition(from: AppState, to: AppState) -> Bool {
+ switch (from, to) {
+ // From loading
+ case (.loading, .unauthenticated): return true
+ case (.loading, .authenticated): return true
+ case (.loading, .error): return true
+
+ // From unauthenticated
+ case (.unauthenticated, .onboarding): return true
+ case (.unauthenticated, .authenticated): return true
+ case (.unauthenticated, .error): return true
+
+ // From onboarding
+ case (.onboarding, .onboarding): return true // Step changes
+ case (.onboarding, .authenticated): return true
+ case (.onboarding, .unauthenticated): return true // Cancelled
+
+ // From authenticated
+ case (.authenticated, .unauthenticated): return true // Logout
+ case (.authenticated, .error): return true
+
+ // From error
+ case (.error, .loading): return true // Retry
+ case (.error, .unauthenticated): return true
+
+ default: return false
+ }
+ }
+
+ // MARK: - Logging
+
+ private func logTransition(from: AppState, to: AppState) {
+ #if DEBUG
+ print("AppState: \(from) → \(to)")
+ #endif
+ }
+
+ private func logInvalidTransition(from: AppState, to: AppState) {
+ // Log to analytics for debugging
+ Analytics.log("InvalidStateTransition", properties: [
+ "from": String(describing: from),
+ "to": String(describing: to)
+ ])
+ }
+}
+```
+
+### Step 3: Initialize from Storage
+
+```swift
+extension AppStateController {
+ func initialize() async {
+ // Check for stored session
+ if let session = await SessionStorage.loadSession() {
+ // Validate session is still valid
+ do {
+ let user = try await AuthService.validateSession(session)
+ transition(to: .authenticated(user))
+ } catch {
+ // Session expired or invalid
+ await SessionStorage.clearSession()
+ transition(to: .unauthenticated)
+ }
+ } else {
+ transition(to: .unauthenticated)
+ }
+ }
+}
+```
+
+## State Machine Diagram
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ .loading │
+└────────────┬───────────────┬────────────────┬───────────────┘
+ │ │ │
+ ▼ ▼ ▼
+ .unauthenticated .authenticated .error
+ │ │ │
+ ▼ │ │
+ .onboarding ─────────►│◄───────────────┘
+ │ │
+ └───────────────┘
+```
+
+## Testing State Machines
+
+```swift
+@Test func testValidTransitions() async {
+ let controller = AppStateController()
+
+ // Loading → Unauthenticated (valid)
+ controller.transition(to: .unauthenticated)
+ #expect(controller.state == .unauthenticated)
+
+ // Unauthenticated → Authenticated (valid)
+ let user = User(id: "1", name: "Test")
+ controller.transition(to: .authenticated(user))
+ #expect(controller.state == .authenticated(user))
+}
+
+@Test func testInvalidTransitionRejected() async {
+ let controller = AppStateController()
+
+ // Loading → Onboarding (invalid — must go through unauthenticated)
+ controller.transition(to: .onboarding(.welcome))
+ #expect(controller.state == .loading) // Unchanged
+}
+
+@Test func testSessionExpiredTransition() async {
+ let controller = AppStateController()
+ let user = User(id: "1", name: "Test")
+ controller.transition(to: .authenticated(user))
+
+ // Authenticated → Error (session expired)
+ controller.transition(to: .error(.sessionExpired))
+ #expect(controller.state == .error(.sessionExpired))
+
+ // Error → Unauthenticated (force re-login)
+ controller.transition(to: .unauthenticated)
+ #expect(controller.state == .unauthenticated)
+}
+```
+
+## The State-as-Bridge Pattern (WWDC 2025/266)
+
+From WWDC 2025's "Explore concurrency in SwiftUI":
+
+> "Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic."
+
+The key insight: **synchronous state changes drive UI** (for animations), **async code lives in the model** (testable without SwiftUI), and **state bridges the two**.
+
+```swift
+// ✅ State-as-Bridge: UI triggers state, model does async work
+struct ColorExtractorView: View {
+ @State private var model = ColorExtractor()
+
+ var body: some View {
+ Button("Extract Colors") {
+ // ✅ Synchronous state change triggers animation
+ withAnimation { model.isExtracting = true }
+
+ // Async work happens in Task
+ Task {
+ await model.extractColors()
+
+ // ✅ Synchronous state change ends animation
+ withAnimation { model.isExtracting = false }
+ }
+ }
+ .scaleEffect(model.isExtracting ? 1.5 : 1.0)
+ }
+}
+
+@Observable
+class ColorExtractor {
+ var isExtracting = false
+ var colors: [Color] = []
+
+ func extractColors() async {
+ // Heavy computation happens here, testable without SwiftUI
+ let extracted = await heavyComputation()
+ colors = extracted
+ }
+}
+```
+
+**Why this matters for app composition**
+- App-level state changes (loading → authenticated) should be **synchronous**
+- Heavy work (session validation, data loading) should be **async in the model**
+- This separation makes state machines testable without SwiftUI imports
+
+---
+
+# Part 2: Root View Switching Patterns
+
+## Core Principle
+
+> "The @main entry point should be a thin shell. All logic belongs in AppStateController."
+
+## The Clean @main Pattern
+
+```swift
+@main
+struct MyApp: App {
+ @State private var appState = AppStateController()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(appState)
+ .task {
+ await appState.initialize()
+ }
+ }
+ }
+}
+```
+
+**What @main does**
+- Creates AppStateController
+- Injects it via environment
+- Triggers initialization
+
+**What @main does NOT do**
+- Business logic
+- Auth checks
+- Conditional rendering
+- Navigation decisions
+
+## RootView: The State Switch
+
+```swift
+struct RootView: View {
+ @Environment(AppStateController.self) private var appState
+
+ var body: some View {
+ Group {
+ switch appState.state {
+ case .loading:
+ LaunchView()
+ case .unauthenticated:
+ AuthenticationFlow()
+ case .onboarding(let step):
+ OnboardingFlow(step: step)
+ case .authenticated(let user):
+ MainTabView(user: user)
+ case .error(let error):
+ ErrorRecoveryView(error: error)
+ }
+ }
+ }
+}
+```
+
+## Testability Benefit
+
+The thin-shell pattern enables up to 60x faster tests. When app logic lives in a Swift Package instead of the app target, tests run with `swift test` (~0.4s) vs `xcodebuild test` (~25s) — no simulator, no app launch.
+
+| Component | Location | Tested With |
+|-----------|----------|-------------|
+| Business logic, models, services | Swift Package (`MyAppCore`) | `swift test` (0.4s) |
+| Root view composition | App target (thin shell) | `xcodebuild test` (25s) |
+
+See `axiom-swift-testing` Strategy 1 for the complete package extraction walkthrough.
+
+## Preventing Flicker During Transitions
+
+### Problem: Flash of Wrong Content
+
+When app state changes, you might see a flash of the old screen before the new one appears. This happens when:
+- State changes before view is ready
+- No transition animation
+- Loading state too short to perceive
+
+### Solution: Animated Transitions
+
+```swift
+struct RootView: View {
+ @Environment(AppStateController.self) private var appState
+
+ var body: some View {
+ ZStack {
+ switch appState.state {
+ case .loading:
+ LaunchView()
+ .transition(.opacity)
+ case .unauthenticated:
+ AuthenticationFlow()
+ .transition(.opacity)
+ case .onboarding(let step):
+ OnboardingFlow(step: step)
+ .transition(.opacity)
+ case .authenticated(let user):
+ MainTabView(user: user)
+ .transition(.opacity)
+ case .error(let error):
+ ErrorRecoveryView(error: error)
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: appState.state)
+ }
+}
+```
+
+### Minimum Loading Duration
+
+For a polished experience, ensure the loading screen is visible long enough:
+
+```swift
+extension AppStateController {
+ func initialize() async {
+ let startTime = Date()
+
+ // Do actual initialization
+ await performInitialization()
+
+ // Ensure minimum display time for loading screen
+ let elapsed = Date().timeIntervalSince(startTime)
+ let minimumDuration: TimeInterval = 0.5
+ if elapsed < minimumDuration {
+ try? await Task.sleep(for: .seconds(minimumDuration - elapsed))
+ }
+ }
+}
+```
+
+## Coordinator Integration
+
+If using coordinators, integrate them at the root level:
+
+```swift
+struct RootView: View {
+ @Environment(AppStateController.self) private var appState
+ @State private var authCoordinator = AuthCoordinator()
+ @State private var mainCoordinator = MainCoordinator()
+
+ var body: some View {
+ Group {
+ switch appState.state {
+ case .loading:
+ LaunchView()
+ case .unauthenticated, .onboarding:
+ AuthenticationFlow()
+ .environment(authCoordinator)
+ case .authenticated(let user):
+ MainTabView(user: user)
+ .environment(mainCoordinator)
+ case .error(let error):
+ ErrorRecoveryView(error: error)
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: appState.state)
+ }
+}
+```
+
+---
+
+# Part 3: Scene Lifecycle Integration
+
+## Core Principle
+
+> "Scene lifecycle events are app-wide concerns handled centrally, not scattered across features."
+
+## Understanding ScenePhase (Apple Documentation)
+
+ScenePhase indicates a scene's operational state. How you interpret the value depends on **where it's read**.
+
+**Read from a View** → Returns the phase of the enclosing scene
+**Read from App** → Returns an **aggregate** value reflecting all scenes
+
+| Phase | Description |
+|-------|-------------|
+| `.active` | Scene is in the foreground and interactive |
+| `.inactive` | Scene is in the foreground but should pause work |
+| `.background` | Scene isn't visible; app may terminate soon |
+
+**Critical insight from Apple docs** When reading at the App level, `.active` means *any* scene is active, and `.background` means *all* scenes are in background.
+
+## scenePhase Handling
+
+```swift
+@main
+struct MyApp: App {
+ @State private var appState = AppStateController()
+ @Environment(\.scenePhase) private var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(appState)
+ .task {
+ await appState.initialize()
+ }
+ }
+ .onChange(of: scenePhase) { oldPhase, newPhase in
+ handleScenePhaseChange(from: oldPhase, to: newPhase)
+ }
+ }
+
+ private func handleScenePhaseChange(from: ScenePhase, to: ScenePhase) {
+ switch to {
+ case .active:
+ // App became active — validate session, refresh data
+ Task {
+ await appState.validateSession()
+ await appState.refreshIfNeeded()
+ }
+
+ case .inactive:
+ // App about to go inactive — save state
+ appState.prepareForBackground()
+
+ case .background:
+ // App in background — release resources
+ appState.releaseResources()
+
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+## Session Validation on Active
+
+```swift
+extension AppStateController {
+ func validateSession() async {
+ guard case .authenticated(let user) = state else { return }
+
+ do {
+ // Check if token is still valid
+ let isValid = try await AuthService.validateToken(user.token)
+ if !isValid {
+ transition(to: .error(.sessionExpired))
+ }
+ } catch {
+ // Network error — keep authenticated but show warning
+ // Don't immediately log out on transient network issues
+ }
+ }
+
+ func prepareForBackground() {
+ // Save any pending data
+ // Cancel non-essential network requests
+ // Prepare for potential termination
+ }
+
+ func releaseResources() {
+ // Release cached images
+ // Stop location updates if not essential
+ // Reduce memory footprint
+ }
+}
+```
+
+## SceneStorage for State Restoration
+
+From Apple documentation: SceneStorage provides automatic state restoration. The system manages saving and restoring on your behalf.
+
+**Key constraints**
+- Keep data lightweight (not full models)
+- Each Scene has its own storage (not shared)
+- Data destroyed when scene is explicitly destroyed
+
+```swift
+struct MainTabView: View {
+ @SceneStorage("selectedTab") private var selectedTab = 0
+ @SceneStorage("lastViewedItemID") private var lastViewedItemID: String?
+
+ var body: some View {
+ TabView(selection: $selectedTab) {
+ HomeTab()
+ .tag(0)
+ SearchTab()
+ .tag(1)
+ ProfileTab()
+ .tag(2)
+ }
+ .onAppear {
+ if let itemID = lastViewedItemID {
+ // Restore to last viewed item
+ navigateToItem(itemID)
+ }
+ }
+ }
+}
+```
+
+### Navigation State Restoration (WWDC 2022/10054)
+
+For complex navigation, use a Codable NavigationModel:
+
+```swift
+// Encapsulate navigation state with Codable conformance
+class NavigationModel: ObservableObject, Codable {
+ @Published var selectedCategory: Category?
+ @Published var recipePath: [Recipe] = []
+
+ enum CodingKeys: String, CodingKey {
+ case selectedCategory
+ case recipePathIds
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
+ // Store only IDs, not full models
+ try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
+ }
+
+ required init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.selectedCategory = try container.decodeIfPresent(
+ Category.self, forKey: .selectedCategory)
+
+ let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
+ // compactMap discards deleted items gracefully
+ self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
+ }
+
+ var jsonData: Data? {
+ get { try? JSONEncoder().encode(self) }
+ set {
+ guard let data = newValue,
+ let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
+ else { return }
+ self.selectedCategory = model.selectedCategory
+ self.recipePath = model.recipePath
+ }
+ }
+}
+
+// Use with SceneStorage
+struct ContentView: View {
+ @StateObject private var navModel = NavigationModel()
+ @SceneStorage("navigation") private var data: Data?
+
+ var body: some View {
+ NavigationSplitView { /* ... */ }
+ .task {
+ if let data = data {
+ navModel.jsonData = data
+ }
+ for await _ in navModel.objectWillChangeSequence {
+ data = navModel.jsonData
+ }
+ }
+ }
+}
+```
+
+**Key patterns from WWDC**
+- Store **IDs only**, not full model objects
+- Use `compactMap` to handle deleted items gracefully
+- Save on every `objectWillChange` for real-time persistence
+
+### Validating Restored State
+
+Never trust restored state blindly:
+
+```swift
+struct DetailView: View {
+ @SceneStorage("detailItemID") private var restoredItemID: String?
+ @State private var item: Item?
+
+ var body: some View {
+ Group {
+ if let item {
+ ItemContent(item: item)
+ } else {
+ ProgressView()
+ }
+ }
+ .task {
+ if let itemID = restoredItemID {
+ // Validate item still exists
+ item = await ItemService.fetch(itemID)
+ if item == nil {
+ // Item was deleted — clear restoration
+ restoredItemID = nil
+ }
+ }
+ }
+ }
+}
+```
+
+## Multi-Window Coordination (iPad, axiom-visionOS)
+
+From Apple documentation: Every window in a WindowGroup maintains **independent state**. The system allocates new storage for @State and @StateObject for each window.
+
+```swift
+@main
+struct MyApp: App {
+ @State private var appState = AppStateController()
+
+ var body: some Scene {
+ // Primary window
+ WindowGroup {
+ MainView()
+ .environment(appState)
+ }
+
+ // Data-presenting window (iPad)
+ // Prefer lightweight data (IDs, not full models)
+ WindowGroup("Detail", id: "detail", for: Item.ID.self) { $itemID in
+ if let itemID {
+ DetailView(itemID: itemID)
+ .environment(appState)
+ }
+ }
+
+ #if os(visionOS)
+ // Immersive space
+ ImmersiveSpace(id: "immersive") {
+ ImmersiveView()
+ .environment(appState)
+ }
+ #endif
+ }
+}
+```
+
+**Key behaviors from Apple docs**
+- If a window with the same value already exists, the system **brings it to front** instead of opening a new one
+- SwiftUI persists the binding value for state restoration
+- Use unique identifier strings for each window group
+
+### Opening Additional Windows
+
+```swift
+struct ItemRow: View {
+ let item: Item
+ @Environment(\.openWindow) private var openWindow
+
+ var body: some View {
+ Button(item.title) {
+ // Open in new window on iPad
+ // Use ID to match window group, value to pass data
+ openWindow(id: "detail", value: item.id)
+ }
+ }
+}
+```
+
+### Dismissing Windows Programmatically
+
+```swift
+struct DetailView: View {
+ var itemID: Item.ID?
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack {
+ // ...
+ Button("Done") {
+ dismiss() // Closes this window
+ }
+ }
+ }
+}
+```
+
+---
+
+# Part 4: Feature Module Basics
+
+## Core Principle
+
+> "Split into modules when features have clear boundaries. Not before."
+
+Premature modularization creates overhead. Late modularization creates pain. Use this decision tree.
+
+## When to Modularize Decision Tree
+
+```
+Should I extract this feature into a module?
+│
+├─ Is the codebase under 5,000 lines with 1-2 developers?
+│ └─ NO modularization needed yet
+│ Single target is fine, revisit at 10,000 lines
+│
+├─ Is the codebase 5,000-20,000 lines with 3+ developers?
+│ └─ CONSIDER modularization
+│ Look for natural boundaries
+│
+├─ Is the codebase over 20,000 lines?
+│ └─ MODULARIZE for build times
+│ Parallel compilation essential
+│
+├─ Could this feature be used in multiple apps?
+│ └─ EXTRACT to reusable module
+│ Shared authentication, analytics, axiom-networking
+│
+├─ Do multiple developers work on this feature daily?
+│ └─ EXTRACT for merge conflict reduction
+│ Isolated codebases = parallel work
+│
+└─ Does the feature have clear input/output boundaries?
+ ├─ YES → Good candidate for module
+ └─ NO → Refactor boundaries first, then extract
+```
+
+## Module Boundary Pattern
+
+### Define a Public API
+
+```swift
+// FeatureModule/Sources/FeatureModule/FeatureAPI.swift
+
+/// Public interface for the feature module
+public protocol FeatureAPI {
+ /// Show the feature's main view
+ @MainActor
+ func makeMainView() -> AnyView
+
+ /// Handle deep link into feature
+ @MainActor
+ func handleDeepLink(_ url: URL) -> Bool
+}
+
+/// Factory to create feature with dependencies
+public struct FeatureFactory {
+ public static func create(
+ analytics: AnalyticsProtocol,
+ networking: NetworkingProtocol
+ ) -> FeatureAPI {
+ FeatureImplementation(
+ analytics: analytics,
+ networking: axiom-networking
+ )
+ }
+}
+```
+
+### Internal Implementation
+
+```swift
+// FeatureModule/Sources/FeatureModule/Internal/FeatureImplementation.swift
+
+internal class FeatureImplementation: FeatureAPI {
+ private let analytics: AnalyticsProtocol
+ private let networking: NetworkingProtocol
+
+ internal init(
+ analytics: AnalyticsProtocol,
+ networking: NetworkingProtocol
+ ) {
+ self.analytics = analytics
+ self.networking = networking
+ }
+
+ @MainActor
+ public func makeMainView() -> AnyView {
+ AnyView(FeatureMainView(viewModel: makeViewModel()))
+ }
+
+ public func handleDeepLink(_ url: URL) -> Bool {
+ // Handle feature-specific deep links
+ return false
+ }
+
+ private func makeViewModel() -> FeatureViewModel {
+ FeatureViewModel(analytics: analytics, axiom-networking: axiom-networking)
+ }
+}
+```
+
+### Use in Main App
+
+```swift
+// MainApp/Sources/App/AppDependencies.swift
+
+@Observable
+class AppDependencies {
+ let analytics: AnalyticsProtocol
+ let networking: NetworkingProtocol
+
+ // Lazy-created feature modules
+ lazy var profileFeature: FeatureAPI = {
+ ProfileFeatureFactory.create(
+ analytics: analytics,
+ networking: axiom-networking
+ )
+ }()
+
+ lazy var settingsFeature: FeatureAPI = {
+ SettingsFeatureFactory.create(
+ analytics: analytics,
+ networking: axiom-networking
+ )
+ }()
+}
+
+// MainApp/Sources/App/MainTabView.swift
+
+struct MainTabView: View {
+ @Environment(AppDependencies.self) private var dependencies
+
+ var body: some View {
+ TabView {
+ dependencies.profileFeature.makeMainView()
+ .tabItem { Label("Profile", systemImage: "person") }
+
+ dependencies.settingsFeature.makeMainView()
+ .tabItem { Label("Settings", systemImage: "gear") }
+ }
+ }
+}
+```
+
+## Navigation Coordination Between Modules
+
+Features should not know about each other directly:
+
+```swift
+// ❌ Feature knows about other features
+struct ProfileView: View {
+ func showSettings() {
+ // ProfileView imports SettingsFeature — circular dependency risk
+ NavigationLink(value: SettingsDestination())
+ }
+}
+
+// ✅ Feature delegates navigation to coordinator
+struct ProfileView: View {
+ let onShowSettings: () -> Void
+
+ func showSettings() {
+ onShowSettings() // ProfileView doesn't know what happens
+ }
+}
+
+// Coordinator wires features together
+class MainCoordinator {
+ func showSettings(from profile: ProfileFeatureAPI) {
+ // Coordinator knows about both features
+ navigationPath.append(SettingsRoute())
+ }
+}
+```
+
+## Module Folder Structure
+
+```
+MyApp/
+├── App/ # Main app target
+│ ├── MyApp.swift # @main entry point
+│ ├── AppDependencies.swift # Dependency container
+│ ├── AppStateController.swift # App state machine
+│ └── Coordinators/ # Navigation coordinators
+│
+├── Packages/
+│ ├── Core/ # Shared utilities
+│ │ ├── Networking/
+│ │ ├── Analytics/
+│ │ └── Design/ # Design system
+│ │
+│ ├── Features/ # Feature modules
+│ │ ├── Profile/
+│ │ ├── Settings/
+│ │ └── Onboarding/
+│ │
+│ └── Domain/ # Business logic
+│ ├── Models/
+│ └── Services/
+```
+
+---
+
+# Part 5: Anti-Patterns
+
+## Anti-Pattern 1: Boolean-Based State
+
+```swift
+// ❌ Boolean soup — impossible to validate
+class AppState {
+ var isLoading = true
+ var isLoggedIn = false
+ var hasCompletedOnboarding = false
+ var hasError = false
+}
+
+// What if isLoading && isLoggedIn && hasError are all true?
+```
+
+**Fix** Use enum-based state (Part 1)
+
+```swift
+// ✅ Explicit states — compiler prevents invalid combinations
+enum AppState {
+ case loading
+ case unauthenticated
+ case onboarding(OnboardingStep)
+ case authenticated(User)
+ case error(AppError)
+}
+```
+
+## Anti-Pattern 2: Logic in @main
+
+```swift
+// ❌ Business logic in App entry point
+@main
+struct MyApp: App {
+ @State private var user: User?
+ @State private var isLoading = true
+
+ var body: some Scene {
+ WindowGroup {
+ if isLoading {
+ LoadingView()
+ } else if let user {
+ MainView(user: user)
+ } else {
+ LoginView(onLogin: { self.user = $0 })
+ }
+ }
+ .task {
+ user = await AuthService.getCurrentUser()
+ isLoading = false
+ }
+ }
+}
+```
+
+**Problems**
+- @main becomes bloated with logic
+- Hard to test without launching app
+- State scattered across multiple @State
+
+**Fix** Delegate to AppStateController (Part 2)
+
+```swift
+// ✅ @main is a thin shell
+@main
+struct MyApp: App {
+ @State private var appState = AppStateController()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(appState)
+ .task { await appState.initialize() }
+ }
+ }
+}
+```
+
+## Anti-Pattern 3: Missing State Validation on Restore
+
+```swift
+// ❌ Trusts restored state blindly
+.onAppear {
+ if let savedState = SceneStorage.appState {
+ appState.state = savedState // Token might be expired!
+ }
+}
+```
+
+**Problems**
+- Session could have expired
+- User could have been logged out on another device
+- Data could have been deleted
+
+**Fix** Validate before applying (Part 3)
+
+```swift
+// ✅ Validates restored state
+.task {
+ if let savedSession = await SessionStorage.loadSession() {
+ do {
+ let user = try await AuthService.validateSession(savedSession)
+ appState.transition(to: .authenticated(user))
+ } catch {
+ // Session invalid — force re-login
+ await SessionStorage.clearSession()
+ appState.transition(to: .unauthenticated)
+ }
+ }
+}
+```
+
+## Anti-Pattern 4: Navigation Logic Scattered Across Features
+
+```swift
+// ❌ Every feature knows about every other feature
+struct ProfileView: View {
+ @Environment(\.navigationPath) private var path
+
+ func showSettings() {
+ path.append(SettingsDestination()) // ProfileView imports Settings
+ }
+
+ func showOrderHistory() {
+ path.append(OrderHistoryDestination()) // ProfileView imports Orders
+ }
+}
+```
+
+**Problems**
+- Circular dependencies
+- Hard to test navigation
+- Changes ripple across modules
+
+**Fix** Delegate to coordinator (Part 4)
+
+```swift
+// ✅ Feature delegates navigation decisions
+struct ProfileView: View {
+ let onShowSettings: () -> Void
+ let onShowOrderHistory: () -> Void
+
+ // ProfileView doesn't know what these do
+}
+```
+
+## Anti-Pattern 5: God Coordinator
+
+```swift
+// ❌ Single coordinator knows all features
+class AppCoordinator {
+ func showProfile() { }
+ func showSettings() { }
+ func showOnboarding() { }
+ func showPayment() { }
+ func showChat() { }
+ func showOrderHistory() { }
+ func showNotifications() { }
+ // ... 50 more methods
+}
+```
+
+**Problems**
+- Massive file that everyone touches
+- Merge conflicts
+- Single point of failure
+
+**Fix** Scoped coordinators
+
+```swift
+// ✅ Scoped coordinators for each domain
+class AuthCoordinator { } // Login, signup, forgot password
+class MainCoordinator { } // Tab navigation, main flows
+class SettingsCoordinator { } // Settings navigation tree
+class OrderCoordinator { } // Order flow, history, details
+```
+
+---
+
+# Part 5b: UIKit Integration (Incremental Adoption)
+
+> For comprehensive bridging patterns (UIViewRepresentable, UIViewControllerRepresentable, UIHostingConfiguration, coordinators, lifecycle, gotchas), see `/skill axiom-uikit-bridging`. This section covers app-level integration strategy only.
+
+## When This Applies
+
+Most production iOS apps have existing UIKit code. Rewriting everything in SwiftUI is rarely practical. Use these patterns for incremental adoption.
+
+## UIHostingController — SwiftUI Inside UIKit
+
+Embed SwiftUI views in an existing UIKit navigation hierarchy:
+
+```swift
+// Present a SwiftUI view from a UIKit view controller
+let settingsView = SettingsView(store: store)
+let hostingController = UIHostingController(rootView: settingsView)
+navigationController?.pushViewController(hostingController, animated: true)
+```
+
+**Key rules**:
+- `UIHostingController` owns the SwiftUI view's lifecycle — don't store the root view separately
+- Use `sizingOptions: .intrinsicContentSize` when embedding as a child for correct Auto Layout sizing
+- For sheets: `hostingController.modalPresentationStyle = .pageSheet` works naturally
+- SwiftUI environment doesn't bridge automatically — inject dependencies through the root view's initializer
+
+## UIViewControllerRepresentable — UIKit Inside SwiftUI
+
+Wrap existing UIKit view controllers for use in SwiftUI:
+
+```swift
+struct DocumentPickerView: UIViewControllerRepresentable {
+ @Binding var selectedURL: URL?
+
+ func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.pdf])
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) { }
+
+ func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ class Coordinator: NSObject, UIDocumentPickerDelegate {
+ let parent: DocumentPickerView
+ init(_ parent: DocumentPickerView) { self.parent = parent }
+
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
+ parent.selectedURL = urls.first
+ }
+ }
+}
+```
+
+**When to use**: Camera UI, document pickers, mail compose, any UIKit controller without a SwiftUI equivalent.
+
+## AppDelegate + SwiftUI @main
+
+Bridge `UIApplicationDelegate` callbacks into a SwiftUI app:
+
+```swift
+@main
+struct MyApp: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ }
+ }
+}
+
+class AppDelegate: NSObject, UIApplicationDelegate {
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Push notification registration, third-party SDK init, etc.
+ return true
+ }
+
+ func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ // Forward to push notification service
+ }
+}
+```
+
+**When to use**: Push notifications, third-party SDKs requiring AppDelegate, background URL sessions, Handoff.
+
+## Migration Priority
+
+When incrementally adopting SwiftUI in a UIKit app:
+
+1. **Leaf screens first** — Settings, About, detail views (no navigation complexity)
+2. **New features in SwiftUI** — Don't rewrite, but build new screens in SwiftUI
+3. **Shared components** — Build reusable SwiftUI components, wrap in `UIHostingController`
+4. **Navigation last** — Don't mix `UINavigationController` with `NavigationStack` in the same flow; migrate entire navigation subtrees
+
+**Don't**: Replace `UINavigationController` with `NavigationStack` for half the app. Either a flow is fully SwiftUI navigation or fully UIKit navigation.
+
+---
+
+# Part 6: Pressure Scenarios
+
+## Scenario 1: "Just hardcode the root for now"
+
+### The Pressure
+
+> "We only have one flow right now. Just show MainView directly, we'll add auth later."
+
+### Red Flags
+- "We'll add X later" → Tech debt that compounds
+- "It's just one flow" → Flows multiply
+- "Keep it simple" → Simplicity now, complexity later
+
+### Time Cost Comparison
+
+| Option | Initial | When Adding Auth | Total |
+|--------|---------|------------------|-------|
+| Hardcode MainView | 0 min | 2-4 hours refactor | 2-4 hours |
+| AppStateController | 30 min | 30 min add state | 1 hour |
+
+### Push-Back Script
+
+> "The AppStateController pattern takes 30 minutes now. When we add auth later — and we will — it'll take another 30 minutes to add the state. Hardcoding now saves 0 minutes because we'll spend 2-4 hours refactoring when we need auth. Let's invest 30 minutes now."
+
+### What to Do
+
+1. Create minimal AppStateController with two states:
+ ```swift
+ enum AppState {
+ case loading
+ case ready
+ }
+ ```
+
+2. When auth is needed, add states:
+ ```swift
+ enum AppState {
+ case loading
+ case unauthenticated // Added
+ case authenticated(User) // Added
+ }
+ ```
+
+3. Total effort: 1 hour instead of 4 hours
+
+---
+
+## Scenario 2: "We don't need modules yet"
+
+### The Pressure
+
+> "Let's keep everything in one target. Modules are over-engineering."
+
+### Decision Framework
+
+| Codebase | Team | Recommendation |
+|----------|------|----------------|
+| < 5,000 lines | 1-2 devs | Single target is fine |
+| 5,000-20,000 lines | 3+ devs | Consider modules |
+| > 20,000 lines | Any | Modules essential |
+
+### Push-Back Script
+
+> "I agree modules add overhead. Let's use this decision tree: We have [X] lines and [Y] developers. Based on that, we [should/shouldn't] modularize yet. If we hit [threshold], we'll revisit. Sound good?"
+
+### What to Do
+
+1. Check codebase size: `find . -name "*.swift" | xargs wc -l`
+2. If under threshold, document decision and threshold for revisit
+3. If over threshold, identify natural boundaries first
+
+---
+
+## Scenario 3: "Navigation is too complex to test"
+
+### The Pressure
+
+> "Testing navigation state is too hard. Let's just do manual QA."
+
+### Why This Fails
+
+- Navigation bugs are #1 "works on my machine" cause
+- Deep linking requires automated verification
+- State restoration needs regression testing
+- Manual QA misses edge cases
+
+### Solution: Test the State Machine
+
+```swift
+// ✅ Test navigation state without UI
+@Test func testLoginCompletesOnboarding() async {
+ let controller = AppStateController()
+ controller.transition(to: .unauthenticated)
+
+ // Simulate login
+ await controller.handleLogin(user: mockUser)
+
+ // First-time user goes to onboarding
+ #expect(controller.state == .onboarding(.welcome))
+}
+
+@Test func testDeepLinkWhileUnauthenticated() async {
+ let controller = AppStateController()
+ controller.transition(to: .unauthenticated)
+
+ // Deep link to order
+ let handled = controller.handleDeepLink(URL(string: "app://order/123")!)
+
+ // Should not navigate — requires auth
+ #expect(handled == false)
+ #expect(controller.state == .unauthenticated)
+}
+```
+
+### Push-Back Script
+
+> "Navigation is complex, which is exactly why we need automated tests. The AppStateController pattern lets us test state transitions without launching the UI. We can verify deep linking, auth flows, and restoration in seconds. Manual QA can't catch all the combinations."
+
+---
+
+# Part 7: Code Review Checklist
+
+## App State
+
+- [ ] App state is an enum, not booleans
+- [ ] All valid states are explicitly defined
+- [ ] State transitions are validated in `isValidTransition`
+- [ ] Invalid transitions are caught (assertion in debug, logged in prod)
+- [ ] State changes are logged for debugging
+
+## Root View
+
+- [ ] @main delegates to AppStateController
+- [ ] No business logic in @main
+- [ ] RootView switches on single source of truth
+- [ ] Transitions are animated (no flicker)
+- [ ] Loading state has minimum display duration
+
+## Scene Lifecycle
+
+- [ ] scenePhase changes handled in one place
+- [ ] Session validated on .active (not blindly trusted)
+- [ ] Resources released on .background
+- [ ] SceneStorage used for tab selection / navigation state
+- [ ] Restored state validated before applying
+
+## Module Boundaries
+
+- [ ] Features have public API protocols
+- [ ] No circular dependencies between modules
+- [ ] Navigation delegates to coordinators
+- [ ] Dependencies injected, not singletons
+- [ ] Module decision documented (why split / not split)
+
+## Testing
+
+- [ ] State transitions tested without UI
+- [ ] Invalid transitions tested
+- [ ] Deep link handling tested
+- [ ] Restoration validation tested
+
+---
+
+## Resources
+
+**WWDC**: 2025-266, 2024-10150, 2023-10149, 2025-256, 2022-10054
+
+**Docs**: /swiftui/scenephase, /swiftui/scene, /swiftui/scenestorage, /swiftui/windowgroup, /observation/observable()
+
+**Skills**: axiom-swiftui-architecture, axiom-swiftui-nav, axiom-swift-concurrency
diff --git a/.claude/skills/axiom-app-composition/agents/openai.yaml b/.claude/skills/axiom-app-composition/agents/openai.yaml
new file mode 100644
index 0000000..a7f618f
--- /dev/null
+++ b/.claude/skills/axiom-app-composition/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Composition"
+ short_description: "Structuring app entry points, managing authentication flows, switching root views, handling scene lifecycle, or askin..."
diff --git a/.claude/skills/axiom-app-discoverability/.openskills.json b/.claude/skills/axiom-app-discoverability/.openskills.json
new file mode 100644
index 0000000..0072276
--- /dev/null
+++ b/.claude/skills/axiom-app-discoverability/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-discoverability",
+ "installedAt": "2026-04-12T08:05:44.178Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-discoverability/SKILL.md b/.claude/skills/axiom-app-discoverability/SKILL.md
new file mode 100644
index 0000000..426eac5
--- /dev/null
+++ b/.claude/skills/axiom-app-discoverability/SKILL.md
@@ -0,0 +1,542 @@
+---
+name: axiom-app-discoverability
+description: Use when making app surface in Spotlight search, Siri suggestions, or system experiences - covers the 6-step strategy combining App Intents, App Shortcuts, Core Spotlight, and NSUserActivity to feed the system metadata for iOS 16+
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Discoverability
+
+## Overview
+
+**Core principle** Feed the system metadata across multiple APIs, let the system decide when to surface your app.
+
+iOS surfaces apps in Spotlight, Siri suggestions, and system experiences based on metadata you provide through App Intents, App Shortcuts, Core Spotlight, and NSUserActivity. The system learns from actual usage and boosts frequently-used actions. No single API is sufficient—comprehensive discoverability requires a multi-API strategy.
+
+**Key insight** iOS boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it. Provide clear, action-oriented metadata and the system does the heavy lifting.
+
+---
+
+## When to Use This Skill
+
+Use this skill when:
+- Making your app appear in Spotlight search results
+- Enabling Siri to suggest your app in relevant contexts
+- Adding app actions to Action Button (iPhone/Apple Watch Ultra)
+- Making app content discoverable system-wide
+- Planning discoverability architecture before implementation
+- Troubleshooting "why isn't my app being suggested?"
+
+Do NOT use this skill when:
+- You need detailed API reference (use app-intents-ref, axiom-app-shortcuts-ref, axiom-core-spotlight-ref)
+- You're implementing a specific API (use the reference skills)
+- You just want to add a single App Intent (use app-intents-ref)
+
+---
+
+## The 6-Step Discoverability Strategy
+
+This is a proven strategy from developers who've implemented discoverability across multiple production apps. **Implementation time: One evening for minimal viable discoverability.**
+
+### Step 1: Add App Intents
+
+App Intents power Spotlight search, Siri requests, and Shortcut suggestions. **Without AppIntents, your app will never surface meaningfully.**
+
+```swift
+struct OrderCoffeeIntent: AppIntent {
+ static var title: LocalizedStringResource = "Order Coffee"
+ static var description = IntentDescription("Orders coffee for pickup")
+
+ @Parameter(title: "Coffee Type")
+ var coffeeType: CoffeeType
+
+ @Parameter(title: "Size")
+ var size: CoffeeSize
+
+ func perform() async throws -> some IntentResult {
+ try await CoffeeService.shared.order(type: coffeeType, size: size)
+ return .result(dialog: "Your \(size) \(coffeeType) is ordered")
+ }
+}
+```
+
+**Why this matters** App Intents are the foundation. Everything else builds on them.
+
+See: **app-intents-ref** for complete API reference
+
+---
+
+### Step 2: Add App Shortcuts with Suggested Phrases
+
+App Shortcuts make your intents **instantly available** after install. No configuration required.
+
+```swift
+struct CoffeeAppShortcuts: AppShortcutsProvider {
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: OrderCoffeeIntent(),
+ phrases: [
+ "Order coffee in \(.applicationName)",
+ "Get my usual coffee from \(.applicationName)"
+ ],
+ shortTitle: "Order Coffee",
+ systemImageName: "cup.and.saucer.fill"
+ )
+ }
+
+ static var shortcutTileColor: ShortcutTileColor = .tangerine
+}
+```
+
+**Why this matters** Without App Shortcuts, users must manually configure shortcuts. With them, your actions appear immediately in Siri, Spotlight, Action Button, and Control Center.
+
+**Critical** Use `suggestedPhrase` patterns—this increases the chance that the system proposes them in Spotlight action suggestions and Siri's carousel.
+
+See: **app-shortcuts-ref** for phrase patterns and best practices
+
+---
+
+### Step 3: Expose Searchable Content via Core Spotlight
+
+Index content that matters. **The system will surface items that match user queries.**
+
+```swift
+import CoreSpotlight
+import UniformTypeIdentifiers
+
+func indexOrder(_ order: Order) {
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Order from \(order.date.formatted())"
+ attributes.keywords = ["coffee", "order", order.coffeeName]
+
+ let item = CSSearchableItem(
+ uniqueIdentifier: order.id.uuidString,
+ domainIdentifier: "orders",
+ attributeSet: attributes
+ )
+
+ CSSearchableIndex.default().indexSearchableItems([item]) { error in
+ if let error = error {
+ print("Indexing error: \(error)")
+ }
+ }
+}
+```
+
+**Why this matters** Core Spotlight makes your app's content searchable. When users search for "latte" in Spotlight, your app's orders appear.
+
+**Index only what matters** Don't index everything. Focus on user-facing content (orders, documents, notes, etc.).
+
+See: **core-spotlight-ref** for batching, deletion patterns, and best practices
+
+---
+
+### Step 4: Use NSUserActivity for High-Value Screens
+
+Mark important screens as eligible for search and prediction.
+
+```swift
+func viewOrder(_ order: Order) {
+ let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.isEligibleForPrediction = true
+ activity.persistentIdentifier = order.id.uuidString
+
+ // Connect to App Intents
+ activity.appEntityIdentifier = order.id.uuidString
+
+ // Provide rich metadata
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.contentDescription = "Your \(order.coffeeName) order"
+ attributes.thumbnailData = order.imageData
+ activity.contentAttributeSet = attributes
+
+ activity.becomeCurrent()
+
+ // In your view controller or SwiftUI view
+ self.userActivity = activity
+}
+```
+
+**Why this matters** The system learns which screens users visit frequently and suggests them proactively. Lock screen widgets, Siri suggestions, and Spotlight all benefit.
+
+**Critical** Only mark screens that users would want to return to. Not settings, not onboarding, not error states.
+
+See: **core-spotlight-ref** for eligibility patterns and activity continuation
+
+---
+
+### Step 5: Provide Correct Intent Metadata
+
+Clear descriptions and titles are critical because **Spotlight displays them directly.**
+
+#### ❌ DON'T: Generic or unclear
+```swift
+static var title: LocalizedStringResource = "Do Thing"
+static var description = IntentDescription("Performs action")
+```
+
+#### ✅ DO: Specific, action-oriented
+```swift
+static var title: LocalizedStringResource = "Order Coffee"
+static var description = IntentDescription("Orders coffee for pickup")
+```
+
+**Parameter summaries must be natural language:**
+
+```swift
+static var parameterSummary: some ParameterSummary {
+ Summary("Order \(\.$size) \(\.$coffeeType)")
+}
+// Siri: "Order large latte"
+```
+
+**Why this matters** Poor metadata means users won't understand what your intent does. Clear metadata = higher usage = system boosts it.
+
+---
+
+### Step 6: Usage-Based Boosting
+
+**The system boosts shortcuts and activities that users actually invoke. If nobody uses an intent, the system hides it.**
+
+This is automatic—you don't control it. What you control:
+1. **Discoverability** — Make it easy to find (Steps 1-5)
+2. **Utility** — Make it worth using (design good intents)
+3. **Promotion** — Show users available shortcuts (SiriTipView)
+
+```swift
+// Promote your shortcuts in-app
+SiriTipView(intent: OrderCoffeeIntent(), isVisible: $showTip)
+ .siriTipViewStyle(.dark)
+```
+
+**Why this matters** Even perfect metadata won't help if users don't know shortcuts exist. Educate users in your app's UI.
+
+See: **app-shortcuts-ref** for SiriTipView and ShortcutsLink patterns
+
+---
+
+## Decision Tree: Which API for Which Use Case
+
+```
+┌─ Need to expose app functionality? ────────────────────────────────┐
+│ │
+│ ┌─ YES → App Intents (AppIntent protocol) │
+│ │ └─ Want instant availability without user setup? │
+│ │ └─ YES → App Shortcuts (AppShortcutsProvider) │
+│ │ │
+│ └─ NO → Exposing app CONTENT (not actions)? │
+│ │ │
+│ ├─ User-initiated activity (viewing screen)? │
+│ │ └─ YES → NSUserActivity with isEligibleForSearch │
+│ │ │
+│ └─ Indexing all content (documents, orders, notes)? │
+│ └─ YES → Core Spotlight (CSSearchableItem) │
+│ │
+│ ┌─ Already using App Intents? │
+│ │ └─ Want automatic Spotlight search for entities? │
+│ │ └─ YES → IndexedEntity protocol │
+│ │ │
+│ └─ Want to connect screen to App Intent entity? │
+│ └─ YES → NSUserActivity.appEntityIdentifier │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Quick Reference Table
+
+| Use Case | API | Example |
+|----------|-----|---------|
+| Expose action to Siri/Shortcuts | `AppIntent` | "Order coffee" |
+| Make action available instantly | `AppShortcut` | Appear in Spotlight immediately |
+| Index all app content | `CSSearchableItem` | All coffee orders searchable |
+| Mark current screen important | `NSUserActivity` | User viewing order detail |
+| Auto-generate Find actions | `IndexedEntity` | "Find orders where..." |
+| Link screen to App Intent | `appEntityIdentifier` | Deep link to specific order |
+
+---
+
+## Quick Implementation Pattern ("One Evening" Approach)
+
+For minimal viable discoverability:
+
+### 1. Define 1-3 Core App Intents (30 minutes)
+```swift
+// Your app's most valuable actions
+struct OrderCoffeeIntent: AppIntent { /* ... */ }
+struct ReorderLastIntent: AppIntent { /* ... */ }
+struct ViewOrdersIntent: AppIntent { /* ... */ }
+```
+
+### 2. Create AppShortcutsProvider (15 minutes)
+```swift
+struct CoffeeAppShortcuts: AppShortcutsProvider {
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: OrderCoffeeIntent(),
+ phrases: ["Order coffee in \(.applicationName)"],
+ shortTitle: "Order",
+ systemImageName: "cup.and.saucer.fill"
+ )
+ // Add 2-3 more shortcuts
+ }
+}
+```
+
+### 3. Index Top-Level Content (30 minutes)
+```swift
+// Index most recent/important content only
+func indexRecentOrders() {
+ let recentOrders = try await OrderService.shared.recent(limit: 20)
+ let items = recentOrders.map { createSearchableItem(from: $0) }
+ CSSearchableIndex.default().indexSearchableItems(items)
+}
+```
+
+### 4. Add NSUserActivity to Detail Screens (30 minutes)
+```swift
+// In your detail view controllers/views
+let activity = NSUserActivity(activityType: "com.app.viewOrder")
+activity.isEligibleForSearch = true
+activity.becomeCurrent()
+self.userActivity = activity
+```
+
+### 5. Test in Spotlight and Shortcuts (15 minutes)
+- Open Shortcuts app → Search for your app → Verify shortcuts appear
+- Search Spotlight → Search for your content → Verify results
+- Invoke Siri → "Order coffee in [YourApp]" → Verify works
+
+**Total time: ~2 hours** for basic discoverability
+
+---
+
+## Batch Indexing for Large Content Libraries
+
+When indexing 1,000+ items, index in batches to avoid launch slowdowns:
+
+```swift
+func indexAllContent() async {
+ let allItems = try await ContentService.shared.all()
+ let batchSize = 100
+
+ for batch in stride(from: 0, to: allItems.count, by: batchSize) {
+ let slice = Array(allItems[batch.. [VisualSearchResult] {
+ // Match visual input to app entities
+ }
+}
+
+// Each entity type needs an OpenIntent
+struct OpenLandmarkIntent: OpenIntent { /* ... */ }
+struct OpenCollectionIntent: OpenIntent { /* ... */ }
+```
+
+### Onscreen Entities
+
+Associate app entities with visible content so users can ask Siri or ChatGPT about what's on screen:
+
+```swift
+struct LandmarkDetailView: View {
+ let landmark: LandmarkEntity
+
+ var body: some View {
+ Group { /* View content */ }
+ .userActivity("com.landmarks.ViewingLandmark") { activity in
+ activity.title = "Viewing \(landmark.name)"
+ activity.appEntityIdentifier = EntityIdentifier(for: landmark)
+ }
+ }
+}
+```
+
+---
+
+## Core Concepts
+
+### The Three Building Blocks
+
+**1. AppIntent** — Executable actions with parameters
+```swift
+struct OrderSoupIntent: AppIntent {
+ static var title: LocalizedStringResource = "Order Soup"
+ static var description: IntentDescription = "Orders soup from the restaurant"
+
+ @Parameter(title: "Soup")
+ var soup: SoupEntity
+
+ @Parameter(title: "Quantity")
+ var quantity: Int?
+
+ func perform() async throws -> some IntentResult {
+ guard let quantity = quantity, quantity < 10 else {
+ throw $quantity.needsValue("Please specify how many soups")
+ }
+
+ try await OrderService.shared.order(soup: soup, quantity: quantity)
+ return .result()
+ }
+}
+```
+
+**2. AppEntity** — Objects users interact with
+```swift
+struct SoupEntity: AppEntity {
+ var id: String
+ var name: String
+ var price: Decimal
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Soup"
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(name)", subtitle: "$\(price)")
+ }
+
+ static var defaultQuery = SoupQuery()
+}
+```
+
+**3. AppEnum** — Enumeration types for parameters
+```swift
+enum SoupSize: String, AppEnum {
+ case small
+ case medium
+ case large
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size"
+ static var caseDisplayRepresentations: [SoupSize: DisplayRepresentation] = [
+ .small: "Small (8 oz)",
+ .medium: "Medium (12 oz)",
+ .large: "Large (16 oz)"
+ ]
+}
+```
+
+---
+
+## AppIntent: Defining Actions
+
+### Essential Properties
+
+```swift
+struct SendMessageIntent: AppIntent {
+ // REQUIRED: Short verb-noun phrase
+ static var title: LocalizedStringResource = "Send Message"
+
+ // REQUIRED: Purpose explanation
+ static var description: IntentDescription = "Sends a message to a contact"
+
+ // OPTIONAL: Discovery in Shortcuts/Spotlight
+ static var isDiscoverable: Bool = true
+
+ // OPTIONAL: Launch app when run
+ static var openAppWhenRun: Bool = false
+
+ // OPTIONAL: Authentication requirement
+ static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
+}
+```
+
+### Parameter Declaration
+
+```swift
+struct BookAppointmentIntent: AppIntent {
+ // Required parameter (non-optional)
+ @Parameter(title: "Service")
+ var service: ServiceEntity
+
+ // Optional parameter
+ @Parameter(title: "Preferred Date")
+ var preferredDate: Date?
+
+ // Parameter with requestValueDialog for disambiguation
+ @Parameter(title: "Location",
+ requestValueDialog: "Which location would you like to visit?")
+ var location: LocationEntity
+
+ // Parameter with default value
+ @Parameter(title: "Duration")
+ var duration: Int = 60
+}
+```
+
+### Parameter Summary (Siri Phrasing)
+
+```swift
+struct OrderIntent: AppIntent {
+ @Parameter(title: "Item")
+ var item: MenuItem
+
+ @Parameter(title: "Quantity")
+ var quantity: Int
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Order \(\.$quantity) \(\.$item)") {
+ \.$quantity
+ \.$item
+ }
+ }
+}
+// Siri: "Order 2 lattes"
+```
+
+### The perform() Method
+
+```swift
+func perform() async throws -> some IntentResult {
+ // 1. Validate parameters
+ guard quantity > 0 && quantity < 100 else {
+ throw ValidationError.invalidQuantity
+ }
+
+ // 2. Execute action
+ let order = try await orderService.placeOrder(
+ item: item,
+ quantity: quantity
+ )
+
+ // 3. Donate for learning (optional)
+ await donation()
+
+ // 4. Return result
+ return .result(
+ value: order,
+ dialog: "Your order for \(quantity) \(item.name) has been placed"
+ )
+}
+```
+
+### Error Handling
+
+```swift
+enum OrderError: Error, CustomLocalizedStringResourceConvertible {
+ case outOfStock(itemName: String)
+ case paymentFailed
+ case networkError
+
+ var localizedStringResource: LocalizedStringResource {
+ switch self {
+ case .outOfStock(let name):
+ return "Sorry, \(name) is out of stock"
+ case .paymentFailed:
+ return "Payment failed. Please check your payment method"
+ case .networkError:
+ return "Network error. Please try again"
+ }
+ }
+}
+
+func perform() async throws -> some IntentResult {
+ if !item.isInStock {
+ throw OrderError.outOfStock(itemName: item.name)
+ }
+ // ...
+}
+```
+
+---
+
+## AppEntity: Representing App Content
+
+### Entity Definition
+
+```swift
+struct BookEntity: AppEntity {
+ // REQUIRED: Unique, persistent identifier
+ var id: UUID
+
+ // App data properties
+ var title: String
+ var author: String
+ var coverImageURL: URL?
+
+ // REQUIRED: Type display name
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book"
+
+ // REQUIRED: Instance display
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(
+ title: "\(title)",
+ subtitle: "by \(author)",
+ image: coverImageURL.map { .init(url: $0) }
+ )
+ }
+
+ // REQUIRED: Query for resolution
+ static var defaultQuery = BookQuery()
+}
+```
+
+### Exposing Properties
+
+```swift
+struct TaskEntity: AppEntity {
+ var id: UUID
+
+ @Property(title: "Title")
+ var title: String
+
+ @Property(title: "Due Date")
+ var dueDate: Date?
+
+ @Property(title: "Priority")
+ var priority: TaskPriority
+
+ @Property(title: "Completed")
+ var isCompleted: Bool
+
+ // Properties exposed to system for filtering/sorting
+}
+```
+
+### Computed and Deferred Properties
+
+#### @ComputedProperty
+
+Computed properties that read directly from a source of truth (no stored value):
+
+```swift
+struct SettingsEntity: UniqueAppEntity {
+ @ComputedProperty
+ var defaultPlace: PlaceDescriptor {
+ UserDefaults.standard.defaultPlace
+ }
+
+ init() { }
+}
+```
+
+#### @DeferredProperty
+
+Properties that are expensive to calculate, only fetched when explicitly requested:
+
+```swift
+struct LandmarkEntity: IndexedEntity {
+ @DeferredProperty
+ var crowdStatus: Int {
+ get async throws {
+ await modelData.getCrowdStatus(self)
+ }
+ }
+}
+```
+
+### Entity Query
+
+```swift
+struct BookQuery: EntityQuery {
+ func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
+ // Fetch entities by IDs
+ return try await BookService.shared.fetchBooks(ids: identifiers)
+ }
+
+ func suggestedEntities() async throws -> [BookEntity] {
+ // Provide suggestions (recent, favorites, etc.)
+ return try await BookService.shared.recentBooks(limit: 10)
+ }
+}
+
+// Optional: Enable string-based search
+extension BookQuery: EntityStringQuery {
+ func entities(matching string: String) async throws -> [BookEntity] {
+ return try await BookService.shared.searchBooks(query: string)
+ }
+}
+```
+
+### Separating Entities from Models
+
+#### ❌ DON'T: Modify core data models
+```swift
+// DON'T make your model conform to AppEntity
+struct Book: AppEntity { // Bad - couples model to intents
+ var id: UUID
+ var title: String
+ // ...
+}
+```
+
+#### ✅ DO: Create dedicated entities
+```swift
+// Your core model
+struct Book {
+ var id: UUID
+ var title: String
+ var isbn: String
+ var pages: Int
+ // ... lots of internal properties
+}
+
+// Separate entity for intents
+struct BookEntity: AppEntity {
+ var id: UUID
+ var title: String
+ var author: String
+
+ // Convert from model
+ init(from book: Book) {
+ self.id = book.id
+ self.title = book.title
+ self.author = book.author.name
+ }
+}
+```
+
+---
+
+## Authentication & Security
+
+### Authentication Policies
+
+```swift
+struct ViewAccountIntent: AppIntent {
+ // No authentication required
+ static var authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed
+}
+
+struct TransferMoneyIntent: AppIntent {
+ // Requires user to be logged in
+ static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication
+}
+
+struct UnlockVaultIntent: AppIntent {
+ // Requires device unlock (Face ID/Touch ID/passcode)
+ static var authenticationPolicy: IntentAuthenticationPolicy = .requiresLocalDeviceAuthentication
+}
+```
+
+---
+
+## Background vs Foreground Execution
+
+### Intent Modes
+
+Use `supportedModes` for granular control over execution context instead of the boolean `openAppWhenRun`:
+
+```swift
+struct GetCrowdStatusIntent: AppIntent {
+ static let supportedModes: IntentModes = [.background, .foreground(.dynamic)]
+
+ func perform() async throws -> some ReturnsValue & ProvidesDialog {
+ guard await modelData.isOpen(landmark) else {
+ return .result(value: 0, dialog: "The landmark is currently closed.")
+ }
+
+ if systemContext.currentMode.canContinueInForeground {
+ do {
+ try await continueInForeground(alwaysConfirm: false)
+ await navigator.navigateToCrowdStatus(landmark)
+ } catch {
+ // Opening app was denied
+ }
+ }
+
+ let status = await modelData.getCrowdStatus(landmark)
+ return .result(value: status, dialog: "Current crowd level: \(status)")
+ }
+}
+```
+
+#### Available Modes
+
+| Mode | Behavior |
+|------|----------|
+| `.background` | Performs entirely in background |
+| `.foreground(.immediate)` | App foregrounded before `perform()` runs |
+| `.foreground(.dynamic)` | Can request foreground during execution |
+| `.foreground(.deferred)` | Background initially, foreground before completion |
+
+#### Common Combinations
+
+| Combination | Use When |
+|-------------|----------|
+| `[.background, .foreground]` | Foreground default, background fallback |
+| `[.background, .foreground(.dynamic)]` | Background default, can request foreground |
+| `[.background, .foreground(.deferred)]` | Background initially, guaranteed foreground when requested |
+
+### Continuing in Foreground
+
+Request foreground transition at runtime when using `.foreground(.dynamic)`:
+
+```swift
+// Normal transition
+try await continueInForeground(alwaysConfirm: false)
+
+// Transition after an error
+throw needsToContinueInForegroundError(
+ IntentDialog("Need to open app to complete this action"),
+ alwaysConfirm: true
+)
+```
+
+### Background Execution (Legacy)
+
+```swift
+struct QuickToggleIntent: AppIntent {
+ static var openAppWhenRun: Bool = false // Runs in background
+
+ func perform() async throws -> some IntentResult {
+ // Executes without opening app
+ await SettingsService.shared.toggle(setting: .darkMode)
+ return .result()
+ }
+}
+```
+
+### Foreground Continuation (Legacy)
+
+```swift
+struct EditDocumentIntent: AppIntent {
+ @Parameter(title: "Document")
+ var document: DocumentEntity
+
+ func perform() async throws -> some IntentResult {
+ // Open app to continue in UI
+ return .result(opensIntent: OpenDocumentIntent(document: document))
+ }
+}
+
+struct OpenDocumentIntent: AppIntent {
+ static var openAppWhenRun: Bool = true
+
+ @Parameter(title: "Document")
+ var document: DocumentEntity
+
+ func perform() async throws -> some IntentResult {
+ // App is now foreground, safe to update UI
+ await MainActor.run {
+ DocumentCoordinator.shared.open(document: document)
+ }
+ return .result()
+ }
+}
+```
+
+---
+
+## Confirmation Dialogs
+
+### Requesting Confirmation
+
+```swift
+struct DeleteTaskIntent: AppIntent {
+ @Parameter(title: "Task")
+ var task: TaskEntity
+
+ func perform() async throws -> some IntentResult {
+ // Request confirmation before destructive action
+ try await requestConfirmation(
+ result: .result(dialog: "Are you sure you want to delete '\(task.title)'?"),
+ confirmationActionName: .init(stringLiteral: "Delete")
+ )
+
+ // User confirmed, proceed
+ try await TaskService.shared.delete(task: task)
+ return .result(dialog: "Task deleted")
+ }
+}
+```
+
+---
+
+## Multiple Choice API
+
+Request user input with structured options:
+
+```swift
+let options = [
+ IntentChoiceOption(title: "Option 1", subtitle: "Description 1"),
+ IntentChoiceOption(title: "Option 2", subtitle: "Description 2"),
+ IntentChoiceOption.cancel(title: "Not now")
+]
+
+let choice = try await requestChoice(
+ between: options,
+ dialog: IntentDialog("Please select an option")
+)
+
+switch choice.id {
+case options[0].id: // Option 1 selected
+case options[1].id: // Option 2 selected
+default: // Cancelled
+}
+```
+
+---
+
+## Interactive Snippets
+
+### Static Snippets
+
+Return a SwiftUI view showing the outcome of an intent:
+
+```swift
+func perform() async throws -> some IntentResult {
+ return .result(view: Text("Order placed!").font(.title))
+}
+```
+
+### SnippetIntent
+
+Return interactive snippets with follow-up action buttons:
+
+```swift
+func perform() async throws -> some IntentResult {
+ let landmark = await findNearestLandmark()
+
+ return .result(
+ value: landmark,
+ opensIntent: OpenLandmarkIntent(landmark: landmark),
+ snippetIntent: LandmarkSnippetIntent(landmark: landmark)
+ )
+}
+
+struct LandmarkSnippetIntent: SnippetIntent {
+ @Parameter var landmark: LandmarkEntity
+
+ var snippet: some View {
+ VStack {
+ Text(landmark.name).font(.headline)
+ Text(landmark.description).font(.body)
+
+ HStack {
+ Button("Add to Favorites") { /* action */ }
+ Button("Search Tickets") { /* action */ }
+ }
+ }
+ .padding()
+ }
+}
+```
+
+---
+
+## Swift Package Support
+
+### AppIntentsPackage
+
+Include App Intents in Swift Packages and static libraries:
+
+```swift
+// In your framework or dynamic library
+public struct LandmarksKitPackage: AppIntentsPackage { }
+
+// In your app target
+struct LandmarksPackage: AppIntentsPackage {
+ static var includedPackages: [any AppIntentsPackage.Type] {
+ [LandmarksKitPackage.self]
+ }
+}
+```
+
+This enables modular intent definitions across package boundaries. The app target aggregates all packages via `includedPackages`.
+
+---
+
+## Apple Intelligence: Use Model Action
+
+### Overview
+
+The **Use Model action** in Shortcuts (iOS 18.1+) allows users to incorporate Apple Intelligence models into their automation workflows. Your app's entities can be passed to language models for filtering, transformation, and reasoning.
+
+**Key capability** Under the hood, the action passes a JSON representation of your entity to the model, so you'll want to make sure to expose any information you want it to be able to reason over, in the entity definition.
+
+### Three Output Types
+
+#### 1. Text (AttributedString)
+- Models often respond with Rich Text (bold, italic, lists, tables)
+- Use `AttributedString` type for text parameters to preserve formatting
+- Enables lossless transfer from model to your app
+
+#### 2. Dictionary
+- Structured data extraction from unstructured input
+- Useful for parsing PDFs, emails, documents
+- Example: Extract vendor, amount, date from invoice
+
+#### 3. App Entities (Your Types)
+- Pass lists of entities to models for filtering/reasoning
+- Model receives JSON representation of entities
+- Example: "Filter calendar events related to my trip"
+
+### Exposing Entities to Models
+
+Models receive a JSON representation of your entities including:
+
+**1. All exposed properties** (converted to strings)
+```swift
+struct EventEntity: AppEntity {
+ var id: UUID
+
+ @Property(title: "Title")
+ var title: String
+
+ @Property(title: "Start Date")
+ var startDate: Date
+
+ @Property(title: "End Date")
+ var endDate: Date
+
+ @Property(title: "Notes")
+ var notes: String?
+
+ // All @Property values included in JSON for model
+}
+```
+
+**2. Type display representation** (hints what entity represents)
+```swift
+static var typeDisplayRepresentation: TypeDisplayRepresentation = "Calendar Event"
+```
+
+**3. Display representation** (title and subtitle)
+```swift
+var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(
+ title: "\(title)",
+ subtitle: "\(startDate.formatted())"
+ )
+}
+```
+
+#### Example JSON sent to model
+```json
+{
+ "type": "Calendar Event",
+ "title": "Team Meeting",
+ "subtitle": "Jan 15, 2025 at 2:00 PM",
+ "properties": {
+ "Title": "Team Meeting",
+ "Start Date": "2025-01-15T14:00:00Z",
+ "End Date": "2025-01-15T15:00:00Z",
+ "Notes": "Discuss Q1 roadmap"
+ }
+}
+```
+
+### Supporting Rich Text with AttributedString
+
+**Why it matters** If your app supports Rich Text content, now is the time to make sure your app intents use the attributed string type for text parameters where appropriate.
+
+#### ❌ DON'T: Use plain String
+```swift
+struct CreateNoteIntent: AppIntent {
+ @Parameter(title: "Content")
+ var content: String // Loses formatting from model
+}
+```
+
+#### ✅ DO: Use AttributedString
+```swift
+struct CreateNoteIntent: AppIntent {
+ @Parameter(title: "Content")
+ var content: AttributedString // Preserves Rich Text
+
+ func perform() async throws -> some IntentResult {
+ let note = Note(content: content) // Rich Text preserved
+ try await NoteService.shared.save(note)
+ return .result()
+ }
+}
+```
+
+#### Real-world example from WWDC
+Bear app's Create Note accepts AttributedString, allowing diary templates from ChatGPT to include:
+- Bold headings
+- Mood logging tables
+- Formatted lists
+- All preserved losslessly
+
+### Automatic Type Conversion
+
+When Use Model output connects to another action, the runtime automatically converts types:
+
+#### Example: Boolean for If actions
+```swift
+// User's shortcut:
+// 1. Get notes created today
+// 2. For each note:
+// - Use Model: "Is this note related to developing features for Shortcuts?"
+// - If [model output] = yes:
+// - Add to Shortcuts Projects folder
+```
+
+Instead of returning verbose text like "Yes, this note seems to be about developing features for the Shortcuts app", the model automatically returns a Boolean (`true`/`false`) when connected to an If action.
+
+#### Explicit output types available
+- Text (AttributedString)
+- Number
+- Boolean
+- Dictionary
+- Date
+- App Entities
+
+### Follow-Up Feature
+
+Enable iterative refinement before passing to next action:
+
+```swift
+// User runs shortcut:
+// 1. Get recipe from Safari
+// 2. Use Model: "Extract ingredients list"
+// - Follow Up: enabled
+// - User types: "Double the recipe"
+// - Model adjusts: 800g flour instead of 400g
+// 3. Add to Grocery List in Things app
+```
+
+#### When to use
+- Recipe modifications (scale servings, substitute ingredients)
+- Content refinement (adjust tone, length, style)
+- Data validation (confirm extracted values before saving)
+
+---
+
+## IndexedEntity: Automatic Find Actions
+
+### Overview
+
+**IndexedEntity** dramatically reduces boilerplate by auto-generating Find actions from your Spotlight integration. Instead of manually implementing `EntityQuery` and `EntityPropertyQuery`, adopt IndexedEntity to get:
+
+- Automatic Find action in Shortcuts
+- Property-based filtering
+- Search support
+- Minimal code required
+
+### Basic Implementation
+
+```swift
+struct EventEntity: AppEntity, IndexedEntity {
+ var id: UUID
+
+ // 1. Properties with indexing keys
+ @Property(title: "Title", indexingKey: \.eventTitle)
+ var title: String
+
+ @Property(title: "Start Date", indexingKey: \.startDate)
+ var startDate: Date
+
+ @Property(title: "End Date", indexingKey: \.endDate)
+ var endDate: Date
+
+ // 2. Custom key for properties without standard Spotlight attribute
+ @Property(title: "Notes", customIndexingKey: "eventNotes")
+ var notes: String?
+
+ // Display representation automatically maps to Spotlight
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(
+ title: "\(title)",
+ subtitle: "\(startDate.formatted())"
+ // title → kMDItemTitle
+ // subtitle → kMDItemDescription
+ // image → kMDItemContentType (if provided)
+ )
+ }
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
+}
+```
+
+### Indexing Key Mapping
+
+#### Standard Spotlight attribute keys
+```swift
+// Common Spotlight keys for events
+@Property(title: "Title", indexingKey: \.eventTitle)
+var title: String
+
+@Property(title: "Start Date", indexingKey: \.startDate)
+var startDate: Date
+
+@Property(title: "Location", indexingKey: \.eventLocation)
+var location: String?
+```
+
+#### Custom keys for non-standard attributes
+```swift
+@Property(title: "Notes", customIndexingKey: "eventNotes")
+var notes: String?
+
+@Property(title: "Attendee Count", customIndexingKey: "attendeeCount")
+var attendeeCount: Int
+```
+
+### Auto-Generated Find Action
+
+With IndexedEntity conformance, users get this Find action automatically:
+
+#### In Shortcuts app
+```
+Find Events where:
+ - Title contains "Team"
+ - Start Date is today
+ - Location is "San Francisco"
+```
+
+#### Without IndexedEntity, you'd need to manually implement
+- `EnumerableEntityQuery` protocol
+- `EntityPropertyQuery` protocol
+- Property filters for each searchable field
+- Search/suggestion logic
+
+**With IndexedEntity** Just add indexing keys, done!
+
+### Search Support
+
+Enable string-based search by implementing `EntityStringQuery`:
+
+```swift
+extension EventEntityQuery: EntityStringQuery {
+ func entities(matching string: String) async throws -> [EventEntity] {
+ return try await EventService.shared.search(query: string)
+ }
+}
+```
+
+Or rely on IndexedEntity + Spotlight for automatic search.
+
+### Explicit Spotlight Indexing
+
+For entities that need custom searchable attributes or manual index management:
+
+```swift
+extension LandmarkEntity {
+ var searchableAttributes: CSSearchableItemAttributeSet {
+ let attributes = CSSearchableItemAttributeSet()
+ attributes.title = name
+ attributes.namedLocation = regionDescription
+ attributes.keywords = activities
+ attributes.latitude = NSNumber(value: coordinate.latitude)
+ attributes.longitude = NSNumber(value: coordinate.longitude)
+ attributes.supportsNavigation = true
+ return attributes
+ }
+}
+
+// Add entities to index
+func indexLandmarks() async {
+ let landmarks = await fetchLandmarks()
+ try await CSSearchableIndex.default().indexAppEntities(landmarks, priority: .normal)
+}
+
+// Remove from index when deleted
+func deleteLandmark(_ landmark: LandmarkEntity) async {
+ await dataStore.delete(landmark)
+ try await CSSearchableIndex.default().deleteAppEntities(
+ identifiedBy: [landmark.id],
+ ofType: LandmarkEntity.self
+ )
+}
+```
+
+### Example: Travel Tracking App
+
+Apple's sample code (App Intents Travel Tracking App) demonstrates IndexedEntity:
+
+```swift
+struct TripEntity: AppEntity, IndexedEntity {
+ var id: UUID
+
+ @Property(title: "Name", indexingKey: \.title)
+ var name: String
+
+ @Property(title: "Start Date", indexingKey: \.startDate)
+ var startDate: Date
+
+ @Property(title: "End Date", indexingKey: \.endDate)
+ var endDate: Date
+
+ @Property(title: "Destination", customIndexingKey: "destination")
+ var destination: String
+
+ // Auto-generated Find Trips action with filters for all properties
+}
+```
+
+---
+
+## Spotlight on Mac
+
+### Overview
+
+**Spotlight on Mac** (macOS Sequoia+) allows users to run your app's intents directly from system search. Intents that work in Shortcuts automatically work in Spotlight with proper configuration.
+
+**Key principle** Spotlight is all about running things quickly. To do that, people need to be able to provide all the information your intent needs to run directly in Spotlight.
+
+### Requirements for Spotlight Visibility
+
+#### 1. Parameter Summary Must Include All Required Parameters
+
+The parameter summary, which is what people will see in Spotlight UI, must contain all required parameters that don't have a default value.
+
+#### ❌ WON'T SHOW in Spotlight
+```swift
+struct CreateEventIntent: AppIntent {
+ static var title: LocalizedStringResource = "Create Event"
+
+ @Parameter(title: "Title")
+ var title: String
+
+ @Parameter(title: "Start Date")
+ var startDate: Date
+
+ @Parameter(title: "End Date")
+ var endDate: Date
+
+ @Parameter(title: "Notes") // Required, no default
+ var notes: String
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)")
+ // Missing 'notes' parameter!
+ }
+}
+```
+
+#### ✅ WILL SHOW in Spotlight (Option 1: Make optional)
+```swift
+@Parameter(title: "Notes")
+var notes: String? // Optional - can omit from summary
+```
+
+#### ✅ WILL SHOW in Spotlight (Option 2: Provide default)
+```swift
+@Parameter(title: "Notes")
+var notes: String = "" // Has default - can omit from summary
+```
+
+#### ✅ WILL SHOW in Spotlight (Option 3: Include in summary)
+```swift
+static var parameterSummary: some ParameterSummary {
+ Summary("Create '\(\.$title)' from \(\.$startDate) to \(\.$endDate)") {
+ \.$notes // All required params included
+ }
+}
+```
+
+#### 2. Intent Must Not Be Hidden
+
+Intents hidden from Shortcuts won't appear in Spotlight:
+
+```swift
+// ❌ Hidden from Spotlight
+static var isDiscoverable: Bool = false
+
+// ❌ Hidden from Spotlight
+static var assistantOnly: Bool = true
+
+// ❌ Hidden from Spotlight
+// Intent with no perform() method (widget configuration only)
+```
+
+### Providing Suggestions
+
+Make parameter filling quick with suggestions:
+
+#### Option 1: Suggested Entities (Subset of Large List)
+```swift
+struct EventEntityQuery: EntityQuery {
+ func entities(for identifiers: [UUID]) async throws -> [EventEntity] {
+ return try await EventService.shared.fetchEvents(ids: identifiers)
+ }
+
+ // Provide upcoming events, not all past/present events
+ func suggestedEntities() async throws -> [EventEntity] {
+ return try await EventService.shared.upcomingEvents(limit: 10)
+ }
+}
+```
+
+#### Option 2: All Entities (Small, Bounded List)
+```swift
+struct TimezoneQuery: EnumerableEntityQuery {
+ func allEntities() async throws -> [TimezoneEntity] {
+ // Small list - provide all
+ return TimezoneEntity.allTimezones
+ }
+}
+```
+
+**Use suggested entities when** List is large or unbounded (calendar events, notes, contacts)
+**Use all entities when** List is small and bounded (timezones, priority levels, categories)
+
+### On-Screen Content Tagging
+
+Suggest currently active content:
+
+```swift
+// In your detail view controller
+func showEventDetail(_ event: Event) {
+ let activity = NSUserActivity(activityType: "com.myapp.viewEvent")
+ activity.persistentIdentifier = event.id.uuidString
+
+ // Spotlight suggests this event for parameters
+ activity.appEntityIdentifier = event.id.uuidString
+
+ userActivity = activity
+}
+```
+
+For more details on on-screen content tagging, see the "Exploring New Advances in App Intents" session.
+
+### Search Beyond Suggestions
+
+**Basic filtering** (automatic):
+If you provide suggestions, Spotlight automatically filters them as user types.
+
+**Deep search** (requires implementation):
+For searching beyond suggestions:
+
+#### Option 1: EntityStringQuery
+```swift
+extension EventQuery: EntityStringQuery {
+ func entities(matching string: String) async throws -> [EventEntity] {
+ return try await EventService.shared.search(query: string)
+ }
+}
+```
+
+#### Option 2: IndexedEntity
+```swift
+struct EventEntity: AppEntity, IndexedEntity {
+ // Spotlight search automatically supported
+}
+```
+
+### Background vs Foreground Intents
+
+#### Pattern: Paired Intents with opensIntent
+
+```swift
+// Background intent - runs without opening app
+struct CreateEventIntent: AppIntent {
+ static var openAppWhenRun: Bool = false
+
+ @Parameter(title: "Title")
+ var title: String
+
+ @Parameter(title: "Start Date")
+ var startDate: Date
+
+ func perform() async throws -> some IntentResult {
+ let event = try await EventService.shared.createEvent(
+ title: title,
+ startDate: startDate
+ )
+
+ // Optionally open app to view created event
+ return .result(
+ value: EventEntity(from: event),
+ opensIntent: OpenEventIntent(event: EventEntity(from: event))
+ )
+ }
+}
+
+// Foreground intent - opens app to specific event
+struct OpenEventIntent: AppIntent {
+ static var openAppWhenRun: Bool = true
+
+ @Parameter(title: "Event")
+ var event: EventEntity
+
+ func perform() async throws -> some IntentResult {
+ await MainActor.run {
+ EventCoordinator.shared.showEvent(id: event.id)
+ }
+ return .result()
+ }
+}
+```
+
+#### User experience
+1. User runs "Create Event" in Spotlight (background)
+2. Event created without opening app
+3. Spotlight shows "Open in App" button (opensIntent)
+4. User taps button → App opens to event detail
+
+### Predictable Intent Protocol
+
+Enable Spotlight suggestions based on usage patterns:
+
+```swift
+struct OrderCoffeeIntent: AppIntent, PredictableIntent {
+ static var title: LocalizedStringResource = "Order Coffee"
+
+ @Parameter(title: "Coffee Type")
+ var coffeeType: CoffeeType
+
+ @Parameter(title: "Size")
+ var size: CoffeeSize
+
+ func perform() async throws -> some IntentResult {
+ // Order logic
+ return .result()
+ }
+}
+```
+
+Spotlight learns when/how user runs this intent and surfaces suggestions proactively.
+
+---
+
+## Automations on Mac
+
+### Overview
+
+**Personal Automations** arrive on macOS (macOS Sequoia+) with Mac-specific triggers:
+
+#### New Mac Automation Types
+- **Folder Automation** — Trigger when files added/removed from folder
+- **External Drive Automation** — Trigger when drive connected/disconnected
+- Time of Day (from iOS)
+- Bluetooth (from iOS)
+- And more...
+
+**Example use case** Invoice processing shortcut runs automatically every time a new invoice is added to ~/Documents/Invoices folder.
+
+### Automatic Availability
+
+As long as your intent is available on macOS, they will also be available to use in Shortcuts to run as a part of Automations on Mac. This includes iOS apps that are installable on macOS.
+
+**No additional code required** — your existing intents work in automations automatically.
+
+### Platform Support
+
+```swift
+struct ProcessInvoiceIntent: AppIntent {
+ static var title: LocalizedStringResource = "Process Invoice"
+
+ // Available on macOS automatically
+ // Also works: iOS apps installed on Mac (Catalyst, Mac Catalyst)
+
+ @Parameter(title: "Invoice")
+ var invoice: FileEntity
+
+ func perform() async throws -> some IntentResult {
+ // Extract data, add to spreadsheet, etc.
+ return .result()
+ }
+}
+```
+
+### Additional System Integration Points
+
+With automations, your intents are now accessible from:
+- **Siri** — Voice commands
+- **Shortcuts app** — Manual workflows
+- **Spotlight** — Quick actions
+- **Automations** — Triggered workflows
+- **Action Button** — Hardware trigger (Apple Watch Ultra)
+- **Control Center** — Quick controls
+- **Widgets** — Interactive elements
+- **Live Activities** — Dynamic Island
+
+---
+
+## Assistant Schemas (Pre-built Intents)
+
+Apple provides pre-built schemas for common app categories:
+
+### Books App Example
+
+```swift
+import AppIntents
+import BooksIntents
+
+struct OpenBookIntent: BooksOpenBookIntent {
+ @Parameter(title: "Book")
+ var target: BookEntity
+
+ func perform() async throws -> some IntentResult {
+ await MainActor.run {
+ BookReader.shared.open(book: target)
+ }
+ return .result()
+ }
+}
+```
+
+### Available Assistant Schemas
+
+- **BooksIntents** — Navigate pages, open books, play audiobooks, search
+- **BrowserIntents** — Bookmark tabs, clear history, manage windows
+- **CameraIntents** — Capture modes, device switching, start/stop
+- **EmailIntents** — Draft management, reply, forward, archive
+- **PhotosIntents** — Album/asset management, editing, filtering
+- **PresentationsIntents** — Slide creation, media insertion, playback
+- **SpreadsheetsIntents** — Sheet management, content addition
+- **DocumentsIntents** — File management, page manipulation, search
+
+---
+
+## Testing & Debugging
+
+### Testing with Shortcuts App
+
+1. **Add intent to Shortcuts**:
+ - Open Shortcuts app
+ - Tap "+" to create new shortcut
+ - Search for your app name
+ - Select your intent
+
+2. **Test parameter resolution**:
+ - Fill in parameters
+ - Run shortcut
+ - Check Xcode console for logs
+
+3. **Test with Siri**:
+ - "Hey Siri, [your intent name]"
+ - Siri should prompt for parameters
+ - Verify dialog text and results
+
+### Xcode Intent Testing
+
+```swift
+// In your app target, not tests
+#if DEBUG
+extension OrderSoupIntent {
+ static func testIntent() async throws {
+ let intent = OrderSoupIntent()
+ intent.soup = SoupEntity(id: "1", name: "Tomato", price: 8.99)
+ intent.quantity = 2
+
+ let result = try await intent.perform()
+ print("Result: \(result)")
+ }
+}
+#endif
+```
+
+### Common Debugging Issues
+
+#### Issue 1: Intent not appearing in Shortcuts
+```swift
+// ❌ Problem: isDiscoverable = false or missing
+struct MyIntent: AppIntent {
+ // Missing isDiscoverable
+}
+
+// ✅ Solution: Make discoverable
+struct MyIntent: AppIntent {
+ static var isDiscoverable: Bool = true
+}
+```
+
+#### Issue 2: Parameter not resolving
+```swift
+// ❌ Problem: Missing defaultQuery
+struct ProductEntity: AppEntity {
+ var id: String
+ // Missing defaultQuery
+}
+
+// ✅ Solution: Add query
+struct ProductEntity: AppEntity {
+ var id: String
+ static var defaultQuery = ProductQuery()
+}
+```
+
+#### Issue 3: Intent crashes in background
+```swift
+// ❌ Problem: Accessing MainActor from background
+func perform() async throws -> some IntentResult {
+ UIApplication.shared.open(url) // Crash! MainActor only
+ return .result()
+}
+
+// ✅ Solution: Use MainActor or openAppWhenRun
+func perform() async throws -> some IntentResult {
+ await MainActor.run {
+ UIApplication.shared.open(url)
+ }
+ return .result()
+}
+```
+
+#### Issue 4: Entity query returns empty results
+```swift
+// ❌ Problem: entities(for:) not implemented
+struct BookQuery: EntityQuery {
+ // Missing entities(for:) implementation
+}
+
+// ✅ Solution: Implement required methods
+struct BookQuery: EntityQuery {
+ func entities(for identifiers: [UUID]) async throws -> [BookEntity] {
+ return try await BookService.shared.fetchBooks(ids: identifiers)
+ }
+
+ func suggestedEntities() async throws -> [BookEntity] {
+ return try await BookService.shared.recentBooks(limit: 10)
+ }
+}
+```
+
+---
+
+## Best Practices
+
+### 1. Intent Naming
+
+#### ❌ DON'T: Generic or unclear
+```swift
+static var title: LocalizedStringResource = "Do Thing"
+static var title: LocalizedStringResource = "Process"
+```
+
+#### ✅ DO: Verb-noun, specific
+```swift
+static var title: LocalizedStringResource = "Send Message"
+static var title: LocalizedStringResource = "Book Appointment"
+static var title: LocalizedStringResource = "Start Workout"
+```
+
+### 2. Parameter Summary
+
+#### ❌ DON'T: Technical or confusing
+```swift
+static var parameterSummary: some ParameterSummary {
+ Summary("Execute \(\.$action) with \(\.$target)")
+}
+```
+
+#### ✅ DO: Natural language
+```swift
+static var parameterSummary: some ParameterSummary {
+ Summary("Send \(\.$message) to \(\.$contact)")
+}
+// Siri: "Send 'Hello' to John"
+```
+
+### 3. Error Messages
+
+#### ❌ DON'T: Technical jargon
+```swift
+throw MyError.validationFailed("Invalid parameter state")
+```
+
+#### ✅ DO: User-friendly
+```swift
+throw MyError.outOfStock("Sorry, this item is currently unavailable")
+```
+
+### 4. Entity Suggestions
+
+#### ❌ DON'T: Return all entities
+```swift
+func suggestedEntities() async throws -> [TaskEntity] {
+ return try await TaskService.shared.allTasks() // Could be thousands!
+}
+```
+
+#### ✅ DO: Limit to recent/relevant
+```swift
+func suggestedEntities() async throws -> [TaskEntity] {
+ return try await TaskService.shared.recentTasks(limit: 10)
+}
+```
+
+### 5. Async Operations
+
+#### ❌ DON'T: Block main thread
+```swift
+func perform() async throws -> some IntentResult {
+ let data = URLSession.shared.synchronousDataTask(url) // Blocks!
+ return .result()
+}
+```
+
+#### ✅ DO: Use async/await
+```swift
+func perform() async throws -> some IntentResult {
+ let data = try await URLSession.shared.data(from: url)
+ return .result()
+}
+```
+
+---
+
+## Real-World Examples
+
+### Example 1: Start Workout Intent
+
+```swift
+struct StartWorkoutIntent: AppIntent {
+ static var title: LocalizedStringResource = "Start Workout"
+ static var description: IntentDescription = "Starts a new workout session"
+ static var openAppWhenRun: Bool = true
+
+ @Parameter(title: "Workout Type")
+ var workoutType: WorkoutType
+
+ @Parameter(title: "Duration (minutes)")
+ var duration: Int?
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Start \(\.$workoutType)") {
+ \.$duration
+ }
+ }
+
+ func perform() async throws -> some IntentResult {
+ let workout = Workout(
+ type: workoutType,
+ duration: duration.map { TimeInterval($0 * 60) }
+ )
+
+ await MainActor.run {
+ WorkoutCoordinator.shared.start(workout)
+ }
+
+ return .result(
+ dialog: "Starting \(workoutType.displayName) workout"
+ )
+ }
+}
+
+enum WorkoutType: String, AppEnum {
+ case running
+ case cycling
+ case swimming
+ case yoga
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Workout Type"
+ static var caseDisplayRepresentations: [WorkoutType: DisplayRepresentation] = [
+ .running: "Running",
+ .cycling: "Cycling",
+ .swimming: "Swimming",
+ .yoga: "Yoga"
+ ]
+
+ var displayName: String {
+ switch self {
+ case .running: return "running"
+ case .cycling: return "cycling"
+ case .swimming: return "swimming"
+ case .yoga: return "yoga"
+ }
+ }
+}
+```
+
+### Example 2: Add Task with Entity Query
+
+```swift
+struct AddTaskIntent: AppIntent {
+ static var title: LocalizedStringResource = "Add Task"
+ static var description: IntentDescription = "Creates a new task"
+ static var isDiscoverable: Bool = true
+
+ @Parameter(title: "Title")
+ var title: String
+
+ @Parameter(title: "List")
+ var list: TaskListEntity?
+
+ @Parameter(title: "Due Date")
+ var dueDate: Date?
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Add '\(\.$title)'") {
+ \.$list
+ \.$dueDate
+ }
+ }
+
+ func perform() async throws -> some IntentResult {
+ let task = try await TaskService.shared.createTask(
+ title: title,
+ list: list?.id,
+ dueDate: dueDate
+ )
+
+ return .result(
+ value: TaskEntity(from: task),
+ dialog: "Task '\(title)' added"
+ )
+ }
+}
+
+struct TaskListEntity: AppEntity {
+ var id: UUID
+ var name: String
+ var color: String
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "List"
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(
+ title: "\(name)",
+ image: .init(systemName: "list.bullet")
+ )
+ }
+
+ static var defaultQuery = TaskListQuery()
+}
+
+struct TaskListQuery: EntityQuery, EntityStringQuery {
+ func entities(for identifiers: [UUID]) async throws -> [TaskListEntity] {
+ return try await TaskService.shared.fetchLists(ids: identifiers)
+ }
+
+ func suggestedEntities() async throws -> [TaskListEntity] {
+ // Provide user's favorite lists
+ return try await TaskService.shared.favoriteLists(limit: 5)
+ }
+
+ func entities(matching string: String) async throws -> [TaskListEntity] {
+ return try await TaskService.shared.searchLists(query: string)
+ }
+}
+```
+
+---
+
+## App Intents Checklist
+
+### Before Submitting to App Store
+
+- ☐ All intents have clear, localized titles and descriptions
+- ☐ Parameter summaries use natural language phrasing
+- ☐ Error messages are user-friendly, not technical
+- ☐ Authentication policies match data sensitivity
+- ☐ Entity queries return reasonable suggestion counts (< 20)
+- ☐ Intents marked `isDiscoverable` appear in Shortcuts
+- ☐ Destructive actions request confirmation
+- ☐ Background intents don't access MainActor
+- ☐ Foreground intents set `openAppWhenRun = true`
+- ☐ Entity `displayRepresentation` shows meaningful info
+- ☐ Tested with Siri voice commands
+- ☐ Tested in Shortcuts app
+- ☐ Tested with different parameter combinations
+- ☐ Verified localization for all supported languages
+
+---
+
+## Resources
+
+**WWDC**: 2025-244, 2025-275, 2025-260
+
+**Docs**: /appintents, /appintents/appintent, /appintents/appentity, /Updates/AppIntents
+
+**Skills**: axiom-app-shortcuts-ref, axiom-core-spotlight-ref, axiom-app-discoverability
+
+---
+
+**Remember** App Intents are how users interact with your app through Siri, Shortcuts, and system features. Well-designed intents feel like a natural extension of your app's functionality and provide value across Apple's ecosystem.
diff --git a/.claude/skills/axiom-app-intents-ref/agents/openai.yaml b/.claude/skills/axiom-app-intents-ref/agents/openai.yaml
new file mode 100644
index 0000000..2077ea0
--- /dev/null
+++ b/.claude/skills/axiom-app-intents-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Intents Reference"
+ short_description: "Integrating App Intents for Siri, Apple Intelligence, Shortcuts, Spotlight, or system experiences"
diff --git a/.claude/skills/axiom-app-shortcuts-ref/.openskills.json b/.claude/skills/axiom-app-shortcuts-ref/.openskills.json
new file mode 100644
index 0000000..8944e11
--- /dev/null
+++ b/.claude/skills/axiom-app-shortcuts-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-shortcuts-ref",
+ "installedAt": "2026-04-12T08:05:45.553Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-shortcuts-ref/SKILL.md b/.claude/skills/axiom-app-shortcuts-ref/SKILL.md
new file mode 100644
index 0000000..3159142
--- /dev/null
+++ b/.claude/skills/axiom-app-shortcuts-ref/SKILL.md
@@ -0,0 +1,828 @@
+---
+name: axiom-app-shortcuts-ref
+description: Use when implementing App Shortcuts for instant Siri/Spotlight availability, configuring AppShortcutsProvider, adding suggested phrases, or debugging shortcuts not appearing - covers complete App Shortcuts API for iOS 16+
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Shortcuts Reference
+
+## Overview
+
+Comprehensive guide to App Shortcuts framework for making your app's actions instantly available in Siri, Spotlight, Action Button, Control Center, and other system experiences. App Shortcuts are pre-configured App Intents that work immediately after app install—no user setup required.
+
+**Key distinction** App Intents are the actions; App Shortcuts are the pre-configured "surface" that makes those actions instantly discoverable system-wide.
+
+---
+
+## When to Use This Skill
+
+Use this skill when:
+- Implementing AppShortcutsProvider for your app
+- Adding suggested phrases for Siri invocation
+- Configuring instant Spotlight availability
+- Creating parameterized shortcuts (skip Siri clarification)
+- Using NegativeAppShortcutPhrase to prevent false positives (iOS 17+)
+- Promoting shortcuts with SiriTipView
+- Updating shortcuts dynamically with updateAppShortcutParameters()
+- Debugging shortcuts not appearing in Shortcuts app or Spotlight
+- Choosing between App Intents and App Shortcuts
+
+Do NOT use this skill for:
+- General App Intents implementation (use app-intents-ref)
+- Core Spotlight indexing (use core-spotlight-ref)
+- Overall discoverability strategy (use app-discoverability)
+
+---
+
+## Related Skills
+
+- **app-intents-ref** — Complete App Intents implementation reference
+- **app-discoverability** — Strategic guide for making apps discoverable
+- **core-spotlight-ref** — Core Spotlight and NSUserActivity integration
+
+---
+
+## App Shortcuts vs App Intents
+
+| Aspect | App Intent | App Shortcut |
+|--------|-----------|--------------|
+| **Discovery** | Must be found in Shortcuts app | Instantly available after install |
+| **Configuration** | User configures in Shortcuts | Pre-configured by developer |
+| **Siri activation** | Requires custom phrase setup | Works immediately with provided phrases |
+| **Spotlight** | Requires donation or IndexedEntity | Appears automatically |
+| **Action button** | Not directly accessible | Can be assigned immediately |
+| **Setup time** | Minutes per user | Zero |
+
+**When to use App Shortcuts** Every app should provide App Shortcuts for core functionality. They dramatically improve discoverability with zero user effort.
+
+---
+
+## Core Concepts
+
+### AppShortcutsProvider Protocol
+
+**Required conformance** Your app must have exactly one type conforming to `AppShortcutsProvider`.
+
+```swift
+struct MyAppShortcuts: AppShortcutsProvider {
+ // Required: Define your shortcuts
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] { get }
+
+ // Optional: Branding color
+ static var shortcutTileColor: ShortcutTileColor { get }
+
+ // Optional: Dynamic updates
+ static func updateAppShortcutParameters()
+
+ // Optional: Negative phrases (iOS 17+)
+ static var negativePhrases: [NegativeAppShortcutPhrase] { get }
+}
+```
+
+**Platform support** iOS 16+, iPadOS 16+, macOS 13+, tvOS 16+, watchOS 9+
+
+---
+
+### AppShortcut Structure
+
+Associates an `AppIntent` with spoken phrases and metadata.
+
+```swift
+AppShortcut(
+ intent: StartMeditationIntent(),
+ phrases: [
+ "Start meditation in \(.applicationName)",
+ "Begin mindfulness with \(.applicationName)"
+ ],
+ shortTitle: "Meditate",
+ systemImageName: "figure.mind.and.body"
+)
+```
+
+**Components:**
+- `intent` — The App Intent to execute
+- `phrases` — Spoken/typed phrases for Siri/Spotlight
+- `shortTitle` — Short label for Shortcuts app tiles
+- `systemImageName` — SF Symbol for visual representation
+
+---
+
+### AppShortcutPhrase (Suggested Phrases)
+
+**String interpolation** Phrases use `\(.applicationName)` to dynamically include your app's name.
+
+```swift
+phrases: [
+ "Start meditation in \(.applicationName)",
+ "Meditate with \(.applicationName)"
+]
+```
+
+**User sees in Siri/Spotlight:**
+- "Start meditation in Calm"
+- "Meditate with Calm"
+
+**Why this matters** The system uses these exact phrases to trigger your intent via Siri and show suggestions in Spotlight.
+
+---
+
+### @AppShortcutsBuilder
+
+Result builder for defining shortcuts array.
+
+```swift
+@AppShortcutsBuilder
+static var appShortcuts: [AppShortcut] {
+ AppShortcut(intent: OrderIntent(), /* ... */)
+ AppShortcut(intent: ReorderIntent(), /* ... */)
+
+ if UserDefaults.standard.bool(forKey: "premiumUser") {
+ AppShortcut(intent: CustomizeIntent(), /* ... */)
+ }
+}
+```
+
+**Result builder features:**
+- Conditional shortcuts (if/else)
+- Loop-generated shortcuts (for-in)
+- Inline array construction
+
+---
+
+## Phrase Template Patterns
+
+### Basic Phrases (No Parameters)
+
+```swift
+AppShortcut(
+ intent: StartWorkoutIntent(),
+ phrases: [
+ "Start workout in \(.applicationName)",
+ "Begin exercise with \(.applicationName)",
+ "Work out in \(.applicationName)"
+ ],
+ shortTitle: "Start Workout",
+ systemImageName: "figure.run"
+)
+```
+
+**Benefits:**
+- Simple, discoverable
+- Works for all users
+- No parameter ambiguity
+
+**Use when** Intent has no required parameters or parameters have defaults.
+
+---
+
+### Parameterized Phrases (Skip Clarification)
+
+Pre-configure intents with specific parameter values to skip Siri's clarification step.
+
+```swift
+// Intent with parameters
+struct StartMeditationIntent: AppIntent {
+ static var title: LocalizedStringResource = "Start Meditation"
+
+ @Parameter(title: "Type")
+ var meditationType: MeditationType?
+
+ @Parameter(title: "Duration")
+ var duration: Int?
+}
+
+// Shortcuts with different parameter combinations
+@AppShortcutsBuilder
+static var appShortcuts: [AppShortcut] {
+ // Generic version (will ask for parameters)
+ AppShortcut(
+ intent: StartMeditationIntent(),
+ phrases: ["Start meditation in \(.applicationName)"],
+ shortTitle: "Meditate",
+ systemImageName: "figure.mind.and.body"
+ )
+
+ // Specific versions (skip parameter step)
+ AppShortcut(
+ intent: StartMeditationIntent(
+ meditationType: .mindfulness,
+ duration: 10
+ ),
+ phrases: [
+ "Start quick mindfulness in \(.applicationName)",
+ "10 minute mindfulness in \(.applicationName)"
+ ],
+ shortTitle: "Quick Mindfulness",
+ systemImageName: "brain.head.profile"
+ )
+
+ AppShortcut(
+ intent: StartMeditationIntent(
+ meditationType: .sleep,
+ duration: 20
+ ),
+ phrases: [
+ "Start sleep meditation in \(.applicationName)"
+ ],
+ shortTitle: "Sleep Meditation",
+ systemImageName: "moon.stars.fill"
+ )
+}
+```
+
+**Benefits:**
+- One-phrase completion (no follow-up questions)
+- Better user experience for common use cases
+- Spotlight shows specific shortcuts
+
+**Trade-off** More shortcuts = more visual clutter in Shortcuts app. Balance common cases (3-5 shortcuts) vs flexibility (generic shortcut with parameters).
+
+---
+
+## NegativeAppShortcutPhrase (iOS 17+)
+
+Train the system to NOT invoke your app for certain phrases.
+
+```swift
+struct MeditationAppShortcuts: AppShortcutsProvider {
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: StartMeditationIntent(),
+ phrases: ["Start meditation in \(.applicationName)"],
+ shortTitle: "Meditate",
+ systemImageName: "figure.mind.and.body"
+ )
+ }
+
+ // Prevent false positives
+ static var negativePhrases: [NegativeAppShortcutPhrase] {
+ NegativeAppShortcutPhrases {
+ "Stop meditation"
+ "Cancel meditation"
+ "End session"
+ }
+ }
+}
+```
+
+**When to use:**
+- Phrases that sound similar to your shortcuts but mean the opposite
+- Common phrases users might say that shouldn't trigger your app
+- Disambiguation when multiple apps have similar capabilities
+
+**Platform** iOS 17.0+, iPadOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+
+
+---
+
+## Discovery UI Components
+
+### SiriTipView — Promote Shortcuts In-App
+
+Display the spoken phrase for a shortcut directly in your app's UI.
+
+```swift
+import AppIntents
+import SwiftUI
+
+struct OrderConfirmationView: View {
+ @State private var showSiriTip = true
+
+ var body: some View {
+ VStack {
+ Text("Order confirmed!")
+
+ // Show Siri tip after successful order
+ SiriTipView(intent: ReorderIntent(), isVisible: $showSiriTip)
+ .siriTipViewStyle(.dark)
+ }
+ }
+}
+```
+
+**Requirements:**
+- Intent must be used in an AppShortcut (otherwise shows empty view)
+- isVisible binding controls display state
+
+**Styles:**
+- `.automatic` — Adapts to environment
+- `.light` — Light background
+- `.dark` — Dark background
+
+**Best practice** Show after users complete actions, suggesting easier ways next time.
+
+---
+
+### ShortcutsLink — Link to Shortcuts App
+
+Opens your app's page in the Shortcuts app, listing all available shortcuts.
+
+```swift
+import AppIntents
+import SwiftUI
+
+struct SettingsView: View {
+ var body: some View {
+ List {
+ Section("Siri & Shortcuts") {
+ ShortcutsLink()
+ // Displays "Shortcuts" with standard link styling
+ }
+ }
+ }
+}
+```
+
+**When to use:**
+- Settings screen
+- Help/Support section
+- Onboarding flow
+
+**Benefits** Single tap takes users to see all your app's shortcuts, with suggested phrases visible.
+
+---
+
+### ShortcutTileColor — Branding
+
+Set the color for your shortcuts in the Shortcuts app.
+
+```swift
+struct CoffeeAppShortcuts: AppShortcutsProvider {
+ static var shortcutTileColor: ShortcutTileColor = .tangerine
+
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ // ...
+ }
+}
+```
+
+**Available colors:**
+| Color | Use Case |
+|-------|----------|
+| `.blue` | Default, professional |
+| `.tangerine` | Energy, food/beverage |
+| `.purple` | Creative, meditation |
+| `.teal` | Health, wellness |
+| `.red` | Urgent, important |
+| `.pink` | Lifestyle, social |
+| `.navy` | Business, finance |
+| `.yellow` | Productivity, notes |
+| `.lime` | Fitness, outdoor |
+
+Full list: `.blue`, `.grape`, `.grayBlue`, `.grayBrown`, `.grayGreen`, `.lightBlue`, `.lime`, `.navy`, `.orange`, `.pink`, `.purple`, `.red`, `.tangerine`, `.teal`, `.yellow`
+
+**Choose color** that matches your app icon or brand identity.
+
+---
+
+## Dynamic Updates
+
+### updateAppShortcutParameters()
+
+Call when parameter options change to refresh stored shortcuts.
+
+```swift
+struct MeditationAppShortcuts: AppShortcutsProvider {
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ // Shortcuts can reference dynamic data
+ for session in MeditationData.favoriteSessions {
+ AppShortcut(
+ intent: StartSessionIntent(session: session),
+ phrases: ["Start \(session.name) in \(.applicationName)"],
+ shortTitle: session.name,
+ systemImageName: session.iconName
+ )
+ }
+ }
+
+ static func updateAppShortcutParameters() {
+ // Called automatically when needed
+ // Override only if you need custom behavior
+ }
+}
+
+// In your app, when data changes
+extension MeditationData {
+ func markAsFavorite(_ session: Session) {
+ favoriteSessions.append(session)
+
+ // Update App Shortcuts to reflect new data
+ MeditationAppShortcuts.updateAppShortcutParameters()
+ }
+}
+```
+
+**When to call:**
+- User adds/removes favorites
+- Available options change
+- App data structure updates
+
+**Automatic invocation** The system calls this periodically, but you can force updates when you know data changed.
+
+---
+
+## Complete Implementation Example
+
+### Step 1: Define App Intents
+
+```swift
+import AppIntents
+
+struct OrderCoffeeIntent: AppIntent {
+ static var title: LocalizedStringResource = "Order Coffee"
+ static var description = IntentDescription("Orders coffee for pickup")
+
+ @Parameter(title: "Coffee Type")
+ var coffeeType: CoffeeType
+
+ @Parameter(title: "Size")
+ var size: CoffeeSize
+
+ @Parameter(title: "Customizations")
+ var customizations: String?
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Order \(\.$size) \(\.$coffeeType)") {
+ \.$customizations
+ }
+ }
+
+ func perform() async throws -> some IntentResult {
+ let order = try await CoffeeService.shared.order(
+ type: coffeeType,
+ size: size,
+ customizations: customizations
+ )
+
+ return .result(
+ value: order,
+ dialog: "Your \(size) \(coffeeType) is ordered for pickup"
+ )
+ }
+}
+
+struct ReorderLastIntent: AppIntent {
+ static var title: LocalizedStringResource = "Reorder Last Coffee"
+ static var description = IntentDescription("Reorders your most recent coffee")
+ static var openAppWhenRun: Bool = false
+
+ func perform() async throws -> some IntentResult {
+ guard let lastOrder = try await CoffeeService.shared.lastOrder() else {
+ throw CoffeeError.noRecentOrders
+ }
+
+ try await CoffeeService.shared.reorder(lastOrder)
+
+ return .result(
+ dialog: "Reordering your \(lastOrder.coffeeName)"
+ )
+ }
+}
+
+enum CoffeeType: String, AppEnum {
+ case latte, cappuccino, americano, espresso
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Coffee"
+ static var caseDisplayRepresentations: [CoffeeType: DisplayRepresentation] = [
+ .latte: "Latte",
+ .cappuccino: "Cappuccino",
+ .americano: "Americano",
+ .espresso: "Espresso"
+ ]
+}
+
+enum CoffeeSize: String, AppEnum {
+ case small, medium, large
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Size"
+ static var caseDisplayRepresentations: [CoffeeSize: DisplayRepresentation] = [
+ .small: "Small",
+ .medium: "Medium",
+ .large: "Large"
+ ]
+}
+```
+
+---
+
+### Step 2: Create AppShortcutsProvider
+
+```swift
+import AppIntents
+
+struct CoffeeAppShortcuts: AppShortcutsProvider {
+
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ // Generic order (will ask for parameters)
+ AppShortcut(
+ intent: OrderCoffeeIntent(),
+ phrases: [
+ "Order coffee in \(.applicationName)",
+ "Get coffee from \(.applicationName)"
+ ],
+ shortTitle: "Order",
+ systemImageName: "cup.and.saucer.fill"
+ )
+
+ // Common specific orders (skip parameter step)
+ AppShortcut(
+ intent: OrderCoffeeIntent(
+ coffeeType: .latte,
+ size: .medium
+ ),
+ phrases: [
+ "Order my usual from \(.applicationName)",
+ "Get my regular coffee from \(.applicationName)"
+ ],
+ shortTitle: "Usual Order",
+ systemImageName: "star.fill"
+ )
+
+ // Reorder last
+ AppShortcut(
+ intent: ReorderLastIntent(),
+ phrases: [
+ "Reorder coffee from \(.applicationName)",
+ "Order again from \(.applicationName)"
+ ],
+ shortTitle: "Reorder",
+ systemImageName: "arrow.clockwise"
+ )
+ }
+
+ // Branding
+ static var shortcutTileColor: ShortcutTileColor = .tangerine
+
+ // Prevent false positives (iOS 17+)
+ static var negativePhrases: [NegativeAppShortcutPhrase] {
+ NegativeAppShortcutPhrases {
+ "Cancel coffee order"
+ "Stop coffee"
+ }
+ }
+}
+```
+
+---
+
+### Step 3: Promote in UI
+
+```swift
+import SwiftUI
+import AppIntents
+
+struct OrderConfirmationView: View {
+ @State private var showReorderTip = true
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 60))
+ .foregroundColor(.green)
+
+ Text("Order Placed!")
+ .font(.title)
+
+ Text("Your coffee will be ready in 10 minutes")
+ .foregroundColor(.secondary)
+
+ // Promote reorder shortcut
+ if showReorderTip {
+ SiriTipView(intent: ReorderLastIntent(), isVisible: $showReorderTip)
+ .siriTipViewStyle(.dark)
+ .padding(.top)
+ }
+
+ // Link to see all shortcuts
+ Section {
+ ShortcutsLink()
+ } header: {
+ Text("See all available shortcuts")
+ .font(.caption)
+ }
+ }
+ .padding()
+ }
+}
+```
+
+---
+
+## Where App Shortcuts Appear
+
+Once implemented, your App Shortcuts are available in:
+
+| Location | User Experience |
+|----------|-----------------|
+| **Siri** | Voice activation with provided phrases |
+| **Spotlight** | Search for action or phrase → Instant execution |
+| **Shortcuts app** | Pre-populated shortcuts, zero configuration |
+| **Action Button** (iPhone 15 Pro) | Assignable to hardware button |
+| **Apple Watch Ultra** | Action Button assignment |
+| **Control Center** | Add shortcuts as controls |
+| **Lock Screen widgets** | Quick actions without unlocking |
+| **Apple Pencil Pro** | Squeeze gesture assignment |
+| **Focus Filters** | Contextual filtering |
+
+**Instant availability** All locations work immediately after app install. No user setup required.
+
+---
+
+## Testing & Debugging
+
+### Verify Shortcuts Appear in Shortcuts App
+
+1. Build and run your app on device
+2. Open Shortcuts app
+3. Tap "+" to create new shortcut
+4. Search for your app name
+5. Verify shortcuts appear with correct titles and icons
+
+**If shortcuts don't appear:**
+- Ensure AppShortcutsProvider is in your main app target
+- Check that `isDiscoverable` is true for the AppIntents (default)
+- Rebuild and reinstall app
+- Check console for AppShortcuts errors
+
+---
+
+### Test Siri Invocation
+
+1. Invoke Siri
+2. Say one of your suggested phrases
+3. Verify Siri executes the intent
+
+**Example**:
+- You: "Order coffee in CoffeeApp"
+- Siri: "What size and type?"
+- You: "Medium latte"
+- Siri: "Your medium latte is ordered for pickup"
+
+**If Siri doesn't recognize phrase:**
+- Check phrase includes `\(.applicationName)`
+- Verify phrase is in appShortcuts array
+- Try simpler phrases (3-6 words ideal)
+- Avoid complex grammar or rare words
+
+---
+
+### Test Spotlight Discovery
+
+1. Swipe down to open Spotlight
+2. Type your app name or shortcut phrase
+3. Verify shortcut appears in results
+4. Tap to execute
+
+**If shortcut doesn't appear in Spotlight:**
+- Wait a few minutes (indexing delay)
+- Restart device
+- Check System Settings → Siri & Search → [Your App] → Show App in Search
+
+---
+
+### Debug with Console Logs
+
+```swift
+#if DEBUG
+struct CoffeeAppShortcuts: AppShortcutsProvider {
+ @AppShortcutsBuilder
+ static var appShortcuts: [AppShortcut] {
+ let shortcuts = [
+ AppShortcut(/* ... */),
+ // ...
+ ]
+
+ print("📱 Registered \(shortcuts.count) App Shortcuts")
+ shortcuts.forEach { shortcut in
+ print(" - \(shortcut.shortTitle)")
+ }
+
+ return shortcuts
+ }
+}
+#endif
+```
+
+**Check Xcode console** after app launch to verify shortcuts are registered.
+
+---
+
+## Best Practices
+
+### 1. Phrase Design
+
+#### ❌ DON'T: Long, complex phrases
+```swift
+phrases: [
+ "I would like to order a coffee from \(.applicationName) please"
+]
+```
+
+#### ✅ DO: Short, natural phrases
+```swift
+phrases: [
+ "Order coffee in \(.applicationName)",
+ "Get coffee from \(.applicationName)"
+]
+```
+
+**Guidelines:**
+- 3-6 words ideal
+- Start with verb (Order, Start, Get, Show)
+- Include `\(.applicationName)` for disambiguation
+- Use natural language users would actually say
+
+---
+
+### 2. Shortcut Quantity
+
+#### ❌ DON'T: Provide 20+ shortcuts
+```swift
+// Bad: Overwhelming
+AppShortcut for every possible combination
+```
+
+#### ✅ DO: Focus on 3-5 core actions
+```swift
+// Good: Focused on common tasks
+AppShortcut(intent: OrderIntent(), /* ... */)
+AppShortcut(intent: ReorderIntent(), /* ... */)
+AppShortcut(intent: ViewOrdersIntent(), /* ... */)
+```
+
+**Why** Too many shortcuts creates clutter. Focus on high-value, frequently-used actions.
+
+---
+
+### 3. Parameter Combinations
+
+#### ❌ DON'T: Parameterize every variant
+```swift
+// Bad: Creates 12 shortcuts (3 sizes × 4 types)
+for size in CoffeeSize.allCases {
+ for type in CoffeeType.allCases {
+ AppShortcut(intent: OrderIntent(type: type, size: size), /* ... */)
+ }
+}
+```
+
+#### ✅ DO: Provide generic + top 2-3 common cases
+```swift
+// Good: Generic + common specific cases
+AppShortcut(intent: OrderIntent(), /* ... */) // Generic
+AppShortcut(intent: OrderIntent(type: .latte, size: .medium), /* ... */) // Usual
+AppShortcut(intent: OrderIntent(type: .espresso, size: .small), /* ... */) // Quick
+```
+
+---
+
+### 4. Short Titles
+
+#### ❌ DON'T: Verbose or redundant
+```swift
+shortTitle: "Order Coffee from Coffee App"
+```
+
+#### ✅ DO: Concise and clear
+```swift
+shortTitle: "Order"
+```
+
+**Context** App name already appears in Shortcuts app, so no need to repeat.
+
+---
+
+### 5. System Images
+
+#### ❌ DON'T: Use custom images
+```swift
+// Not supported
+shortImage: UIImage(named: "custom")
+```
+
+#### ✅ DO: Use SF Symbols
+```swift
+systemImageName: "cup.and.saucer.fill"
+```
+
+**Why** SF Symbols scale properly, support dark mode, and integrate with system UI.
+
+---
+
+## Resources
+
+**WWDC**: 2022-10170, 2022-10169, 260
+
+**Docs**: /appintents/appshortcutsprovider, /appintents/appshortcut, /appintents/app-shortcuts
+
+**Skills**: axiom-app-intents-ref, axiom-app-discoverability, axiom-core-spotlight-ref
+
+---
+
+**Remember** App Shortcuts make your app's functionality instantly available across iOS. Define 3-5 core shortcuts with natural phrases, promote them in your UI with SiriTipView, and users can invoke them immediately via Siri, Spotlight, Action Button, and more.
diff --git a/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml b/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml
new file mode 100644
index 0000000..a09d71b
--- /dev/null
+++ b/.claude/skills/axiom-app-shortcuts-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Shortcuts Reference"
+ short_description: "Implementing App Shortcuts for instant Siri/Spotlight availability, configuring AppShortcutsProvider, adding suggeste..."
diff --git a/.claude/skills/axiom-app-store-connect-ref/.openskills.json b/.claude/skills/axiom-app-store-connect-ref/.openskills.json
new file mode 100644
index 0000000..7f42c04
--- /dev/null
+++ b/.claude/skills/axiom-app-store-connect-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-store-connect-ref",
+ "installedAt": "2026-04-12T08:05:46.228Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-store-connect-ref/SKILL.md b/.claude/skills/axiom-app-store-connect-ref/SKILL.md
new file mode 100644
index 0000000..f2443e8
--- /dev/null
+++ b/.claude/skills/axiom-app-store-connect-ref/SKILL.md
@@ -0,0 +1,347 @@
+---
+name: axiom-app-store-connect-ref
+description: Use when navigating App Store Connect to find crash data, read TestFlight feedback, interpret metrics dashboards, or export diagnostic logs. Covers crash-free rates, dSYM symbolication, termination types, MetricKit.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Store Connect Reference
+
+## Overview
+
+App Store Connect (ASC) provides crash reports, TestFlight feedback, and performance metrics for your apps. This reference covers how to navigate ASC to find and export crash data for analysis.
+
+#### ASC vs Xcode Organizer
+
+| Task | Best Tool |
+|------|-----------|
+| Quick crash triage during development | Xcode Organizer |
+| Team-wide crash visibility | App Store Connect |
+| TestFlight feedback with screenshots | App Store Connect |
+| Historical metrics and trends | App Store Connect |
+| Downloading crash logs for analysis | Either (ASC has better export) |
+| Symbolication | Xcode Organizer |
+
+---
+
+## Navigating to Crash Data
+
+### Path to Crashes
+
+```
+App Store Connect
+└── My Apps
+ └── [Your App]
+ └── Analytics
+ └── Crashes
+```
+
+**Direct URL pattern:** `https://appstoreconnect.apple.com/analytics/app/[APP_ID]/crashes`
+
+### Crashes Dashboard Sections
+
+1. **Filters bar** — Platform, Version, Date Range, Compare
+2. **Crash-Free Users graph** — Daily percentage trend line
+3. **Crash Count by Version** — Bar chart comparing versions
+4. **Top Crash Signatures** — Ranked by share percentage, shows exception type and function name
+
+### Key Metrics Explained
+
+| Metric | What It Means |
+|--------|---------------|
+| **Crash-Free Users** | Percentage of daily active users who didn't experience a crash |
+| **Crash Count** | Total number of crash reports received |
+| **Crash Rate** | Crashes per 1,000 sessions |
+| **Affected Devices** | Number of unique devices that crashed |
+| **Crash Signature** | Grouped crashes with same stack trace |
+
+### Filtering Options
+
+| Filter | Use Case |
+|--------|----------|
+| **Platform** | iOS, iPadOS, macOS, watchOS, tvOS |
+| **Version** | Drill into specific app versions |
+| **Date Range** | Last 7/30/90 days or custom range |
+| **Compare** | Compare crash rates between versions |
+| **Device** | Filter by iPhone model, iPad, etc. |
+| **OS Version** | Find OS-specific crashes |
+
+---
+
+## Viewing Individual Crash Reports
+
+### Crash Signature Detail
+
+Each crash signature shows:
+- **Header** — Exception type and affected device/crash share counts, first seen date
+- **Exception Information** — Type (e.g., EXC_BAD_ACCESS), codes, address
+- **Crashed Thread** — Stack frames with binary, function, and offset
+- **Distribution** — Breakdown by iOS version and device model
+
+### Downloading Crash Logs
+
+1. Click on a crash signature
+2. Look for **Download Logs** button (top right)
+3. Select format:
+ - **.ips** (JSON format, iOS 15+)
+ - **.crash** (text format, legacy)
+4. Use `crash-analyzer` agent to parse: `/axiom:analyze-crash`
+
+---
+
+## TestFlight Feedback
+
+### Path to Feedback
+
+```
+App Store Connect
+└── My Apps
+ └── [Your App]
+ └── TestFlight
+ └── Feedback
+```
+
+### Feedback Entry Contents
+
+Each feedback submission includes:
+
+| Field | Description |
+|-------|-------------|
+| **Screenshot** | What the tester saw (often most valuable) |
+| **Comment** | Tester's description of the issue |
+| **App Version** | Exact TestFlight build number |
+| **Device Model** | iPhone 15 Pro Max, iPad Air, etc. |
+| **OS Version** | iOS 17.2.1, etc. |
+| **Battery Level** | Low battery can affect behavior |
+| **Available Disk** | Low disk can cause write failures |
+| **Network Type** | WiFi vs Cellular |
+| **Locale** | Language and region settings |
+| **Timestamp** | When submitted |
+
+### Feedback Filtering
+
+| Filter | Use Case |
+|--------|----------|
+| **Build** | Focus on specific TestFlight builds |
+| **Date** | Recent feedback first |
+| **Has Screenshot** | Find visual issues quickly |
+
+### Limitation: No Reply
+
+TestFlight feedback is **one-way**. You cannot respond to testers through ASC. For follow-up:
+
+- Contact through TestFlight group email
+- Add in-app feedback mechanism
+- Include your email in TestFlight notes
+
+---
+
+## Metrics Dashboard
+
+### Path to Metrics
+
+```
+App Store Connect
+└── My Apps
+ └── [Your App]
+ └── Analytics
+ └── Metrics
+```
+
+### Available Metrics Categories
+
+| Category | What It Shows |
+|----------|---------------|
+| **Crashes** | Crash-free users, crash count, top signatures |
+| **Hang Rate** | Main thread hangs > 250ms |
+| **Disk Writes** | Excessive disk I/O patterns |
+| **Launch Time** | App startup performance |
+| **Memory** | Peak memory usage, terminations |
+| **Battery** | Energy usage during foreground/background |
+| **Scrolling** | Scroll hitch rate |
+
+### Terminations (Non-Crash Kills)
+
+The Metrics dashboard shows terminations that don't produce crash reports:
+
+| Termination Type | Cause |
+|------------------|-------|
+| **Memory Limit** | Jetsam killed app for memory pressure |
+| **CPU Limit (Background)** | Exceeded background CPU quota |
+| **Launch Timeout** | App took too long to launch |
+| **Background Task Timeout** | Background task exceeded time limit |
+
+### Comparing Versions
+
+Use the **Compare** filter to see:
+
+- Did crash rate improve or regress?
+- Which version introduced a spike?
+- Performance trends over releases
+
+---
+
+## Exporting Data
+
+### Manual Export
+
+1. Navigate to Crashes or Metrics
+2. Use date range filter to select period
+3. Click **Export** (if available) or download individual crash logs
+
+### App Store Connect API
+
+For automated export, use the [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi):
+
+```bash
+# Get crash diagnostic insights
+GET /v1/apps/{id}/perfPowerMetrics
+
+# Authentication requires API key from ASC
+# Users and Access → Keys → App Store Connect API
+```
+
+**API capabilities:**
+
+| Endpoint | Data |
+|----------|------|
+| `perfPowerMetrics` | Performance and power metrics |
+| `diagnosticSignatures` | Crash signature aggregates |
+| `diagnosticLogs` | Individual crash logs |
+| `betaTesters` | TestFlight tester info |
+| `betaFeedback` | TestFlight feedback entries |
+
+### MCP-Powered Access
+
+If **asc-mcp** is configured, you can access ASC data programmatically from Claude Code:
+
+| Manual ASC Action | asc-mcp Tool |
+|-------------------|-------------|
+| View crash metrics | `metrics_app_perf`, `metrics_build_diagnostics` |
+| Download crash logs | `metrics_get_diagnostic_logs` |
+| List TestFlight testers | `builds_get_beta_testers` |
+| View app reviews | `reviews_list`, `reviews_stats` |
+| Respond to reviews | `reviews_create_response` |
+| Check build status | `builds_get_processing_state` |
+| Export sales data | `analytics_sales_report` (requires vendor_number) |
+
+**Setup and workflows**: `/skill axiom-asc-mcp`
+
+### Xcode Cloud Integration
+
+If using Xcode Cloud, crash data integrates with CI/CD:
+
+- View crashes per workflow run
+- Compare crash rates between branches
+- Automated alerts on crash spikes
+
+---
+
+## Best Practices
+
+### Daily Monitoring
+
+1. Check crash-free users percentage
+2. Review any new crash signatures
+3. Monitor for version-to-version regressions
+
+### Crash Triage Priority
+
+| Priority | Criteria |
+|----------|----------|
+| **P0 - Critical** | >1% of users affected, data loss risk |
+| **P1 - High** | >0.5% affected, user-facing impact |
+| **P2 - Medium** | <0.5% affected, workaround exists |
+| **P3 - Low** | Rare, edge case, no impact |
+
+### Correlating with Releases
+
+After each release:
+
+1. Wait 24-48 hours for crash data to populate
+2. Compare crash-free rate to previous version
+3. Investigate any new top crash signatures
+4. Check TestFlight feedback for user reports
+
+---
+
+## Common Questions
+
+### Why don't I see crashes in ASC?
+
+| Cause | Fix |
+|-------|-----|
+| Too recent | Wait 24 hours for processing |
+| No users yet | Need active installs to report |
+| User opted out | Requires device analytics sharing |
+| Build not distributed | Must be TestFlight or App Store |
+
+### Why are crashes unsymbolicated?
+
+ASC crashes should auto-symbolicate if you uploaded dSYMs during distribution. **dSYM files** contain the debug symbols that map memory addresses back to function names and line numbers.
+
+**Verify dSYMs were uploaded:**
+1. Xcode → Window → Organizer → Archives → select build
+2. Right-click → "Show in Finder" → right-click `.xcarchive` → "Show Package Contents"
+3. Check `dSYMs/` folder contains `.dSYM` bundles
+
+**Manual symbolication workflow:**
+```bash
+# 1. Download .ips file from ASC (Crashes → signature → Download Logs)
+
+# 2. Find the binary UUID from the crash report
+grep --after-context=2 "Binary Images" crash.ips
+# Look for: 0x100000000 - 0x100ffffff MyApp arm64
+
+# 3. Locate matching dSYM on your machine
+mdfind "com_apple_xcode_dsym_uuids == "
+
+# 4. Symbolicate an address
+atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
+ -l 0x100000000 0x100045abc
+# Output: -[UserManager currentUser] (UserManager.m:42)
+```
+
+**Common symbolication failures:**
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| All addresses unsymbolicated | dSYMs not uploaded | Re-upload from Xcode Organizer |
+| Only your code unsymbolicated | dSYM UUID mismatch | Rebuild from same commit |
+| System frameworks unsymbolicated | Normal for device-specific | Use `atos` with device support files |
+| Bitcode builds unsymbolicated | Apple recompiled binary | Download dSYMs from ASC: Xcode → Organizer → Download Debug Symbols |
+
+See `crash-analyzer` agent for automated parsing: `/axiom:analyze-crash`
+
+### ASC vs Organizer: Which stack trace is better?
+
+Both show the same data, but:
+- **Organizer** integrates with Xcode projects (click to jump to code)
+- **ASC** better for team-wide visibility and historical trends
+
+---
+
+## Field Diagnostics with MetricKit
+
+For device-level crash diagnostics, hang call stacks, and custom telemetry beyond ASC's aggregated dashboards, see `axiom-metrickit-ref`.
+
+**Key difference**: ASC shows aggregated trends for team visibility. MetricKit provides per-device diagnostics you can correlate with your own telemetry.
+
+---
+
+## Related
+
+**Skills**: axiom-testflight-triage (Xcode Organizer workflows), axiom-asc-mcp (programmatic ASC access via MCP)
+
+**Agents**: crash-analyzer (automated crash log parsing)
+
+**Commands**: `/axiom:analyze-crash`
+
+---
+
+## Resources
+
+**WWDC:** 2020-10076, 2020-10078, 2021-10203, 2021-10258
+
+**Docs:** /app-store-connect/api, /xcode/diagnosing-issues-using-crash-reports-and-device-logs
diff --git a/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml b/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml
new file mode 100644
index 0000000..798305a
--- /dev/null
+++ b/.claude/skills/axiom-app-store-connect-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Store Connect Reference"
+ short_description: "Navigating App Store Connect to find crash data, read TestFlight feedback, interpret metrics dashboards, or export di..."
diff --git a/.claude/skills/axiom-app-store-diag/.openskills.json b/.claude/skills/axiom-app-store-diag/.openskills.json
new file mode 100644
index 0000000..635674c
--- /dev/null
+++ b/.claude/skills/axiom-app-store-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-store-diag",
+ "installedAt": "2026-04-12T08:05:46.842Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-store-diag/SKILL.md b/.claude/skills/axiom-app-store-diag/SKILL.md
new file mode 100644
index 0000000..d3857f0
--- /dev/null
+++ b/.claude/skills/axiom-app-store-diag/SKILL.md
@@ -0,0 +1,1202 @@
+---
+name: axiom-app-store-diag
+description: Use when app is rejected by App Review, submission blocked, or appeal needed - systematic diagnosis from rejection message to fix with guideline-specific remediation patterns and appeal writing
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Store Rejection Diagnostics
+
+## Overview
+
+Systematic App Store rejection diagnosis and remediation. 9 diagnostic patterns covering the most common rejection categories including technical, metadata, privacy, business, subjective, and safety violations.
+
+**Core principle** Most App Store rejections fall into well-known categories. Reading the rejection message carefully and mapping to the correct guideline prevents the #1 mistake: fixing the wrong thing and getting rejected again for the same reason.
+
+Most developers waste 1-2 weeks on rejection cycles because they skim the rejection message, assume the cause, and "fix" something that wasn't the problem. This skill provides systematic diagnosis from rejection message to targeted fix.
+
+## Red Flags — Suspect Submission Issue
+
+If you see ANY of these, suspect a submission issue and use this skill:
+
+- Rejection message cites a specific guideline number
+- "Binary Rejected" without clear guideline (technical gate failure)
+- Same app rejected multiple times for different reasons
+- "Metadata Rejected" (no code change needed)
+- Rejection mentions "privacy" or "data collection"
+- Rejection mentions "login" or "authentication"
+- Reviewer asks for demo account or more information
+
+- ❌ **FORBIDDEN** "The reviewer is wrong, let's just resubmit"
+ - Re-read the rejection. App Review is right 95% of the time.
+ - Resubmitting without changes wastes 3-7 days per cycle.
+ - If you genuinely disagree, use the appeal process (Pattern 7).
+
+## Mandatory First Steps
+
+**ALWAYS do these BEFORE changing any code:**
+
+1. **Read the FULL rejection message** — Don't skim. Copy the exact text. Note every guideline number cited.
+2. **Identify rejection type**:
+ - "App Rejected" → Guideline violation, code/content fix needed
+ - "Metadata Rejected" → ASC metadata issue, no build needed
+ - "Binary Rejected" → Technical gate (SDK, manifest, encryption)
+ - "Removed from Sale" → Post-approval enforcement
+3. **Check the specific guideline** — Look up the exact number in app-store-ref
+4. **Screenshot the rejection** — Save for team communication and appeal reference
+5. **Check App Review messages in ASC** — Sometimes they ask for information, not reject
+
+#### What this tells you
+
+| Rejection Type | What Changed | Next Step |
+|---|---|---|
+| "App Rejected" + Guideline 2.1 | App crashed or had placeholders | Pattern 1 |
+| "Metadata Rejected" | Screenshots or description wrong | Pattern 2 |
+| "App Rejected" + Guideline 5.1 | Privacy policy or manifest gaps | Pattern 3 |
+| "App Rejected" + Guideline 4.8 | Missing Sign in with Apple | Pattern 4 |
+| "App Rejected" + Guideline 3.x | Business/monetization violation | Pattern 5 |
+| "Binary Rejected" / no guideline | SDK, signing, or encryption issue | Pattern 6 |
+| Reviewer seems incorrect | Genuine misunderstanding | Pattern 7 |
+| Guideline 1.x cited | Safety/content issue | Pattern 9 |
+| Guideline 4.1-4.3 cited | Design/originality issue | Pattern 8 |
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, identify ONE of these:
+
+1. If "App Rejected" with guideline number → Map to specific pattern (1-5)
+2. If "Metadata Rejected" → Fix in ASC, no build required (Pattern 2)
+3. If "Binary Rejected" → Technical gate failure (Pattern 6)
+4. If multiple guidelines cited → Fix ALL cited issues, not just the first one. Both binary AND metadata can be rejected simultaneously — binary issues need a new build, metadata issues can be fixed in ASC. Fix both before resubmitting.
+5. If reviewer asks for information → Reply in ASC before making code changes
+
+#### If rejection reason is unclear or contradictory
+- STOP. Do NOT start fixing code yet
+- Reply to App Review in ASC asking for clarification
+- Include screenshots or video showing the feature working
+- Wait for response before making changes
+
+## Decision Tree
+
+```
+App Store rejection?
+│
+├─ What does the rejection say?
+│ │
+│ ├─ Cites Guideline 2.1?
+│ │ ├─ App crashed during review? → Pattern 1 (check crash logs)
+│ │ ├─ Placeholder content found? → Pattern 1 (search project)
+│ │ ├─ Broken links? → Pattern 1 (verify URLs)
+│ │ └─ Missing demo credentials? → Pattern 1 (provide in review notes)
+│ │
+│ ├─ Cites Guideline 2.3?
+│ │ ├─ Screenshots don't match app? → Pattern 2 (retake screenshots)
+│ │ ├─ Description promises missing features? → Pattern 2 (update text)
+│ │ └─ Keywords contain trademarks? → Pattern 2 (remove keywords)
+│ │
+│ ├─ Cites Guideline 5.1?
+│ │ ├─ Privacy policy missing/inaccessible? → Pattern 3 (add/fix policy)
+│ │ ├─ Purpose strings missing? → Pattern 3 (add to Info.plist)
+│ │ ├─ Privacy manifest incomplete? → Pattern 3 (update PrivacyInfo)
+│ │ └─ Tracking without ATT? → Pattern 3 (implement ATT)
+│ │
+│ ├─ Cites Guideline 4.8?
+│ │ ├─ Third-party login without SIWA? → Pattern 4 (add SIWA)
+│ │ ├─ SIWA button hidden or broken? → Pattern 4 (fix prominence)
+│ │ └─ Exception applies? → Pattern 4 (verify exemption)
+│ │
+│ ├─ Cites Guideline 3.x?
+│ │ ├─ Digital content without IAP? → Pattern 5 (implement StoreKit)
+│ │ ├─ Subscription issues? → Pattern 5 (fix terms/value)
+│ │ └─ Loot box odds not disclosed? → Pattern 5 (add disclosure)
+│ │
+│ ├─ "Binary Rejected" / no guideline?
+│ │ ├─ Wrong SDK version? → Pattern 6 (update Xcode)
+│ │ ├─ Privacy manifest missing? → Pattern 6 (add PrivacyInfo)
+│ │ ├─ Encryption not declared? → Pattern 6 (add ITSAppUsesNonExemptEncryption)
+│ │ └─ Invalid signing? → Pattern 6 (regenerate provisioning)
+│ │
+│ ├─ "I believe the reviewer is wrong"?
+│ │ └─ → Pattern 7 (Appeal Process)
+│ │
+│ ├─ Cites Guideline 1.x?
+│ │ └─ Safety/content issue → Pattern 9
+│ │
+│ └─ Cites Guideline 4.1-4.3?
+│ └─ Design/originality issue → Pattern 8
+```
+
+## Pattern Selection Rules (MANDATORY)
+
+Before proceeding to a pattern:
+
+1. **Copy the exact rejection text** — Word for word, including guideline numbers
+2. **Match guideline number to pattern** — Don't guess, map directly
+3. **If multiple guidelines cited** — Fix ALL of them before resubmitting
+4. **If no guideline number** — Likely Binary Rejected, start with Pattern 6
+5. **If unsure** — Reply to reviewer for clarification first
+
+#### Apply ONE pattern at a time
+- Identify the correct pattern from the rejection message
+- Implement the complete fix for that pattern
+- If multiple guidelines cited, fix each one before resubmitting
+- DO NOT resubmit after fixing only one of multiple cited issues
+
+#### FORBIDDEN
+- Resubmitting without changes hoping for a different reviewer
+- Skimming the rejection and guessing the fix
+- Fixing only the first cited guideline when multiple are cited
+- Arguing emotionally in App Review messages
+- Disabling privacy features to avoid Guideline 5.1
+
+## Diagnostic Patterns
+
+### Pattern 1: Guideline 2.1 — App Completeness
+
+**Time cost** 3-7 days per rejection cycle
+
+#### Symptom
+- Rejection citing "App Completeness"
+- Crashes during review
+- Placeholder content found
+- Broken links (support URL, privacy policy, in-app links)
+- Missing demo credentials for login-required apps
+
+#### Common causes
+1. App crashes on reviewer's device (different OS version, different device class)
+2. Placeholder text or images visible in any screen
+3. Broken links (support URL, privacy policy, in-app links)
+4. Missing demo credentials for login-required apps
+5. Backend service was down during review window
+
+#### Diagnosis
+```bash
+# 1. Check crash logs in App Store Connect
+# Xcode Organizer > Crashes > Filter by version
+
+# 2. Search for placeholder strings
+grep -r "Lorem\|TODO\|FIXME\|placeholder\|sample\|test data" \
+ --include="*.swift" --include="*.storyboard" --include="*.xib" .
+
+# 3. Verify all URLs resolve
+curl -sI "https://your-support-url.com" | head -1
+curl -sI "https://your-privacy-policy-url.com" | head -1
+
+# 4. Test on latest shipping iOS
+# Check ASC for specific iOS version reviewer used (noted in rejection)
+```
+
+#### Fix
+```swift
+// ❌ WRONG — Demo credentials that expire
+// Review Notes: "Login: test@test.com / password123"
+// (If this account expires or gets locked, instant rejection)
+
+// ✅ CORRECT — Permanent demo credentials
+// Review Notes:
+// "Demo Account: demo@yourapp.com / ReviewDemo2024!
+// This account has pre-populated sample data.
+// Account will not expire during review period."
+```
+
+```swift
+// ❌ WRONG — Placeholder still in code
+Text("Lorem ipsum dolor sit amet")
+
+// ✅ CORRECT — Real content in every screen
+Text("Welcome to YourApp. Get started by creating your first project.")
+```
+
+#### Verification
+- Submit to TestFlight first, test every screen on multiple devices
+- Verify ALL URLs load successfully (including privacy policy from within the app)
+- Ensure demo credentials work and won't expire
+- Test on the specific iOS version mentioned in rejection (check rejection message or ASC Activity → Build → review device info)
+- Monitor backend uptime during review window (don't deploy during review)
+- Check ASC crash logs (Xcode Organizer → Crashes) for the specific device and OS version the reviewer used
+
+---
+
+### Pattern 2: Guideline 2.3 — Metadata Issues
+
+**Time cost** 1-3 days (metadata fix, no build needed)
+
+#### Symptom
+- "Metadata Rejected" — no code change required
+- Screenshots don't match current app UI
+- Description promises features not in the app
+- Keywords contain trademarked or competitor names
+
+#### Common causes
+1. Screenshots show old UI or features that no longer exist
+2. Description promises features not yet implemented
+3. Keywords contain trademarked terms or competitor names
+4. App name implies functionality that doesn't exist
+5. Category selection doesn't match app's primary function
+
+#### Diagnosis
+
+Compare every screenshot to current app UI. Read description word by word — does each claim exist in the app? Check keywords against Apple's trademark list.
+
+```
+Checklist:
+☐ Every screenshot matches current build
+☐ Every feature mentioned in description exists and works
+☐ No trademarked terms in keywords (e.g., "Instagram", "Uber")
+☐ App icon appropriate for all audiences
+☐ Age rating matches actual content
+☐ Category selection accurate
+☐ "What's New" text matches actual changes
+```
+
+#### Fix
+
+Update metadata directly in App Store Connect. No new build needed for metadata-only rejections.
+
+```
+✅ Take fresh screenshots FROM THE SUBMITTED BUILD (not dev build)
+✅ Remove any features from description that aren't fully functional
+✅ Replace trademarked keywords with generic equivalents
+ ("photo sharing" not "Instagram-like")
+✅ Ensure "What's New" describes changes in this specific version
+```
+
+#### Verification
+- Take screenshots on the exact build version submitted
+- Have someone outside the team read the description and verify each claim
+- Search keywords for any trademarked terms
+
+---
+
+### Pattern 3: Guideline 5.1 — Privacy Violations
+
+**Time cost** 3-10 days (code + manifest + policy changes)
+
+#### Symptom
+- Rejection citing privacy policy, data collection, purpose strings, or tracking
+- Privacy manifest missing required reason API declarations
+- Third-party SDK collects data not disclosed
+
+#### Common causes
+1. Privacy policy missing or not accessible from within the app
+2. Privacy policy doesn't match actual data collection
+3. Missing purpose strings for permission requests
+4. Privacy manifest (PrivacyInfo.xcprivacy) missing required reason API declarations
+5. Third-party SDK collects data not disclosed in privacy nutrition labels
+6. App tracks users without ATT (App Tracking Transparency) consent
+
+#### Diagnosis
+```swift
+// 1. Check: Is privacy policy URL in ASC AND accessible from within the app?
+// Both are required. In-app access is commonly missed.
+
+// 2. Check purpose strings
+// ❌ WRONG — Generic purpose string
+"NSCameraUsageDescription" = "Camera access needed"
+
+// ✅ CORRECT — Specific purpose string explaining why
+"NSCameraUsageDescription" = "Take photos for your profile picture and upload to your account"
+
+// 3. Generate privacy report
+// Xcode: Product → Archive → Generate Privacy Report
+// This shows aggregate data from all frameworks and your code
+
+// 4. Check privacy manifest
+// Verify PrivacyInfo.xcprivacy exists in your app target
+// AND in every framework target that uses required reason APIs
+```
+
+#### Fix
+
+##### Purpose strings (Info.plist)
+```xml
+
+NSCameraUsageDescription
+Take photos for your profile picture and upload to your account
+
+NSLocationWhenInUseUsageDescription
+Show nearby restaurants on the map and calculate delivery distance
+
+NSPhotoLibraryUsageDescription
+Select photos from your library to attach to messages
+```
+
+##### Privacy manifest (PrivacyInfo.xcprivacy)
+```xml
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+```
+
+##### Privacy policy requirements
+```
+Your privacy policy MUST specifically list:
+☐ What data is collected (every type)
+☐ How data is collected (automatically, user-provided)
+☐ All uses of collected data
+☐ Third-party sharing (who, why)
+☐ Data retention period
+☐ How users can request deletion
+☐ Contact information for privacy inquiries
+```
+
+##### App Tracking Transparency
+```swift
+// Required if app tracks users across other companies' apps/websites
+import AppTrackingTransparency
+
+func requestTrackingPermission() {
+ ATTrackingManager.requestTrackingAuthorization { status in
+ switch status {
+ case .authorized:
+ // Enable tracking (analytics, ad attribution)
+ break
+ case .denied, .restricted, .notDetermined:
+ // Disable ALL tracking
+ // Remove IDFA access, disable third-party analytics that track
+ break
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+#### Verification
+- Generate Privacy Report (Product > Archive > Generate Privacy Report) and verify all APIs declared
+- Test privacy policy link from within the app (not just browser)
+- Verify every permission request has a specific, honest purpose string
+- Audit all third-party SDKs for undisclosed data collection
+- Test ATT flow: deny tracking, verify app works correctly without it
+
+---
+
+### Pattern 4: Guideline 4.8 — Missing Sign in with Apple
+
+**Time cost** 3-7 days (implementation + resubmit)
+
+#### Symptom
+- Rejection citing Guideline 4.8
+- App has third-party login but no Sign in with Apple (SIWA)
+
+#### Common causes
+1. App has Google/Facebook/Twitter login but no SIWA
+2. SIWA button exists but doesn't work
+3. SIWA not offered at equal prominence (hidden or secondary)
+4. SIWA flow doesn't handle credential revocation
+
+#### Diagnosis
+
+The rule is simple: If your app uses ANY third-party or social login service, you MUST offer Sign in with Apple as an equivalent option.
+
+**Exceptions** (SIWA not required):
+- Company-internal or employee-only apps
+- Education or enterprise apps with existing institutional auth
+- Government/tax/banking apps requiring government ID
+- Apps that are a client for a specific third-party service (e.g., email client)
+
+#### Fix
+```swift
+import AuthenticationServices
+
+// ✅ CORRECT — SIWA at same prominence as other login options
+struct LoginView: View {
+ var body: some View {
+ VStack(spacing: 16) {
+ // Sign in with Apple — MUST be at same visual level
+ SignInWithAppleButton(.signIn) { request in
+ request.requestedScopes = [.fullName, .email]
+ } onCompletion: { result in
+ switch result {
+ case .success(let authorization):
+ handleAuthorization(authorization)
+ case .failure(let error):
+ handleError(error)
+ }
+ }
+ .signInWithAppleButtonStyle(.black)
+ .frame(height: 50)
+
+ // Other login options at same size/prominence
+ GoogleSignInButton()
+ .frame(height: 50)
+ }
+ }
+
+ func handleAuthorization(_ authorization: ASAuthorization) {
+ guard let credential = authorization.credential
+ as? ASAuthorizationAppleIDCredential else { return }
+
+ let userIdentifier = credential.user
+ let fullName = credential.fullName
+ let email = credential.email
+ // Note: fullName and email only provided on FIRST sign-in
+ // Store them immediately — they won't be provided again
+
+ // Send to your backend for account creation/login
+ }
+}
+```
+
+```swift
+// ✅ Handle credential revocation (required for account deletion support)
+func checkCredentialState() {
+ let provider = ASAuthorizationAppleIDProvider()
+ provider.getCredentialState(forUserID: storedUserIdentifier) { state, error in
+ switch state {
+ case .authorized:
+ break // User is still signed in
+ case .revoked:
+ // User revoked credentials — sign out immediately
+ signOut()
+ case .notFound:
+ // Credential not found — show sign-in
+ showLogin()
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+#### Verification
+- SIWA button is visually equal to other login buttons (same size, same screen)
+- Full SIWA flow works: sign in, account creation, credential check
+- Handle revocation: user can revoke in Settings > Apple ID > Sign-In & Security
+- Test account deletion flow (required since June 2022)
+
+---
+
+### Pattern 5: Guideline 3.x — Business/Monetization
+
+**Time cost** 3-14 days (may require architectural changes)
+
+#### Symptom
+- Rejection citing business guidelines
+- IAP requirements not met
+- Subscription doesn't provide ongoing value
+- External payment for digital content
+
+#### Common causes
+1. Digital content unlocked without IAP (using external payment for in-app features)
+2. Subscription doesn't provide ongoing value (one-time content sold as subscription)
+3. Loot box or random item purchase odds not disclosed
+4. Deceptive subscription flow (dark patterns, misleading free trial)
+5. IAP metadata incomplete or not submitted for review
+
+#### Diagnosis
+
+The key question: Is any digital content or feature unlocked without Apple IAP?
+
+```
+Digital goods/features → MUST use Apple IAP
+ Examples: premium features, virtual currency, ad removal, content
+ packs, subscription access to digital content
+
+Physical goods/services → MAY use external payment
+ Examples: physical merchandise, ride-sharing, food delivery,
+ person-to-person services
+
+Certain categories → MAY use external payment (3.1.3 exceptions)
+ Examples: "reader" apps (Kindle, Netflix, Spotify), one-to-one
+ real-time services
+```
+
+#### Fix
+```swift
+// ❌ WRONG — Unlocking features via external payment
+func unlockPremium(receiptFromServer: String) {
+ // Bypass Apple IAP → rejection
+ UserDefaults.standard.set(true, forKey: "isPremium")
+}
+
+// ✅ CORRECT — StoreKit 2 for all digital goods
+import StoreKit
+
+func purchasePremium() async throws {
+ let product = try await Product.products(for: ["com.app.premium"]).first!
+ let result = try await product.purchase()
+
+ switch result {
+ case .success(let verification):
+ let transaction = try checkVerified(verification)
+ // Unlock feature
+ await transaction.finish()
+ case .pending:
+ // Payment pending (Ask to Buy, etc.)
+ break
+ case .userCancelled:
+ break
+ @unknown default:
+ break
+ }
+}
+```
+
+```swift
+// ✅ Loot box disclosure (required if random items for purchase)
+struct LootBoxView: View {
+ var body: some View {
+ VStack {
+ Text("Mystery Box — $4.99")
+ Text("Contents are random. Odds:")
+ .font(.caption)
+
+ // MUST disclose odds before purchase
+ VStack(alignment: .leading) {
+ Text("Common item: 60%")
+ Text("Rare item: 30%")
+ Text("Legendary item: 10%")
+ }
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+```
+
+#### Verification
+- ALL digital content/features use Apple IAP (StoreKit 2)
+- IAP products submitted and approved in ASC before app submission
+- Subscription terms clearly communicated before purchase screen
+- Free trial duration and auto-renewal price clearly visible
+- Loot box odds disclosed before any purchase
+- No external payment links for digital goods (unless "reader" app exception applies)
+
+---
+
+### Pattern 6: Binary Rejected — Technical Gates
+
+**Time cost** 1-3 days (build configuration fix)
+
+#### Symptom
+- "Binary Rejected" with no specific guideline
+- Automated rejection during processing
+- Build stuck in "Processing" state
+
+#### Common causes
+1. Built with outdated SDK version (must meet Apple's minimum)
+2. Privacy manifest (PrivacyInfo.xcprivacy) missing or invalid
+3. Encryption compliance not declared (ITSAppUsesNonExemptEncryption)
+4. Invalid signing or provisioning profile
+5. Missing required device capabilities in Info.plist
+6. App uses private or deprecated APIs
+7. App binary too large without on-demand resources
+
+#### Diagnosis
+```bash
+# 1. Check Xcode and SDK version
+xcodebuild -version
+# Must be current or previous major Xcode version
+
+# 2. Check processing logs in ASC
+# App Store Connect → My Apps → [App] → Activity → Build → Processing Log
+
+# 3. Verify encryption declaration
+grep -c "ITSAppUsesNonExemptEncryption" Info.plist
+# Must exist and be set to YES or NO
+
+# 4. Check provisioning
+security cms -D -i embedded.mobileprovision 2>/dev/null | head -20
+# Verify not expired
+
+# 5. Check for private API usage
+# Xcode: Product → Archive → Distribute App → Validate App
+# This catches most private API issues before submission
+```
+
+#### Fix
+```xml
+
+
+ITSAppUsesNonExemptEncryption
+
+
+
+ITSAppUsesNonExemptEncryption
+
+
+```
+
+**Encryption decision flow**:
+1. Does your app use ONLY standard OS-provided HTTPS (URLSession, Alamofire)? → Set `false`, done
+2. Does your app call OpenSSL, libsodium, or custom crypto directly? → Set `true`, upload BIS docs
+3. Does your app implement proprietary encryption protocols? → Set `true`, upload BIS docs
+4. Unsure? → Run `strings YourApp | grep -i "openssl\|libcrypto\|CCCrypt"` to check
+
+```bash
+# Validate before submitting
+# Xcode: Product → Archive → Distribute App → Validate App
+# Catches ~80% of binary rejection causes
+
+# Clean build if signing issues
+rm -rf ~/Library/Developer/Xcode/DerivedData
+# Re-download provisioning profiles in Xcode Preferences → Accounts
+```
+
+#### Verification
+- Run "Validate App" in Xcode Organizer before submitting
+- Verify Xcode version meets Apple's current requirements
+- Check PrivacyInfo.xcprivacy exists and is included in the app bundle
+- Verify ITSAppUsesNonExemptEncryption key is present
+- Ensure provisioning profile is not expired
+- Test app on physical device with release configuration
+
+---
+
+### Pattern 7: Appeal Process
+
+**Time to resolve** 3-14 days
+
+#### When to use
+- You genuinely believe the reviewer misunderstood your app
+- You believe the wrong guideline was applied
+- Your app complies and you have evidence
+
+#### When NOT to use
+- You disagree with Apple's rules (they won't change for your app)
+- You're hoping a different reviewer will approve without changes
+- You want to skip implementing a required feature (like SIWA)
+
+#### Step 1: Reply in App Store Connect first
+
+Most issues resolve without a formal appeal. Reply to App Review messages in ASC with:
+- Specific evidence of compliance
+- Screenshots or video demonstrating the feature
+- Clear reference to the guideline you believe you comply with
+
+#### Step 2: If unresolved, submit formal appeal
+
+URL: developer.apple.com/contact/app-store/?topic=appeal
+
+#### Appeal writing
+
+```
+✅ GOOD appeal structure:
+
+"Our app complies with Guideline [X.Y] because [specific evidence].
+
+The reviewer noted: '[quote exact rejection text]'
+
+However, our app [specific counter-evidence with details]:
+1. [Feature X] works as shown in [attached screenshot/video]
+2. [Policy Y] is accessible at [URL] and within the app at [screen]
+3. [Requirement Z] is implemented as described in [technical detail]
+
+Attached: [screenshots, screen recording, or documentation]
+
+We respectfully request re-review of this decision."
+```
+
+```
+❌ BAD appeal examples:
+
+"This is unfair. Other apps do the same thing. Please approve."
+→ Apple reviews each app independently
+
+"We've been rejected 3 times and are losing money."
+→ Financial pressure is not relevant to guideline compliance
+
+"The reviewer didn't understand our app."
+→ Vague. Show specifically what they missed.
+
+"We need this approved by Friday for our launch."
+→ Deadlines are not App Review's concern
+```
+
+#### Step 3: Escalate if needed
+
+If appeal is denied:
+1. Request a phone call with App Review (available through appeal process)
+2. Contact Apple Developer Relations as last resort
+3. Consider whether the app genuinely needs architectural changes
+
+#### Verification
+- Wait for response before making code changes (if appealing)
+- Include ONE appeal per rejection (multiple appeals slow the process)
+- Respond to any information requests before filing appeal
+
+---
+
+### Pattern 8: Guideline 4.2/4.3 — Minimum Functionality / Spam
+
+> Patterns 8-9 address subjective rejections where the fix is demonstrating value or compliance, not changing code.
+
+**Time to resolve** 1-4 weeks (requires app changes, not just metadata)
+
+#### Rejection messages you'll see
+
+- **4.2**: "Your app does not include sufficient content and features to be appropriate for the App Store"
+- **4.3**: "Your app duplicates the content and functionality of other apps submitted by you or another developer"
+
+#### What it really means
+
+- **4.2** (#3 most common rejection): "Your app doesn't do enough to justify existing as a native app" — not enough value beyond what a website provides.
+- **4.3** (#2 most common rejection): "Your app looks too similar to another app, or is template-generated." Apple actively fights template-mill spam.
+
+#### Detection — is this really a 4.2/4.3?
+
+Before assuming the rejection is valid, check for misclassification:
+
+```bash
+# Is the app a WKWebView wrapper?
+grep -rn "WKWebView\|SFSafariViewController\|UIWebView" --include="*.swift" .
+
+# Does the app use native features at all?
+grep -rn "import CoreLocation\|import AVFoundation\|import UserNotifications\|import HealthKit\|import CoreML" --include="*.swift" .
+
+# Is the codebase template-generated? (common template markers)
+grep -rn "powered by\|generated by\|template\|starter kit" --include="*.swift" --include="*.plist" .
+```
+
+If `WKWebView` is the primary UI and native feature imports are absent, the rejection is likely valid.
+
+#### Response decision tree
+
+```
+Is the rejection valid? (be honest)
+│
+├─ YES, app is thin / web wrapper
+│ ├─ Can you add meaningful native features? → Add them (see Evidence Checklist)
+│ └─ Core value IS the web content? → Consider reader app model or PWA instead
+│
+├─ PARTIALLY, app has value but reviewer missed it
+│ ├─ Did you provide demo credentials? → If not, resubmit with access
+│ ├─ Is the value hidden behind onboarding? → Add review notes explaining path to value
+│ └─ Does the app need content/data to show value? → Pre-populate sample data for review
+│
+└─ NO, app is clearly feature-rich
+ └─ Appeal with detailed functionality walkthrough (Pattern 7)
+ Include: feature list, screenshots of each major screen, video demo
+```
+
+#### Evidence checklist — what satisfies reviewers
+
+**For 4.2 (Minimum Functionality)** — demonstrate native value:
+
+```
+Features that prove native value:
+☐ Offline functionality (data available without network)
+☐ Push notifications with meaningful triggers
+☐ Device API usage (camera, location, sensors, HealthKit)
+☐ Custom UI beyond web content (native controls, animations, gestures)
+☐ Local data persistence and sync
+☐ Widget, Live Activity, or Watch companion
+☐ Accessibility features (VoiceOver, Dynamic Type)
+☐ System integration (Shortcuts, Share Extension, Spotlight)
+
+Changes that DON'T help:
+✗ Adding a splash screen or settings-only screen to a web wrapper
+✗ Cosmetic changes (colors/fonts) without functional native features
+```
+
+**For 4.3 (Spam / Duplicate)** — demonstrate uniqueness:
+
+```
+How to differentiate:
+☐ Unique UI that doesn't match other apps in your catalog
+☐ Different target audience with distinct feature set
+☐ Separate branding (icon, name, color scheme)
+☐ Features that justify a separate app vs. being a mode in an existing app
+☐ Distinct App Store description and screenshots
+☐ Different primary use case (not just themed variants)
+
+If you have multiple similar apps:
+☐ Consolidate into one app with multiple modes/themes
+☐ Remove duplicates before resubmitting
+☐ Explain in review notes why apps need to be separate
+```
+
+#### Communication template
+
+```
+Resolution Center response (adapt for 4.2 or 4.3):
+
+"Thank you for your feedback. [Choose one]:
+
+For 4.2: We have added native features that provide value beyond our
+web presence: [list features with device capabilities used].
+
+For 4.3: Our app is distinct from [similar app] — different target
+audience ([X] vs [Y]), unique features ([list]), separate branding.
+
+[Attach: screenshots of each major screen, 30-60s video walkthrough]
+Demo credentials: [username] / [password]"
+```
+
+#### Verification
+
+- Test the app yourself: would YOU download this instead of using the website?
+- Compare against your other apps: can you clearly articulate why they're separate?
+- Pre-populate demo data so the reviewer sees value immediately
+- Record a 30-60 second walkthrough video showing native features in action
+
+---
+
+### Pattern 9: Guideline 1.x — Safety: Content / UGC / Kids
+
+**Time to resolve** 1-3 weeks (requires moderation infrastructure or content changes)
+
+#### Rejection messages and what they mean
+
+- **1.1**: "Your app includes content that many users would find objectionable" — Apple reviews content strictly, including content accessible through your app.
+- **1.2**: "Your app enables the display of user-generated content but does not include sufficient mechanisms to report offensive content" — UGC without adequate moderation. Non-negotiable: if users can post anything, you need a complete moderation system.
+- **1.3**: "Your Kids category app must comply with COPPA" / "includes third-party analytics not appropriate for Kids" — Kids category has the tightest rules. Any gap is a rejection.
+
+#### Detection — which sub-guideline?
+
+```
+Does the app have UGC?
+├─ YES → Likely 1.2 (moderation required)
+│ ├─ Comments, posts, or forums? → Need reporting + moderation
+│ ├─ Photo/video uploads visible to others? → Need content review
+│ ├─ User profiles visible to others? → Need profile reporting
+│ └─ Chat or messaging? → Need blocking + reporting + content filtering
+│
+├─ Is it in the Kids category?
+│ └─ YES → Likely 1.3 (strict requirements)
+│ ├─ Any third-party SDKs? → Must be certified for kids
+│ ├─ Any external links? → Must be gated or removed
+│ └─ Any purchases? → Must have parental gate
+│
+└─ Does it display third-party or app-provided content?
+ └─ YES → Likely 1.1 (content standards)
+ ├─ Web content via WKWebView? → Must filter or restrict
+ ├─ AI-generated content? → Must moderate outputs
+ └─ Content from third-party APIs? → Must filter before display
+```
+
+#### Response decision tree
+
+**UGC rejection (1.2)**:
+```
+What moderation exists?
+│
+├─ No moderation at all
+│ └─ Implement complete moderation system (see Implementation Checklist)
+│
+├─ Reporting exists but insufficient
+│ ├─ Missing blocking? → Add user blocking (immediate effect)
+│ ├─ No response workflow? → Add 24-hour review commitment
+│ └─ No pre-publish review? → Add content queue or ML filtering
+│
+└─ Moderation exists but not visible to reviewer
+ └─ Add review notes explaining moderation flow + demo how to trigger it
+```
+
+**Kids category rejection (1.3)**:
+```
+What triggered the rejection?
+│
+├─ Third-party SDKs
+│ └─ Remove ALL analytics/ads SDKs not certified for Kids category
+│ Common offenders: Firebase Analytics, Facebook SDK, AdMob
+│ Allowed: Apple's own frameworks, COPPA-certified SDKs only
+│
+├─ External links
+│ └─ Remove all links OR gate behind parental verification
+│ Includes: "Visit our website", social media links, "More apps"
+│
+├─ In-app purchases
+│ └─ Add parental gate before ANY purchase flow
+│ Gate must require adult knowledge (e.g., "spell this word", math problem)
+│ Simple "Are you 18?" button is NOT sufficient
+│
+└─ Data collection
+ └─ Remove ALL data collection not essential to app function
+ No device IDs, no location, no contact info, no tracking
+```
+
+#### Implementation checklist — minimum viable moderation (1.2)
+
+```
+Required for ANY app with UGC:
+☐ Report button on every piece of user content (visible, not buried)
+☐ Block user functionality (immediate, no delay)
+☐ Visible Terms of Use / Community Guidelines (accessible from within app)
+☐ Content review workflow (human review queue or ML pre-screening)
+☐ 24-hour response commitment for reported content
+☐ Ability to remove content and ban users
+☐ In-app link to Terms of Use from content creation screens
+```
+
+#### Kids category requirements (1.3)
+
+```
+MANDATORY for Kids category:
+☐ COPPA compliance (no data collection from children under 13)
+☐ No third-party advertising (none, not even "child-safe" networks)
+☐ No external links (or gated behind parental verification)
+☐ No social features (no chat, no profiles, no friend lists)
+☐ Parental gate before any purchase (not a simple age button)
+☐ Remove ALL non-COPPA-certified SDKs (Firebase Analytics, Facebook, AdMob, Crashlytics)
+☐ No user tracking of any kind
+☐ Privacy policy specifically addresses children's data
+```
+
+#### Communication template
+
+```
+Resolution Center response (adapt for 1.2 or 1.3):
+
+"Thank you for your feedback. [Choose one]:
+
+For 1.2 (UGC): We have implemented moderation: report button on all
+content [location], user blocking, [review workflow], Terms of Use at
+[URL]. To test: create a post → [...] menu → Report.
+
+For 1.3 (Kids): Removed [SDKs]. External links [removed/gated].
+Added parental gate for purchases. No user data collected.
+Privacy policy updated at [URL].
+
+[Attach: screenshots showing moderation/parental gate flow]"
+```
+
+#### Verification
+
+- Test the report flow end-to-end: report content, verify it reaches a review queue
+- Test the block flow: block a user, verify their content is hidden immediately
+- For Kids: remove the app from a test device, reinstall, verify no data persists
+- For Kids: run `strings YourApp.app/YourApp | grep -i "firebase\|facebook\|google\|admob"` to catch embedded SDKs
+- Have someone unfamiliar with the app try to find the report/block buttons — if they can't find them in 5 seconds, they're not prominent enough
+
+---
+
+## Quick Reference Table
+
+| Rejection Type | Likely Cause | First Check | Pattern | Typical Fix Time |
+|---|---|---|---|---|
+| Guideline 2.1 | Crashes/placeholders | Test on device, search placeholders | 1 | 1-3 days |
+| Guideline 2.3 | Metadata mismatch | Compare screenshots to app | 2 | 1 day (no build) |
+| Guideline 5.1 | Privacy gaps | Check policy + manifest + purpose strings | 3 | 2-5 days |
+| Guideline 4.8 | Missing SIWA | Check for third-party login | 4 | 3-5 days |
+| Guideline 3.x | Payment method | Review IAP flows | 5 | 3-14 days |
+| Binary Rejected | Technical gate | Check SDK, manifest, encryption | 6 | 1-2 days |
+| Guideline 1.x | Safety/content/UGC | Check UGC moderation + Kids compliance | 9 | 1-3 weeks |
+| Guideline 4.2/4.3 | Thin app/spam | Audit native features + app uniqueness | 8 | 1-4 weeks |
+
+## Production Crisis Scenario
+
+### Context: App rejected for 3rd time, different reason each time, launch is tomorrow
+
+**Situation**: Marketing committed to a launch date. App was rejected for crashes (fixed), then metadata (fixed), now privacy policy "doesn't match actual data collection."
+
+**Pressure signals**:
+- Product team already sent press releases with launch date
+- App Store rating will drop if launch delayed
+- Manager asking "why wasn't this caught earlier?"
+- Temptation to quick-fix only the cited privacy issue
+
+**Why this happens**: Each review pass goes deeper. First pass catches obvious issues (crashes). Second pass checks metadata. Third pass audits privacy compliance. This is normal, not "the reviewer is picking on you."
+
+#### Rationalization traps (DO NOT fall into these)
+
+1. *"Just fix the privacy policy wording and resubmit"*
+ - The reviewer said "doesn't match actual data collection"
+ - That means your app collects data you didn't disclose
+ - A wording change without auditing actual data collection = another rejection
+
+2. *"The reviewer is being unreasonable, let's appeal"*
+ - Three rejections for three different valid issues is not unreasonable
+ - Appealing wastes 3-14 days when you could fix and resubmit in 1-3 days
+
+3. *"Let's remove the privacy-sensitive features to ship faster"*
+ - Removing features changes the app, requiring re-review of everything
+ - May introduce new issues (broken UI, missing functionality)
+
+4. *"Different reviewer next time might not notice"*
+ - Reviewers see the rejection history — they check previously cited issues
+ - Repeat rejections get escalated to senior reviewers
+
+#### MANDATORY approach
+
+1. Don't panic. Don't resubmit without a thorough fix.
+2. Run the COMPLETE pre-flight checklist — not just the cited issue.
+3. Audit all data collection: every SDK, every analytics call, every API request that sends user data.
+4. Generate privacy report (Product > Archive > Generate Privacy Report) and cross-reference with privacy policy.
+5. Fix privacy policy to specifically list every data type actually collected.
+6. Verify all previous rejection issues still fixed (crashes, metadata).
+7. Request expedited review at developer.apple.com/contact/app-store/?topic=expedite if genuinely time-critical.
+8. Communicate to stakeholders: "Each review fixes more issues. This submission addresses privacy compliance comprehensively."
+
+#### Time comparison
+
+| Approach | Time to Approval |
+|---|---|
+| Quick fix + resubmit | 7-14 more days (likely rejected again) |
+| Full audit + thorough fix | 3-5 days (high confidence) |
+| Full audit + expedited review | 1-3 days (if granted) |
+
+#### Professional communication template
+
+```
+To stakeholders:
+
+"Root cause: Our third-party analytics SDK collects device identifiers
+that weren't disclosed in our privacy policy or nutrition labels.
+
+Fix: Updated privacy policy, privacy nutrition labels in ASC, and
+PrivacyInfo.xcprivacy to accurately reflect all data collection.
+Also audited all SDKs for undisclosed collection.
+
+Timeline: Resubmitting today with expedited review request.
+Expected approval: 1-3 business days.
+
+Prevention: Adding privacy audit to our pre-submission checklist
+so future submissions include accurate disclosure from the start."
+```
+
+---
+
+## Common Mistakes
+
+### 1. Skimming the Rejection Message
+
+**Problem** Developer reads "Guideline 5.1" and assumes they know the issue without reading the full explanation.
+
+**Why it fails** Guideline 5.1 covers privacy policy, purpose strings, privacy manifest, tracking, AND data collection disclosure. The rejection message tells you exactly which aspect failed. Guessing the wrong one wastes a full review cycle (3-7 days).
+
+**Fix**: Copy the FULL rejection text. Highlight every specific requirement mentioned. Map each one to the fix before writing any code.
+
+### 2. Fixing Only the Cited Issue
+
+**Problem** Rejection cites Guideline 5.1 (privacy). Developer fixes privacy but doesn't check for other issues.
+
+**Why it fails** Reviewers find new issues on each pass. First pass catches crashes, second catches metadata, third catches privacy. If you only fix privacy, the fourth pass might find a Guideline 4.8 (SIWA) issue.
+
+**Fix**: Before every resubmission, run through ALL common rejection patterns (1-6). Fix everything proactively. One thorough submission beats three partial ones.
+
+### 3. Resubmitting Without Changes
+
+**Problem** "Maybe a different reviewer will approve it."
+
+**Why it fails** Reviewers see the rejection history. Unchanged resubmissions get the same result or escalated to senior reviewers. Each wasted cycle costs 3-7 days.
+
+**Fix**: Always make at least the changes the reviewer requested. If you believe the rejection is wrong, reply in ASC with evidence first.
+
+### 4. Arguing Emotionally in App Review Messages
+
+**Problem** "This is unfair! Other apps do this! You're blocking our business!"
+
+**Why it fails** App Review is a technical compliance review, not a negotiation. Emotional arguments are ignored. Specific evidence of compliance works.
+
+**Fix**: Be factual, specific, and professional. Quote the guideline. Show screenshots. Provide technical evidence.
+
+### 5. Ignoring Third-Party SDK Issues
+
+**Problem** "We don't collect that data — it must be the SDK."
+
+**Why it fails** Your app is responsible for ALL SDK behavior. If Facebook SDK collects device identifiers, YOUR privacy policy and nutrition labels must disclose it.
+
+**Fix**: Audit every third-party SDK. Generate Privacy Report to see aggregate data collection. Update privacy policy and nutrition labels to cover all SDK behavior.
+
+### 6. Deploying Backend Changes During Review
+
+**Problem** Pushing a backend update that changes API responses while the app is under review.
+
+**Why it fails** Reviewers may test at any time during the review window. A backend change that breaks the reviewed build = crash during review = Guideline 2.1 rejection.
+
+**Fix**: Freeze backend during review period. If changes are necessary, ensure backward compatibility with the submitted build.
+
+### 7. Not Using Expedited Review When Available
+
+**Problem** Developer doesn't know about or doesn't use expedited review for critical situations.
+
+**Why it fails** Waiting 3-7 days for standard review when a 1-day expedited review is available for legitimate reasons.
+
+**Fix**: Request expedited review at developer.apple.com/contact/app-store/?topic=expedite for: critical bug fixes, time-sensitive events, or security patches. Don't abuse it — Apple tracks usage and may deny future requests.
+
+---
+
+## Pre-Submission Checklist
+
+Run through this BEFORE every App Store submission to prevent rejections:
+
+```
+App Completeness (2.1):
+☐ Tested on latest shipping iOS version on physical device
+☐ Tested on at least 2 device sizes (iPhone SE, iPhone Pro Max)
+☐ No placeholder text (search: Lorem, TODO, FIXME, placeholder, sample)
+☐ All URLs resolve (support URL, privacy policy, in-app links)
+☐ Demo credentials provided if login required (non-expiring)
+☐ Backend stable and not deploying during review window
+
+Metadata (2.3):
+☐ Screenshots taken from submitted build (not dev build)
+☐ Every feature in description exists and works
+☐ No trademarked terms in keywords
+☐ Age rating matches content
+☐ "What's New" text accurate
+
+Privacy (5.1):
+☐ Privacy policy accessible in-app AND via URL in ASC
+☐ Privacy policy matches actual data collection
+☐ Every permission has specific, honest purpose string
+☐ PrivacyInfo.xcprivacy exists and lists all required reason APIs
+☐ Privacy Report generated and cross-referenced
+☐ ATT implemented if any cross-app tracking
+☐ Privacy nutrition labels accurate (including third-party SDKs)
+
+Sign in with Apple (4.8):
+☐ If third-party login exists, SIWA offered at same prominence
+☐ SIWA flow works: sign in, account creation, revocation handling
+☐ Account deletion supported (required since June 2022)
+
+Business (3.x):
+☐ All digital goods/features use Apple IAP
+☐ IAP products approved in ASC before app submission
+☐ Subscription terms clear before purchase
+☐ Loot box odds disclosed if applicable
+
+Technical (Binary):
+☐ Xcode version meets Apple's current requirements
+☐ "Validate App" passes in Xcode Organizer
+☐ ITSAppUsesNonExemptEncryption key present
+☐ Provisioning profile not expired
+☐ Tested with release configuration on device
+
+Safety & Content (1.x):
+☐ If UGC: report, block, and moderation workflow all functional
+☐ If Kids category: no non-COPPA SDKs, no external links, parental gate on purchases
+☐ AI-generated or third-party content has moderation/filtering
+
+Design & Originality (4.2/4.3):
+☐ App provides native value beyond a website (offline, push, device APIs)
+☐ App is distinct from other apps in your catalog
+☐ Demo data pre-populated for reviewer if app needs content to show value
+```
+
+---
+
+## Cross-References
+
+- **app-store-connect-ref** — ASC crash analysis, TestFlight feedback, metrics dashboards
+- **privacy-ux** — Privacy manifest implementation details and required reason APIs
+- **storekit-ref** — StoreKit 2 IAP/subscription implementation
+- **accessibility-diag** — Accessibility compliance (VoiceOver, Dynamic Type, WCAG)
+- **ios-build** — Build and signing issues that cause Binary Rejected
+
+## Resources
+
+**WWDC**: 2025-328
+
+**Docs**: /app-store/review/guidelines, /distribute/app-review, /support/offering-account-deletion-in-your-app, /contact/app-store/?topic=appeal
+
+**Skills**: app-store-connect-ref, privacy-ux, storekit-ref, accessibility-diag
diff --git a/.claude/skills/axiom-app-store-diag/agents/openai.yaml b/.claude/skills/axiom-app-store-diag/agents/openai.yaml
new file mode 100644
index 0000000..f2cc648
--- /dev/null
+++ b/.claude/skills/axiom-app-store-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Store Diagnostics"
+ short_description: "App is rejected by App Review, submission blocked, or appeal needed"
diff --git a/.claude/skills/axiom-app-store-ref/.openskills.json b/.claude/skills/axiom-app-store-ref/.openskills.json
new file mode 100644
index 0000000..2b9fc87
--- /dev/null
+++ b/.claude/skills/axiom-app-store-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-store-ref",
+ "installedAt": "2026-04-12T08:05:47.497Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-store-ref/SKILL.md b/.claude/skills/axiom-app-store-ref/SKILL.md
new file mode 100644
index 0000000..cf5d69b
--- /dev/null
+++ b/.claude/skills/axiom-app-store-ref/SKILL.md
@@ -0,0 +1,1052 @@
+---
+name: axiom-app-store-ref
+description: Use when looking up ANY App Store metadata field requirement, privacy manifest schema, age rating tier, export compliance decision, EU DSA trader status, IAP review pipeline, or WWDC25 submission change. Covers character limits, screenshot specs, encryption decision tree, account deletion rules.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Store Submission Reference
+
+## Overview
+
+Complete reference for every App Store submission requirement:
+
+- **Part 1** — Required metadata fields (descriptions, screenshots, keywords, App Review info)
+- **Part 2** — Privacy requirements (manifest schema, nutrition labels, ATT, Required Reason APIs)
+- **Part 3** — App Review Guidelines quick reference (all sections 1-5)
+- **Part 4** — Age rating system (5-tier, capabilities, regional variations)
+- **Part 5** — Export compliance (encryption decision tree)
+- **Part 6** — Account and authentication requirements (deletion, SIWA)
+- **Part 7** — Monetization and IAP submission pipeline
+- **Part 8** — EU-specific compliance (DSA trader status)
+- **Part 9** — Build upload and processing
+- **Part 10** — WWDC25 changes (draft submissions, accessibility labels, tags)
+
+## When to Use This Skill
+
+#### Use when
+- Looking up specific metadata field requirements or character limits
+- Checking App Review guideline numbers for a specific topic
+- Verifying privacy manifest schema fields or Required Reason API categories
+- Understanding age rating tiers and new capability declarations
+- Checking EU compliance requirements for DSA trader status
+- Understanding IAP submission pipeline and review flow
+- Preparing builds for upload (SDK requirements, encryption, signing)
+
+#### Do NOT use when
+- Deciding if your app is ready to submit (use **app-store-submission**)
+- Troubleshooting a rejection (use **app-store-diag**)
+- Implementing in-app purchases (use **storekit-ref**)
+- Writing privacy manifest code (use **privacy-ux**)
+- Auditing accessibility compliance (use **accessibility-diag**)
+
+## Related Skills
+
+- **app-store-submission** — Discipline skill with pre-flight checklist and workflow
+- **app-store-diag** — Rejection troubleshooting and appeal guidance
+- **privacy-ux** — Privacy manifest implementation, ATT UX, permission requests
+- **storekit-ref** — StoreKit 2 API reference for IAP implementation
+- **accessibility-diag** — Accessibility compliance scanning and VoiceOver testing
+
+## Key Terminology
+
+| Term | Definition |
+|------|-----------|
+| **App Store Connect** | Web portal and API for managing app metadata, builds, pricing, TestFlight, and analytics |
+| **App Review** | Apple's human review process that evaluates every app update against the App Review Guidelines |
+| **Privacy Manifest** | `PrivacyInfo.xcprivacy` file declaring data collection, tracking domains, and Required Reason API usage |
+| **Required Reason API** | System APIs (file timestamps, disk space, user defaults, etc.) that require declared usage reasons |
+| **Privacy Nutrition Label** | App Store privacy cards showing what data your app collects and how it uses it |
+| **DSA Trader Status** | EU Digital Services Act classification determining if you are a "trader" selling to EU consumers |
+| **Build String** | Unique identifier for each uploaded build (e.g., "1.2.3.4"), separate from version number |
+| **Bundle ID** | Reverse-domain identifier (e.g., "com.company.app") uniquely identifying your app across Apple's ecosystem |
+
+---
+
+## Part 1: Required Metadata Fields
+
+### Immutable Fields (Cannot Change After Creation)
+
+These fields are locked once set. Choose carefully:
+- **Bundle ID** — must match Xcode project exactly
+- **SKU** — internal identifier, never shown to users
+- **IAP Product ID** — cannot be changed or reused after creation
+- **Subscription Duration** — locked after subscription is created
+
+### App Information
+
+| Field | Required | Localizable | Max Length | Notes |
+|-------|----------|-------------|------------|-------|
+| App Name | Yes | Yes | 30 chars | Must be unique on the App Store |
+| Subtitle | No | Yes | 30 chars | Appears below app name in search results |
+| Description | Yes | Yes | 4000 chars | Plain text, no HTML or rich formatting |
+| Promotional Text | No | Yes | 170 chars | Editable without new submission |
+| Keywords | Yes | Yes | 100 bytes | Comma-separated, each keyword >2 chars |
+| What's New | Yes* | Yes | 4000 chars | *Required for all versions except first |
+| Copyright | Yes | No | — | Format: "YYYY Company Name" |
+| Support URL | Yes | Yes | — | Must link to actual contact information |
+| Marketing URL | No | Yes | — | Optional promotional page |
+| Privacy Policy URL | Yes | Yes | — | HTTPS, publicly accessible |
+
+### Visual Assets
+
+| Asset | Required | Localizable | Specification |
+|-------|----------|-------------|---------------|
+| App Icon | Yes | No | 1024x1024 PNG, no alpha, no rounded corners |
+| Screenshots | Yes | Yes | Per device size, 2-10 per locale per device |
+| App Preview | No | Yes | Up to 3 videos per device size per locale |
+
+#### Screenshot Requirements
+
+Format: `.jpeg`, `.jpg`, `.png`. Min 1, max 10 per device size per locale.
+
+**Fallback**: Provide highest resolution only — App Store auto-scales to smaller sizes. Required: 6.9" or 6.5" for iPhone, 13" for iPad.
+
+| Device | Portrait | Landscape |
+|--------|----------|-----------|
+| iPhone 6.9" (Air, 17 Pro Max, 16 Pro Max) | 1260x2736 | 2736x1260 |
+| iPhone 6.5" (14 Plus, 13 Pro Max, XS Max) | 1284x2778 or 1242x2688 | 2778x1284 or 2688x1242 |
+| iPhone 6.3" (17 Pro, 16 Pro, 16, 15 Pro) | 1179x2556 or 1206x2622 | 2556x1179 or 2622x1206 |
+| iPhone 6.1" (17e, 16e, 14, 13, 12) | 1170x2532, 1125x2436, or 1080x2340 | 2532x1170, 2436x1125, or 2340x1080 |
+| iPhone 5.5" (8 Plus, 7 Plus) | 1242x2208 | 2208x1242 |
+| iPad 13" (Pro M5/M4, Air M4/M3) | 2064x2752 or 2048x2732 | 2752x2064 or 2732x2048 |
+| iPad 11" (Pro, Air, mini, 10th gen) | 1488x2266 or 1668x2420 | 2266x1488 or 2420x1668 |
+| Mac | 1280x800, 1440x900, 2560x1600, or 2880x1800 | — |
+| Apple TV | 1920x1080 or 3840x2160 | — |
+| Apple Vision Pro | 3840x2160 | — |
+
+Screenshots must show the app in actual use. Not permitted: title art alone, login screens, splash screens, or screens from other platforms. Cannot update screenshots on an approved version — must create a new version.
+
+#### App Preview Video Specifications
+
+| Spec | Value |
+|------|-------|
+| Duration | 15-30 seconds |
+| Max file size | 500 MB |
+| Max frame rate | 30 fps |
+| Max per device/locale | 3 |
+| H.264 | 10-12 Mbps, `.mov`/`.m4v`/`.mp4`, 256 kbps AAC stereo |
+| ProRes 422 HQ | ~220 Mbps VBR, `.mov` only, PCM or 256 kbps AAC stereo |
+| Audio sample rate | 44.1 or 48 kHz |
+| Orientation | Portrait or landscape (Mac/tvOS/visionOS: landscape only) |
+
+**Gotchas**: Processing can take up to 24 hours — do not submit for review immediately after uploading previews. All audio tracks must be enabled (disabled tracks cause rejection). Previews always appear before screenshots on the product page.
+
+#### App Icon Requirements
+
+| Specification | Requirement |
+|---------------|-------------|
+| Size | 1024 x 1024 pixels |
+| Format | PNG |
+| Color space | sRGB or P3 |
+| Alpha channel | Not allowed |
+| Rounded corners | Not allowed (system applies automatically) |
+| Layers/transparency | Not allowed |
+| Content | Must be appropriate for 4+ rating regardless of app's actual rating |
+
+### App Review Information
+
+| Field | Required | Notes |
+|-------|----------|-------|
+| Contact First Name | Yes | Reviewer contact |
+| Contact Last Name | Yes | Reviewer contact |
+| Contact Email | Yes | Must be monitored |
+| Contact Phone | Yes | Include country code |
+| Notes for Review | No | Up to 4000 bytes; explain non-obvious features |
+| Sign-in Username | If login required | Must not expire during review |
+| Sign-in Password | If login required | Must not expire during review |
+| Attachment | No | Up to 10 files, max 512 MB total |
+
+### Metadata Rules (Guideline 2.3)
+
+- App names must be unique, max 30 characters
+- Keywords must not include trademarked terms, popular app names, or pricing terms ("free", "sale")
+- Screenshots must show the app in use, not just marketing art
+- Icons, screenshots, and previews must be appropriate for a 4+ rating even if the app is rated higher
+- "For Kids" and "For Children" are reserved for the Kids category
+- No other mobile platform names or imagery in screenshots (no Android phones, Windows logos)
+- Metadata must accurately reflect app functionality; misleading metadata is grounds for rejection
+
+### Localization Requirements
+
+| Aspect | Details |
+|--------|---------|
+| Minimum | Primary language required; all other localizations optional |
+| Per-locale metadata | App name, subtitle, description, keywords, What's New, screenshots |
+| Promotional Text | Localizable and editable without new submission |
+| Screenshots | Can differ per locale (show localized UI) |
+| App Previews | Can differ per locale (show localized audio/UI) |
+| URL fields | Support URL and Marketing URL can differ per locale |
+
+When localizing, provide screenshots that match the localized UI. Reviewers check that screenshots accurately represent the app in each locale.
+
+### Category Selection
+
+| Primary Category | Secondary Category | Rules |
+|-----------------|-------------------|-------|
+| Required | Optional | Choose the category that best describes your app |
+| Must be accurate | Can complement primary | Inaccurate category is grounds for rejection (2.3.7) |
+| Games have subcategories | — | Games must also select up to 2 game subcategories |
+
+Available categories: Books, Business, Developer Tools, Education, Entertainment, Finance, Food & Drink, Games, Graphics & Design, Health & Fitness, Lifestyle, Magazines & Newspapers, Medical, Music, Navigation, News, Photo & Video, Productivity, Reference, Shopping, Social Networking, Sports, Travel, Utilities, Weather.
+
+---
+
+## Part 2: Privacy Requirements
+
+### Privacy Policy (Guideline 5.1.1(i))
+
+Required in BOTH locations:
+1. **App Store Connect** metadata (Privacy Policy URL field)
+2. **Within the app** itself (accessible from settings or equivalent)
+
+The privacy policy must identify:
+- What data is collected and by what means
+- All uses of collected data
+- Third-party sharing practices
+- Data retention and deletion policies
+- How users can revoke consent
+
+### Privacy Manifest Schema (PrivacyInfo.xcprivacy)
+
+```xml
+
+NSPrivacyTracking
+NSPrivacyTrackingDomains
+NSPrivacyCollectedDataTypes
+NSPrivacyAccessedAPITypes
+```
+
+#### NSPrivacyCollectedDataTypes Entry
+
+Each dictionary in the array contains:
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `NSPrivacyCollectedDataType` | String | Category key (e.g., "NSPrivacyCollectedDataTypeName") |
+| `NSPrivacyCollectedDataTypePurposes` | Array<String> | Purpose keys for this data type |
+| `NSPrivacyCollectedDataTypeLinked` | Boolean | Is this data linked to user identity? |
+| `NSPrivacyCollectedDataTypeTracking` | Boolean | Is this data used for tracking? |
+
+#### NSPrivacyAccessedAPITypes Entry
+
+Each dictionary in the array contains:
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `NSPrivacyAccessedAPIType` | String | API category identifier |
+| `NSPrivacyAccessedAPITypeReasons` | Array<String> | Approved reason codes for usage |
+
+#### Complete PrivacyInfo.xcprivacy Example
+
+```xml
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeEmailAddress
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+
+```
+
+#### API Category Identifiers
+
+| Category | Identifier String |
+|----------|------------------|
+| File timestamp | `NSPrivacyAccessedAPICategoryFileTimestamp` |
+| System boot time | `NSPrivacyAccessedAPICategorySystemBootTime` |
+| Disk space | `NSPrivacyAccessedAPICategoryDiskSpace` |
+| Active keyboard | `NSPrivacyAccessedAPICategoryActiveKeyboards` |
+| User defaults | `NSPrivacyAccessedAPICategoryUserDefaults` |
+
+#### Generating Aggregate Privacy Report
+
+```
+Xcode > Product > Archive > Generate Privacy Report
+```
+
+This produces a PDF summarizing privacy manifests from your app and all embedded frameworks.
+
+**Privacy manifest gotchas**:
+- Custom string values for `NSPrivacyCollectedDataType` or `NSPrivacyCollectedDataTypePurposes` are silently rejected by Xcode — must use Apple's predefined constants
+- iOS 17 automatically blocks connections to declared tracking domains when user denies tracking
+- IP address collection must be declared (as location, device ID, or diagnostics)
+- Web view data collection must be declared
+- You are responsible for ALL data collected by third-party SDKs in your app
+- Some SDKs default to tracking unless explicitly disabled — creates unintentional tracking
+- Fingerprinting is NEVER allowed, regardless of ATT permission
+- Since May 1, 2024: missing required-reason API declarations cause automatic rejection (no human review)
+
+### Required Reason API Categories
+
+| Category | APIs Covered | Common Reasons |
+|----------|-------------|----------------|
+| File timestamp | `NSFileCreationDate`, `NSFileModificationDate`, `NSURLContentModificationDateKey` | DDA9.1 (display to user), C617.1 (inside app container) |
+| System boot time | `systemUptime`, `mach_absolute_time` | 35F9.1 (measure elapsed time) |
+| Disk space | `NSFileSystemFreeSize`, `NSFileSystemSize`, `volumeAvailableCapacityKey` | E174.1 (check before writing), 85F4.1 (display to user) |
+| Active keyboard | `activeInputModes` | 54BD.1 (customize UI for keyboard) |
+| User defaults | `UserDefaults` (all access requires declaration) | CA92.1 (access within app group), 1C8F.1 (access within same app) |
+
+### App Privacy Details (Nutrition Labels)
+
+#### Data Type Categories
+
+| Category | Examples |
+|----------|---------|
+| Contact Info | Name, email address, phone number, physical address |
+| Health & Fitness | Health data, fitness data |
+| Financial Info | Payment info, credit info |
+| Location | Precise location, coarse location |
+| Sensitive Info | Racial or ethnic data, sexual orientation, religion, biometrics |
+| Contacts | Address book contacts |
+| User Content | Photos, videos, audio, gameplay content, customer support messages |
+| Browsing History | Web browsing history |
+| Search History | In-app search history |
+| Identifiers | User ID, device ID |
+| Purchases | Purchase history |
+| Usage Data | Product interaction, advertising data, app launches, taps, scrolls |
+| Diagnostics | Crash data, performance data |
+| Surroundings | Environment scanning (e.g., AR data) |
+| Body | Hands, head (e.g., hand tracking in visionOS) |
+
+#### Purpose Categories
+
+| Purpose | Description |
+|---------|-------------|
+| Third-Party Advertising | Displaying third-party ads or sharing with ad networks |
+| Developer's Advertising/Marketing | Your own marketing campaigns |
+| Analytics | Understanding user behavior and measuring effectiveness |
+| Product Personalization | Customizing features, content recommendations |
+| App Functionality | Required for app to work (e.g., authentication, data sync) |
+| Other | Any purpose not listed above |
+
+### Tracking and Collection Definitions
+
+**"Collected"** means data is transmitted off-device and accessible beyond what is needed to service the current request. On-device-only processing is NOT collection.
+
+**"Tracking"** means:
+- Linking user/device data from your app with third-party data for advertising or measurement, OR
+- Sharing user/device data with a data broker
+
+### App Tracking Transparency (ATT)
+
+Required if your app "tracks" per Apple's definition above.
+
+- Add `NSUserTrackingUsageDescription` to Info.plist (explains why tracking is needed)
+- Call `ATTrackingManager.requestTrackingAuthorization()` before tracking
+- Respect the result:
+ - `.authorized` — User granted permission to track
+ - `.denied` — User denied tracking; do not track
+ - `.notDetermined` — User has not yet been asked
+ - `.restricted` — Device-level restriction prevents tracking
+
+Request at a contextually appropriate moment, not at first launch.
+
+### Common Purpose Strings (NS*UsageDescription)
+
+These Info.plist keys must be present for each system permission your app requests:
+
+| Permission | Info.plist Key |
+|------------|---------------|
+| Camera | `NSCameraUsageDescription` |
+| Microphone | `NSMicrophoneUsageDescription` |
+| Photo Library (read) | `NSPhotoLibraryUsageDescription` |
+| Photo Library (write) | `NSPhotoLibraryAddUsageDescription` |
+| Location (when in use) | `NSLocationWhenInUseUsageDescription` |
+| Location (always) | `NSLocationAlwaysAndWhenInUseUsageDescription` |
+| Contacts | `NSContactsUsageDescription` |
+| Calendars (full access) | `NSCalendarsFullAccessUsageDescription` |
+| Reminders (full access) | `NSRemindersFullAccessUsageDescription` |
+| Health | `NSHealthShareUsageDescription`, `NSHealthUpdateUsageDescription` |
+| Motion | `NSMotionUsageDescription` |
+| Bluetooth | `NSBluetoothAlwaysUsageDescription` |
+| Face ID | `NSFaceIDUsageDescription` |
+| Local Network | `NSLocalNetworkUsageDescription` |
+| Tracking | `NSUserTrackingUsageDescription` |
+| Speech Recognition | `NSSpeechRecognitionUsageDescription` |
+| Apple Music | `NSAppleMusicUsageDescription` |
+
+Missing purpose strings cause immediate rejection. Purpose string text must clearly explain why the permission is needed in the context of your app's functionality.
+
+### Third-Party SDK Privacy Manifests
+
+Apple maintains a list of commonly used SDKs that require privacy manifests. Starting spring 2024, if your app includes these SDKs without privacy manifests, it will be flagged during submission.
+
+Third-party SDKs should include their own `PrivacyInfo.xcprivacy` in their framework bundle. The aggregate privacy report combines all manifests from your app and embedded frameworks.
+
+If a third-party SDK does not include a privacy manifest, you must declare its data collection in your app's privacy manifest.
+
+---
+
+## Part 3: App Review Guidelines Quick Reference
+
+For the complete guideline index (Sections 1-5), see `references/app-review-guidelines.md`.
+
+### Most Common Rejection Reasons
+
+See `references/app-review-guidelines.md` for the full top-10 rejection causes table with percentages.
+
+### App Review Timeline
+
+| Stage | Typical Duration |
+|-------|-----------------|
+| Waiting for Review | Minutes to hours |
+| In Review | Minutes to 24 hours |
+| Total (90th percentile) | Under 24 hours |
+| Total (edge cases) | Up to 7 days |
+| Expedited Review | Same day to 24 hours (if approved) |
+
+Review times increase during holidays and major iOS release periods. Plan submissions accordingly.
+
+---
+
+## Part 4: Age Rating System
+
+### Five-Tier Rating System (Updated January 31, 2026)
+
+| Rating | Triggers |
+|--------|----------|
+| **4+** | No objectionable material |
+| **9+** | Infrequent or mild: profanity, cartoon/fantasy violence, horror/fear themes. Loot boxes present |
+| **13+** | Frequent or intense: profanity or crude humor. Infrequent: alcohol/tobacco/drugs references, sexual content/nudity, realistic violence |
+| **16+** | Unrestricted web access, frequent medical/treatment info, mature/suggestive themes |
+| **18+** | Frequent or intense: alcohol/tobacco/drugs use, sexual content/nudity, realistic violence. Simulated gambling with real-money elements |
+| **Unrated** | App cannot be published without completing the questionnaire |
+
+### Capability Declarations (New, WWDC25)
+
+Apps must declare if they include these capabilities:
+
+| Capability | When to Declare |
+|------------|----------------|
+| Messaging/chat | Any in-app messaging between users |
+| User-generated content | Users can post, share, or upload content visible to others |
+| Advertising | App displays ads from any ad network |
+| Parental controls | App has parental restrictions or family features |
+| Age assurance | App verifies user age for restricted content |
+
+These declarations appear alongside the age rating on the App Store product page, giving parents and users additional transparency.
+
+### Regional Variations
+
+Age ratings map differently across regions:
+
+| Apple Rating | Australia | Brazil | Korea | Germany (USK) |
+|-------------|-----------|--------|-------|----------------|
+| 4+ | 4+ | L (All ages) | All | 0 |
+| 9+ | 9+ | A10 | 12+ | 6 |
+| 13+ | 13+ | A12 | 15+ | 12 |
+| 16+ | 15+ | A16 | 19+ | 16 |
+| 18+ | R 18+ | A18 | 19+ | 18 |
+
+The age rating questionnaire automatically generates the appropriate regional ratings based on your answers.
+
+### Age Rating Best Practices
+
+- Answer the questionnaire conservatively; under-rating leads to rejection
+- If your app accesses unrestricted web content (WebView without content filter), it will be rated 16+ minimum
+- UGC apps typically need 13+ minimum due to moderation requirements
+- Simulated gambling (even without real money) requires at least 9+
+- Realistic violence in gameplay requires at least 13+
+
+### Age Rating Questionnaire Topics
+
+The questionnaire covers these content categories:
+
+| Category | Options |
+|----------|---------|
+| Cartoon or Fantasy Violence | None, Infrequent/Mild, Frequent/Intense |
+| Realistic Violence | None, Infrequent/Mild, Frequent/Intense |
+| Profanity or Crude Humor | None, Infrequent/Mild, Frequent/Intense |
+| Mature/Suggestive Themes | None, Infrequent/Mild, Frequent/Intense |
+| Alcohol, Tobacco, or Drug Use or References | None, Infrequent/Mild, Frequent/Intense |
+| Sexual Content and Nudity | None, Infrequent/Mild, Frequent/Intense |
+| Horror/Fear Themes | None, Infrequent/Mild, Frequent/Intense |
+| Simulated Gambling | None, Infrequent/Mild, Frequent/Intense |
+| Medical/Treatment Information | None, Infrequent/Mild, Frequent/Intense |
+| Unrestricted Web Access | Yes/No |
+
+The system automatically calculates your app's age rating across all regions based on your answers.
+
+---
+
+## Part 5: Export Compliance
+
+### Three-Tier Encryption System
+
+| Tier | Encryption Type | Documentation Required |
+|------|----------------|----------------------|
+| **Exempt** | Apple OS built-in (HTTPS via URLSession, AES data protection, CryptoKit, Keychain, Secure Enclave) | None — set `ITSAppUsesNonExemptEncryption = NO` |
+| **Standard** | Industry-standard algorithms (IEEE, IETF, ISO, ITU, ETSI, 3GPP approved) | French ANSSI declaration only (if distributing in France) |
+| **Proprietary** | Custom/unpublished algorithms not adopted by standards bodies | US CCATS + French declaration (if France) |
+
+### Encryption Decision Tree
+
+```
+Does your app use encryption?
+├── No → ITSAppUsesNonExemptEncryption = NO → Done
+├── Only Apple OS encryption (HTTPS, Keychain, CryptoKit)?
+│ ├── Yes → ITSAppUsesNonExemptEncryption = NO → Done
+│ │ (May need annual BIS self-classification report)
+│ └── No → Industry-standard algorithm (AES, RSA, etc.)?
+│ ├── Yes → ITSAppUsesNonExemptEncryption = YES
+│ │ French declaration if distributing in France
+│ │ Upload docs → receive ITSEncryptionExportComplianceCode
+│ └── No (custom/proprietary) →
+│ ITSAppUsesNonExemptEncryption = YES
+│ US CCATS required (~2 business days)
+│ French declaration if France
+│ Upload docs → receive ITSEncryptionExportComplianceCode
+```
+
+### Info.plist Keys
+
+```xml
+
+ITSAppUsesNonExemptEncryption
+
+
+
+ITSAppUsesNonExemptEncryption
+
+ITSEncryptionExportComplianceCode
+YOUR_COMPLIANCE_CODE
+```
+
+Setting `ITSAppUsesNonExemptEncryption` in Info.plist skips the encryption questionnaire on every submission.
+
+### Exempt Encryption Uses
+
+No documentation needed:
+- HTTPS/TLS (URLSession, Network.framework, WKWebView)
+- Secure Enclave, Keychain, biometric auth
+- CryptoKit, Security.framework per Apple docs
+- Password hashing (bcrypt, scrypt, PBKDF2)
+
+### Non-Exempt Encryption Uses
+
+Documentation required:
+- Custom encryption algorithms
+- OpenSSL, libsodium for non-standard purposes
+- End-to-end encrypted messaging
+- VPN implementations
+- Custom DRM systems
+
+### France-Specific
+
+Import/export controlled by ANSSI. Banking and medical apps are exempt. Applies to: secure storage, secure communications, security/antivirus apps.
+
+---
+
+## Part 6: Account and Authentication
+
+### Account Deletion (Required Since June 2022)
+
+Apps that support account creation must offer account deletion. Requirements:
+
+| Requirement | Details |
+|-------------|---------|
+| Full deletion | Must fully delete the account, not just deactivate |
+| Easy to find | Must be accessible from app settings; not buried behind support tickets |
+| Inform timeline | Tell user how long deletion takes |
+| Confirm completion | Notify user when deletion is complete |
+| Delete shared UGC | Must handle user-generated content shared with others |
+| Revoke SIWA tokens | Call Apple's revoke token endpoint for Sign in with Apple accounts |
+| Handle subscriptions | Warn about active subscriptions; direct to subscription management |
+
+#### Sign in with Apple Token Revocation
+
+```swift
+// Server-side: revoke SIWA tokens when account deleted
+// POST https://appleid.apple.com/auth/revoke
+// Parameters: client_id, client_secret, token, token_type_hint
+```
+
+Failing to revoke SIWA tokens during account deletion is a common rejection reason.
+
+### Sign in with Apple (Guideline 4.8)
+
+**Required when:** Your app offers ANY third-party or social login option (Google, Facebook, Twitter, email/password via third-party provider).
+
+#### Exceptions — SIWA not required when
+- App is for company employees only (internal enterprise app)
+- App is for education or enterprise with existing institutional auth
+- App uses government or industry-backed citizen ID systems
+- App is a client for a specific third-party service (e.g., Gmail app, Slack)
+
+When SIWA is required, it must be offered as an equally prominent option alongside other sign-in methods. It cannot be hidden or given less visual weight.
+
+### Account Deletion Implementation Checklist
+
+| Step | Details |
+|------|---------|
+| 1. Add UI entry point | Settings screen, clearly labeled "Delete Account" |
+| 2. Explain consequences | Show what will be deleted (data, subscriptions, purchases) |
+| 3. Require confirmation | User must explicitly confirm deletion |
+| 4. Handle active subscriptions | Direct user to cancel active subscriptions before deletion |
+| 5. Process deletion | Delete all user data from your servers |
+| 6. Revoke SIWA tokens | Call Apple's revoke endpoint if SIWA was used |
+| 7. Confirm to user | Send email or in-app confirmation when deletion is complete |
+| 8. Define timeline | State how long deletion takes (immediately, 30 days, etc.) |
+
+Apple specifically rejects apps that:
+- Require users to call a phone number to delete their account
+- Require users to send an email to request deletion
+- Only offer account deactivation (hiding profile) instead of full deletion
+- Don't handle SIWA token revocation
+
+---
+
+## Part 7: Monetization and IAP
+
+### IAP Submission Pipeline
+
+In-app purchases have a separate review process from app submissions:
+
+| Scenario | Behavior |
+|----------|----------|
+| First IAP ever | Must be bundled with a new app version submission |
+| Subsequent IAPs | Can be submitted independently of app updates |
+| IAP metadata change | Submitted for review independently |
+| IAP price change | Takes effect without review |
+
+#### Required IAP Metadata
+
+| Field | Required | Notes |
+|-------|----------|-------|
+| Reference Name | Yes | Internal name (not visible to users) |
+| Product ID | Yes | Unique, cannot be reused after deletion |
+| Type | Yes | Consumable, non-consumable, auto-renewable, non-renewing |
+| Price | Yes | Select from Apple's price tiers |
+| Display Name | Yes | Localizable, shown to users |
+| Description | Yes | Localizable, shown to users |
+| Screenshot | Yes | One screenshot showing the IAP in context |
+| Review Notes | No | Explain what the IAP unlocks |
+
+#### IAP Status Flow
+
+```
+Missing Metadata → Ready to Submit → Waiting for Review → In Review → Approved
+ → Rejected
+```
+
+IAP must be in "Ready to Submit" status before it can be included in an app submission.
+
+### Subscription Rules (Guideline 3.1.2)
+
+| Rule | Details |
+|------|---------|
+| Ongoing value | Subscriptions must provide continuing value over time |
+| Minimum duration | 7 days minimum subscription period |
+| Cross-device | Must work across all user's devices where app is available |
+| Transparent terms | Clearly state price, duration, auto-renewal, and cancellation |
+| No removing features | Cannot remove previously paid functionality to force subscription |
+| Grace period | Support billing grace period (user retains access during retry) |
+| Upgrade/downgrade | Must support plan changes within subscription group |
+
+### Loot Boxes (Guideline 3.1.1)
+
+Apps offering loot boxes or random item mechanics must disclose the odds of receiving each type of item before purchase.
+
+### External Payment Eligibility
+
+| Category | Guideline | What's Allowed |
+|----------|-----------|----------------|
+| Reader apps | 3.1.3(a) | Link to website for previously purchased content (magazines, newspapers, books, audio, music, video) |
+| Multiplatform services | 3.1.3(b) | Cross-platform subscriptions (e.g., Netflix, Spotify) |
+| Enterprise services | 3.1.3(c) | B2B apps for organizations, not individual consumers |
+| Person-to-person | 3.1.3(d) | Real-time one-to-one services (tutoring, consulting, ride-sharing) |
+| Physical goods/services | 3.1.3(e) | Goods consumed outside the app (food delivery, clothing, physical subscriptions) |
+
+Apps in these categories may accept payment outside the IAP system.
+
+### Subscription Group Architecture
+
+| Concept | Details |
+|---------|---------|
+| Subscription Group | Collection of related subscription tiers (e.g., Basic, Pro, Premium) |
+| Service Level | Rank within a group; determines upgrade/downgrade behavior |
+| Upgrade | Moving to higher service level (immediate, prorated) |
+| Downgrade | Moving to lower service level (effective at next renewal) |
+| Crossgrade | Same service level, different duration (monthly ↔ annual) |
+| Family Sharing | Can be enabled per subscription group |
+
+#### Subscription Pricing
+
+| Feature | Details |
+|---------|---------|
+| Price tiers | Apple provides 900+ price points across 175+ storefronts |
+| Price equalization | Apple auto-equalizes prices across currencies |
+| Custom pricing | Set custom prices per storefront |
+| Introductory offers | Free trial, pay-as-you-go, pay-up-front |
+| Promotional offers | For existing/lapsed subscribers; requires server-signed JWS |
+| Win-back offers | For lapsed subscribers; displayed by system automatically |
+| Offer codes | Distributable codes for free/discounted access |
+
+#### Subscription Restore Purchases
+
+All subscription apps must implement Restore Purchases functionality. This is tested during App Review. Implement via:
+
+```swift
+try await AppStore.sync()
+```
+
+If Restore Purchases is missing or non-functional, the app will be rejected.
+
+### Free Trial Best Practices
+
+| Practice | Details |
+|----------|---------|
+| Duration display | Clearly show trial length before user commits |
+| Post-trial pricing | Show what price will be charged after trial ends |
+| Cancellation | Explain how to cancel before trial ends |
+| No dark patterns | Don't make cancellation difficult or hard to find |
+| Reminder | Consider sending a push notification before trial ends |
+
+---
+
+## Part 8: EU-Specific Compliance
+
+### Digital Services Act (DSA) Trader Status
+
+**Applies to:** ALL apps distributed in the EU (27 member states)
+
+**Timeline:** Since February 17, 2025, apps without declared trader status are subject to removal from the EU App Store.
+
+#### What is Trader Status?
+
+A self-assessment: are you acting as a "trader" (selling goods/services to EU consumers) or a non-trader (hobby, open-source, non-commercial)? Apple cannot determine this for you.
+
+#### Trader Requirements
+
+If you declare as a trader, you must provide:
+
+| Field | Required | Verification |
+|-------|----------|-------------|
+| Legal name | Yes | — |
+| Address | Yes | — |
+| Phone number | Yes | Verified via 2FA |
+| Email address | Yes | Verified via 2FA |
+| Company registration | Where applicable | — |
+| VAT ID | Where applicable | — |
+
+This contact information is displayed on your EU product page.
+
+#### Declaring in App Store Connect
+
+```
+App Store Connect > Users and Access > Developer Profile > Trader Status
+```
+
+Select your trader status for each app. If you have both paid and free apps, each app may have a different trader classification.
+
+### EU Alternative Distribution
+
+Under the Digital Markets Act (DMA), Apple allows alternative app distribution in the EU:
+- Alternative app marketplaces
+- Web distribution (notarized apps)
+- Alternative payment processing
+
+These require separate business terms (Alternative Terms Addendum) and additional compliance steps. See Apple's EU developer documentation for details.
+
+### EU 27 Member States
+
+Apps distributed in any of these territories require DSA compliance:
+
+Austria, Belgium, Bulgaria, Croatia, Cyprus, Czech Republic, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden.
+
+If your app is available in "All Territories" (the default), it is available in the EU and DSA compliance is required.
+
+---
+
+## Part 9: Build Upload and Processing
+
+### Upload Methods
+
+| Method | Best For |
+|--------|---------|
+| **Xcode** (recommended) | Most developers; integrated with Archive workflow |
+| **Xcode Cloud** | CI/CD with automatic builds and distribution |
+| **Transporter** | Standalone macOS app for batch uploads |
+| **altool** (CLI) | Scripted CI/CD pipelines |
+| **App Store Connect API** | Fully automated workflows |
+
+### Build Identifiers
+
+| Identifier | Purpose | Example | Rules |
+|------------|---------|---------|-------|
+| Bundle ID | Uniquely identifies your app | `com.company.app` | Set once, cannot change |
+| Version Number | User-facing version | `2.1.0` | Must increment for each release |
+| Build String | Distinguishes builds of same version | `2.1.0.42` | Must be unique per version per platform |
+
+### Build Selection
+
+- Only one build can be selected per version
+- Build selection can be changed until the version is submitted for review
+- "Missing Compliance" status blocks build selection until export compliance questions are answered
+
+### SDK Requirements
+
+| Effective Date | Requirement |
+|----------------|-------------|
+| April 2025 (current) | Xcode 16, iOS 18 SDK |
+| April 28, 2026 (upcoming) | Xcode 26, iOS 26 SDK |
+
+Apps built with outdated SDKs will be rejected after the effective date for new submissions. Existing apps on the store are not affected until they submit an update.
+
+### Build Processing
+
+After upload, Apple processes your build:
+
+1. **Upload** — Binary transferred to Apple (5-30 minutes depending on size)
+2. **Processing** — Apple validates binary, runs automated checks (15-60 minutes)
+3. **Available** — Build appears in App Store Connect, ready for TestFlight or submission
+4. **Email notification** — Sent when processing completes or fails
+
+Common processing failures:
+- Missing required architectures (arm64 required)
+- Invalid provisioning profile or signing identity
+- Missing privacy manifest for third-party SDKs on Apple's list
+- Info.plist missing required keys
+- Binary too large (OTA download limit: 200 MB over cellular)
+
+### IPv6 Compatibility
+
+All apps must work on IPv6-only networks. Apple's review environment uses IPv6. Common issues:
+- Hard-coded IPv4 addresses
+- Using low-level socket APIs instead of high-level networking
+- Third-party SDKs with IPv4-only code
+
+Use `URLSession` or `Network.framework` to ensure IPv6 compatibility automatically.
+
+### App Thinning and Bitcode
+
+| Topic | Status |
+|-------|--------|
+| Bitcode | Deprecated since Xcode 14; no longer accepted |
+| App Thinning | Active; Apple generates device-specific variants |
+| On-Demand Resources | Active; tag resources for download on demand |
+| Asset catalogs | Used for app thinning of images (1x/2x/3x) |
+
+### Entitlements and Capabilities
+
+Certain features require entitlements configured in Xcode and provisioning profiles:
+
+| Capability | Entitlement | Common Issues |
+|------------|-------------|---------------|
+| Push Notifications | `aps-environment` | Certificate expiry, missing provisioning |
+| App Groups | `com.apple.security.application-groups` | Shared container ID mismatch |
+| Associated Domains | `com.apple.developer.associated-domains` | AASA file not served correctly |
+| HealthKit | `com.apple.developer.healthkit` | Missing required capabilities |
+| Background Modes | `UIBackgroundModes` | Using modes without justification |
+| Sign in with Apple | `com.apple.developer.applesignin` | Missing from provisioning profile |
+| CloudKit | `com.apple.developer.icloud-services` | Container ID mismatch |
+| In-App Purchase | — | Enabled by default; StoreKit config needed for testing |
+
+### TestFlight Submission
+
+| Aspect | Internal Testing | External Testing |
+|--------|-----------------|-----------------|
+| Testers | Up to 100 ASC users | Up to 10,000 via email/public link |
+| Review required | No | Yes (first build per version, full App Review) |
+| Review time | — | Usually under 24 hours |
+| Build expiry | 90 days from upload | 90 days from upload |
+| Groups | Automatic (ASC roles) | Custom groups with "What to Test" notes |
+| Feedback | Crash reports | Screenshots, text feedback, crash reports |
+| Submission limit | — | Max 6 builds per 24-hour period |
+
+**TestFlight readiness checklist**:
+- [ ] Internal tester group exists (required before creating external groups)
+- [ ] Beta App Description set (can differ from production)
+- [ ] Feedback email configured
+- [ ] "What to Test" notes written for each external group
+- [ ] Export compliance answered (required for beta builds too)
+- [ ] Demo credentials included (if login required)
+- [ ] First external build: expect full App Review (guideline compliance checked)
+
+**Gotchas**:
+- Builds uploaded as "TestFlight Internal Only" from Xcode/Xcode Cloud can only go to internal groups
+- Managed Apple Accounts (School/Business Manager) cannot be testers
+- Public link tester cap is configurable (1-10,000) per link
+- Builds remain testable even after the app goes live on the App Store
+- Developer can manually expire builds before 90 days
+
+---
+
+## Part 10: WWDC25 Changes
+
+### Draft Submissions (WWDC 2025-328)
+
+Group multiple items into a single draft submission:
+- App version + new IAPs + product page changes
+- Review everything together instead of separate submissions
+- Draft state: prepare items over time, submit when ready
+
+### Reusable Build Numbers on Failure
+
+When a build is rejected due to metadata issues (not binary issues), you can reuse the same build without re-uploading. Previously, rejected builds required a new build string.
+
+### Builds Retained After Error Rejection
+
+Builds are no longer removed from App Store Connect after certain rejection types. You can fix metadata issues and resubmit with the same build.
+
+### Accessibility Nutrition Labels
+
+New App Store metadata for accessibility features:
+- Declare which accessibility features your app supports
+- Displayed on your App Store product page
+- Categories include VoiceOver support, Dynamic Type, Switch Control, etc.
+- Helps users find apps that meet their accessibility needs
+
+### App Store Tags (LLM-Generated, Editable)
+
+Apple generates descriptive tags for your app using AI:
+- Tags appear on your product page
+- You can review and edit suggested tags
+- Tags improve discoverability in search
+- Based on app metadata, description, and functionality
+
+### Custom Product Page Keywords
+
+Product pages can now have unique keywords:
+- Different keywords per custom product page
+- Improves targeting for different audiences
+- Each custom page can appear in different search results
+
+### Offer Codes Expanded
+
+Offer codes now support all IAP types:
+- Consumables
+- Non-consumables
+- Non-renewing subscriptions
+- Auto-renewable subscriptions (existing)
+
+### Review Summaries (AI-Generated)
+
+Apple generates AI summaries of user reviews:
+- Summarizes common themes across reviews
+- Displayed on the product page
+- Updated as new reviews come in
+- Helps users quickly understand app quality and common feedback
+
+### Analytics Enhancements
+
+100+ new analytics metrics including:
+- Pre-order conversion funnels
+- Custom product page performance comparison
+- Subscription lifecycle metrics (trial to paid conversion, churn timing)
+- Peer group benchmarking (compare performance against similar apps)
+- Download source attribution refinements
+
+### Age Rating Overhaul
+
+Five-tier system with new capability declarations (see Part 4 for full details).
+
+### Custom Product Pages (Existing, Enhanced in WWDC25)
+
+Custom product pages allow different App Store presentations for different audiences:
+
+| Feature | Details |
+|---------|---------|
+| Maximum | Up to 35 custom product pages per app |
+| Customizable | Screenshots, app previews, promotional text |
+| NOT customizable | App name, icon, description, What's New |
+| URL | Unique URL per custom page for attribution |
+| Keywords | New in WWDC25: unique keywords per custom product page |
+| Analytics | Impressions, downloads, conversion rates per page |
+
+### App Store Pricing Changes
+
+| Feature | Details |
+|---------|---------|
+| 900+ price points | Expanded from original 87 tiers |
+| Global equalization | Automatic currency conversion with regional pricing |
+| Custom pricing | Override auto-equalization for specific storefronts |
+| Price increases | Existing subscribers notified; must consent for >50% increase |
+| Regional pricing | Set prices optimized for each market's purchasing power |
+
+---
+
+## Expert Review Checklist
+
+For the comprehensive 9-section submission checklist, see `references/expert-review-checklist.md`.
+For the discipline-focused pre-flight workflow, see `app-store-submission`.
+
+---
+
+## Troubleshooting
+
+### 10 Common Submission Issues
+
+| # | Issue | Cause | Fix |
+|---|-------|-------|-----|
+| 1 | "Missing Compliance" on build | Export compliance questions not answered | App Store Connect > build > answer encryption questions |
+| 2 | Build not appearing in ASC | Processing delay or failure | Wait 15-60 min; check email for processing errors |
+| 3 | "Add for Review" button grayed | Missing required metadata | Check all required fields in App Information and Version Information |
+| 4 | Screenshots wrong size | Device spec mismatch | Use exact pixel dimensions for each device size class |
+| 5 | Privacy policy URL invalid | Not HTTPS or not publicly accessible | Must be `https://` URL accessible without login |
+| 6 | IAP not available for review | IAP not in "Ready to Submit" status | Complete all IAP metadata including screenshot; set status |
+| 7 | Age rating warnings | Questionnaire incomplete or capabilities not declared | Complete questionnaire; answer new capability questions |
+| 8 | DSA trader status incomplete | Email or phone not verified | Complete 2FA verification for both email and phone |
+| 9 | Build string conflict | Duplicate build string for same version | Each build upload must have a unique build string |
+| 10 | "In Review" for extended period | Complex review or holiday backlog | 90% of apps reviewed in <24h; use expedited review for critical/urgent issues |
+
+For rejection handling (expedited review, appeals, Resolution Center), see `app-store-diag` Pattern 7.
+For pre-submission testing checklists, see `app-store-diag` or `app-store-submission`.
+
+### App Store Connect API for Submissions
+
+For automated submission workflows:
+
+| Endpoint | Purpose |
+|----------|---------|
+| `POST /v1/appStoreVersions` | Create new version |
+| `PATCH /v1/appStoreVersions/{id}` | Update version metadata |
+| `POST /v1/appStoreVersionSubmissions` | Submit version for review |
+| `GET /v1/apps/{id}/appStoreVersions` | List all versions |
+| `POST /v1/appScreenshots` | Upload screenshots |
+| `POST /v1/appPreviews` | Upload app preview videos |
+| `GET /v1/apps/{id}/builds` | List processed builds |
+
+Authentication requires an API key from App Store Connect (Users and Access > Integrations > App Store Connect API).
+
+---
+
+## Resources
+
+**WWDC**: 2022-10166, 2025-224, 2025-241, 2025-252, 2025-328
+
+**Docs**: /app-store/review/guidelines, /app-store/submitting, /app-store/app-privacy-details, /help/app-store-connect
+
+**Skills**: app-store-submission, app-store-diag, privacy-ux, storekit-ref, accessibility-diag
diff --git a/.claude/skills/axiom-app-store-ref/agents/openai.yaml b/.claude/skills/axiom-app-store-ref/agents/openai.yaml
new file mode 100644
index 0000000..b3225c3
--- /dev/null
+++ b/.claude/skills/axiom-app-store-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Store Reference"
+ short_description: "Looking up ANY App Store metadata field requirement, privacy manifest schema, age rating tier, export compliance deci..."
diff --git a/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md b/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md
new file mode 100644
index 0000000..6930d26
--- /dev/null
+++ b/.claude/skills/axiom-app-store-ref/references/app-review-guidelines.md
@@ -0,0 +1,133 @@
+# App Review Guidelines Index
+
+Verified against Apple's published guidelines (February 6, 2026 revision).
+
+## Section 1: Safety
+
+| Guideline | Topic |
+|-----------|-------|
+| 1.1 | Objectionable Content |
+| 1.1.1 | Defamatory, discriminatory, or mean-spirited content |
+| 1.1.2 | Realistic portrayals of people or animals being killed/maimed/tortured/abused |
+| 1.1.3 | Depictions encouraging weapons use against people/animals |
+| 1.1.4 | Pornographic material (immediate removal) |
+| 1.1.5 | Religious/cultural/ethnic commentary that fosters prejudice |
+| 1.1.6 | False information, fake functionality ("for entertainment" does NOT excuse this) |
+| 1.1.7 | Capitalizing on recent events (tragedies, conflicts, epidemics) |
+| 1.2 | User-Generated Content — must have filtering, reporting, blocking, contact info, age verification |
+| 1.3 | Kids Category — no third-party analytics/advertising, COPPA/GDPR-Kids compliance |
+| 1.4 | Physical Harm |
+| 1.4.1 | Medical apps: disclose limitations, link to real medical help |
+| 1.4.2 | Drug dosage calculators: recognized institutions only |
+| 1.4.3 | Tobacco, e-cigarettes, vape, illegal drug use encouragement |
+| 1.4.4 | DUI/checkpoint apps that encourage reckless behavior |
+| 1.4.5 | Activities that risk physical harm (bets, dares, body modification) |
+| 1.5 | Developer Information — program membership must be current |
+| 1.6 | Data Security — ATS required, justified exceptions only |
+
+## Section 2: Performance
+
+| Guideline | Topic |
+|-----------|-------|
+| 2.1 | App Completeness — no crashes, broken links, placeholders, missing demo accounts |
+| 2.2 | Beta/Demo/Trial — use TestFlight, not "beta" in app name or bundle ID |
+| 2.3 | Accurate Metadata |
+| 2.3.1 | No hidden/undocumented features; no misleading descriptions |
+| 2.3.2 | No concealed features |
+| 2.3.3 | Screenshots must reflect actual app experience on correct device |
+| 2.3.5 | Use accurate App Store category |
+| 2.3.6 | Age rating must match actual content |
+| 2.3.7 | App name max 30 chars; no keyword stuffing in name/subtitle |
+| 2.3.8 | Metadata must be age-appropriate; "For Kids"/"For Children" reserved for Kids category |
+| 2.4 | Hardware Compatibility — must work with current OS |
+| 2.5 | Software Requirements |
+| 2.5.1 | Only public APIs |
+| 2.5.2 | Self-contained; no code downloads that change functionality |
+| 2.5.3 | No viruses, malware, code injection (immediate removal) |
+| 2.5.4 | Multitasking must use proper background modes |
+| 2.5.5 | Must be fully functional on IPv6-only networks |
+| 2.5.6 | Web browsing must use WebKit (alternative engine entitlement available) |
+| 2.5.9 | Request only necessary permissions |
+| 2.5.11 | SiriKit/HealthKit must actually use the declared feature |
+| 2.5.17 | Matter integration must use Apple's framework; third-party components CSA-certified |
+| 2.5.18 | No display advertising in extensions, App Clips, widgets, notifications, keyboards, watchOS |
+
+## Section 3: Business
+
+| Guideline | Topic |
+|-----------|-------|
+| 3.1.1 | In-App Purchase required for digital goods/services. Loot box odds must be disclosed before purchase. NFTs: may sell via IAP, ownership must not unlock features. |
+| 3.1.2 | Subscriptions: ongoing value, 7-day minimum period, cross-device, transparent terms (price, duration, auto-renewal, cancellation). Schedule 2 of DPLA requires ToS/PP on purchase screen. |
+| 3.1.3(a-e) | External payments: reader apps, multiplatform, enterprise, person-to-person, physical goods |
+| 3.1.4 | No artificial barriers between IAP and web purchase options |
+| 3.1.5 | Cryptocurrency: wallets require organization enrollment, exchanges need licensing, no on-device mining, no crypto rewards for tasks |
+| 3.2.2(viii) | Binary options trading apps prohibited |
+| 3.2.2(ix) | Loan apps: max 36% APR including fees, no full repayment required within 60 days |
+
+## Section 4: Design
+
+| Guideline | Topic |
+|-----------|-------|
+| 4.0 | General design standards (HIG compliance) |
+| 4.1 | Copycats — apps confusingly similar to existing apps (4.1(b): impersonation = removal from Developer Program) |
+| 4.2 | Minimum Functionality — no web wrappers, no single-media apps, must have lasting value |
+| 4.2.6 | Template/app-generation-service apps rejected unless submitted by content provider |
+| 4.3 | Spam — no duplicate apps from same developer |
+| 4.4.1 | Keyboard extensions must include next-keyboard switching |
+| 4.5.4 | Push notifications: no advertising, marketing, or spam |
+| 4.7 | Mini apps, streaming games, chatbots, emulators: must provide universal link index, age restrictions, content filtering |
+| 4.8 | Sign in with Apple required when ANY third-party/social login offered (exceptions: company-internal, education, government, client apps for specific services) |
+| 4.10 | Cannot monetize built-in capabilities (push, camera, gyroscope, Apple Music, iCloud storage, Screen Time APIs) |
+
+## Section 5: Legal
+
+| Guideline | Topic |
+|-----------|-------|
+| 5.1.1(i) | Privacy policy required in App Store Connect AND within app |
+| 5.1.1(ii) | Permission requests must explain purpose with benefit to user |
+| 5.1.1(iii) | Don't require unnecessary personal info |
+| 5.1.1(v) | Account deletion must be offered if account creation supported |
+| 5.1.1(vi) | Surreptitiously discovering passwords (removal from Developer Program) |
+| 5.1.2(i) | No sharing with third parties without consent; ATT required for tracking |
+| 5.1.3 | Health data must not be stored in iCloud; no false HealthKit data |
+| 5.1.4 | Kids Category requirements (COPPA) |
+| 5.1.5 | Location Services must have clear purpose |
+| 5.2 | Intellectual Property — no unauthorized copyrighted material |
+| 5.3 | Gaming/Gambling — real-money gambling requires licensing |
+| 5.4 | VPN Apps — must use NEVPNManager API |
+| 5.5 | Developer Code of Conduct |
+| 5.6 | Telecommunications |
+
+## Zero-Tolerance Guidelines (Immediate Removal Risk)
+
+| Guideline | Consequence |
+|-----------|-------------|
+| 1.1.4 | Pornographic content → immediate removal |
+| 2.5.3 | Viruses/malware → immediate removal |
+| 4.1(b) | App impersonation → removal from Developer Program |
+| 5.1.1(vi) | Surreptitious password discovery → removal from Developer Program |
+
+## Top 10 Rejection Causes
+
+| Rank | Guideline | Issue | % of Rejections |
+|------|-----------|-------|-----------------|
+| 1 | 2.1 | App Completeness (crashes, placeholders, broken flows) | ~40% |
+| 2 | 5.1.1(i) | Privacy policy missing/inadequate | — |
+| 3 | 2.1 | Incomplete review info (missing demo accounts) | — |
+| 4 | 2.3.3 | Screenshots don't match app | — |
+| 5 | 4.0 | Substandard UI / HIG violations | — |
+| 6 | 4.2 | Web wrapper / insufficient functionality | — |
+| 7 | 2.3.1 | Misleading metadata | — |
+| 8 | 4.2 | Insufficient lasting value | — |
+| 9 | 4.1 | Copycat app | — |
+| 10 | 4.3 | Repeated similar apps | — |
+
+## Sensitive App Types Requiring Extra Documentation
+
+| Type | Requirements |
+|------|-------------|
+| Kids apps with third-party ads | Links to ad policies, proof of human review |
+| Medical hardware integration | Regulatory clearance for all regions |
+| Third-party content/trademarks | Authorization documentation |
+| Gambling, VPN, real money gaming | Licensing documentation |
+| Banking, crypto, healthcare, air travel | Must be submitted by legal entity (not individuals) |
diff --git a/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md b/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md
new file mode 100644
index 0000000..26343fe
--- /dev/null
+++ b/.claude/skills/axiom-app-store-ref/references/expert-review-checklist.md
@@ -0,0 +1,95 @@
+# Expert Review Checklist
+
+Comprehensive 9-section submission checklist. For the discipline-focused pre-flight workflow, see `app-store-submission`.
+
+## Build
+
+- [ ] Built with required SDK version (currently Xcode 16, iOS 18 SDK)
+- [ ] Export compliance answered (`ITSAppUsesNonExemptEncryption`)
+- [ ] Encryption documentation uploaded (if custom encryption)
+- [ ] IPv6-only network compatible
+- [ ] Signed with distribution certificate and provisioning profile
+- [ ] Correct bundle ID for target environment (production, not development)
+- [ ] Build string unique for this version
+- [ ] Binary under 200 MB OTA cellular limit (or warn users)
+- [ ] All required architectures included (arm64)
+- [ ] No private API usage
+
+## Privacy
+
+- [ ] `PrivacyInfo.xcprivacy` present and complete
+- [ ] Privacy policy URL set in App Store Connect
+- [ ] Privacy policy accessible within the app
+- [ ] All purpose strings (`NS*UsageDescription`) present for requested permissions
+- [ ] ATT implemented if app tracks users
+- [ ] Required Reason APIs declared with approved reasons
+- [ ] Privacy Nutrition Labels match actual data collection
+- [ ] Third-party SDK privacy manifests included
+- [ ] Privacy report generated and reviewed (`Product > Archive > Generate Privacy Report`)
+
+## Metadata
+
+- [ ] App name unique, max 30 characters
+- [ ] Description complete, max 4000 characters, plain text
+- [ ] Keywords set, max 100 bytes, no trademarked terms
+- [ ] Screenshots provided for all supported device sizes
+- [ ] Screenshots show app in actual use (not title art or splash screens)
+- [ ] What's New text updated for this version
+- [ ] Copyright field current year
+- [ ] Support URL links to real contact information
+- [ ] Privacy Policy URL is HTTPS and publicly accessible
+- [ ] Promotional Text set (editable without submission)
+- [ ] App category accurate
+- [ ] All metadata localized for target markets
+
+## Account
+
+- [ ] Account deletion implemented and easy to find
+- [ ] SIWA token revocation on account deletion
+- [ ] Sign in with Apple offered if any third-party login exists
+- [ ] SIWA given equal visual prominence to other login options
+- [ ] Demo credentials provided in App Review Information (if login required)
+- [ ] Demo credentials will not expire during review period
+
+## Content
+
+- [ ] No placeholder content ("Lorem ipsum", "Coming Soon", etc.)
+- [ ] All links functional and leading to real content
+- [ ] Final production assets (not development/staging URLs)
+- [ ] No test data visible in screenshots or app
+- [ ] No references to other mobile platforms in metadata
+
+## Age Rating
+
+- [ ] Age rating questionnaire completed
+- [ ] New capability declarations answered (messaging, UGC, advertising, parental, age assurance)
+- [ ] UGC moderation implemented if applicable
+- [ ] Content filtering in place for web views (or accept 16+ minimum)
+- [ ] Loot box odds disclosed if applicable
+
+## Monetization
+
+- [ ] All IAPs configured and in "Ready to Submit" status
+- [ ] IAP screenshots uploaded
+- [ ] Subscription terms clear (price, duration, auto-renewal, cancellation)
+- [ ] Loot box odds displayed before purchase
+- [ ] Restore Purchases functionality working
+- [ ] No removing paid features to force new purchases
+- [ ] Subscription grace period supported
+- [ ] Offer codes configured if planned
+
+## EU Compliance
+
+- [ ] DSA trader status declared for all EU-distributed apps
+- [ ] Trader email verified via 2FA
+- [ ] Trader phone verified via 2FA
+- [ ] Contact information accurate and current
+- [ ] Labels and markings complete (if applicable for product category)
+
+## App Review
+
+- [ ] Contact information complete (name, email, phone)
+- [ ] Demo account credentials provided (if login required)
+- [ ] Notes for Review explain any non-obvious features
+- [ ] Attachment uploaded for features requiring special hardware or setup
+- [ ] Review contact email actively monitored
diff --git a/.claude/skills/axiom-app-store-submission/.openskills.json b/.claude/skills/axiom-app-store-submission/.openskills.json
new file mode 100644
index 0000000..c74aaaa
--- /dev/null
+++ b/.claude/skills/axiom-app-store-submission/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-app-store-submission",
+ "installedAt": "2026-04-12T08:05:48.151Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-app-store-submission/SKILL.md b/.claude/skills/axiom-app-store-submission/SKILL.md
new file mode 100644
index 0000000..7f80a8a
--- /dev/null
+++ b/.claude/skills/axiom-app-store-submission/SKILL.md
@@ -0,0 +1,753 @@
+---
+name: axiom-app-store-submission
+description: Use when preparing ANY app for App Store submission, responding to App Review rejections, or running a pre-submission audit. Covers privacy manifests, metadata requirements, IAP review, account deletion, SIWA, age ratings, export compliance, first-time developer setup.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# App Store Submission
+
+## Overview
+
+Systematic pre-flight checklist that catches 90% of App Store rejection causes before submission. **Core principle**: Ship once, ship right. Over 40% of App Store rejections cite Guideline 2.1 (App Completeness) — crashes, placeholders, broken links. Another 30% are metadata and privacy issues. A disciplined pre-flight process eliminates these preventable rejections.
+
+**Key insight**: Apple rejected nearly 1.93 million submissions in 2024. Most rejections are not policy disagreements — they are checklist failures. A 30-minute pre-flight saves 3-7 days of rejection-fix-resubmit cycles.
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Preparing to submit an app or update to the App Store
+- Submitting your first app as a new developer
+- Responding to an App Review rejection
+- Running a pre-submission audit before TestFlight or production
+- Updating an existing app after a long gap (new requirements may apply)
+- Wondering "is my app ready to submit?"
+
+❌ **Do NOT use this skill for**:
+- Code signing and provisioning profiles (use build-debugging)
+- CI/CD pipeline setup
+- Performance optimization (use ios-performance)
+- UI testing automation (use ui-testing)
+- In-app purchase implementation (use storekit-ref)
+- Privacy manifest details (use privacy-ux for deep implementation)
+
+## Example Prompts
+
+Real questions developers ask that this skill answers:
+
+#### 1. "Is my app ready to submit to the App Store?"
+> The skill provides a complete pre-flight checklist covering build, privacy, metadata, accounts, review info, content, and regional requirements
+
+#### 2. "What do I need before submitting my first iOS app?"
+> The skill walks through every requirement from scratch — privacy manifest, metadata fields, screenshots, demo credentials, age rating
+
+#### 3. "I keep getting rejected, what am I missing?"
+> The skill provides anti-patterns with specific rejection causes and the decision tree to identify gaps
+
+#### 4. "What's the pre-submission checklist for App Store?"
+> The skill provides a categorized mandatory checklist with every item that triggers rejection if missing
+
+#### 5. "Do I need a privacy manifest?"
+> Yes. Since May 2024, missing privacy manifests cause automatic rejection. The skill explains when and how.
+
+#### 6. "My app update was rejected for metadata issues"
+> The skill covers metadata completeness requirements and the common gaps that trigger Guideline 2.3 rejections
+
+---
+
+## Anti-Patterns
+
+### 1. Submitting without device testing
+
+**Time cost**: 3-7 days (rejection + fix + resubmit wait)
+
+**Symptom**: Rejection for Guideline 2.1 — App Completeness. Crashes, broken flows, or missing functionality discovered by App Review.
+
+❌ **BAD**: Test only in Simulator, submit when build succeeds
+```
+"It works in Simulator, ship it"
+→ Rejection: App crashes on launch on iPhone 15 Pro (memory limit)
+→ 3-7 day delay
+```
+
+✅ **GOOD**: Test on physical device with latest shipping OS, exercise all user flows
+```bash
+# Build for device
+xcodebuild -scheme YourApp \
+ -destination 'platform=iOS,name=Your iPhone'
+
+# Test critical paths:
+# - Launch → main screen loads
+# - All tabs/screens accessible
+# - Core user flows complete without crash
+# - Edge cases: no network, low storage, interruptions
+```
+
+**Why it works**: Simulator hides real-device constraints — memory limits, cellular networking behavior, hardware-specific APIs, thermal throttling. App Review tests on physical devices.
+
+---
+
+### 2. Missing or inadequate privacy policy
+
+**Time cost**: 2-5 days (rejection + policy creation + resubmit)
+
+**Symptom**: Rejection for Guideline 5.1.1(i) — Data Collection and Storage. Privacy policy missing, inaccessible, or inconsistent with actual data practices.
+
+❌ **BAD**: No privacy policy URL, or a generic template that doesn't match actual data collection
+```
+Privacy Policy URL: (empty)
+— or —
+Privacy Policy: "We respect your privacy" (generic, no specifics)
+```
+
+✅ **GOOD**: Privacy policy accessible in two places, specific to your app's data practices
+```
+1. App Store Connect → App Information → Privacy Policy URL
+2. In-app → Settings/About screen → Privacy Policy link
+3. Policy content lists:
+ - All collected data types
+ - How each type is used
+ - Third-party sharing (who and why)
+ - Data retention period
+ - How to request deletion
+```
+
+**Three-way consistency**: Apple compares (a) your app's actual behavior, (b) your privacy policy content, and (c) your Privacy Nutrition Labels in ASC. All three must agree. If any of these three disagree, you get a 5.1.1 rejection. Check each SDK's documentation for its privacy manifest and data collection disclosure — your app's total data collection is your code PLUS all SDK data collection.
+
+**Why it works**: Guideline 5.1.1(i) requires privacy policy accessible BOTH in ASC metadata AND within the app. The policy must specifically describe your app's data practices, not a generic template.
+
+---
+
+### 3. Placeholder content left in build
+
+**Time cost**: 3-5 days (rejection + content replacement + resubmit)
+
+**Symptom**: Rejection for Guideline 2.1 — App Completeness. Reviewers find placeholder text, empty screens, or TODO artifacts.
+
+❌ **BAD**: Ship with development artifacts visible to users
+```
+- "Lorem ipsum" text in onboarding
+- Empty tab that shows "Coming Soon"
+- Button that opens alert "Not implemented yet"
+- Default app icon (white grid)
+```
+
+✅ **GOOD**: Every screen has final content and production assets
+```
+Pre-submission content audit:
+- [ ] Every screen has real content (no lorem ipsum)
+- [ ] All images are final production assets
+- [ ] No "Coming Soon" or "Under Construction" screens
+- [ ] All buttons perform their intended action
+- [ ] Default/empty states have proper messaging
+- [ ] App icon is final and meets spec (1024x1024, no alpha)
+```
+
+**Why it works**: App Review tests every screen and tab, including states you might consider edge cases. They open every menu, tap every button, and switch every tab.
+
+---
+
+### 4. Ignoring privacy manifest
+
+**Time cost**: 1-3 days (automatic rejection + manifest creation + resubmit)
+
+**Symptom**: Automatic rejection before human review. Missing `PrivacyInfo.xcprivacy` or undeclared Required Reason APIs.
+
+❌ **BAD**: No privacy manifest, or missing Required Reason API declarations
+```
+No PrivacyInfo.xcprivacy in project
+— or —
+Using UserDefaults, file timestamps, disk space APIs
+without declaring approved reasons
+```
+
+✅ **GOOD**: Privacy manifest present with all Required Reason APIs declared
+```
+Project must contain:
+├── PrivacyInfo.xcprivacy
+│ ├── NSPrivacyTracking (true/false)
+│ ├── NSPrivacyTrackingDomains (if tracking)
+│ ├── NSPrivacyCollectedDataTypes (all types)
+│ └── NSPrivacyAccessedAPITypes (all Required Reason APIs)
+│
+└── Third-party SDK manifests (each SDK includes its own)
+```
+
+Common Required Reason APIs that need declaration:
+- `UserDefaults` → Reason `CA92.1`
+- File timestamp APIs → Reason `C617.1`
+- Disk space APIs → Reason `E174.1`
+- System boot time → Reason `35F9.1`
+
+**Why it works**: Since May 2024, this is an automated gate. No human reviewer involved — the build processing system rejects submissions missing required privacy declarations.
+
+---
+
+### 5. Missing Sign in with Apple
+
+**Time cost**: 3-7 days (SIWA implementation + resubmit)
+
+**Symptom**: Rejection for Guideline 4.8. App offers third-party login (Google, Facebook, email) but no Sign in with Apple option.
+
+❌ **BAD**: Third-party login without SIWA
+```swift
+// Login screen offers:
+// - Sign in with Google
+// - Sign in with Facebook
+// - Email/password
+// ← Missing: Sign in with Apple
+```
+
+✅ **GOOD**: SIWA offered as equivalent option alongside any third-party login
+```swift
+// Login screen offers:
+// - Sign in with Apple ← Required if others exist
+// - Sign in with Google
+// - Sign in with Facebook
+// - Email/password
+```
+
+**Exceptions** (SIWA not required):
+- Company-internal or employee-only apps
+- Education apps using existing school accounts
+- Government/tax/banking apps requiring government ID
+- Apps that are a client for a specific third-party service
+- Apps using only the company's own authentication system
+
+**Why it works**: Guideline 4.8 requires SIWA as an option whenever ANY third-party or social login is offered. Apple enforces this strictly.
+
+---
+
+### 6. No account deletion flow
+
+**Time cost**: 5-10 days (implementation + testing + resubmit)
+
+**Symptom**: Rejection for Guideline 5.1.1(v). App allows account creation but provides no way to delete the account.
+
+❌ **BAD**: Account creation without deletion capability
+```
+- Sign up button exists
+- No "Delete Account" anywhere in app
+- "Contact support to delete" (not sufficient)
+- "Deactivate account" (not the same as delete)
+```
+
+✅ **GOOD**: Full account deletion flow accessible in-app
+```
+Account deletion requirements:
+1. Discoverable in Settings/Profile (not hidden)
+2. Clearly labeled "Delete Account" (not "Deactivate")
+3. Explains what deletion means (data removed, timeline)
+4. Confirms completion to user
+5. If Sign in with Apple used → revoke SIWA token
+6. If active subscriptions → inform user to cancel first
+7. Deletion completes within reasonable timeframe (days, not months)
+```
+
+```swift
+// Revoking Sign in with Apple token (required)
+let appleIDProvider = ASAuthorizationAppleIDProvider()
+let request = appleIDProvider.createRequest()
+
+// After user confirms deletion:
+// POST to Apple's revoke endpoint with the user's token
+// Then delete server-side account data
+```
+
+**Why it works**: Required since June 2022. Must be actual deletion (not deactivation), must be in-app (not just email/website), and must revoke SIWA tokens if used. Apple tests this flow specifically.
+
+---
+
+### 7. Wrong age rating
+
+**Time cost**: 2-4 days (re-answer questionnaire + possible content changes + resubmit)
+
+**Symptom**: Rejection for Guideline 2.3.6 — Inaccurate metadata. Age rating doesn't reflect actual app content or capabilities.
+
+❌ **BAD**: Understate content to get lower rating
+```
+App has user-generated content (chat, posts)
+but age rating questionnaire answered "No UGC"
+
+App has cartoon violence in gameplay
+but answered "No violence"
+```
+
+✅ **GOOD**: Answer age rating questionnaire accurately
+```
+Declare honestly:
+- User-generated content (chat, forums, social features)
+- Violence (even cartoon/fantasy)
+- Mature themes
+- Profanity / crude humor
+- Gambling (simulated or real)
+- Horror / fear themes
+- Medical / treatment information
+- Unrestricted web access (WebView with open URLs)
+```
+
+**Updated age ratings (effective January 31, 2026)**: Apple expanded from 4+/9+/12+/17+ to 5 tiers (4+/9+/13+/16+/18+) with new capability declarations for messaging, UGC, advertising, and parental controls. All developers must have completed the updated questionnaire — app updates are blocked without it.
+
+**Why it works**: Mismatched ratings violate Guideline 2.3.6. Apple compares your questionnaire answers against observed app behavior. UGC and web access are the most commonly missed declarations.
+
+---
+
+### 8. Missing demo credentials
+
+**Time cost**: 3-5 days (rejection + credential creation + resubmit wait)
+
+**Symptom**: Rejection for Guideline 2.1. Reviewer unable to test app because login is required and no test account was provided.
+
+❌ **BAD**: App requires login, but no demo account in review notes
+```
+App Review Information:
+ Notes: (empty)
+ Demo Account: (empty)
+ Demo Password: (empty)
+→ Reviewer sees login screen, can't proceed, rejects
+```
+
+✅ **GOOD**: Working demo credentials with clear instructions
+```
+App Review Information:
+ Demo Account: demo@yourapp.com
+ Demo Password: AppReview2025!
+ Notes: "Log in with the demo account above.
+ The account has sample data pre-loaded.
+ To test [feature X], navigate to Tab 2 > Settings.
+ If 2FA is required, use code: 123456"
+
+Requirements:
+- Account must not expire during review (1-2 weeks minimum)
+- Account must have representative data
+- Include any special setup steps
+- If hardware required, explain workarounds
+- If location-specific, provide test coordinates
+```
+
+**Why it works**: Reviewers cannot test what they cannot access. They will not create their own account. If your app requires any form of authentication, demo credentials are mandatory. This is one of the most common rejection reasons for apps with login flows.
+
+---
+
+## Decision Tree
+
+```
+Is my app ready to submit?
+│
+├─ Does it crash on a real device?
+│ ├─ YES → STOP. Fix crashes first (Guideline 2.1)
+│ └─ NO → Continue
+│
+├─ Privacy manifest (PrivacyInfo.xcprivacy) present?
+│ ├─ NO → Add privacy manifest with Required Reason APIs
+│ └─ YES → Continue
+│
+├─ Privacy policy URL set in App Store Connect?
+│ ├─ NO → Add privacy policy URL in ASC
+│ └─ YES → Is it also accessible in-app?
+│ ├─ NO → Add in-app privacy policy link
+│ └─ YES → Continue
+│
+├─ All screenshots final and matching current app?
+│ ├─ NO → Update screenshots for all required device sizes
+│ └─ YES → Continue
+│
+├─ Does app create user accounts?
+│ ├─ YES → Account deletion implemented and discoverable?
+│ │ ├─ NO → Implement account deletion flow
+│ │ └─ YES → Continue
+│ └─ NO → Continue
+│
+├─ Does app offer third-party login (Google, Facebook, etc.)?
+│ ├─ YES → Sign in with Apple offered?
+│ │ ├─ NO → Add SIWA (unless exemption applies)
+│ │ └─ YES → Continue
+│ └─ NO → Continue
+│
+├─ Does app have in-app purchases or subscriptions?
+│ ├─ YES → IAP items submitted for review in ASC?
+│ │ ├─ NO → Submit IAP for review (can be reviewed separately)
+│ │ └─ YES → Restore Purchases button implemented?
+│ │ ├─ NO → Add Restore Purchases functionality
+│ │ └─ YES → Continue
+│ └─ NO → Continue
+│
+├─ Does app use encryption beyond standard HTTPS?
+│ ├─ YES → Export compliance documentation uploaded?
+│ │ ├─ NO → Add ITSAppUsesNonExemptEncryption to Info.plist
+│ │ │ and upload compliance documentation
+│ │ └─ YES → Continue
+│ └─ NO → Set ITSAppUsesNonExemptEncryption = NO in Info.plist
+│
+├─ Distributing in EU?
+│ ├─ YES → DSA trader status verified in ASC?
+│ │ ├─ NO → Complete trader verification in ASC
+│ │ └─ YES → Continue
+│ └─ NO → Continue
+│
+├─ Does app require login to function?
+│ ├─ YES → Demo credentials in App Review notes?
+│ │ ├─ NO → Add working demo account + password + instructions
+│ │ └─ YES → Continue
+│ └─ NO → Continue
+│
+├─ Age rating questionnaire completed honestly?
+│ ├─ NO → Complete updated questionnaire (new 13+/16+/18+ ratings)
+│ └─ YES → Continue
+│
+├─ Any placeholder content remaining?
+│ ├─ YES → Replace all placeholders with final content
+│ └─ NO → Continue
+│
+└─ All checks passed → READY TO SUBMIT
+```
+
+---
+
+## Mandatory Pre-Flight Checklist
+
+Run this entire checklist before every submission. Check every item, not just the ones you think apply.
+
+### 1. Build Configuration
+
+- [ ] Built with current required SDK (iOS 18 SDK / Xcode 16 as of 2025; iOS 26 SDK / Xcode 26 required starting April 28, 2026)
+- [ ] `ITSAppUsesNonExemptEncryption` set in Info.plist (`NO` if only HTTPS)
+- [ ] App tested on physical device with latest shipping iOS version
+- [ ] App works over IPv6-only network (Apple review network is IPv6)
+- [ ] No private/undocumented API usage
+- [ ] No references to pre-release/beta OS features unless targeting that OS
+- [ ] Minimum deployment target is reasonable (not unnecessarily high)
+- [ ] Release build tested (not just Debug configuration)
+
+### 2. Privacy
+
+- [ ] `PrivacyInfo.xcprivacy` file present in app target
+- [ ] All Required Reason APIs declared with approved reason codes
+- [ ] Third-party SDKs each include their own privacy manifests
+- [ ] Privacy policy URL set in App Store Connect
+- [ ] Privacy policy accessible in-app (Settings/About screen)
+- [ ] Privacy policy content matches actual data collection practices
+- [ ] All `NS*UsageDescription` purpose strings present (Camera, Location, etc.)
+- [ ] Purpose strings explain user benefit (not just "we need access")
+- [ ] App Tracking Transparency implemented if tracking users (ATT)
+- [ ] Privacy Nutrition Labels completed in ASC matching manifest
+
+### 3. Metadata
+
+- [ ] App name (30 character limit, no keyword stuffing)
+- [ ] Subtitle (30 character limit)
+- [ ] Description (accurate, no misleading claims)
+- [ ] Keywords (100 character limit, comma-separated)
+- [ ] Category and secondary category set
+- [ ] Screenshots for all required device sizes (6.9", 6.7", 6.5", 5.5" for iPhone; 13" for iPad if universal)
+- [ ] Screenshots reflect current app UI (not outdated)
+- [ ] App Preview videos current (if using)
+- [ ] Support URL valid and accessible
+- [ ] Marketing URL valid (if set)
+- [ ] Copyright string current year
+- [ ] Version number and build number incremented
+- [ ] "What's New" text written (for updates)
+
+### 4. Account and Authentication
+
+- [ ] Account deletion flow implemented (if account creation exists)
+- [ ] Account deletion is actual deletion (not just deactivation)
+- [ ] SIWA token revocation on account deletion (if SIWA used)
+- [ ] Sign in with Apple offered (if any third-party login exists)
+- [ ] Active subscriptions handled during account deletion
+- [ ] Restore Purchases button works (if IAP exists)
+- [ ] IAP items submitted for review in ASC (if new/changed)
+- [ ] Subscription terms clearly communicated before purchase
+
+### 5. App Review Information
+
+- [ ] Contact information (name, phone, email) provided
+- [ ] Demo account username provided (if login required)
+- [ ] Demo account password provided (if login required)
+- [ ] Demo account won't expire during review period (1-2 weeks)
+- [ ] Demo account has representative sample data
+- [ ] Special instructions for hardware-dependent features
+- [ ] Notes explain any non-obvious features or flows
+- [ ] If app uses location, provide test coordinates
+
+### 6. Content Completeness
+
+- [ ] No placeholder text (lorem ipsum, TODO, "Coming Soon")
+- [ ] No broken links or dead-end screens
+- [ ] All images are production assets (no stock watermarks)
+- [ ] App icon meets spec (1024x1024, no alpha channel, no rounded corners)
+- [ ] All tabs/screens have functional content
+- [ ] Error states and empty states have proper messaging
+- [ ] Onboarding/tutorial flows complete and accurate
+- [ ] All deep links and universal links resolve correctly
+
+### 7. Regional and Compliance
+
+- [ ] EU DSA trader status verified (if distributing in EU)
+- [ ] Age rating questionnaire completed with updated categories (13+/16+/18+)
+- [ ] Age rating reflects actual content (UGC, violence, web access declared)
+- [ ] Export compliance documentation uploaded (if non-exempt encryption)
+- [ ] Content complies with local laws for each distribution territory
+- [ ] GDPR compliance (if distributing in EU)
+
+### 8. New for 2025-2026
+
+- [ ] Updated age rating questionnaire completed (required since January 31, 2026)
+- [ ] Accessibility Nutrition Labels declared (becoming required for new submissions)
+- [ ] External AI service consent modal (if app sends personal data to external AI)
+- [ ] SDK minimum version met (Xcode 16/iOS 18 SDK now; Xcode 26/iOS 26 SDK starting April 28, 2026)
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Ship by end of day"
+
+**Setup**: PM says the app must be submitted today for a marketing launch next week.
+
+**Pressure**: Deadline + executive visibility
+
+**Rationalization traps**:
+- "We'll fix the privacy policy after approval"
+- "The placeholder is only on one screen, they won't notice"
+- "We'll add account deletion in the next update"
+- "It passed internal testing, no need for device testing"
+
+**MANDATORY**: Run the full pre-flight checklist. Every item. Missing items cause rejection, which costs 3-7 MORE days — far worse than the 30 minutes the checklist takes.
+
+Skipping the checklist to save 30 minutes costs 3-7 days when it causes rejection.
+
+**Communication template**: "The pre-flight check found [N] issues that will cause rejection. Fixing them takes [X hours]. Submitting without fixing guarantees rejection, which costs 3-7 days minimum. Let me fix these now — it's the fastest path to being live."
+
+---
+
+### Scenario 2: "Third rejection, just make it work"
+
+**Setup**: App rejected 3 times for different issues each time. Developer is frustrated and tempted to cut corners or argue with Apple.
+
+**Pressure**: Frustration + sunk cost + temptation to appeal instead of fix
+
+**Rationalization traps**:
+- "They keep finding new issues — they're being unfair"
+- "I'll appeal this one, it's unreasonable"
+- "I'll just hide that screen from reviewers"
+
+**MANDATORY**: Read the FULL text of every rejection message. Run the complete pre-flight checklist from scratch. Reviewers often find new issues on subsequent reviews because they test deeper each pass — they explore screens they didn't reach before, test flows they skipped, and review with stricter attention.
+
+Each rejection is a signal that the pre-submission process has gaps. Do not fight the feedback — absorb it and close the gaps systematically.
+
+**Communication template**: "Multiple rejections mean we have systematic gaps, not bad luck. I'm running the complete pre-flight checklist — this takes 30 minutes but prevents the 3-7 day cycle of partial fixes followed by new rejections."
+
+---
+
+### Scenario 3: "It's just a bug fix update"
+
+**Setup**: Simple one-line bug fix. Developer assumes the update will sail through because the app was already approved.
+
+**Pressure**: Complacency + false confidence from prior approval
+
+**Rationalization traps**:
+- "It was approved last time with the same metadata"
+- "I only changed one file, they don't need to re-review everything"
+- "They won't re-check the privacy stuff, it hasn't changed"
+
+**MANDATORY**: Updates are reviewed against CURRENT guidelines. Requirements change between releases. Privacy manifests became mandatory mid-cycle. Age rating questionnaire was overhauled. SDK minimums increase annually. A bug fix update can be rejected for issues that didn't exist when the previous version was approved.
+
+Run the pre-flight checklist every time. Requirements that didn't exist when your app was last reviewed may now be enforced.
+
+**Communication template**: "Even for a bug fix, App Review applies current guidelines — not the ones from when we were last approved. The privacy manifest requirement and age rating overhaul both came mid-cycle. Running the 30-minute pre-flight now prevents a surprise rejection."
+
+---
+
+## Screenshot Requirements
+
+Screenshots must match current app UI. See `app-store-ref` Part 1 for required sizes, dimensions, and rules.
+
+---
+
+## Handling Rejections
+
+### Reading the Rejection Message
+
+Every rejection includes:
+1. **Guideline number** — Specific section violated
+2. **Description** — What the reviewer found
+3. **Screenshots/recordings** — Visual evidence (if applicable)
+4. **Suggestions** — Sometimes included with fixes
+
+### Response Strategy
+
+```
+1. Read the FULL rejection message (every word)
+2. Identify ALL guidelines cited (may be multiple)
+3. Fix EVERY cited issue (not just the first one)
+4. Run the complete pre-flight checklist
+5. In your resubmission notes, explain what you fixed
+6. Do NOT argue or explain why you think the rejection is wrong
+```
+
+### When to Appeal
+
+Appeals are appropriate when:
+- You believe the reviewer misunderstood your app's functionality
+- Your app clearly complies with the cited guideline
+- You have evidence supporting your position
+
+Appeals are NOT appropriate when:
+- You disagree with the guideline itself
+- The rejection is technically correct but feels unfair
+- You want to delay fixing the issue
+
+### Appeal Process
+
+```
+App Store Connect → Resolution Center → Reply
+- Explain clearly why you believe the rejection is incorrect
+- Provide specific evidence (screenshots, documentation)
+- Remain professional and factual
+- Apple's App Review Board will re-review
+- One appeal per rejection
+- Must respond to all information requests BEFORE appealing
+```
+
+### Expedited Review
+
+Two eligible scenarios:
+1. **Critical Bug Fix** — include steps to reproduce the bug on the current live version
+2. **Event-Related App** — include event name, date, and your association with the event
+
+### Communication Options (Often Overlooked)
+
+- **"Meet with App Review" sessions** — Apple periodically offers 30-minute appointments; check developer.apple.com/events for availability (not always open)
+- **Bug fix leniency** — Apple may sometimes approve a bug fix submission with informational notes about additional non-legal/safety issues to address in the next update, rather than blocking the release. This is not guaranteed but happens in practice.
+- **App Store Connect mobile app** for status tracking on the go
+
+### Metadata Rejected vs Binary Rejected
+
+| Type | What it means | What to do |
+|------|--------------|------------|
+| Metadata Rejected | Screenshots, description, or ASC fields need fixing | Fix in ASC, resubmit (no new build needed) |
+| Binary Rejected | Code/app issue needs fixing | Fix code, create new archive, upload new build |
+
+---
+
+## In-App Purchase Review
+
+IAP items require separate review. Missing or broken IAP is a top rejection cause under Guideline 3.1.1.
+
+### IAP Submission Checklist
+
+- [ ] All IAP products created in ASC with complete metadata
+- [ ] **IAP review screenshot uploaded** for each product (shows what user sees when purchasing — review-only, not displayed on App Store)
+- [ ] **IAP products attached to THIS version** (first submission only: App Version page → In-App Purchases section → Select → checkbox each product). After first approval, subsequent IAPs can be submitted independently.
+- [ ] IAP products submitted for review
+- [ ] Restore Purchases button visible and functional
+- [ ] Subscription terms displayed before purchase confirmation (price, period, auto-renewal)
+- [ ] **Terms of Use and Privacy Policy links visible on the purchase screen** (required by Schedule 2 of the DPLA, referenced by Guideline 3.1.2(c)). `SubscriptionStoreView` handles this automatically from ASC metadata — if using custom paywall UI, add links manually.
+- [ ] Free trial terms clearly communicated
+- [ ] Pricing displayed matches ASC configuration
+- [ ] No external purchase links (Guideline 3.1.1) unless eligible for entitlement
+- [ ] StoreKit testing completed in sandbox environment
+
+### Common IAP Rejection Patterns
+
+```
+❌ "Buy Premium" button that does nothing → Guideline 3.1.1
+❌ No Restore Purchases option → Guideline 3.1.1
+❌ Subscription auto-renews without clear disclosure → Guideline 3.1.2
+❌ Free trial duration not shown before purchase → Guideline 3.1.2
+❌ External purchase link without entitlement → Guideline 3.1.1
+❌ IAP products not attached to submission version → Guideline 2.1 (reviewer can't see them)
+❌ No review screenshot on IAP product → Guideline 2.1 (reviewer can't verify purchase UI)
+❌ Terms of Use / Privacy Policy missing from paywall → DPLA Schedule 2 (use SubscriptionStoreView to avoid)
+```
+
+---
+
+## Common Rejection Reasons Quick Reference
+
+| Guideline | Issue | Prevention |
+|-----------|-------|------------|
+| 2.1 | Crashes, broken features, incomplete | Device testing, content audit |
+| 2.1 | IAP not visible to reviewer | Attach IAP to version (checkbox in ASC), upload review screenshots |
+| 2.3 | Inaccurate metadata, wrong screenshots | Screenshot audit, metadata review |
+| 2.3.6 | Incorrect age rating | Honest questionnaire, declare UGC |
+| 3.1.1 | IAP issues, missing Restore Purchases | Test all IAP flows, add restore |
+| 4.0 | Design: poor UI, non-standard patterns | Follow HIG, test on all sizes |
+| 4.8 | Missing Sign in with Apple | Add SIWA with any third-party login |
+| 5.1.1(i) | Privacy policy missing/inadequate | Both ASC and in-app, specific content |
+| 5.1.1(v) | No account deletion | In-app deletion, not just deactivation |
+| 5.1.2 | Missing Required Reason APIs | Complete privacy manifest |
+
+---
+
+## App Store Connect Submission Workflow
+
+See `app-store-ref` Part 9 for the ASC upload workflow and build processing details.
+
+---
+
+## Encryption Export Compliance
+
+Most apps need `ITSAppUsesNonExemptEncryption = false`. See `app-store-ref` Part 5 for the full decision tree.
+
+---
+
+## Accessibility Nutrition Labels (New 2025)
+
+Accessibility Nutrition Labels are becoming required for new submissions. See `app-store-ref` Part 10 for the full label list and declaration rules. Run `axiom-accessibility-diag` before declaring.
+
+---
+
+## First-Time Developer Checklist
+
+For developers submitting their first app, these are additional items often missed. If you need to create a privacy policy from scratch, use a privacy policy generator (many free options exist) and customize it to match your app's actual data practices — a generic template will be rejected.
+
+### Apple Developer Program
+
+- [ ] Enrolled in Apple Developer Program ($99/year)
+- [ ] Accepted latest Apple Developer Program License Agreement
+- [ ] Tax and banking information completed in ASC (for paid apps/IAP)
+- [ ] Distribution certificate created
+- [ ] App ID registered with correct bundle identifier
+- [ ] Provisioning profile created for App Store distribution
+
+### App Store Connect Setup
+
+- [ ] New app record created in ASC
+- [ ] Bundle ID matches Xcode project exactly
+- [ ] Primary language set
+- [ ] Content rights declared (original content or licensed)
+- [ ] Pricing and availability configured
+- [ ] Territory selection (worldwide or specific countries)
+
+### Common First-Time Mistakes
+
+| Mistake | Result | Fix |
+|---------|--------|-----|
+| Bundle ID mismatch between Xcode and ASC | Upload rejected | Match exactly, including case |
+| Distribution cert expired/missing | Archive fails to upload | Create new cert in Developer Portal |
+| License agreement not accepted | Upload blocked | Accept in developer.apple.com |
+| Tax forms incomplete | Paid app not distributed | Complete in ASC → Agreements, Tax, and Banking |
+| Wrong team selected in Xcode | Signing errors | Select correct team in Signing & Capabilities |
+
+---
+
+## Real-World Impact
+
+**Before**: Developer submits without checklist → rejected for missing privacy manifest (3 days) → fixes, resubmits → rejected for missing SIWA (5 days) → fixes, resubmits → rejected for placeholder content (3 days) → 11 days lost to preventable issues
+
+**After**: Developer runs 30-minute pre-flight → catches all three issues → fixes in 4 hours → approved on first submission
+
+**Key insight**: The checklist takes 30 minutes. Each rejection cycle takes 3-7 days. The math is simple.
+
+---
+
+## Resources
+
+**WWDC**: 2022-10166, 2025-328, 2025-224, 2025-241
+
+**Docs**: /app-store/review/guidelines, /app-store/submitting, /app-store/app-privacy-details, /support/offering-account-deletion-in-your-app, /documentation/security/complying-with-encryption-export-regulations
+
+**Skills**: axiom-privacy-ux, axiom-storekit-ref, axiom-accessibility-diag, axiom-testflight-triage, axiom-app-store-connect-ref
diff --git a/.claude/skills/axiom-app-store-submission/agents/openai.yaml b/.claude/skills/axiom-app-store-submission/agents/openai.yaml
new file mode 100644
index 0000000..0066d6b
--- /dev/null
+++ b/.claude/skills/axiom-app-store-submission/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "App Store Submission"
+ short_description: "Preparing ANY app for App Store submission, responding to App Review rejections, or running a pre-submission audit"
diff --git a/.claude/skills/axiom-apple-docs-research/.openskills.json b/.claude/skills/axiom-apple-docs-research/.openskills.json
new file mode 100644
index 0000000..3f99d9a
--- /dev/null
+++ b/.claude/skills/axiom-apple-docs-research/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-apple-docs-research",
+ "installedAt": "2026-04-12T08:05:48.717Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-apple-docs-research/SKILL.md b/.claude/skills/axiom-apple-docs-research/SKILL.md
new file mode 100644
index 0000000..5957c9c
--- /dev/null
+++ b/.claude/skills/axiom-apple-docs-research/SKILL.md
@@ -0,0 +1,327 @@
+---
+name: axiom-apple-docs-research
+description: Use when researching Apple frameworks, APIs, or WWDC sessions - provides techniques for retrieving full transcripts, code samples, and documentation using Chrome browser and sosumi.ai
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Apple Documentation Research
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Researching Apple frameworks or APIs (WidgetKit, SwiftUI, etc.)
+- Need full WWDC session transcripts with code samples
+- Looking for Apple Developer documentation
+- Want to extract code examples from WWDC presentations
+- Building comprehensive skills based on Apple technologies
+
+❌ **Do NOT use this skill for**:
+- Third-party framework documentation
+- General web research
+- Questions already answered in existing skills
+- Basic Swift language questions (use Swift documentation)
+
+## Related Skills
+
+- Use **superpowers-chrome:browsing** for interactive browser control
+- Use **writing-skills** when creating new skills from Apple documentation
+- Use **reviewing-reference-skills** to validate Apple documentation skills
+
+## Core Philosophy
+
+> Apple Developer video pages contain full verbatim transcripts with timestamps and complete code samples. Chrome's auto-capture feature makes this content instantly accessible without manual copying.
+
+**Key insight**: Don't manually transcribe or copy code from WWDC videos. The transcripts are already on the page, fully timestamped and formatted.
+
+## WWDC Session Transcripts via Chrome
+
+### The Technique
+
+Apple Developer video pages (`developer.apple.com/videos/play/wwdc20XX/XXXXX/`) contain complete transcripts that Chrome auto-captures.
+
+#### Step-by-Step Process
+
+1. **Navigate** using Chrome browser MCP tool:
+ ```json
+ {
+ "action": "navigate",
+ "payload": "https://developer.apple.com/videos/play/wwdc2025/278/"
+ }
+ ```
+
+ Tool name: `mcp__plugin_superpowers-chrome_chrome__use_browser`
+
+ **Complete invocation**:
+ ```
+ Use the mcp__plugin_superpowers-chrome_chrome__use_browser tool with:
+ - action: "navigate"
+ - payload: "https://developer.apple.com/videos/play/wwdc2025/278/"
+ ```
+
+2. **Locate** the auto-captured file:
+ - Chrome saves to: `~/.../superpowers/browser/YYYY-MM-DD/session-TIMESTAMP/`
+ - Session directory uses Unix timestamp in milliseconds (e.g., `session-1765217804099`)
+ - Filename pattern: `NNN-navigate.md` (e.g., `001-navigate.md`)
+
+ **Finding the latest session**:
+ ```bash
+ # List sessions sorted by modification time (newest first)
+ ls -lt ~/Library/Caches/superpowers/browser/*/session-* | head -5
+ ```
+
+3. **Read** the captured transcript:
+ - Full spoken content with timestamps (e.g., `[0:07]`, `[1:23]`)
+ - Descriptions of code and API usage (spoken, not formatted)
+ - Chapter markers and resource links
+
+### What You Get
+
+**✅ WWDC transcripts contain:**
+- Full spoken content with timestamps (e.g., `[0:07]`, `[1:23]`)
+- API names mentioned by speakers (e.g., `widgetRenderingMode`, `supportedMountingStyles`)
+- Descriptions of what code does ("I'll add the widgetRenderingMode environment variable")
+- Step-by-step explanations of implementations
+- Chapter markers and resource links
+
+**❌ WWDC transcripts do NOT contain:**
+- Formatted Swift code blocks ready to copy-paste
+- Complete implementations
+- Structured code examples
+
+**Critical Understanding**: Transcripts are **spoken word, not code**. You'll read sentences like "I'll add the widgetRenderingMode environment variable to my widget view" and need to **reconstruct the code yourself** from these descriptions.
+
+### When Code Isn't Clear from Transcript
+
+If the transcript's code descriptions aren't detailed enough, follow this fallback workflow:
+
+1. **Check Resources Tab**
+ - Navigate back to the WWDC session page
+ - Click "Resources" tab
+ - Look for "Download Sample Code" or "View on GitHub"
+ - Download Xcode project with complete working implementation
+
+2. **Use sosumi.ai for API Details**
+ - Look up specific APIs mentioned in transcript
+ - Example: Transcript says "widgetAccentedRenderingMode" → look up `sosumi.ai/documentation/swiftui/widgetaccentedrenderingmode`
+ - Get exact signature, parameters, usage
+
+3. **Jump to Timestamp in Video**
+ - Use transcript timestamp to jump directly to code explanation in video
+ - Example: Transcript says code at `[4:23]` → watch that specific 30-second segment
+ - Faster than watching entire 45-minute session
+
+4. **Combine Sources**
+ - Transcript = conceptual understanding + workflow
+ - Resources = complete code
+ - sosumi.ai = API details
+ - Result: Full picture without manually reconstructing everything
+
+**Example transcript structure**:
+```markdown
+# Session Title - WWDC## - Videos - Apple Developer
+
+## Chapters
+- 0:00 - Introduction
+- 1:23 - Key Topic 1
+
+## Transcript
+0:00
+Speaker: Welcome to this session...
+
+[timestamp]
+Now I'll add the widgetAccentedRenderingMode modifier...
+```
+
+### Example Session
+
+**WWDC 2025-278** "What's new in widgets":
+- Navigate: `https://developer.apple.com/videos/play/wwdc2025/278/`
+- Captured: `001-navigate.md`
+- Contains: ~15 minutes of full transcript with API references and code concepts
+
+## Apple Documentation via sosumi.ai
+
+### Why sosumi.ai
+
+Developer.apple.com documentation is HTML-heavy and difficult to parse. sosumi.ai provides the same content in clean markdown format.
+
+### URL Pattern
+
+**Instead of**:
+```
+https://developer.apple.com/documentation/widgetkit
+```
+
+**Use**:
+```
+https://sosumi.ai/documentation/widgetkit
+```
+
+### URL Pattern Rules
+
+**Format**: `https://sosumi.ai/documentation/[framework]`
+
+**Rules for framework name**:
+1. **Lowercase** - Use lowercase even if framework is capitalized (SwiftUI → swiftui)
+2. **No spaces** - Remove all spaces (Core Data → coredata)
+3. **No hyphens** - Remove all hyphens (App Intents → appintents, NOT app-intents)
+4. **Case-insensitive** - Both `SwiftUI` and `swiftui` work, but lowercase is recommended
+
+**Common mistakes**:
+- ❌ `app-intents` → ✅ `appintents`
+- ❌ `axiom-core-data` → ✅ `coredata`
+- ❌ `AVFoundation` → ✅ `avfoundation`
+
+**Examples**:
+| Framework Name | sosumi.ai URL |
+|----------------|---------------|
+| SwiftUI | `sosumi.ai/documentation/swiftui` |
+| App Intents | `sosumi.ai/documentation/appintents` |
+| Core Data | `sosumi.ai/documentation/coredata` |
+| AVFoundation | `sosumi.ai/documentation/avfoundation` |
+| UIKit | `sosumi.ai/documentation/uikit` |
+
+### Using with WebFetch or Read Tools
+
+```
+WebFetch:
+ url: https://sosumi.ai/documentation/widgetkit/widget
+ prompt: "Extract information about Widget protocol"
+
+Result: Clean markdown with API signatures, descriptions, examples
+```
+
+### Framework Examples
+
+| Framework | sosumi.ai URL |
+|-----------|---------------|
+| WidgetKit | `https://sosumi.ai/documentation/widgetkit` |
+| SwiftUI | `https://sosumi.ai/documentation/swiftui` |
+| ActivityKit | `https://sosumi.ai/documentation/activitykit` |
+| App Intents | `https://sosumi.ai/documentation/appintents` |
+| Foundation | `https://sosumi.ai/documentation/foundation` |
+
+## Common Research Workflows
+
+### Workflow 1: New iOS Feature Research
+
+**Goal**: Create a comprehensive skill for a new iOS 26 feature.
+
+1. **Find WWDC sessions** — Search "WWDC 2025 [feature name]"
+2. **Get transcripts** — Navigate with Chrome to each session
+3. **Read transcripts** — Extract key concepts, code patterns, gotchas
+4. **Get API docs** — Use sosumi.ai for framework reference
+5. **Cross-reference** — Verify code samples match documentation
+6. **Create skill** — Combine transcript insights + API reference
+
+**Time saved**: 3-4 hours vs. watching videos and manual transcription
+
+### Workflow 2: API Deep Dive
+
+**Goal**: Understand a specific API or protocol.
+
+1. **sosumi.ai docs** — Get protocol/class definition
+2. **WWDC sessions** — Search for sessions mentioning the API
+3. **Code samples** — Extract from transcript code blocks
+4. **Verify patterns** — Ensure examples match latest API
+
+### Workflow 3: Multiple Sessions Research
+
+**Goal**: Comprehensive coverage across multiple years (e.g., widgets evolution).
+
+1. **Parallel navigation** — Use Chrome to visit 3-6 sessions
+2. **Read all transcripts** — Compare how APIs evolved
+3. **Extract timeline** — iOS 14 → 17 → 18 → 26 changes
+4. **Consolidate** — Create unified skill with version annotations
+
+**Example**: Extensions & Widgets skill used 6 WWDC sessions (2023-2025)
+
+## Anti-Patterns
+
+### ❌ DON'T: Manual Video Watching
+
+```
+BAD:
+1. Play WWDC video
+2. Pause and take notes
+3. Rewind to capture code
+4. Type out examples manually
+
+Result: 45 minutes per session
+```
+
+### ✅ DO: Chrome Auto-Capture
+
+```
+GOOD:
+1. Navigate with Chrome
+2. Read captured .md file
+3. Copy code blocks directly
+4. Reference timestamps for context
+
+Result: 5 minutes per session
+```
+
+### ❌ DON'T: Scrape developer.apple.com HTML
+
+```
+BAD:
+Use WebFetch on developer.apple.com/documentation
+Result: Complex HTML parsing required
+```
+
+### ✅ DO: Use sosumi.ai
+
+```
+GOOD:
+Use WebFetch on sosumi.ai/documentation
+Result: Clean markdown, instant access
+```
+
+## Troubleshooting
+
+### Chrome Session Directory Not Found
+
+**Symptom**: Can't locate `001-navigate.md` file
+
+**Solution**:
+1. Check Chrome actually navigated (look for URL confirmation)
+2. Find latest session: `ls -lt ~/Library/Caches/superpowers/browser/*/`
+3. Session directory format: `YYYY-MM-DD/session-TIMESTAMP/`
+
+### Transcript Incomplete
+
+**Symptom**: File exists but missing transcript
+
+**Solution**:
+1. Page may still be loading - wait 2-3 seconds
+2. Try navigating again
+3. Some sessions require scrolling to load full content
+
+### sosumi.ai Returns Error
+
+**Symptom**: 404 or invalid URL
+
+**Solution**:
+1. Verify framework name spelling
+2. Check sosumi.ai format: `/documentation/[frameworkname]`
+3. Fallback: Use developer.apple.com but expect HTML
+
+## Verification Checklist
+
+Before using captured content:
+- ☐ Transcript includes timestamps
+- ☐ Code samples are complete (not truncated)
+- ☐ Speaker names and chapter markers present
+- ☐ Multiple speakers properly attributed
+- ☐ Code syntax highlighting preserved
+
+## Resources
+
+**Skills**: superpowers-chrome:browsing, writing-skills, reviewing-reference-skills
+
+---
+
+**Time Saved**: Using this technique saves 30-40 minutes per WWDC session vs. manual video watching and transcription. For comprehensive research spanning multiple sessions, savings compound to 3-4 hours per skill.
diff --git a/.claude/skills/axiom-apple-docs-research/agents/openai.yaml b/.claude/skills/axiom-apple-docs-research/agents/openai.yaml
new file mode 100644
index 0000000..11e44a2
--- /dev/null
+++ b/.claude/skills/axiom-apple-docs-research/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Apple Docs Research"
+ short_description: "Researching Apple frameworks, APIs, or WWDC sessions"
diff --git a/.claude/skills/axiom-apple-docs/.openskills.json b/.claude/skills/axiom-apple-docs/.openskills.json
new file mode 100644
index 0000000..6df171f
--- /dev/null
+++ b/.claude/skills/axiom-apple-docs/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-apple-docs",
+ "installedAt": "2026-04-12T08:05:35.591Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-apple-docs/SKILL.md b/.claude/skills/axiom-apple-docs/SKILL.md
new file mode 100644
index 0000000..e361f6d
--- /dev/null
+++ b/.claude/skills/axiom-apple-docs/SKILL.md
@@ -0,0 +1,156 @@
+---
+name: axiom-apple-docs
+description: Use when ANY question involves Apple framework APIs, Swift compiler errors, or Xcode-bundled documentation. Covers Liquid Glass, Swift 6.2 concurrency, Foundation Models, SwiftData, StoreKit, 32 Swift compiler diagnostics.
+license: MIT
+---
+
+# Apple Documentation Router
+
+Apple bundles for-LLM markdown documentation inside Xcode. These are authoritative, up-to-date guides and diagnostics written by Apple engineers. Use them alongside Axiom skills for the most accurate information.
+
+## When to Use
+
+Use Apple's bundled docs when:
+- You need the exact API signature or behavior from Apple
+- Axiom skills reference an Apple framework and you want the official source
+- A Swift compiler diagnostic needs explanation
+- The user asks about a specific Apple framework feature
+
+**Priority**: Axiom skills provide opinionated guidance (decision trees, anti-patterns, pressure scenarios). Apple docs provide authoritative API details. Use both together.
+
+## Guide Topics (AdditionalDocumentation)
+
+Read these with the MCP `axiom_read_skill` tool using the skill name.
+
+### UI & Design
+
+| Topic | Skill Name |
+|-------|-----------|
+| Liquid Glass in SwiftUI | `apple-guide-swiftui-implementing-liquid-glass-design` |
+| Liquid Glass in UIKit | `apple-guide-uikit-implementing-liquid-glass-design` |
+| Liquid Glass in AppKit | `apple-guide-appkit-implementing-liquid-glass-design` |
+| Liquid Glass in WidgetKit | `apple-guide-widgetkit-implementing-liquid-glass-design` |
+| SwiftUI toolbar features | `apple-guide-swiftui-new-toolbar-features` |
+| SwiftUI styled text editing | `apple-guide-swiftui-styled-text-editing` |
+| SwiftUI WebKit integration | `apple-guide-swiftui-webkit-integration` |
+| SwiftUI AlarmKit integration | `apple-guide-swiftui-alarmkit-integration` |
+| Swift Charts 3D visualization | `apple-guide-swift-charts-3d-visualization` |
+| Foundation AttributedString | `apple-guide-foundation-attributedstring-updates` |
+
+### Data & Persistence
+
+| Topic | Skill Name |
+|-------|-----------|
+| SwiftData class inheritance | `apple-guide-swiftdata-class-inheritance` |
+
+### Concurrency & Performance
+
+| Topic | Skill Name |
+|-------|-----------|
+| Swift concurrency updates | `apple-guide-swift-concurrency-updates` |
+| InlineArray and Span | `apple-guide-swift-inlinearray-span` |
+
+### Apple Intelligence
+
+| Topic | Skill Name |
+|-------|-----------|
+| Foundation Models (on-device LLM) | `apple-guide-foundationmodels-using-on-device-llm-in-your-app` |
+
+### System Integration
+
+| Topic | Skill Name |
+|-------|-----------|
+| App Intents updates | `apple-guide-appintents-updates` |
+| StoreKit updates | `apple-guide-storekit-updates` |
+| MapKit GeoToolbox | `apple-guide-mapkit-geotoolbox-placedescriptors` |
+| Widgets for visionOS | `apple-guide-widgets-for-visionos` |
+
+### Accessibility
+
+| Topic | Skill Name |
+|-------|-----------|
+| Assistive Access in iOS | `apple-guide-implementing-assistive-access-in-ios` |
+
+### Computer Vision
+
+| Topic | Skill Name |
+|-------|-----------|
+| Visual Intelligence in iOS | `apple-guide-implementing-visual-intelligence-in-ios` |
+
+## Swift Compiler Diagnostics
+
+These explain specific Swift compiler errors and warnings with examples and fixes.
+
+### Concurrency Diagnostics
+
+| Diagnostic | Skill Name |
+|-----------|-----------|
+| Actor-isolated call from nonisolated context | `apple-diag-actor-isolated-call` |
+| Conformance isolation | `apple-diag-conformance-isolation` |
+| Isolated conformances | `apple-diag-isolated-conformances` |
+| Nonisolated nonsending by default | `apple-diag-nonisolated-nonsending-by-default` |
+| Sendable closure captures | `apple-diag-sendable-closure-captures` |
+| Sendable metatypes | `apple-diag-sendable-metatypes` |
+| Sending closure risks data race | `apple-diag-sending-closure-risks-data-race` |
+| Sending risks data race | `apple-diag-sending-risks-data-race` |
+| Mutable global variable | `apple-diag-mutable-global-variable` |
+| Preconcurrency import | `apple-diag-preconcurrency-import` |
+
+### Type System Diagnostics
+
+| Diagnostic | Skill Name |
+|-----------|-----------|
+| Existential any | `apple-diag-existential-any` |
+| Existential member access limitations | `apple-diag-existential-member-access-limitations` |
+| Nominal types | `apple-diag-nominal-types` |
+| Multiple inheritance | `apple-diag-multiple-inheritance` |
+| Protocol type non-conformance | `apple-diag-protocol-type-non-conformance` |
+| Opaque type inference | `apple-diag-opaque-type-inference` |
+
+### Build & Migration Diagnostics
+
+| Diagnostic | Skill Name |
+|-----------|-----------|
+| Deprecated declaration | `apple-diag-deprecated-declaration` |
+| Error in future Swift version | `apple-diag-error-in-future-swift-version` |
+| Strict language features | `apple-diag-strict-language-features` |
+| Strict memory safety | `apple-diag-strict-memory-safety` |
+| Implementation only deprecated | `apple-diag-implementation-only-deprecated` |
+| Member import visibility | `apple-diag-member-import-visibility` |
+| Missing module on known paths | `apple-diag-missing-module-on-known-paths` |
+| Clang declaration import | `apple-diag-clang-declaration-import` |
+| Availability unrecognized name | `apple-diag-availability-unrecognized-name` |
+| Unknown warning group | `apple-diag-unknown-warning-group` |
+
+### Swift Language Diagnostics
+
+| Diagnostic | Skill Name |
+|-----------|-----------|
+| Dynamic callable requirements | `apple-diag-dynamic-callable-requirements` |
+| Property wrapper requirements | `apple-diag-property-wrapper-requirements` |
+| Result builder methods | `apple-diag-result-builder-methods` |
+| String interpolation conformance | `apple-diag-string-interpolation-conformance` |
+| Trailing closure matching | `apple-diag-trailing-closure-matching` |
+| Temporary pointers | `apple-diag-temporary-pointers` |
+
+## Routing Decision Tree
+
+```
+User question about Apple API/framework?
+├── Specific compiler error/warning → Read matching apple-diag-* skill
+├── Liquid Glass implementation → Read apple-guide-*-liquid-glass-design (SwiftUI/UIKit/AppKit)
+├── Swift concurrency patterns → Read apple-guide-swift-concurrency-updates
+├── Foundation Models / on-device AI → Read apple-guide-foundationmodels-*
+├── SwiftData features → Read apple-guide-swiftdata-*
+├── StoreKit / IAP → Read apple-guide-storekit-updates
+├── App Intents / Siri → Read apple-guide-appintents-updates
+├── Charts / visualization → Read apple-guide-swift-charts-3d-visualization
+├── Text editing / AttributedString → Read apple-guide-swiftui-styled-text-editing or apple-guide-foundation-attributedstring-updates
+├── WebKit in SwiftUI → Read apple-guide-swiftui-webkit-integration
+├── Toolbar features → Read apple-guide-swiftui-new-toolbar-features
+└── Other → Search with axiom_search_skills using source filter "apple"
+```
+
+## Resources
+
+**Skills**: axiom-ios-ui, axiom-ios-concurrency, axiom-ios-data, axiom-ios-ai, axiom-ios-integration
diff --git a/.claude/skills/axiom-asc-mcp/.openskills.json b/.claude/skills/axiom-asc-mcp/.openskills.json
new file mode 100644
index 0000000..5c75c5e
--- /dev/null
+++ b/.claude/skills/axiom-asc-mcp/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-asc-mcp",
+ "installedAt": "2026-04-12T08:05:49.297Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-asc-mcp/SKILL.md b/.claude/skills/axiom-asc-mcp/SKILL.md
new file mode 100644
index 0000000..76b0271
--- /dev/null
+++ b/.claude/skills/axiom-asc-mcp/SKILL.md
@@ -0,0 +1,303 @@
+---
+name: axiom-asc-mcp
+description: Use when automating App Store Connect via MCP — submit builds, manage TestFlight, respond to reviews, triage feedback programmatically
+license: MIT
+---
+
+# App Store Connect MCP Integration
+
+**Core principle**: When asc-mcp is configured, you can manage the entire App Store Connect workflow without leaving Claude Code — submit builds, distribute to TestFlight, respond to reviews, and monitor metrics programmatically.
+
+## Setup
+
+### Install
+
+```bash
+brew install mint
+mint install zelentsov-dev/asc-mcp@1.4.0
+```
+
+### Create API Key
+
+1. Open [App Store Connect → Users and Access → Integrations → API](https://appstoreconnect.apple.com/access/integrations/api)
+2. Generate key with **Admin** or **App Manager** role
+3. Download the `.p8` file (one-time download — save it securely)
+4. Note the **Key ID** and **Issuer ID**
+
+### Add to Claude Code
+
+```bash
+claude mcp add asc-mcp \
+ -e ASC_KEY_ID=YOUR_KEY_ID \
+ -e ASC_ISSUER_ID=YOUR_ISSUER_ID \
+ -e ASC_PRIVATE_KEY_PATH=/path/to/AuthKey.p8 \
+ -- ~/.mint/bin/asc-mcp
+```
+
+### Verify
+
+Ask Claude to call `company_current`. If it returns your team name, you're connected.
+
+---
+
+## Worker Filtering
+
+asc-mcp has 25 workers (~208 tools). Loading all of them wastes context. Use `--workers` to load only what you need.
+
+### Presets
+
+| Preset | Workers | Tools | Use Case |
+|--------|---------|-------|----------|
+| **TestFlight** | `apps,builds,beta_groups,beta_testers` | ~34 | Beta distribution |
+| **Release** | `apps,builds,versions,reviews` | ~40 | App Store submission |
+| **Monetization** | `apps,iap,subscriptions,offer_codes,pricing` | ~55 | IAP and subscriptions |
+| **Full** | (default, all workers) | ~208 | Everything |
+
+To use a preset, add `--workers` when registering the server:
+
+```bash
+claude mcp add asc-mcp \
+ -e ASC_KEY_ID=... \
+ -e ASC_ISSUER_ID=... \
+ -e ASC_PRIVATE_KEY_PATH=... \
+ -- ~/.mint/bin/asc-mcp --workers apps,builds,versions,reviews
+```
+
+**Note**: `company` and `auth` workers always load regardless of `--workers`. When `builds` is enabled, `build_processing` and `build_beta` are included automatically.
+
+### Worker Selection Decision Tree
+
+```dot
+digraph workers {
+ "What are you doing?" [shape=diamond];
+ "TestFlight preset" [shape=box, label="--workers apps,builds,\nbeta_groups,beta_testers"];
+ "Release preset" [shape=box, label="--workers apps,builds,\nversions,reviews"];
+ "Monetization preset" [shape=box, label="--workers apps,iap,\nsubscriptions,offer_codes,pricing"];
+ "Full (no flag)" [shape=box, label="No --workers flag\n(all 208 tools)"];
+
+ "What are you doing?" -> "TestFlight preset" [label="distributing beta builds"];
+ "What are you doing?" -> "Release preset" [label="submitting to App Store"];
+ "What are you doing?" -> "Monetization preset" [label="managing IAP/subscriptions"];
+ "What are you doing?" -> "Full (no flag)" [label="multiple tasks or unsure"];
+}
+```
+
+---
+
+## Workflow: Release Pipeline
+
+Submit a new version to the App Store.
+
+```
+1. apps_search(query: "MyApp") → get app ID
+2. builds_list(appId, limit: 5) → find latest processed build
+3. app_versions_create(appId, platform: "IOS", versionString: "2.1.0")
+4. app_versions_attach_build(versionId, buildId)
+5. app_versions_set_review_details(versionId, { contactEmail, notes, ... })
+6. app_versions_submit_for_review(versionId)
+7. (After approval) app_versions_create_phased_release(versionId)
+```
+
+**Before step 3**: Version string must not already exist. Check with `app_versions_list`.
+
+**Before step 4**: Build must be in `VALID` processing state. Check with `builds_get_processing_state`.
+
+**Before step 6**: Version must be in `PREPARE_FOR_SUBMISSION` state. Attaching a build and setting review details are prerequisites.
+
+---
+
+## Workflow: TestFlight Distribution
+
+Distribute a build to beta testers.
+
+```
+1. apps_search(query: "MyApp") → get app ID
+2. builds_list(appId, limit: 5) → find latest build
+3. builds_set_beta_localization(buildId, locale: "en-US", whatsNew: "Bug fixes")
+4. beta_groups_list(appId) → find or create group
+ OR beta_groups_create(appId, name: "Internal Testers", isInternal: true)
+5. beta_groups_add_builds(groupId, [buildId])
+6. builds_send_beta_notification(buildId) → notify testers (optional)
+```
+
+**Tip**: Internal testers (up to 100) get builds immediately. External testers (up to 10,000) require Beta App Review for the first build of each version.
+
+---
+
+## Workflow: Review Management
+
+Monitor and respond to App Store reviews.
+
+```
+1. apps_search(query: "MyApp") → get app ID
+2. reviews_list(appId, sort: "-createdDate", limit: 20)
+ OR reviews_list(appId, filterRating: "1,2") → negative reviews only
+3. reviews_stats(appId) → rating distribution summary
+4. reviews_create_response(reviewId, responseBody: "Thank you for...")
+```
+
+**Response guidelines**:
+- Respond to negative reviews within 24-48 hours
+- Be professional and offer specific help
+- Updating a response replaces the previous one (users see "Developer Response")
+
+---
+
+## Workflow: Feedback Triage
+
+Correlate TestFlight feedback with build diagnostics.
+
+```
+1. builds_list(appId, limit: 10) → recent builds
+2. builds_get_beta_testers(buildId) → who tested this build
+3. metrics_build_diagnostics(buildId) → crash signatures for this build
+4. metrics_get_diagnostic_logs(signatureId) → individual crash logs
+```
+
+**Limitation**: TestFlight text feedback and screenshots are NOT available via the App Store Connect API. Use Xcode Organizer or the ASC web dashboard for feedback content.
+
+---
+
+## Workflow: Multi-Company
+
+Switch between App Store Connect teams.
+
+```
+1. company_list → see configured accounts
+2. company_switch(companyId: "client-a") → switch active account
+3. (All subsequent calls use client-a's credentials)
+```
+
+### Multi-Company Setup
+
+Add to `~/.config/asc-mcp/companies.json`:
+
+```json
+{
+ "companies": [
+ {
+ "id": "my-company",
+ "name": "My Company",
+ "key_id": "KEY_ID_1",
+ "issuer_id": "ISSUER_1",
+ "key_path": "/Users/you/.keys/AuthKey1.p8"
+ },
+ {
+ "id": "client-a",
+ "name": "Client A",
+ "key_id": "KEY_ID_2",
+ "issuer_id": "ISSUER_2",
+ "key_path": "/Users/you/.keys/AuthKey2.p8"
+ }
+ ]
+}
+```
+
+Or use numbered environment variables: `ASC_COMPANY_1_NAME`, `ASC_COMPANY_1_KEY_ID`, etc.
+
+---
+
+## Key Tool Quick Reference
+
+### Apps & Builds
+
+| Tool | Parameters | Returns |
+|------|-----------|---------|
+| `apps_search` | `query` | App ID, name, bundleId, platform |
+| `apps_list` | `limit` | All apps in account |
+| `builds_list` | `appId`, `limit`, `sort` | Build number, version, processing state |
+| `builds_find_by_number` | `appId`, `buildNumber` | Specific build details |
+| `builds_get_processing_state` | `buildId` | PROCESSING, VALID, INVALID, etc. |
+| `builds_check_readiness` | `buildId` | Whether build is ready for distribution |
+
+### Versions & Submission
+
+| Tool | Parameters | Returns |
+|------|-----------|---------|
+| `app_versions_create` | `appId`, `platform`, `versionString` | New version in PREPARE_FOR_SUBMISSION |
+| `app_versions_attach_build` | `versionId`, `buildId` | Attached build |
+| `app_versions_set_review_details` | `versionId`, `contactEmail`, `notes`, etc. | Review details |
+| `app_versions_submit_for_review` | `versionId` | Submitted for review |
+| `app_versions_cancel_review` | `versionId` | Cancelled review |
+| `app_versions_release` | `versionId` | Manual release |
+| `app_versions_create_phased_release` | `versionId` | 7-day phased rollout |
+
+### TestFlight
+
+| Tool | Parameters | Returns |
+|------|-----------|---------|
+| `beta_groups_create` | `appId`, `name`, `isInternal` | New group |
+| `beta_groups_add_testers` | `groupId`, `testerIds` | Updated group |
+| `beta_groups_add_builds` | `groupId`, `buildIds` | Build distributed |
+| `builds_set_beta_localization` | `buildId`, `locale`, `whatsNew` | "What to Test" text |
+| `builds_send_beta_notification` | `buildId` | Testers notified |
+
+### Reviews & Metrics
+
+| Tool | Parameters | Returns |
+|------|-----------|---------|
+| `reviews_list` | `appId`, `sort`, `filterRating`, `limit` | Review entries |
+| `reviews_stats` | `appId` | Rating distribution |
+| `reviews_create_response` | `reviewId`, `responseBody` | Developer response |
+| `metrics_app_perf` | `appId` | App-level performance metrics |
+| `metrics_build_diagnostics` | `buildId` | Crash signatures per build |
+
+---
+
+## API Constraints
+
+| Constraint | Details |
+|-----------|---------|
+| **No emoji in metadata** | Version "What's New", descriptions, keywords — use words, not emoji |
+| **Version state** | Only `PREPARE_FOR_SUBMISSION` versions are editable. Once submitted, create a new version to make changes. |
+| **JWT lifetime** | 20-minute tokens, auto-refreshed by asc-mcp |
+| **Rate limits** | Apple enforces per-account limits. asc-mcp retries with exponential backoff on 429s. |
+| **Locale format** | Standard codes: `en-US`, `ja`, `de-DE`, `zh-Hans`, `ru` |
+| **Build processing** | Newly uploaded builds take 15-30 minutes to process. Poll `builds_get_processing_state` before attaching. |
+| **Phased release** | Only available after approval. Can pause/resume with `app_versions_update_phased_release`. |
+
+---
+
+## Gotchas
+
+| Gotcha | Details |
+|--------|---------|
+| **Build not found** | Build may still be processing. Check `builds_get_processing_state` — must be `VALID`. |
+| **"Version already exists"** | Can't create duplicate version strings. Use `app_versions_list` to check first. |
+| **Attach fails** | Build must be processed AND the version must be in `PREPARE_FOR_SUBMISSION`. |
+| **Review details rejected** | Contact info fields have format requirements. Email must be valid, phone must include country code. |
+| **Wrong app** | `apps_search` is fuzzy. Verify the returned bundleId matches your target app. |
+| **Multi-company confusion** | Always call `company_current` first to confirm which account is active before making changes. |
+| **Beta App Review** | First external TestFlight build per version requires review. Subsequent builds to the same version are auto-approved. |
+| **Missing analytics** | `analytics_*` tools require `vendor_number` in company config. Without it, sales/financial reports fail silently. |
+
+---
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I'll just use the ASC web dashboard" | MCP tools are faster for repetitive tasks — respond to 20 reviews, distribute to 5 groups, create versions across apps. |
+| "I don't need worker filtering" | 208 tools consume ~30K tokens of context. Filter to what you need. |
+| "I'll submit without review details" | Submission will fail. `app_versions_set_review_details` is required before `app_versions_submit_for_review`. |
+| "I'll skip `company_current` — I only have one account" | Multi-company configs persist between sessions. Always verify. |
+| "Feedback triage is the same via API" | Text feedback and screenshots are NOT in the ASC API. Use Organizer for feedback content, MCP for crash diagnostics. |
+
+---
+
+## When asc-mcp is NOT Available
+
+If asc-mcp is not configured, fall back to manual workflows:
+
+- **Crash analysis**: Use Xcode Organizer (see `axiom-testflight-triage`) or App Store Connect web dashboard (see `axiom-app-store-connect-ref`)
+- **TestFlight distribution**: Use Xcode → Product → Archive → Distribute, or `xcodebuild` + `altool`
+- **Review management**: Use App Store Connect web dashboard
+- **Submission**: Use Xcode → Product → Archive → Distribute to App Store
+
+---
+
+## Resources
+
+**Skills**: axiom-app-store-submission, axiom-app-store-ref, axiom-app-store-connect-ref, axiom-testflight-triage
+
+**Agents**: crash-analyzer, security-privacy-scanner, iap-auditor
diff --git a/.claude/skills/axiom-asc-mcp/agents/openai.yaml b/.claude/skills/axiom-asc-mcp/agents/openai.yaml
new file mode 100644
index 0000000..5b9267f
--- /dev/null
+++ b/.claude/skills/axiom-asc-mcp/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "ASC MCP"
+ short_description: "Automating App Store Connect via MCP"
diff --git a/.claude/skills/axiom-assume-isolated/.openskills.json b/.claude/skills/axiom-assume-isolated/.openskills.json
new file mode 100644
index 0000000..a7d877e
--- /dev/null
+++ b/.claude/skills/axiom-assume-isolated/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-assume-isolated",
+ "installedAt": "2026-04-12T08:05:49.835Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-assume-isolated/SKILL.md b/.claude/skills/axiom-assume-isolated/SKILL.md
new file mode 100644
index 0000000..a8cb7d7
--- /dev/null
+++ b/.claude/skills/axiom-assume-isolated/SKILL.md
@@ -0,0 +1,233 @@
+---
+name: axiom-assume-isolated
+description: Use when needing synchronous actor access in tests, legacy delegate callbacks, or performance-critical code. Covers MainActor.assumeIsolated, @preconcurrency protocol conformances, crash behavior, Task vs assumeIsolated.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# assumeIsolated — Synchronous Actor Access
+
+Synchronously access actor-isolated state when you **know** you're already on the correct isolation domain.
+
+## When to Use
+
+✅ **Use when:**
+- Testing MainActor code synchronously (avoiding Task overhead)
+- Legacy delegate callbacks documented to run on main thread
+- Performance-critical code avoiding async hop overhead
+- Protocol conformances where callbacks are guaranteed on specific actor
+
+❌ **Don't use when:**
+- Uncertain about current isolation (use `await` instead)
+- Already in async context (you have isolation)
+- Cross-actor calls needed (use async)
+- Callback origin is unknown or untrusted
+
+## API Reference
+
+### MainActor.assumeIsolated
+
+```swift
+static func assumeIsolated(
+ _ operation: @MainActor () throws -> T,
+ file: StaticString = #fileID,
+ line: UInt = #line
+) rethrows -> T where T: Sendable
+```
+
+**Behavior**: Executes synchronously. **Crashes** if not on MainActor's serial executor.
+
+### Custom Actor assumeIsolated
+
+```swift
+func assumeIsolated(
+ _ operation: (isolated Self) throws -> T,
+ file: StaticString = #fileID,
+ line: UInt = #line
+) rethrows -> T where T: Sendable
+```
+
+## Task vs assumeIsolated
+
+| Aspect | `Task { @MainActor in }` | `MainActor.assumeIsolated` |
+|--------|--------------------------|---------------------------|
+| Timing | Deferred (next run loop) | Synchronous (inline) |
+| Async support | Yes (can await) | No (sync only) |
+| Context | From any context | Must be sync function |
+| Failure mode | Runs anyway | **Crashes** if wrong isolation |
+| Use case | Start async work | Verify + access isolated state |
+
+## Patterns
+
+### Pattern 1: Testing MainActor Code
+
+```swift
+@Test func viewModelUpdates() {
+ MainActor.assumeIsolated {
+ let vm = ViewModel()
+ vm.update()
+ #expect(vm.state == .updated)
+ }
+}
+```
+
+### Pattern 2: Legacy Delegate Callbacks
+
+From WWDC 2024-10169 — When documentation guarantees main thread delivery:
+
+```swift
+@MainActor
+class LocationDelegate: NSObject, CLLocationManagerDelegate {
+ var location: CLLocation?
+
+ // CLLocationManager created on main thread delivers callbacks on main thread
+ nonisolated func locationManager(
+ _ manager: CLLocationManager,
+ didUpdateLocations locations: [CLLocation]
+ ) {
+ MainActor.assumeIsolated {
+ self.location = locations.last
+ }
+ }
+}
+```
+
+### Pattern 3: @preconcurrency Shorthand
+
+`@preconcurrency` is equivalent shorthand — wraps in `assumeIsolated` automatically:
+
+```swift
+// ❌ Manual approach (verbose)
+extension MyClass: SomeDelegate {
+ nonisolated func callback() {
+ MainActor.assumeIsolated {
+ self.updateUI()
+ }
+ }
+}
+
+// ✅ Using @preconcurrency (equivalent, cleaner)
+extension MyClass: @preconcurrency SomeDelegate {
+ func callback() {
+ self.updateUI() // Compiler wraps in assumeIsolated
+ }
+}
+```
+
+**When protocol adds isolation**: `@preconcurrency` becomes unnecessary and compiler warns.
+
+### Pattern 4: Thread Check Before assumeIsolated
+
+When caller context is unknown (e.g., library code):
+
+```swift
+func getView() -> UIView {
+ if Thread.isMainThread {
+ return createHostingViewOnMain()
+ } else {
+ return DispatchQueue.main.sync {
+ createHostingViewOnMain()
+ }
+ }
+}
+
+private func createHostingViewOnMain() -> UIView {
+ MainActor.assumeIsolated {
+ let hosting = UIHostingController(rootView: MyView())
+ return hosting.view
+ }
+}
+```
+
+### Pattern 5: Custom Actor Access
+
+```swift
+actor DataStore {
+ var cache: [String: Data] = [:]
+
+ nonisolated func synchronousRead(key: String) -> Data? {
+ // Only safe if called from DataStore's executor
+ assumeIsolated { isolated in
+ isolated.cache[key]
+ }
+ }
+}
+```
+
+## Common Mistakes
+
+### Mistake 1: Silencing Compiler Errors
+
+```swift
+// ❌ DANGEROUS: Using assumeIsolated to silence warnings
+func unknownContext() {
+ MainActor.assumeIsolated {
+ updateUI() // Crashes if not actually on main actor!
+ }
+}
+
+// ✅ When uncertain, use proper async
+func unknownContext() async {
+ await MainActor.run {
+ updateUI()
+ }
+}
+```
+
+### Mistake 2: Assuming GCD Main Queue == MainActor
+
+They're **usually** the same, but not guaranteed. Check documentation or use async.
+
+### Mistake 3: Using in Async Context
+
+```swift
+// ❌ Unnecessary — you already have isolation
+@MainActor
+func updateState() async {
+ MainActor.assumeIsolated { // Pointless
+ self.state = .ready
+ }
+}
+
+// ✅ Direct access
+@MainActor
+func updateState() async {
+ self.state = .ready
+}
+```
+
+## When @preconcurrency Becomes Unnecessary
+
+If the protocol later adds MainActor isolation:
+
+```swift
+// Library update:
+@MainActor
+protocol CaffeineThresholdDelegate: AnyObject {
+ func caffeineLevel(at level: Double)
+}
+
+// Your code — @preconcurrency now warns:
+// "@preconcurrency attribute on conformance has no effect"
+extension Recaffeinater: CaffeineThresholdDelegate {
+ func caffeineLevel(at level: Double) {
+ // Direct access, no wrapper needed
+ }
+}
+```
+
+## Crash Behavior
+
+Per Apple documentation:
+> "If the current context is not running on the actor's serial executor... this method will crash with a fatal error."
+
+**Trapping is intentional**: Better to crash than corrupt user data with a race condition.
+
+## Resources
+
+**WWDC**: 2024-10169
+
+**Docs**: /swift/mainactor/assumeisolated, /swift/actor/assumeisolated
+
+**Skills**: axiom-swift-concurrency
diff --git a/.claude/skills/axiom-assume-isolated/agents/openai.yaml b/.claude/skills/axiom-assume-isolated/agents/openai.yaml
new file mode 100644
index 0000000..c41bba2
--- /dev/null
+++ b/.claude/skills/axiom-assume-isolated/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Assume Isolated"
+ short_description: "Needing synchronous actor access in tests, legacy delegate callbacks, or performance-critical code"
diff --git a/.claude/skills/axiom-audit-accessibility/.openskills.json b/.claude/skills/axiom-audit-accessibility/.openskills.json
new file mode 100644
index 0000000..a1bb505
--- /dev/null
+++ b/.claude/skills/axiom-audit-accessibility/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-accessibility",
+ "installedAt": "2026-04-12T08:05:49.836Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-accessibility/SKILL.md b/.claude/skills/axiom-audit-accessibility/SKILL.md
new file mode 100644
index 0000000..45876b2
--- /dev/null
+++ b/.claude/skills/axiom-audit-accessibility/SKILL.md
@@ -0,0 +1,252 @@
+---
+name: axiom-audit-accessibility
+description: Use when the user mentions accessibility checking, App Store submission, code review, or WCAG compliance.
+license: MIT
+disable-model-invocation: true
+---
+# Accessibility Auditor Agent
+
+You are an expert at detecting accessibility violations — both known anti-patterns AND missing/incomplete assistive technology support that prevents users with disabilities from using the app and causes App Store rejections.
+
+## Your Mission
+
+Run a comprehensive accessibility audit using 5 phases: map the UI hierarchy and assistive technology surface, detect known violations, reason about what's unreachable or incomplete, correlate compound issues, and score accessibility health. Report all issues with:
+- File:line references with confidence levels
+- WCAG compliance levels
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map UI Hierarchy and Assistive Technology Surface
+
+Before grepping for violations, build a mental model of the app's UI and how assistive technologies would experience it.
+
+### Step 1: Identify Interactive Surfaces
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `Button`, `NavigationLink`, `Toggle`, `Picker`, `Slider` — standard interactive elements
+ - `.onTapGesture`, `.onLongPressGesture`, `DragGesture`, `MagnificationGesture` — gesture-based interactions
+ - `.swipeActions` — swipe actions (automatically VoiceOver-accessible)
+ - `UIButton`, `UISwitch`, `UISlider`, `addTarget` — UIKit interactive elements
+```
+
+### Step 2: Identify Content Surfaces
+
+```
+Grep for:
+ - `Image("` — custom images (need labels or accessibilityHidden)
+ - `AsyncImage(` — network images (need labels or accessibilityHidden)
+ - `Image(systemName:` — SF Symbols (auto-labeled, usually safe)
+ - `.font(.system(size:`, `UIFont.systemFont(ofSize:` — explicit font sizing
+ - `.custom(` — custom fonts
+```
+
+### Step 3: Identify Accessibility Configuration
+
+Read 3-5 key view files to understand:
+- Is there a consistent accessibility pattern? (labels, traits, hints)
+- Are there custom controls? (custom gestures, drawn content)
+- Is Dynamic Type supported? (@ScaledMetric, preferredFont, relativeTo)
+- Are there accessibility-specific modifiers? (accessibilityElement, accessibilityChildren)
+
+### Output
+
+Write a brief **Accessibility Surface Map** (8-12 lines) summarizing:
+- Interactive element types and count
+- Gesture-based interactions (require manual accessibility support)
+- Custom image count (need labels or hidden)
+- Font sizing strategy (semantic vs fixed vs mixed)
+- Existing accessibility configuration patterns
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 8 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Missing VoiceOver Labels (CRITICAL — App Store Rejection Risk)
+
+**Pattern**: Interactive elements and images without accessibility labels
+**Search**: `Image("` without `accessibilityLabel` or `accessibilityHidden` in nearby lines; `Button` with only `systemName` without `accessibilityLabel`; `AsyncImage(` without `accessibilityLabel` or `accessibilityHidden`; `accessibilityLabel("Button")` or `accessibilityLabel("Image")` (generic labels)
+**Issue**: VoiceOver users can't identify or interact with elements
+**Fix**: Add descriptive `.accessibilityLabel("Add to cart")`
+**Note**: `Image(systemName:)` auto-generates VoiceOver labels — don't flag
+
+### 2. Fixed Font Sizes — Dynamic Type (HIGH)
+
+**Pattern**: Hardcoded font sizes that won't scale with Dynamic Type
+**Search**: `.font(.system(size:` without `relativeTo:`; `UIFont.systemFont(ofSize:` without UIFontMetrics; `UIFont(name:` without UIFontMetrics; `.withSize(` without UIFontMetrics
+**Issue**: Text stays tiny when user enables larger text (WCAG 1.4.4)
+**Fix**: Use `.font(.body)` or `.font(.system(size: 17, design: .default).relativeTo(.body))`
+**Note**: Before flagging `.system(size: variable)`, check if the variable is `@ScaledMetric` — already scales
+
+### 3. Custom Font Scaling (HIGH)
+
+**Pattern**: Custom fonts without scaling support
+**Search**: `UIFont(name:` without UIFontMetrics; `UIFont(descriptor:` without UIFontMetrics; `.custom(` without `relativeTo:`
+**Issue**: Custom fonts ignore Dynamic Type settings (WCAG 1.4.4)
+**Fix**: UIKit: `UIFontMetrics(forTextStyle: .body).scaledFont(for: customFont)`. SwiftUI: `.custom("FontName", size: X, relativeTo: .body)`
+
+### 4. Layout Scaling (MEDIUM)
+
+**Pattern**: Fixed padding/spacing that doesn't scale with Dynamic Type
+**Search**: Check for `@ScaledMetric` usage, `scaledValue` usage. Absence of both with fixed padding constants indicates issue.
+**Issue**: Layout doesn't adapt to larger text sizes (WCAG 1.4.4)
+**Fix**: SwiftUI: `@ScaledMetric(relativeTo: .body) var spacing: CGFloat = 20`. UIKit: `UIFontMetrics(forTextStyle: .body).scaledValue(for: 20.0)`
+
+### 5. Color Contrast (HIGH)
+
+**Pattern**: Low contrast text/background combinations
+**Search**: `.foregroundColor(.gray)`, `.foregroundStyle(.secondary)` on small text; custom color definitions with low contrast pairs; missing `accessibilityDifferentiateWithoutColor`
+**Issue**: Text unreadable for low vision users (WCAG 1.4.3 — 4.5:1 for text, 3:1 for large text)
+**Fix**: Use semantic colors, verify contrast ratios, add differentiation without color
+
+### 6. Touch Target Sizes (MEDIUM)
+
+**Pattern**: Interactive elements smaller than 44x44pt
+**Search**: `.frame(` with width or height under 44 on buttons/tappable elements
+**Issue**: Hard to tap for users with motor impairments (WCAG 2.5.5)
+**Fix**: Use `.frame(minWidth: 44, minHeight: 44)` or increase contentShape
+
+### 7. Reduce Motion Support (MEDIUM)
+
+**Pattern**: Animations without Reduce Motion check
+**Search**: `withAnimation` without `isReduceMotionEnabled` check; `.animation(` without motion check
+**Issue**: Causes discomfort for users with vestibular disorders (WCAG 2.3.3)
+**Fix**: Check `UIAccessibility.isReduceMotionEnabled` or use `.animation(.default, value:)` which respects Reduce Motion
+
+### 8. Keyboard Navigation (MEDIUM — iPadOS/macOS)
+
+**Pattern**: Missing keyboard shortcuts and focus management
+**Search**: Missing `.keyboardShortcut` on primary actions; non-focusable interactive elements; missing `.focusable()` on custom controls
+**Issue**: Keyboard-only users can't navigate (iPadOS with external keyboard, macOS)
+**Fix**: Add keyboard shortcuts for primary actions, ensure focus traversal
+
+## Phase 3: Reason About Accessibility Completeness
+
+Using the Accessibility Surface Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are there flows that are completely inaccessible via VoiceOver? (gesture-only interactions without accessibility equivalents) | Inaccessible critical paths | VoiceOver users can't complete core tasks — App Store rejection risk |
+| Are there screens where the only way to perform an action is via a gesture (drag, long press, pinch) with no button alternative? | Gesture-only paths | Users who can't perform gestures (motor impairments, Switch Control) are blocked |
+| Do custom-drawn views (Canvas, UIView with drawRect) expose their content to assistive technologies? | Hidden custom content | Custom rendering is invisible to VoiceOver unless manually exposed |
+| Is there a consistent accessibility pattern across the app, or do some views have labels while others don't? | Inconsistent coverage | Partial accessibility is worse than none — users start trusting VoiceOver then hit a wall |
+| Do modal flows (sheets, alerts, full-screen covers) properly manage VoiceOver focus? | Focus management gaps | VoiceOver focus stays on the background view instead of the presented modal |
+| Are there information-conveying images that are marked as decorative (accessibilityHidden)? | Over-hidden content | Meaningful images hidden from VoiceOver users lose information |
+| Does the app support the full range of Dynamic Type sizes (up to AX5) without layout breakage? | Partial Dynamic Type support | Users at accessibility text sizes get clipped/overlapping content |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Gesture-only interaction | No accessibilityAction | Feature completely inaccessible | CRITICAL |
+| Missing labels on buttons | In critical flow (purchase, auth) | Core transaction inaccessible | CRITICAL |
+| Fixed font sizes | No @ScaledMetric for spacing | Completely ignores Dynamic Type | CRITICAL |
+| Custom font without scaling | In main content area | Primary text doesn't scale | HIGH |
+| Missing Reduce Motion | Looping/auto-play animation | Persistent discomfort trigger | HIGH |
+| Small touch targets | In frequently used controls | Repeated frustration for motor-impaired users | HIGH |
+| Missing labels | In list cells (repeated N times) | Entire list unusable for VoiceOver | HIGH |
+| Inconsistent labeling | Some views labeled, others not | Users can't predict what's accessible | MEDIUM |
+
+Also note overlaps with other auditors:
+- Gesture-only + no accessibilityAction → compound with ux-flow-auditor
+- Missing labels in navigation destinations → compound with swiftui-nav-auditor
+- Dynamic Type + layout issues → compound with swiftui-layout-auditor
+
+## Phase 5: Accessibility Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Accessibility Health Score
+
+| Metric | Value |
+|--------|-------|
+| VoiceOver label coverage | N interactive elements, M with labels (Z%) |
+| Dynamic Type support | Semantic fonts: N, Fixed fonts: M, Scaling coverage: Z% |
+| Gesture accessibility | N gesture-based interactions, M with accessibilityAction equivalents (Z%) |
+| WCAG Level A | N violations |
+| WCAG Level AA | N violations |
+| WCAG Level AAA | N violations |
+| **Health** | **COMPLIANT / GAPS / NON-COMPLIANT** |
+```
+
+Scoring:
+- **COMPLIANT**: No CRITICAL issues, 0 Level A violations, >90% VoiceOver label coverage, all gestures have accessibility equivalents
+- **GAPS**: No CRITICAL issues, but Level A or AA violations present, or 70-90% label coverage, or some gesture-only paths
+- **NON-COMPLIANT**: Any CRITICAL issues, or multiple Level A violations, or <70% label coverage, or critical flows inaccessible
+
+## Output Format
+
+```markdown
+# Accessibility Audit Results
+
+## Accessibility Surface Map
+[8-12 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues (App Store rejection risk)
+- HIGH: [N] issues (Major usability impact)
+- MEDIUM: [N] issues (Moderate usability impact)
+- LOW: [N] issues (Best practices)
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Accessibility Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY/CONFIDENCE] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**WCAG**: [guideline number and level]
+**Issue**: What's wrong or missing
+**Impact**: What users with disabilities experience
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (App Store rejection risk)]
+2. [Short-term — HIGH fixes (WCAG Level A/AA compliance)]
+3. [Long-term — accessibility improvements from Phase 3 findings]
+
+## Testing Checklist
+- [ ] Test with VoiceOver (Cmd+F5 on simulator)
+- [ ] Test with Dynamic Type at AX5 (Settings → Accessibility → Display & Text Size → Larger Text)
+- [ ] Test with Reduce Motion (Settings → Accessibility → Motion → Reduce Motion)
+- [ ] Test with external keyboard on iPad (Tab, arrow keys, Enter)
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Decorative images with `.accessibilityHidden(true)`
+- Spacer views without labels
+- Background images marked as decorative
+- `.swipeActions` on List rows — automatically exposed via VoiceOver Actions rotor
+- `.font(.system(size: variable))` where the variable is `@ScaledMetric`
+- `Image(systemName:)` — auto-generates VoiceOver labels
+- Static/singleton formatters (not in view body)
+- `.animation(.default, value:)` — already respects Reduce Motion system setting
+
+## Related
+
+For comprehensive accessibility debugging: `axiom-accessibility-diag` skill
+For Dynamic Type and typography: `axiom-typography-ref` skill
+For UX flow accessibility: `axiom-ux-flow-audit` skill
diff --git a/.claude/skills/axiom-audit-accessibility/agents/openai.yaml b/.claude/skills/axiom-audit-accessibility/agents/openai.yaml
new file mode 100644
index 0000000..24a0b9a
--- /dev/null
+++ b/.claude/skills/axiom-audit-accessibility/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Accessibility"
+ short_description: "The user mentions accessibility checking, App Store submission, code review, or WCAG compliance."
diff --git a/.claude/skills/axiom-audit-camera/.openskills.json b/.claude/skills/axiom-audit-camera/.openskills.json
new file mode 100644
index 0000000..8535c79
--- /dev/null
+++ b/.claude/skills/axiom-audit-camera/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-camera",
+ "installedAt": "2026-04-12T08:05:49.837Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-camera/SKILL.md b/.claude/skills/axiom-audit-camera/SKILL.md
new file mode 100644
index 0000000..abbeef5
--- /dev/null
+++ b/.claude/skills/axiom-audit-camera/SKILL.md
@@ -0,0 +1,235 @@
+---
+name: axiom-audit-camera
+description: Use this agent to scan Swift code for camera, video, and audio capture issues including deprecated APIs, missing interruption handlers, threading violations, and permission anti-patterns.
+license: MIT
+disable-model-invocation: true
+---
+# Camera & Capture Auditor Agent
+
+You are an expert at detecting camera, video, and audio capture issues in iOS apps that cause freezes, poor UX, App Store rejections, and reliability problems.
+
+## Your Mission
+
+Run a comprehensive camera/capture audit and report all issues with:
+- File:line references with confidence levels
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific fix recommendations
+- Links to relevant skill patterns
+
+## Files to Scan
+
+Look for capture code in:
+- `**/*.swift` - All Swift files
+- Focus on files containing: `AVCaptureSession`, `AVCaptureDevice`, `AVCapturePhotoOutput`, `AVAudioSession`
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## What You Check
+
+### 1. Main Thread Session Work (CRITICAL - UI Freezes)
+
+**Pattern to find**:
+```swift
+// BAD: startRunning on main thread
+session.startRunning() // Without being on session queue
+```
+
+**What to look for**:
+- `startRunning()` or `stopRunning()` not wrapped in `DispatchQueue` async
+- Missing `let sessionQueue = DispatchQueue(label:` pattern
+- Session configuration without dedicated queue
+
+**Fix**: Move all session work to dedicated serial queue
+
+### 2. Deprecated videoOrientation API (HIGH - iOS 17+ Issues)
+
+**Pattern to find**:
+```swift
+// DEPRECATED
+connection.videoOrientation = .portrait
+AVCaptureConnection.videoOrientation
+```
+
+**What to look for**:
+- Any use of `videoOrientation` property
+- Manual device orientation observation for camera
+- Missing `RotationCoordinator`
+
+**Fix**: Use `AVCaptureDevice.RotationCoordinator` (iOS 17+)
+
+### 3. Missing Interruption Handling (HIGH - Camera Freezes)
+
+**Pattern to find**:
+```swift
+// Missing observer for:
+.AVCaptureSessionWasInterrupted
+AVCaptureSession.interruptionEndedNotification
+```
+
+**What to look for**:
+- Files with `AVCaptureSession` but no interruption notification observers
+- No handling for phone calls, multitasking
+- No UI feedback for interrupted state
+
+**Fix**: Add observers for session interruption notifications
+
+### 4. UIImagePickerController for Photo Selection (MEDIUM - Deprecated)
+
+**Pattern to find**:
+```swift
+// DEPRECATED for photo selection
+UIImagePickerController()
+.sourceType = .photoLibrary
+```
+
+**What to look for**:
+- `UIImagePickerController` with `photoLibrary` source type
+- Should use `PHPickerViewController` or `PhotosPicker` instead
+
+**Fix**: Replace with PHPicker (UIKit) or PhotosPicker (SwiftUI)
+
+### 5. Over-Requesting Photo Library Access (MEDIUM - Privacy Issue)
+
+**Pattern to find**:
+```swift
+// BAD: Requesting access just to pick photos
+PHPhotoLibrary.requestAuthorization
+PHPhotoLibrary.authorizationStatus
+// Before showing PHPicker or PhotosPicker
+```
+
+**What to look for**:
+- Permission requests when only using system pickers
+- PHPicker/PhotosPicker don't need library permission
+- Unnecessary privacy prompts
+
+**Fix**: Remove permission requests if only using system pickers
+
+### 6. Missing Photo Quality Settings (MEDIUM - Slow Capture)
+
+**Pattern to find**:
+```swift
+// Missing quality prioritization
+AVCapturePhotoSettings()
+// Without setting photoQualityPrioritization
+```
+
+**What to look for**:
+- `AVCapturePhotoSettings` without `photoQualityPrioritization`
+- Default is often `.quality` which is slow
+- Social/sharing apps should use `.speed` or `.balanced`
+
+**Fix**: Set appropriate `photoQualityPrioritization`
+
+### 7. AVAudioSession Category Mismatch (MEDIUM - Audio Issues)
+
+**Pattern to find**:
+```swift
+// BAD: Wrong category for recording
+.setCategory(.playback) // Can't record with this
+.setCategory(.ambient) // Can't record with this
+```
+
+**What to look for**:
+- Video recording code with non-recording audio category
+- Should use `.playAndRecord` for video with audio
+- Missing category configuration before recording
+
+**Fix**: Set appropriate AVAudioSession category (`.playAndRecord` or `.record`)
+
+### 8. Missing Purpose Strings (CRITICAL - App Store Rejection)
+
+**What to check**:
+- Look for camera/audio usage without corresponding Info.plist keys
+- Required keys:
+ - `NSCameraUsageDescription` - For camera access
+ - `NSMicrophoneUsageDescription` - For audio recording
+ - `NSPhotoLibraryUsageDescription` - For photo library access
+ - `NSPhotoLibraryAddUsageDescription` - For saving photos
+
+**Note**: You may not be able to check Info.plist directly, but flag when camera/audio code exists
+
+### 9. Configuration Without Block (LOW - Race Conditions)
+
+**Pattern to find**:
+```swift
+// BAD: Modifying session without configuration block
+session.addInput(input)
+session.addOutput(output)
+// Without beginConfiguration/commitConfiguration
+```
+
+**What to look for**:
+- `addInput` or `addOutput` without surrounding `beginConfiguration`/`commitConfiguration`
+- Session modifications that could cause race conditions
+
+**Fix**: Wrap session changes in `beginConfiguration()`/`commitConfiguration()`
+
+### 10. Synchronous Photo Loading (LOW - UI Blocking)
+
+**Pattern to find**:
+```swift
+// BAD: Blocking main thread
+try! item.loadTransferable(type:) // Force try, no async
+```
+
+**What to look for**:
+- Non-async Transferable loading
+- `PHImageManager.requestImage` without async handling
+- Image loading on main thread
+
+**Fix**: Use async/await for all image loading
+
+## Output Format
+
+For each issue found:
+
+```
+## [SEVERITY] Issue Title
+
+**File**: `path/to/File.swift:123`
+**Confidence**: HIGH/MEDIUM/LOW
+
+**What was found**:
+```swift
+// The problematic code
+```
+
+**Why it's a problem**:
+Brief explanation of the issue
+
+**Fix**:
+```swift
+// The corrected code
+```
+
+**See**: camera-capture skill, Pattern X
+```
+
+## Summary Section
+
+After listing all issues, provide a summary:
+
+```
+## Audit Summary
+
+- **CRITICAL**: X issues
+- **HIGH**: X issues
+- **MEDIUM**: X issues
+- **LOW**: X issues
+
+**Top priority fixes**:
+1. [Most important issue]
+2. [Second most important]
+3. [Third most important]
+```
+
+## Related Skills
+
+For detailed patterns and solutions, refer developers to:
+- `axiom-camera-capture` - Session setup, rotation, interruption handling
+- `axiom-camera-capture-diag` - Troubleshooting decision trees
+- `axiom-camera-capture-ref` - API reference
+- `axiom-photo-library` - Photo picker and library patterns
diff --git a/.claude/skills/axiom-audit-camera/agents/openai.yaml b/.claude/skills/axiom-audit-camera/agents/openai.yaml
new file mode 100644
index 0000000..a86c680
--- /dev/null
+++ b/.claude/skills/axiom-audit-camera/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Camera"
+ short_description: "Use this agent to scan Swift code for camera, video, and audio capture issues including deprecated APIs, missing inte..."
diff --git a/.claude/skills/axiom-audit-codable/.openskills.json b/.claude/skills/axiom-audit-codable/.openskills.json
new file mode 100644
index 0000000..7b55780
--- /dev/null
+++ b/.claude/skills/axiom-audit-codable/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-codable",
+ "installedAt": "2026-04-12T08:05:49.838Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-codable/SKILL.md b/.claude/skills/axiom-audit-codable/SKILL.md
new file mode 100644
index 0000000..3f14e97
--- /dev/null
+++ b/.claude/skills/axiom-audit-codable/SKILL.md
@@ -0,0 +1,362 @@
+---
+name: axiom-audit-codable
+description: Use when the user mentions Codable review, JSON encoding/decoding issues, data serialization audit, or modernizing legacy code.
+license: MIT
+disable-model-invocation: true
+---
+# Codable Auditor Agent
+
+You are an expert at detecting Codable anti-patterns and JSON serialization issues that cause silent data loss and production bugs.
+
+## Your Mission
+
+Run a comprehensive Codable audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (HIGH/MEDIUM/LOW)
+- Specific issue types (anti-patterns vs configuration issues)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### High-Severity Anti-Patterns
+
+#### 1. Manual JSON String Building (HIGH)
+**Patterns to detect**:
+```swift
+// String interpolation with JSON
+"\"{" or "'{\""
+"\\\"" in string literals
+
+// Common examples:
+let json = "{\"key\": \"\(value)\"}"
+let json = "{ \"name\": \"\(name)\", \"age\": \(age) }"
+```
+
+**Why it's bad**: Injection vulnerabilities, escaping bugs, no type safety
+**Impact**: Production crashes, security vulnerabilities, data corruption
+
+**Fix recommendation**:
+```swift
+// ❌ Manual string building
+let json = "{\"name\": \"\(user.name)\", \"id\": \(user.id)}"
+
+// ✅ Use JSONEncoder
+struct UserPayload: Codable {
+ let name: String
+ let id: Int
+}
+let data = try JSONEncoder().encode(UserPayload(name: user.name, id: user.id))
+```
+
+#### 2. try? Swallowing DecodingError (HIGH)
+**Patterns to detect**:
+```swift
+"try? JSONDecoder"
+"try? decoder.decode"
+"try? JSONEncoder"
+"try? encoder.encode"
+```
+
+**Why it's bad**: Silent failures, debugging nightmares, data loss
+**Impact**: Users lose data without knowing, impossible to debug in production
+
+**Fix recommendation**:
+```swift
+// ❌ Silent failure
+let user = try? JSONDecoder().decode(User.self, from: data)
+
+// ✅ Explicit error handling
+do {
+ let user = try JSONDecoder().decode(User.self, from: data)
+} catch DecodingError.keyNotFound(let key, let context) {
+ logger.error("Missing key '\(key)' at path: \(context.codingPath)")
+} catch {
+ logger.error("Failed to decode User: \(error)")
+}
+```
+
+#### 3. String Interpolation in JSON (HIGH)
+**Patterns to detect**:
+```swift
+// String interpolation with \(
+"\\\(.*\)" in context with { or }
+
+// Common patterns:
+"\\(variable)"
+```
+
+**Why it's bad**: Escaping issues, injection, breaks on special characters
+**Impact**: Production crashes when names contain quotes or backslashes
+
+**Fix recommendation**: Use Codable types with JSONEncoder
+
+### Medium-Severity Issues
+
+#### 4. JSONSerialization Instead of Codable (MEDIUM)
+**Patterns to detect**:
+```swift
+"JSONSerialization.jsonObject"
+"JSONSerialization.data"
+"NSJSONSerialization"
+```
+
+**Why it's bad**: Legacy pattern, manual type casting, error-prone
+**Impact**: 3x more boilerplate, no type safety, harder to maintain
+
+**Fix recommendation**:
+```swift
+// ❌ JSONSerialization
+let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+let name = json?["name"] as? String
+
+// ✅ Codable
+struct User: Codable {
+ let name: String
+}
+let user = try JSONDecoder().decode(User.self, from: data)
+```
+
+#### 5. Date Without Explicit Strategy (MEDIUM)
+**Patterns to detect**:
+```swift
+// Date property in Codable type
+struct.*:.*Codable.*\n.*Date
+
+// But no dateDecodingStrategy configuration in the file
+// (check if file contains JSONDecoder but no dateDecodingStrategy)
+```
+
+**Why it's bad**: Timezone bugs, intermittent failures across regions
+**Impact**: Data corruption, bugs only appear for users in different timezones
+
+**Fix recommendation**:
+```swift
+// ❌ No strategy configured
+let decoder = JSONDecoder()
+let user = try decoder.decode(User.self, from: data)
+
+// ✅ Explicit strategy
+let decoder = JSONDecoder()
+decoder.dateDecodingStrategy = .iso8601 // Or .secondsSince1970, etc.
+let user = try decoder.decode(User.self, from: data)
+```
+
+#### 6. DateFormatter Without Locale/Timezone (MEDIUM)
+**Patterns to detect**:
+```swift
+"DateFormatter()" without "locale" or "timeZone" in nearby lines
+"DateFormatter.dateFormat" without "locale"
+```
+
+**Why it's bad**: Locale-dependent parsing failures
+**Impact**: App breaks for users with non-US locale settings
+
+**Fix recommendation**:
+```swift
+// ❌ No locale/timezone
+let formatter = DateFormatter()
+formatter.dateFormat = "yyyy-MM-dd"
+
+// ✅ With locale and timezone
+let formatter = DateFormatter()
+formatter.dateFormat = "yyyy-MM-dd"
+formatter.locale = Locale(identifier: "en_US_POSIX")
+formatter.timeZone = TimeZone(secondsFromGMT: 0)
+```
+
+#### 7. Optional Properties to Avoid Decode Errors (MEDIUM)
+**Pattern**: Look for optional properties with comments mentioning "decode", "fail", "error", "crash"
+
+**Why it's bad**: Masks structural problems, runtime crashes, nil checks everywhere
+**Impact**: Field is required but marked optional, leads to crashes later
+
+**Fix recommendation**:
+```swift
+// ❌ Optional to avoid decode errors
+struct User: Codable {
+ let id: UUID
+ let email: String? // Made optional because decoding was failing
+}
+
+// ✅ Fix root cause
+// 1. Check if API structure changed (nested? renamed?)
+// 2. Use CodingKeys to map to correct key
+// 3. Use DecodableWithConfiguration if data comes from elsewhere
+```
+
+### Low-Severity Issues
+
+#### 8. No Error Context in Catch Blocks (LOW)
+**Patterns to detect**:
+```swift
+catch {
+ print("Failed") // No error variable
+}
+```
+
+**Why it's bad**: No debugging information when things fail
+**Impact**: Cannot diagnose production issues
+
+**Fix recommendation**:
+```swift
+// ❌ No context
+catch {
+ print("Failed to decode")
+}
+
+// ✅ Include error
+catch {
+ print("Failed to decode: \(error)")
+ // Or use structured logging
+ logger.error("Decode failed", error: error)
+}
+```
+
+## Audit Workflow
+
+### Step 1: Find Swift Files
+
+```
+Use Glob: **/*.swift (apply Skip exclusions above)
+```
+
+### Step 2: Scan for Anti-Patterns
+
+For each severity level:
+
+**HIGH severity (fail fast)**:
+1. Manual JSON building: `"\"{"`
+2. try? with decoder: `"try? JSONDecoder"`, `"try? decoder.decode"`
+3. String interpolation in JSON context
+
+**MEDIUM severity**:
+1. JSONSerialization: `"JSONSerialization"`, `"NSJSONSerialization"`
+2. Date properties without strategy
+3. DateFormatter without locale
+4. Suspicious optionals (grep for comments mentioning decode/fail/error near optional Date/String properties)
+
+**LOW severity**:
+1. Empty catch blocks or print-only error handling
+
+### Step 3: Read Context
+
+For each match:
+1. Read the file with context (-B 5 -A 5)
+2. Determine if it's a true positive
+3. Identify the specific issue type
+4. Formulate fix recommendation
+
+### Step 4: Generate Report
+
+Format output as:
+
+```markdown
+# Codable Audit Results
+
+## Summary
+- Files scanned: [X]
+- Total issues: [Y]
+ - HIGH: [Z]
+ - MEDIUM: [A]
+ - LOW: [B]
+
+## 🔴 High Priority Issues ([count])
+
+### Manual JSON String Building
+- **file/path.swift:45** - Building JSON with string interpolation
+ ```swift
+ let json = "{\"key\": \"\(value)\"}"
+ ```
+ **Fix**: Use JSONEncoder with Codable type
+ **Impact**: Injection vulnerabilities, escaping bugs
+
+### try? Swallowing Errors
+- **file/path.swift:89** - Silent decode failure with try?
+ ```swift
+ let user = try? decoder.decode(User.self, from: data)
+ ```
+ **Fix**: Handle DecodingError cases explicitly
+ **Impact**: Silent data loss, impossible to debug
+
+## 🟡 Medium Priority Issues ([count])
+
+### JSONSerialization Usage
+- **file/path.swift:112** - Using legacy JSONSerialization
+ **Fix**: Migrate to Codable
+ **Time saved**: Reduce boilerplate by 60%
+
+### Date Handling
+- **file/path.swift:134** - Date property without explicit strategy
+ **Fix**: Set decoder.dateDecodingStrategy = .iso8601
+ **Impact**: Prevents timezone bugs
+
+## 🟢 Low Priority Issues ([count])
+
+[List issues with file:line and brief description]
+
+## Recommendations
+
+1. **Immediate**: Fix all HIGH severity issues (silent failures, injection risks)
+2. **This sprint**: Address MEDIUM severity (technical debt, potential bugs)
+3. **Backlog**: Clean up LOW severity (code quality improvements)
+
+## Quick Wins
+
+[List 2-3 most impactful fixes that take <10 minutes each]
+```
+
+## Audit Guidelines
+
+1. Focus on true positives - explain why including/excluding patterns in comments or tests
+2. Provide context by showing surrounding code in reports
+3. Give actionable fixes - show the correct pattern, not just "fix this"
+4. Prioritize HIGH severity issues first - these cause production data loss
+5. Be helpful with try? - suggest which DecodingError cases to handle
+
+## Common False Positives
+
+1. **String interpolation in logging**: `logger.debug("{...}")` - OK, not building actual JSON
+2. **JSON in comments or documentation**: Ignore
+3. **Test fixtures**: String JSON for test data is acceptable (but note it)
+4. **try? for optional decoding**: If the optional is intentional, it's OK (but verify)
+
+## If No Issues Found
+
+```markdown
+# Codable Audit Results
+
+✅ **No issues found**
+
+Your codebase follows Codable best practices:
+- No manual JSON string building
+- Proper error handling (no try? swallowing errors)
+- Using Codable instead of JSONSerialization
+- [Any other positive findings]
+
+Keep up the good work!
+```
+
+## Your Tone
+
+- **Direct but helpful**: "This pattern causes silent data loss" not "This might be a problem"
+- **Evidence-based**: Show the code, explain the impact
+- **Action-oriented**: Always provide the fix
+- **Respectful**: Acknowledge when patterns are edge cases or acceptable tradeoffs
+
+Good luck! Be thorough but concise.
diff --git a/.claude/skills/axiom-audit-codable/agents/openai.yaml b/.claude/skills/axiom-audit-codable/agents/openai.yaml
new file mode 100644
index 0000000..b8f3628
--- /dev/null
+++ b/.claude/skills/axiom-audit-codable/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Codable"
+ short_description: "The user mentions Codable review, JSON encoding/decoding issues, data serialization audit, or modernizing legacy code."
diff --git a/.claude/skills/axiom-audit-concurrency/.openskills.json b/.claude/skills/axiom-audit-concurrency/.openskills.json
new file mode 100644
index 0000000..6af1d44
--- /dev/null
+++ b/.claude/skills/axiom-audit-concurrency/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-concurrency",
+ "installedAt": "2026-04-12T08:05:49.839Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-concurrency/SKILL.md b/.claude/skills/axiom-audit-concurrency/SKILL.md
new file mode 100644
index 0000000..cefb914
--- /dev/null
+++ b/.claude/skills/axiom-audit-concurrency/SKILL.md
@@ -0,0 +1,240 @@
+---
+name: axiom-audit-concurrency
+description: Use when the user mentions concurrency checking, Swift 6 compliance, data race prevention, or async code review.
+license: MIT
+disable-model-invocation: true
+---
+# Concurrency Auditor Agent
+
+You are an expert at detecting Swift 6 concurrency issues — both known anti-patterns AND missing/incomplete patterns that cause data races, UI freezes, and resource leaks.
+
+## Your Mission
+
+Run a comprehensive concurrency audit using 5 phases: map the isolation architecture, detect known anti-patterns, reason about what's missing, correlate compound issues, and score readiness. Report all issues with:
+- File:line references
+- Severity/Confidence ratings (e.g., CRITICAL/HIGH, HIGH/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map Isolation Architecture
+
+Before grepping, build a mental model of the codebase's concurrency architecture.
+
+### Step 1: Identify Isolation Boundaries
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `actor ` declarations — which types are actors
+ - `@MainActor` — which types/functions are MainActor-isolated
+ - `@concurrent` — which functions opt into background execution
+ - `nonisolated` — which functions explicitly opt out of isolation
+```
+
+### Step 2: Identify Concurrency Entry Points
+
+```
+Grep for:
+ - `.task {`, `.task(id:` — SwiftUI task modifiers
+ - `Task {`, `Task.detached` — unstructured task creation
+ - `async let` — structured child tasks
+ - `TaskGroup`, `withTaskGroup`, `withThrowingTaskGroup` — structured parallel work
+ - `AsyncStream`, `AsyncThrowingStream`, `for await` — async sequences
+```
+
+### Step 3: Identify Default Isolation Strategy
+
+Read 2-3 key files (App entry point, main view model, a networking layer file) to understand:
+- Is this a MainActor-by-default codebase or per-type isolation?
+- Where are the actor boundaries? (types that communicate across isolation domains)
+- What's the cancellation strategy? (stored Tasks, cleanup in deinit/onDisappear)
+
+### Output
+
+Write a brief **Isolation Architecture Map** (5-10 lines) summarizing:
+- Default isolation strategy
+- Actor boundary locations
+- Concurrency entry point pattern (structured vs unstructured)
+- Cancellation approach
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 8 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Missing @MainActor on UI Classes (CRITICAL/HIGH)
+
+**Pattern**: UIViewController, UIView, ObservableObject without @MainActor
+**Search**: `class.*UIViewController`, `class.*ObservableObject` — check 5 lines before for @MainActor
+**Issue**: Crashes when UI modified from background threads
+**Fix**: Add `@MainActor` to class declaration
+**Note**: SwiftUI Views are implicitly @MainActor — not an issue
+
+### 2. Unsafe Task Self Capture (HIGH/HIGH)
+
+**Pattern**: `Task { self.property }` without `[weak self]` in a class
+**Search**: `Task\s*\{` then check for `self.` without `[weak self]`
+**Issue**: Strong capture extends object lifetime for the Task's duration. For fire-and-forget Tasks this is temporary; for stored Tasks it's a retain cycle (see Pattern 6).
+**Fix**: Use `Task { [weak self] in ... }`
+**Note**: Only applies to class types — struct self capture is fine. For stored Tasks (`var task: Task<...>?`), Pattern 6 covers the retain cycle case specifically.
+
+### 3. Unsafe Delegate Callback Pattern (CRITICAL/HIGH)
+
+**Pattern**: `nonisolated func` with `Task { self.property }` inside
+**Search**: `nonisolated func` — Read context, check for Task containing `self.`
+**Issue**: "Sending 'self' risks causing data races" in Swift 6
+**Fix**: Capture values before Task, use captured values inside
+
+### 4. Sendable Violations (HIGH/LOW)
+
+**Pattern**: Non-Sendable types across actor boundaries
+**Search**: `@Sendable`, `: Sendable` patterns
+**Issue**: Data races
+**Note**: High false positive rate — compiler is more reliable. Flag but defer to `-strict-concurrency=complete`.
+
+### 5. Actor Isolation Problems (MEDIUM/MEDIUM)
+
+**Pattern**: Actor property accessed without await
+**Search**: `actor\s+` declarations — requires code reading for context
+**Issue**: Compiler errors in Swift 6 strict mode
+**Fix**: Add `await` or restructure
+
+### 6. Missing Weak Self in Stored Tasks (MEDIUM/HIGH)
+
+**Pattern**: `var task: Task<...>? = Task { self.method() }`
+**Search**: `var.*Task<` — check for weak capture
+**Issue**: Retain cycles in long-running tasks
+**Fix**: Use `[weak self]` capture
+
+### 7. Missing @concurrent on CPU Work (MEDIUM/MEDIUM)
+
+**Pattern**: Image/video processing, parsing, heavy computation without `@concurrent` (Swift 6.2+)
+**Search**: Functions with CPU-heavy keywords (process, parse, encode, decode, compress, render) that are async but lack `@concurrent`. Read the function body to confirm significant computation before flagging — name matching alone produces false positives.
+**Issue**: Blocks cooperative thread pool, starving other async work
+**Fix**: Add `@concurrent` attribute
+
+### 8. Thread Confinement Violations (HIGH/HIGH)
+
+**Pattern**: @MainActor properties accessed from `Task.detached`
+**Search**: `Task\.detached` — Read context for @MainActor access
+**Issue**: Crashes or data corruption
+**Fix**: Use `await MainActor.run { }`
+
+## Phase 3: Reason About Concurrency Completeness
+
+Using the Isolation Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are there unstructured `Task {}` in loops where TaskGroup would be better? | Missing structured concurrency | Unstructured Tasks in loops have no backpressure, can spawn unbounded work |
+| Do async functions assume they run on background when they actually inherit the calling actor? | async ≠ background misconception | Common cause of UI freezes — async functions stay on MainActor unless explicitly moved off |
+| Is there GCD usage (`DispatchQueue`, `DispatchGroup`) alongside modern async/await? | Legacy bridge patterns in new code | Mixing GCD and actors for the same state creates incoherent isolation |
+| Do stored Tasks have cleanup in deinit or onDisappear? | Missing cancellation | Zombie Tasks continue running after the owning object is gone |
+| Are `@unchecked Sendable`, `@preconcurrency`, `nonisolated(unsafe)` used without migration comments? | Permanent escape hatches | These should be temporary bridges, not permanent fixtures |
+| Is there CPU-intensive work in async functions without `@concurrent`? | Missing background offload | Starves the cooperative thread pool |
+| Do async sequences (`for await`) have proper cancellation and cleanup? | Missing lifecycle management | Infinite sequences retain their consuming Task forever |
+| Is the isolation architecture consistent? (e.g., mixing actors and GCD for the same state) | Incoherent concurrency strategy | Two concurrency models protecting the same state = neither works |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Unstructured Tasks in loops | No error handling in those Tasks | Silent failures at scale | CRITICAL |
+| Missing @concurrent on CPU work | @MainActor caller | UI freeze | CRITICAL |
+| Stored Tasks without deinit cleanup | No cancellation on view disappear | Resource leak + zombie work | HIGH |
+| @unchecked Sendable | Mutable state without lock | Hidden data race | CRITICAL |
+| GCD usage | Also using actors for same state | Incoherent isolation | HIGH |
+| async ≠ background misconception | Heavy computation in async func | Main thread stall | CRITICAL |
+| nonisolated(unsafe) | Accessed from multiple Tasks | Unprotected shared state | CRITICAL |
+
+Also note overlaps with other auditors:
+- Missing cancellation + no deinit → compound with memory auditor
+- @MainActor missing + UI class → compound with SwiftUI performance
+- Sendable violation + networking layer → compound with networking auditor
+
+## Phase 5: Concurrency Health Score
+
+Calculate and present a readiness score:
+
+```markdown
+## Concurrency Health Score
+
+| Metric | Value |
+|--------|-------|
+| Isolation coverage | X% of types have explicit isolation (@MainActor, actor, nonisolated) |
+| Structured concurrency | X% of parallel work uses TaskGroup/async let vs unstructured Task |
+| Escape hatches | N @unchecked Sendable, N @preconcurrency, N nonisolated(unsafe) |
+| Cancellation coverage | X% of stored Tasks have cleanup |
+| GCD legacy | N DispatchQueue usages remaining |
+| **Readiness** | **READY / NEEDS WORK / NOT READY** |
+```
+
+Scoring:
+- **READY**: No CRITICAL issues, <3 HIGH issues, >80% isolation coverage, 0 escape hatches
+- **NEEDS WORK**: No CRITICAL issues, some HIGH issues, or escape hatches with migration comments
+- **NOT READY**: Any CRITICAL issues, or escape hatches without migration plan
+
+## Output Format
+
+```markdown
+# Swift Concurrency Audit Results
+
+## Isolation Architecture Map
+[5-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- Phase 2 (pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Concurrency Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY/CONFIDENCE] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What happens if not fixed
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes]
+2. [Short-term — HIGH fixes and escape hatch migration]
+3. [Long-term — architectural improvements from Phase 3 findings]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Actor classes (already thread-safe)
+- Structs with immutable properties (implicitly Sendable)
+- Async functions with minimal computation (a single network call, a short string format) — don't flag for missing @concurrent
+- @MainActor classes accessing their own properties
+- SwiftUI Views (implicitly @MainActor)
+- Task captures where self is a struct (value type)
+- `@unchecked Sendable` with clear migration comment (downgrade to LOW)
+- GCD usage in legacy modules marked for future migration
+
+## Related
+
+For detailed concurrency patterns: `axiom-swift-concurrency` skill
+For migration guidance: Enable `-strict-concurrency=complete` and fix warnings
+For memory lifecycle issues found during audit: `axiom-memory-debugging` skill
diff --git a/.claude/skills/axiom-audit-concurrency/agents/openai.yaml b/.claude/skills/axiom-audit-concurrency/agents/openai.yaml
new file mode 100644
index 0000000..d46ebaf
--- /dev/null
+++ b/.claude/skills/axiom-audit-concurrency/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Concurrency"
+ short_description: "The user mentions concurrency checking, Swift 6 compliance, data race prevention, or async code review."
diff --git a/.claude/skills/axiom-audit-core-data/.openskills.json b/.claude/skills/axiom-audit-core-data/.openskills.json
new file mode 100644
index 0000000..585ca62
--- /dev/null
+++ b/.claude/skills/axiom-audit-core-data/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-core-data",
+ "installedAt": "2026-04-12T08:05:49.840Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-core-data/SKILL.md b/.claude/skills/axiom-audit-core-data/SKILL.md
new file mode 100644
index 0000000..e576cf7
--- /dev/null
+++ b/.claude/skills/axiom-audit-core-data/SKILL.md
@@ -0,0 +1,407 @@
+---
+name: axiom-audit-core-data
+description: Use when the user mentions Core Data review, schema migration, production crashes, or data safety checking.
+license: MIT
+disable-model-invocation: true
+---
+# Core Data Auditor Agent
+
+You are an expert at detecting Core Data safety violations that cause production crashes and permanent data loss.
+
+## Your Mission
+
+Run a comprehensive Core Data safety audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific violation types
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. Schema Migration Safety (CRITICAL)
+**Pattern**: Missing `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption`
+**Issue**: 100% of users crash on app launch when schema changes
+**Fix**: Add lightweight migration options to store configuration
+
+### 2. Thread-Confinement Violations (CRITICAL)
+**Pattern**: NSManagedObject accessed outside `perform/performAndWait`
+**Issue**: Production crashes when objects accessed from wrong threads
+**Fix**: Use `perform` or `performAndWait` for all context access
+
+### 3. N+1 Query Patterns (MEDIUM)
+**Pattern**: Relationship access inside loops without prefetching
+**Issue**: 1000 items = 1000 extra database queries, 30x slower
+**Fix**: Use `relationshipKeyPathsForPrefetching` before fetch
+
+### 4. Production Risk Patterns (CRITICAL)
+**Pattern**: Hard-coded store deletion, `try!` on migration
+**Issue**: Permanent data loss for all users
+**Fix**: Remove delete patterns, add proper error handling
+
+### 5. Performance Issues (LOW)
+**Pattern**: Missing `fetchBatchSize`, no faulting controls
+**Issue**: Higher memory usage with large result sets
+**Fix**: Add `fetchBatchSize = 20` to fetch requests
+
+## Audit Process
+
+### Step 1: Find All Core Data Files
+
+Use Glob tool to find files:
+- Swift files: `**/*.swift`
+- Core Data models: `**/*.xcdatamodeld`
+
+### Step 2: Search for Safety Violations
+
+**Schema Migration Safety**:
+```bash
+# Find persistent store coordinator usage
+grep -rn "NSPersistentStoreCoordinator" --include="*.swift"
+grep -rn "addPersistentStore" --include="*.swift"
+
+# Check for migration options (should match coordinator count)
+grep -rn "NSMigratePersistentStoresAutomaticallyOption" --include="*.swift"
+grep -rn "NSInferMappingModelAutomaticallyOption" --include="*.swift"
+
+# Find dangerous store deletion
+grep -rn "FileManager.*removeItem.*storeURL" --include="*.swift"
+grep -rn "FileManager.*removeItem.*persistent" --include="*.swift"
+```
+
+**Thread-Confinement Violations**:
+```bash
+# Find DispatchQueue usage with managed objects
+grep -rn "DispatchQueue.*NSManagedObject" --include="*.swift"
+grep -rn "Task.*NSManagedObject" --include="*.swift"
+
+# Find async/await usage with managed objects (Swift 5.5+)
+grep -rn "async.*NSManagedObject" --include="*.swift"
+grep -rn "await.*\.save\(\)" --include="*.swift" | grep -v "perform"
+
+# Check for proper context usage (should be frequent)
+grep -rn "\.perform\s*{" --include="*.swift"
+grep -rn "\.performAndWait" --include="*.swift"
+
+# Check for Swift Concurrency context access (iOS 15+)
+grep -rn "context\.perform.*async" --include="*.swift"
+```
+
+**N+1 Query Patterns**:
+```bash
+# Find relationship access in loops (more comprehensive)
+grep -rn "for.*in.*\." --include="*.swift" -A 3 | grep -E "\..*\?\..*|\..*\..*"
+
+# Find fetch requests followed by loops without prefetching
+grep -rn "NSFetchRequest" --include="*.swift" -A 10 | grep "for.*in"
+
+# Check for prefetching (should match fetch requests with loops)
+grep -rn "relationshipKeyPathsForPrefetching" --include="*.swift"
+
+# Check for batch faulting as alternative
+grep -rn "\.propertiesToFetch" --include="*.swift"
+```
+
+**Production Risk Patterns**:
+```bash
+# Find forced unwrapping/try! in Core Data
+grep -rn "try!\s*.*addPersistentStore" --include="*.swift"
+grep -rn "try!\s*.*coordinator" --include="*.swift"
+grep -rn "try!\s*.*context\.save" --include="*.swift"
+
+# Find store deletion patterns
+grep -rn "removeItem.*persistent" --include="*.swift"
+
+# Find saveContext without error handling
+grep -rn "func saveContext" --include="*.swift" -A 10 | grep -v "catch"
+grep -rn "context\.save\(\)" --include="*.swift" | grep -v "try" | grep -v "throws"
+```
+
+**Performance Issues**:
+```bash
+# Find fetch requests
+grep -rn "NSFetchRequest" --include="*.swift"
+
+# Check for batch size usage (should match fetch requests)
+grep -rn "fetchBatchSize" --include="*.swift"
+
+# Check for faulting controls
+grep -rn "returnsObjectsAsFaults" --include="*.swift"
+```
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Guaranteed crash or data loss):
+- Missing lightweight migration options
+- Thread-confinement violations
+- Hard-coded store deletion
+- `try!` on migration operations
+
+**MEDIUM** (Performance degradation):
+- N+1 query patterns in loops
+- Missing relationship prefetching
+
+**LOW** (Memory pressure):
+- Missing fetchBatchSize
+- No faulting controls
+
+## Output Format
+
+```markdown
+# Core Data Safety Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Crash/data loss risk)
+- **MEDIUM Issues**: [count] (Performance degradation)
+- **LOW Issues**: [count] (Memory pressure)
+
+## Risk Score: [0-10]
+(Each CRITICAL = +3 points, MEDIUM = +1 point, LOW = +0.5 points)
+
+## CRITICAL Issues
+
+### Missing Lightweight Migration Options
+- `AppDelegate.swift:45` - NSPersistentStoreCoordinator without migration options
+ - **Risk**: 100% crash rate on schema change with error "The model used to open the store is incompatible with the one used to create the store"
+ - **Fix**: Add migration options to store configuration
+ ```swift
+ let options = [
+ NSMigratePersistentStoresAutomaticallyOption: true,
+ NSInferMappingModelAutomaticallyOption: true
+ ]
+ try coordinator.addPersistentStore(
+ ofType: NSSQLiteStoreType,
+ configurationName: nil,
+ at: storeURL,
+ options: options // ✅ Enables automatic lightweight migration
+ )
+ ```
+
+### Thread-Confinement Violations
+- `DataManager.swift:67` - NSManagedObject accessed from DispatchQueue.global()
+ - **Risk**: Production crash with "NSManagedObject accessed from wrong thread"
+ - **Fix**: Use backgroundContext.perform { }
+ ```swift
+ // ❌ DANGER
+ DispatchQueue.global().async {
+ let user = context.object(with: objectID) as! User
+ print(user.name) // Thread-confinement violation!
+ }
+
+ // ✅ SAFE
+ backgroundContext.perform {
+ let user = backgroundContext.object(with: objectID) as! User
+ print(user.name) // Safe - on correct thread
+ }
+ ```
+
+### Hard-Coded Store Deletion
+- `SetupManager.swift:89` - FileManager.removeItem(storeURL) in production code path
+ - **Risk**: Permanent data loss for all users who hit this code path
+ - **Typical scenario**: 10,000 users → 10,000 uninstalls + 1-star reviews
+ - **Fix**: Remove or gate behind debug flag
+ ```swift
+ // Option 1: Remove entirely
+ // Deleted: try? FileManager.default.removeItem(at: storeURL)
+
+ // Option 2: Debug-only
+ #if DEBUG
+ try? FileManager.default.removeItem(at: storeURL)
+ #endif
+ ```
+
+### Forced Try on Migration
+- `PersistenceController.swift:123` - try! coordinator.addPersistentStore(...)
+ - **Risk**: App crashes immediately on launch if migration fails
+ - **Fix**: Add proper error handling
+ ```swift
+ // ❌ DANGER
+ try! coordinator.addPersistentStore(...) // Crashes if migration fails
+
+ // ✅ SAFE
+ do {
+ try coordinator.addPersistentStore(
+ ofType: NSSQLiteStoreType,
+ configurationName: nil,
+ at: storeURL,
+ options: migrationOptions
+ )
+ } catch {
+ // Log error, show user message, attempt recovery
+ handleMigrationFailure(error)
+ }
+ ```
+
+## MEDIUM Issues
+
+### N+1 Query Pattern
+- `UserListView.swift:89` - Accessing user.posts in loop without prefetching
+ - **Impact**: 1000 users = 1000 extra queries, 30x slower
+ - **Fix**: Prefetch relationships before loop
+ ```swift
+ // ❌ N+1 PROBLEM
+ for user in users {
+ print(user.posts.count) // Fires 1 query per user!
+ }
+
+ // ✅ SOLUTION
+ fetchRequest.relationshipKeyPathsForPrefetching = ["posts"]
+ let users = try context.fetch(fetchRequest)
+ for user in users {
+ print(user.posts.count) // No extra queries!
+ }
+ ```
+
+- `DataSync.swift:201` - Accessing relationships in sync loop
+ - **Impact**: Sync takes 30 seconds instead of 3 seconds
+ - **Fix**: Same as above - prefetch relationships
+
+## LOW Issues
+
+### Missing Fetch Batch Size
+- `FetchController.swift:45` - NSFetchRequest without fetchBatchSize
+ - **Impact**: Higher memory usage with large result sets (10,000 objects loaded at once)
+ - **Fix**: Add batch size
+ ```swift
+ fetchRequest.fetchBatchSize = 20
+ // Loads 20 at a time - lower memory usage
+ ```
+
+## Next Steps
+
+1. **Fix CRITICAL issues immediately** - Production crash and data loss risk
+2. **Fix MEDIUM issues in next sprint** - Performance degradation
+3. **Test migration on real device** with production data copy
+4. **Add Core Data unit tests** for migration safety
+
+## Testing Recommendations
+
+After fixes:
+```bash
+# Test migration safety
+1. Install current version on device
+2. Add test data
+3. Build new version with schema change
+4. Install new version
+5. Verify: App launches + data intact
+
+# Test thread-confinement
+1. Enable Thread Sanitizer in scheme
+2. Run app with extensive Core Data usage
+3. Check console for thread-confinement warnings
+
+# Test N+1 queries
+1. Add logging to fetch requests
+2. Run UI with 1000+ items
+3. Count queries - should be minimal
+```
+
+## For Detailed Diagnosis
+
+Use `/skill axiom-core-data-diag` for:
+- Comprehensive Core Data diagnostics
+- Production crisis defense scenarios
+- Safe migration patterns
+- Schema change workflows
+```
+
+## Audit Guidelines
+
+1. Run all 5 pattern searches for comprehensive coverage
+2. Provide file:line references to make issues easy to locate
+3. Show exact fixes with code examples for each issue
+4. Categorize by severity to help prioritize fixes
+5. Calculate risk score to quantify overall safety level
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize crash risk and data loss
+- Recommend fixing before production release
+- Provide explicit error handling code examples
+- Calculate time to fix (usually 5-20 minutes per issue)
+
+If NO issues found:
+- Report "No Core Data safety violations detected"
+- Note that runtime testing is still recommended
+- Suggest migration testing checklist
+
+## False Positives
+
+These are acceptable (not issues):
+- Store deletion behind `#if DEBUG` flag
+- One-time migration scripts (not in production code)
+- Background context access with proper `perform` blocks
+- Small loops (< 10 iterations) may not need prefetching
+
+## Risk Score Calculation
+
+- Each 🔴 CRITICAL issue: +3 points
+- Each 🟡 MEDIUM issue: +1 point
+- Each 🟢 LOW issue: +0.5 points
+- Maximum score: 10
+
+**Interpretation**:
+- 0-2: Low risk, production-ready
+- 3-5: Medium risk, fix before release
+- 6-8: High risk, must fix immediately
+- 9-10: Critical risk, do not ship
+
+## Common Findings
+
+From auditing 100+ production codebases:
+1. **60% missing lightweight migration options** (most common)
+2. **40% have N+1 query patterns** (second most common)
+3. **20% have thread-confinement violations** (most dangerous)
+4. **10% have hard-coded store deletion** (data loss risk)
+
+## Testing Scenarios
+
+After fixes, test these scenarios:
+```
+1. Schema Migration
+ - Add new Core Data attribute
+ - Build and run on device with existing data
+ - Verify: App launches + data migrates + new attribute works
+
+2. Thread-Safety
+ - Enable Thread Sanitizer
+ - Use Core Data from background queues
+ - Verify: No thread-confinement warnings
+
+3. Performance
+ - Load 1000+ items in list
+ - Scroll through all items
+ - Verify: < 10 database queries total (not 1000+)
+
+4. Production Simulation
+ - Test on real device (not simulator)
+ - Use production data size (1000+ records)
+ - Monitor memory usage and query count
+```
+
+## Summary
+
+This audit scans for:
+- **5 categories** covering 90% of Core Data production issues
+- **3 CRITICAL patterns** that cause crashes or data loss
+- **2 MEDIUM patterns** that cause performance degradation
+
+**Fix time**: Most issues take 5-20 minutes each. Total audit + fixes typically < 2 hours.
+
+**When to run**: Before every App Store submission, after schema changes, or quarterly for technical debt tracking.
diff --git a/.claude/skills/axiom-audit-core-data/agents/openai.yaml b/.claude/skills/axiom-audit-core-data/agents/openai.yaml
new file mode 100644
index 0000000..c158cc4
--- /dev/null
+++ b/.claude/skills/axiom-audit-core-data/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Core Data"
+ short_description: "The user mentions Core Data review, schema migration, production crashes, or data safety checking."
diff --git a/.claude/skills/axiom-audit-database-schema/.openskills.json b/.claude/skills/axiom-audit-database-schema/.openskills.json
new file mode 100644
index 0000000..95d7d38
--- /dev/null
+++ b/.claude/skills/axiom-audit-database-schema/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-database-schema",
+ "installedAt": "2026-04-12T08:05:49.841Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-database-schema/SKILL.md b/.claude/skills/axiom-audit-database-schema/SKILL.md
new file mode 100644
index 0000000..bb38104
--- /dev/null
+++ b/.claude/skills/axiom-audit-database-schema/SKILL.md
@@ -0,0 +1,286 @@
+---
+name: axiom-audit-database-schema
+description: Use when the user mentions database schema review, migration safety, GRDB migration audit, or SQLite schema checking.
+license: MIT
+disable-model-invocation: true
+---
+# Database Schema Auditor Agent
+
+You are an expert at detecting database schema and migration violations that cause data loss, crashes, and silent corruption in SQLite/GRDB apps.
+
+## Your Mission
+
+Run a comprehensive database schema audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific violation types
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. ADD COLUMN NOT NULL Without DEFAULT (CRITICAL)
+**Pattern**: `ADD COLUMN ... NOT NULL` without a `DEFAULT` clause
+**Issue**: SQLite requires a DEFAULT for NOT NULL columns added to existing tables. Without it, the migration crashes for any table with existing rows — guaranteed data loss or app crash on update.
+**Fix**: Always add `DEFAULT` when adding NOT NULL columns: `ADD COLUMN name TEXT NOT NULL DEFAULT ''`
+
+### 2. DROP TABLE on User Data (CRITICAL)
+**Pattern**: `DROP TABLE` in migration code
+**Issue**: Dropping a table permanently deletes all user data in that table. There is no undo.
+**Fix**: Rename table instead of dropping, or migrate data to a new table first. If intentional, add a comment explaining why.
+
+### 3. DROP COLUMN (SQLite Unsupported Before 3.35.0) (CRITICAL)
+**Pattern**: `DROP COLUMN` in migration code
+**Issue**: SQLite only supports DROP COLUMN since version 3.35.0 (iOS 16+). On older iOS versions, this crashes the migration. Even on supported versions, it has restrictions (can't drop PRIMARY KEY, UNIQUE, or referenced columns).
+**Fix**: Use the 12-step table recreation pattern: create new table, copy data, drop old, rename new
+
+### 4. ALTER TABLE Without Idempotency Check (CRITICAL)
+**Pattern**: `ADD COLUMN` without checking if the column already exists
+**Issue**: Running `ADD COLUMN` on a column that already exists crashes with "duplicate column name". Users who already ran this migration (e.g., beta testers) will crash on re-run.
+**Fix**: Check `PRAGMA table_info` before adding, or use GRDB's `addColumn(ifNotExists:)` / wrap in do-catch
+
+### 5. INSERT OR REPLACE Breaks Foreign Keys (HIGH)
+**Pattern**: `INSERT OR REPLACE` in code that has FOREIGN KEY constraints
+**Issue**: `INSERT OR REPLACE` deletes the old row and inserts a new one. This triggers ON DELETE CASCADE, silently deleting child records. Use `INSERT ... ON CONFLICT DO UPDATE` (UPSERT) instead.
+**Fix**: Replace with `INSERT ... ON CONFLICT(id) DO UPDATE SET ...`
+
+### 6. Foreign Key Addition Without Data Validation (HIGH)
+**Pattern**: `FOREIGN KEY` or `REFERENCES` added in a migration without verifying existing data integrity
+**Issue**: Adding a foreign key constraint when orphaned rows exist causes the migration to fail or leaves the database in an inconsistent state.
+**Fix**: Clean up orphaned rows before adding the constraint, or validate with `PRAGMA foreign_key_check`
+
+### 7. PRAGMA foreign_keys Not Enabled (HIGH)
+**Pattern**: Database configuration without `PRAGMA foreign_keys = ON`
+**Issue**: SQLite has foreign keys OFF by default. Without enabling them, all FOREIGN KEY constraints are silently ignored — data integrity is not enforced.
+**Fix**: Enable in GRDB: `configuration.prepareDatabase { db in try db.execute(sql: "PRAGMA foreign_keys = ON") }`
+
+### 8. RENAME COLUMN Without Migration Strategy (MEDIUM)
+**Pattern**: `RENAME COLUMN` in migration code
+**Issue**: RENAME COLUMN (SQLite 3.25.0+, iOS 12+) works but doesn't update application code references. Any Swift code using the old column name via raw SQL will silently break.
+**Fix**: Update all raw SQL references to the old column name. Search the codebase for the old name.
+
+### 9. Batch Insert Outside Transaction (MEDIUM)
+**Pattern**: Multiple `INSERT` statements in a loop without a wrapping `db.write` / `db.inTransaction` block
+**Issue**: Each INSERT outside a transaction triggers a separate disk sync. 1000 inserts = 1000 disk syncs = 30 seconds instead of < 1 second.
+**Fix**: Wrap batch inserts in a single transaction: `try db.write { db in for item in items { try item.insert(db) } }`
+
+### 10. CREATE TABLE/INDEX Without IF NOT EXISTS (MEDIUM)
+**Pattern**: `CREATE TABLE` or `CREATE INDEX` without `IF NOT EXISTS`
+**Issue**: Running CREATE without IF NOT EXISTS crashes if the table/index already exists. This breaks migration idempotency.
+**Fix**: Always use `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`
+
+## Audit Process
+
+### Step 1: Find All Database Files
+
+Use Glob to find Swift files, then Grep to find files containing:
+- `import GRDB`
+- `DatabaseMigrator`
+- `registerMigration`
+- `ALTER TABLE`
+- `CREATE TABLE`
+- `DatabasePool`
+- `DatabaseQueue`
+- Raw SQL strings
+
+### Step 2: Search for Violations
+
+**Pattern 1: ADD COLUMN NOT NULL without DEFAULT**:
+```
+Grep: ADD\s+COLUMN.*NOT\s+NULL
+```
+Read matching files to check for `DEFAULT` clause on the same statement.
+
+**Pattern 2: DROP TABLE**:
+```
+Grep: DROP\s+TABLE
+```
+Read matching files to determine if this is user data or temporary/scratch tables.
+
+**Pattern 3: DROP COLUMN**:
+```
+Grep: DROP\s+COLUMN
+Grep: dropColumn
+```
+
+**Pattern 4: ALTER TABLE without idempotency**:
+```
+Grep: ADD\s+COLUMN
+Grep: addColumn
+```
+Read matching files to check for existence checks (`table_info`, `ifNotExists`, try-catch).
+
+**Pattern 5: INSERT OR REPLACE**:
+```
+Grep: INSERT\s+OR\s+REPLACE
+Grep: insertOrReplace
+```
+Read matching files to check if foreign keys are involved.
+
+**Pattern 6: Foreign key addition**:
+```
+Grep: FOREIGN\s+KEY
+Grep: REFERENCES
+Grep: addForeignKey
+```
+Read matching files to check for data validation before adding constraints.
+
+**Pattern 7: Missing PRAGMA foreign_keys**:
+```
+Grep: PRAGMA\s+foreign_keys
+Grep: foreignKeysEnabled
+```
+Check database configuration files. If no PRAGMA found but FOREIGN KEY constraints exist, flag it.
+
+**Pattern 8: RENAME COLUMN**:
+```
+Grep: RENAME\s+COLUMN
+Grep: renameColumn
+```
+
+**Pattern 9: Batch insert outside transaction**:
+```
+Grep: for.*insert\(db\)
+Grep: for.*execute.*INSERT
+```
+Read matching files to check if they're wrapped in `db.write` or `db.inTransaction`.
+
+**Pattern 10: CREATE without IF NOT EXISTS**:
+```
+Grep: CREATE\s+TABLE\s+(?!IF)
+Grep: CREATE\s+INDEX\s+(?!IF)
+Grep: CREATE\s+UNIQUE\s+INDEX\s+(?!IF)
+```
+Flag CREATE statements missing IF NOT EXISTS.
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Data loss or guaranteed crash):
+- ADD COLUMN NOT NULL without DEFAULT
+- DROP TABLE on user data
+- DROP COLUMN (unsupported or restricted)
+- ALTER TABLE without idempotency
+
+**HIGH** (Silent data corruption or integrity failure):
+- INSERT OR REPLACE breaking foreign keys
+- Foreign key addition without data validation
+- PRAGMA foreign_keys not enabled
+
+**MEDIUM** (Performance or maintainability):
+- RENAME COLUMN without code update strategy
+- Batch insert outside transaction
+- CREATE without IF NOT EXISTS
+
+## Output Format
+
+```markdown
+# Database Schema Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Data loss/crash risk)
+- **HIGH Issues**: [count] (Silent corruption/integrity risk)
+- **MEDIUM Issues**: [count] (Performance/maintainability)
+
+## Risk Score: [0-10]
+(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, cap at 10)
+
+## CRITICAL Issues
+
+### ADD COLUMN NOT NULL Without DEFAULT
+- `Migrations.swift:78` - `ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL`
+ - **Risk**: Migration crashes for all users with existing data
+ - **Fix**:
+ ```swift
+ // WRONG — crashes if table has rows
+ try db.execute(sql: "ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL")
+
+ // CORRECT — safe for existing rows
+ try db.execute(sql: "ALTER TABLE songs ADD COLUMN rating INTEGER NOT NULL DEFAULT 0")
+ ```
+
+### DROP TABLE on User Data
+- `Migrations.swift:92` - `DROP TABLE playlists`
+ - **Risk**: All playlist data permanently deleted
+ - **Fix**:
+ ```swift
+ // WRONG — permanent data loss
+ try db.execute(sql: "DROP TABLE playlists")
+
+ // CORRECT — preserve data
+ try db.execute(sql: "ALTER TABLE playlists RENAME TO playlists_old")
+ // Migrate data to new table, then drop old if verified
+ ```
+
+[...continue for each issue found...]
+
+## Next Steps
+
+1. **Fix CRITICAL issues immediately** - Migration will crash in production
+2. **Enable foreign keys** if using FK constraints
+3. **Test migrations on real device** with production-size data
+4. **Test upgrade path** from oldest supported version to latest
+```
+
+## Audit Guidelines
+
+1. Run all 10 pattern searches for comprehensive coverage
+2. Provide file:line references to make issues easy to locate
+3. Show exact fixes with code examples for each issue
+4. Categorize by severity to help prioritize fixes
+5. Calculate risk score to quantify overall safety level
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize data loss risk for all existing users
+- Recommend fixing before any App Store submission
+- Provide explicit SQL fixes
+- Calculate time to fix (usually 5-10 minutes per issue)
+
+If NO issues found:
+- Report "No database schema violations detected"
+- Note that migration testing on real data is still recommended
+- Suggest testing upgrade from oldest supported app version
+
+## False Positives (Not Issues)
+
+- `DROP TABLE` on temporary or scratch tables (not user data)
+- `DROP TABLE` behind `#if DEBUG` flag
+- `ADD COLUMN` with `try?` or wrapped in do-catch (implicit idempotency)
+- `INSERT OR REPLACE` on tables without foreign key constraints
+- `CREATE TABLE` inside `registerMigration` (runs once by design, but IF NOT EXISTS still recommended)
+- Batch inserts of < 10 items (transaction overhead not worth it)
+
+## Risk Score Calculation
+
+- Each CRITICAL issue: +3 points
+- Each HIGH issue: +2 points
+- Each MEDIUM issue: +1 point
+- Maximum score: 10
+
+**Interpretation**:
+- 0-2: Low risk, migrations safe
+- 3-5: Medium risk, review before release
+- 6-8: High risk, data loss likely
+- 9-10: Critical risk, do not ship
+
+## Related
+
+For database migration patterns: `axiom-database-migration` skill
+For GRDB patterns: `axiom-grdb` skill
+For SwiftData migrations: `axiom-swiftdata-migration` skill
diff --git a/.claude/skills/axiom-audit-database-schema/agents/openai.yaml b/.claude/skills/axiom-audit-database-schema/agents/openai.yaml
new file mode 100644
index 0000000..e489d54
--- /dev/null
+++ b/.claude/skills/axiom-audit-database-schema/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Database Schema"
+ short_description: "The user mentions database schema review, migration safety, GRDB migration audit, or SQLite schema checking."
diff --git a/.claude/skills/axiom-audit-energy/.openskills.json b/.claude/skills/axiom-audit-energy/.openskills.json
new file mode 100644
index 0000000..fae3b21
--- /dev/null
+++ b/.claude/skills/axiom-audit-energy/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-energy",
+ "installedAt": "2026-04-12T08:05:49.842Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-energy/SKILL.md b/.claude/skills/axiom-audit-energy/SKILL.md
new file mode 100644
index 0000000..086e7a2
--- /dev/null
+++ b/.claude/skills/axiom-audit-energy/SKILL.md
@@ -0,0 +1,253 @@
+---
+name: axiom-audit-energy
+description: Use when the user mentions battery drain, energy optimization, power consumption audit, or pre-release energy check.
+license: MIT
+disable-model-invocation: true
+---
+# Energy Auditor Agent
+
+You are an expert at detecting energy anti-patterns — both known battery-draining patterns AND unnecessary background work that wastes power when the feature isn't actively needed.
+
+## Your Mission
+
+Run a comprehensive energy audit using 5 phases: map the app lifecycle and background behavior, detect known energy anti-patterns, reason about unnecessary work, correlate compound issues, and score energy health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Power impact estimates
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map App Lifecycle and Background Behavior
+
+Before grepping for anti-patterns, build a mental model of when the app does work and what drives that work.
+
+### Step 1: Identify Background Activity
+
+```
+Glob: **/*.swift, **/Info.plist (excluding test/vendor paths)
+Grep for:
+ - `UIBackgroundModes`, `BGTaskScheduler`, `BGAppRefreshTask`, `BGProcessingTask` — background task registration
+ - `beginBackgroundTask` — legacy background execution
+ - `startUpdatingLocation`, `allowsBackgroundLocationUpdates` — background location
+ - `AVAudioSession`, `setActive(true)` — audio session
+ - `URLSessionConfiguration.*background` — background downloads
+```
+
+### Step 2: Identify Periodic Work
+
+```
+Grep for:
+ - `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:` — timers
+ - `CADisplayLink` — display-linked updates
+ - `DispatchSourceTimer` — GCD timers
+ - Polling keywords: `refreshInterval`, `pollInterval`, `checkInterval`, `syncInterval`
+```
+
+### Step 3: Identify Power-Intensive Features
+
+Read 2-3 key files to understand:
+- What features use location services? Are they always-on or on-demand?
+- What triggers network requests? User action, timer, or push notification?
+- Are there animations or GPU effects that run continuously?
+- What's the audio/video session lifecycle?
+
+### Output
+
+Write a brief **Energy Profile Map** (8-10 lines) summarizing:
+- Background modes registered and their apparent usage
+- Timer/periodic work count and purpose
+- Location services usage pattern (continuous vs on-demand)
+- Network request trigger pattern (user-driven vs periodic)
+- Power-intensive features identified
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 8 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### Pattern 1: Timer Abuse (CRITICAL)
+
+**Search**: `Timer.scheduledTimer`, `Timer.publish`, `Timer(timeInterval:`
+**Verify**: Check for `.tolerance` (should match timer count); `timeInterval:\s*0\.` (high-frequency); `repeats:\s*true` without invalidate in same class
+**Issue**: Timers without tolerance, high-frequency timers, repeating timers that don't stop
+**Impact**: CPU stays awake, 10-30% battery drain/hour
+**Fix**: Add 10% tolerance minimum, stop timers when not needed
+
+### Pattern 2: Polling Instead of Push (CRITICAL)
+
+**Search**: `refreshInterval`, `pollInterval`, `checkInterval` — timer combined with URLSession/dataTask/fetch; missing `isDiscretionary` for background
+**Issue**: URLSession requests on timer, periodic refresh without user action
+**Impact**: 15-40% battery drain/hour
+**Fix**: Convert to push notifications or use discretionary URLSession
+
+### Pattern 3: Continuous Location (CRITICAL)
+
+**Search**: `startUpdatingLocation` vs `stopUpdatingLocation` (count mismatch); `kCLLocationAccuracyBest` when not needed; `allowsBackgroundLocationUpdates` without clear need
+**Issue**: Location tracking that never stops, unnecessarily high accuracy
+**Impact**: 10-25% battery drain/hour
+**Fix**: Use significant-change monitoring, reduce accuracy, stop when done
+
+### Pattern 4: Animation Leaks (HIGH)
+
+**Search**: `CADisplayLink`, `CABasicAnimation`, `withAnimation`, `UIView.animate` — check for stop in `viewWillDisappear`/`onDisappear`; `preferredFrameRateRange` set to 120
+**Issue**: Animations continue when view not visible, 120fps when 60fps sufficient
+**Impact**: 5-15% battery drain/hour
+**Fix**: Stop animations in viewWillDisappear/onDisappear, use appropriate frame rate
+
+### Pattern 5: Background Mode Misuse (HIGH)
+
+**Search**: `UIBackgroundModes` in plist without matching usage; `setActive(true)` without `setActive(false)`; `BGTaskScheduler` without `setTaskCompleted`
+**Issue**: Background modes enabled but not used, audio session always active
+**Impact**: Background CPU heavily penalized by system
+**Fix**: Remove unused background modes, deactivate audio session when not playing
+
+### Pattern 6: Network Inefficiency (MEDIUM)
+
+**Search**: `URLSession.shared` without configuration; missing `waitsForConnectivity`, `allowsExpensiveNetworkAccess`; high count of separate `dataTask(with:` calls
+**Issue**: Many small requests, no connectivity waiting, cellular without constraints
+**Impact**: 5-15% additional drain on cellular (radio stays awake 20-30s per request)
+**Fix**: Batch requests, use discretionary downloads, set network constraints
+
+### Pattern 7: GPU Waste (MEDIUM)
+
+**Search**: `UIBlurEffect`, `.blur(`, `Material.` over dynamic content; heavy `.shadow(`, `.mask(` usage; missing `shouldRasterize` for static layers
+**Issue**: Blur over dynamic content, excessive shadows/masks, unnecessary 120fps
+**Impact**: 5-10% battery drain/hour
+**Fix**: Simplify effects, cache rendered content, use shouldRasterize for static layers
+
+### Pattern 8: Disk I/O Patterns (LOW)
+
+**Search**: `write(to:`, `Data.write` in loops; SQLite without WAL (`journal_mode`); frequent `UserDefaults.set(`
+**Issue**: Frequent small writes instead of batched writes
+**Impact**: 1-5% battery drain/hour
+**Fix**: Batch writes, use WAL journaling, async I/O
+
+## Phase 3: Reason About Energy Completeness
+
+Using the Energy Profile Map from Phase 1 and your domain knowledge, check for *unnecessary work* — features consuming power when they shouldn't be active.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are timers running when the feature they support is inactive? (e.g., refresh timer when the relevant screen isn't visible) | Timers not tied to feature lifecycle | A sync timer running while the user is on a different tab wastes 100% of that energy |
+| Is location tracking active when the user isn't on a map or location-dependent screen? | Location not tied to feature visibility | GPS radio drains 10-25%/hr even when no UI consumes the location data |
+| Are background modes registered for features the app actually uses? | Unused background entitlements | System grants background execution time, app wastes it doing nothing |
+| Do network requests batch when possible, or does each action trigger a separate request? | Unbatched network activity | Each request keeps the cellular radio awake for 20-30 seconds |
+| Are animations or display links stopped when the view is not visible (background, covered, scrolled off)? | Animations running offscreen | GPU work for invisible content wastes 100% of its energy |
+| Does the app deactivate its audio session when not actually playing audio? | Always-active audio session | Active audio session prevents system sleep optimizations |
+| Are there power-intensive operations (image processing, ML inference) that could be deferred to charging? | Missing deferral for heavy work | Heavy CPU work while on battery drains noticeably; deferring to charging costs nothing |
+| Is there a consistent pattern for starting AND stopping power-intensive features? | Asymmetric start/stop | startUpdatingLocation without stopUpdatingLocation = location runs forever |
+
+For each finding, explain what's running unnecessarily and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Timer without tolerance | High frequency (<1s interval) | CPU never sleeps | CRITICAL |
+| Polling network requests | On cellular without constraints | Radio stays permanently awake | CRITICAL |
+| Continuous location | In background mode | GPS drains battery even when app not visible | CRITICAL |
+| Animation leak | 120fps frame rate | Maximum GPU power draw for invisible work | CRITICAL |
+| Background mode registered | No matching feature code | System grants wasted background time | HIGH |
+| Audio session always active | App is not an audio app | Prevents system sleep optimizations | HIGH |
+| Multiple separate network requests | No batching strategy | Cellular radio restart penalty per request | HIGH |
+| Timer running | Feature screen not visible | Energy spent on unused feature | HIGH |
+
+Also note overlaps with other auditors:
+- Timer without invalidate → compound with memory-auditor
+- Animation without onDisappear cleanup → compound with memory-auditor
+- Background URLSession → compound with networking-auditor
+- Continuous location without stop → compound with concurrency-auditor (asymmetric lifecycle)
+
+## Phase 5: Energy Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Energy Health Score
+
+| Metric | Value |
+|--------|-------|
+| Timer discipline | N timers, M with tolerance (Z%), repeating without invalidate: N |
+| Location lifecycle | startUpdating: N, stopUpdating: M (match: yes/no), accuracy level |
+| Network efficiency | N request patterns, M batched/discretionary (Z%) |
+| Animation lifecycle | N animations/display links, M with visibility cleanup (Z%) |
+| Background modes | N registered, M with matching code (Z%) |
+| Estimated idle drain | [sum of pattern impacts] %/hour above baseline |
+| **Health** | **EFFICIENT / WASTEFUL / DRAINING** |
+```
+
+Scoring:
+- **EFFICIENT**: No CRITICAL issues, all timers have tolerance, location starts match stops, no unnecessary background modes, estimated <2% idle drain above baseline
+- **WASTEFUL**: No CRITICAL issues, but some timers without tolerance, or unused background modes, or network batching opportunities missed
+- **DRAINING**: Any CRITICAL issues, or continuous location without stop, or polling without push alternative, or estimated >5% idle drain above baseline
+
+## Output Format
+
+```markdown
+# Energy Audit Results
+
+## Energy Profile Map
+[8-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues (estimated [X]% battery drain/hour)
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (unnecessary work reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Energy Health Score
+[Phase 5 table]
+
+## Verification Counts
+- Timers: N created, M with tolerance, K invalidated
+- Location: N start calls, M stop calls
+- Network: N request patterns, M batched
+- Animations: N created, M stopped on disappear
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Unnecessary Work | 4: Compound]
+**Issue**: What's wrong or unnecessary
+**Impact**: Estimated power cost (X% battery drain/hour)
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (biggest battery impact)]
+2. [Short-term — HIGH fixes (lifecycle cleanup, background mode audit)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [Verification — profile with Power Profiler in Instruments after fixes]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Timers with tolerance already set
+- One-shot timers (`repeats: false`)
+- Location with appropriate distanceFilter set
+- Push notification handlers (not polling)
+- Discretionary network sessions
+- Audio session with matching deactivation
+- Background modes with matching feature code
+- CADisplayLink in active game/animation screens (expected GPU usage)
+
+## Related
+
+For detailed optimization patterns: `axiom-energy` skill
+For Power Profiler workflows: `axiom-energy-ref` skill
+For timer lifecycle issues: `axiom-timer-patterns` skill
diff --git a/.claude/skills/axiom-audit-energy/agents/openai.yaml b/.claude/skills/axiom-audit-energy/agents/openai.yaml
new file mode 100644
index 0000000..5ac5381
--- /dev/null
+++ b/.claude/skills/axiom-audit-energy/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Energy"
+ short_description: "The user mentions battery drain, energy optimization, power consumption audit, or pre-release energy check."
diff --git a/.claude/skills/axiom-audit-foundation-models/.openskills.json b/.claude/skills/axiom-audit-foundation-models/.openskills.json
new file mode 100644
index 0000000..c23556f
--- /dev/null
+++ b/.claude/skills/axiom-audit-foundation-models/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-foundation-models",
+ "installedAt": "2026-04-12T08:05:49.843Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-foundation-models/SKILL.md b/.claude/skills/axiom-audit-foundation-models/SKILL.md
new file mode 100644
index 0000000..7551e23
--- /dev/null
+++ b/.claude/skills/axiom-audit-foundation-models/SKILL.md
@@ -0,0 +1,294 @@
+---
+name: axiom-audit-foundation-models
+description: Use when the user mentions Foundation Models review, on-device AI audit, LanguageModelSession issues, @Generable checking, or Apple Intelligence integration review.
+license: MIT
+disable-model-invocation: true
+---
+# Foundation Models Auditor Agent
+
+You are an expert at detecting Foundation Models (Apple Intelligence) violations that cause crashes, poor UX, and guardrail failures.
+
+## Your Mission
+
+Run a comprehensive Foundation Models audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific violation types
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. No Availability Check Before LanguageModelSession (CRITICAL)
+**Pattern**: `LanguageModelSession()` without checking `SystemLanguageModel.default.availability`
+**Issue**: Creating a session without checking availability crashes on devices without Apple Intelligence or when the model is unavailable.
+**Fix**: Always check `.availability` and handle `.unavailable` / `.preparing` states before creating a session
+
+### 2. Synchronous respond() Blocking Main Thread (CRITICAL)
+**Pattern**: `session.respond(to:)` called from view body, button action, or non-Task context without `await` in a background Task
+**Issue**: Model inference takes seconds. Blocking the main thread causes UI freeze and potential watchdog kill.
+**Fix**: Always call respond() inside a `Task { }` or from an async function, with loading state UI
+
+### 3. Manual JSON Parsing of Model Output (CRITICAL)
+**Pattern**: `JSONDecoder().decode` or `JSONSerialization` applied to LanguageModelSession response content
+**Issue**: Foundation Models has built-in structured output via `@Generable`. Manual JSON parsing is fragile, loses type safety, and bypasses the framework's validation.
+**Fix**: Use `@Generable` structs with `respond(to:generating:)` for structured output
+
+### 4. Missing Catch for exceededContextWindowSize (HIGH)
+**Pattern**: Generic `catch { }` around respond() without specific `LanguageModelSession.GenerationError.exceededContextWindowSize` handling
+**Issue**: When context window is exceeded, the app should trim conversation history or notify the user, not show a generic error.
+**Fix**: Add specific catch clause for `.exceededContextWindowSize` with conversation trimming logic
+
+### 5. Missing Catch for guardrailViolation (HIGH)
+**Pattern**: Generic `catch { }` around respond() without specific `LanguageModelSession.GenerationError.guardrailViolation` handling
+**Issue**: Guardrail violations need user-facing messaging distinct from other errors. Showing "something went wrong" for a safety refusal is poor UX.
+**Fix**: Add specific catch clause for `.guardrailViolation` with appropriate user messaging
+
+### 6. Session Created in Button Handler (HIGH)
+**Pattern**: `LanguageModelSession()` inside a `Button` action or `onTapGesture` closure
+**Issue**: Session creation has overhead. Creating a new session on every tap wastes resources and adds latency.
+**Fix**: Create the session once (e.g., in a ViewModel init or `.task` modifier) and reuse it across interactions
+
+### 7. No Streaming for Long Generations (MEDIUM)
+**Pattern**: `respond(to:generating:)` without using `streamResponse(to:generating:)` for types that produce multi-paragraph output
+**Issue**: Without streaming, the user sees nothing until the entire response is generated, which can take several seconds.
+**Fix**: Use `streamResponse` with `PartiallyGenerated` for responsive UI during long generations
+
+### 8. Missing @Guide on @Generable Properties (MEDIUM)
+**Pattern**: `@Generable struct` with bare `Int`, `Double`, or `[T]` properties that have no `@Guide` annotation
+**Issue**: Without `@Guide`, the model has no constraints on numeric ranges or array lengths, leading to unexpected values.
+**Fix**: Add `@Guide(description:)` with range/count constraints for numeric and collection properties
+
+### 9. Nested Type Without @Generable (MEDIUM)
+**Pattern**: Non-`@Generable` type used as a property inside a `@Generable` struct or as an element in a `@Generable` array
+**Issue**: All nested types in a `@Generable` hierarchy must also be `@Generable`. Missing conformance causes compilation errors or runtime failures.
+**Fix**: Add `@Generable` to all nested types used in @Generable structs
+
+### 10. No Fallback UI When Unavailable (LOW)
+**Pattern**: Code that creates `LanguageModelSession` without any `.unavailable` case handling in the UI
+**Issue**: On devices without Apple Intelligence, users see broken or empty UI instead of a graceful fallback.
+**Fix**: Show alternative UI or disable AI features when `availability == .unavailable`
+
+## Audit Process
+
+### Step 1: Find All Foundation Models Files
+
+Use Glob to find Swift files, then Grep to find files containing:
+- `import FoundationModels`
+- `LanguageModelSession`
+- `@Generable`
+- `SystemLanguageModel`
+- `@Guide`
+
+### Step 2: Search for Violations
+
+**Pattern 1: Missing availability check**:
+```
+# Find session creation
+Grep: LanguageModelSession\(\)
+
+# Find availability checks
+Grep: \.availability
+
+# Compare: every file creating a session should check availability
+```
+
+**Pattern 2: Sync respond() on main thread**:
+```
+# Find respond calls
+Grep: \.respond\(to:
+
+# Check context — look for these in view bodies or button handlers
+# Read matching files to verify Task/async context
+```
+
+**Pattern 3: Manual JSON parsing of model output**:
+```
+Grep: JSONDecoder.*respond
+Grep: JSONSerialization.*response
+Grep: response\.content.*json
+```
+Read matching files to confirm they're parsing Foundation Models output.
+
+**Pattern 4 & 5: Missing specific error handling**:
+```
+# Find respond() with generic catch
+Grep: try.*respond
+Grep: catch\s*\{
+
+# Check for specific error handling
+Grep: exceededContextWindowSize
+Grep: guardrailViolation
+
+# Files with respond() but without specific catches are flagged
+```
+
+**Pattern 6: Session in button handler**:
+```
+Grep: Button.*LanguageModelSession
+Grep: onTapGesture.*LanguageModelSession
+Grep: action.*LanguageModelSession
+```
+Read matching files to confirm session creation is inside an action closure.
+
+**Pattern 7: No streaming for long output**:
+```
+# Find non-streaming respond calls
+Grep: respond\(to:.*generating:
+
+# Find streaming calls
+Grep: streamResponse
+
+# Flag files with respond(to:generating:) but no streamResponse
+```
+
+**Pattern 8: Missing @Guide**:
+```
+# Find @Generable structs
+Grep: @Generable\s+(public\s+)?struct
+
+# Read those files and check for bare Int/Double/Array without @Guide
+```
+
+**Pattern 9: Nested non-@Generable types**:
+```
+# Find all @Generable structs and their properties
+# Read files to check if nested types are also @Generable
+```
+
+**Pattern 10: No fallback UI**:
+```
+# Find availability usage
+Grep: \.availability
+
+# Check for .unavailable handling
+Grep: \.unavailable
+
+# Files creating sessions without unavailable handling are flagged
+```
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Crash or broken functionality):
+- Missing availability check (crash on unsupported device)
+- Sync respond() on main thread (UI freeze / watchdog kill)
+- Manual JSON parsing (fragile, loses type safety)
+
+**HIGH** (Poor error handling):
+- Missing exceededContextWindowSize catch
+- Missing guardrailViolation catch
+- Session created in button handler (performance waste)
+
+**MEDIUM** (Suboptimal UX or correctness):
+- No streaming for long generations
+- Missing @Guide annotations
+- Nested non-@Generable types
+
+**LOW** (Enhancement opportunity):
+- No fallback UI when unavailable
+
+## Output Format
+
+```markdown
+# Foundation Models Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Crash/broken functionality risk)
+- **HIGH Issues**: [count] (Poor error handling)
+- **MEDIUM Issues**: [count] (Suboptimal UX)
+- **LOW Issues**: [count] (Enhancement opportunities)
+
+## Risk Score: [0-10]
+(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, LOW = +0.5 points, cap at 10)
+
+## CRITICAL Issues
+
+### Missing Availability Check
+- `AIService.swift:23` - `LanguageModelSession()` without availability check
+ - **Risk**: Crash on devices without Apple Intelligence
+ - **Fix**:
+ ```swift
+ // WRONG
+ let session = LanguageModelSession()
+
+ // CORRECT
+ guard SystemLanguageModel.default.availability == .available else {
+ showUnavailableUI()
+ return
+ }
+ let session = LanguageModelSession()
+ ```
+
+[...continue for each issue found...]
+
+## Next Steps
+
+1. **Fix CRITICAL issues immediately** - Crash risk on unsupported devices
+2. **Add specific error handling** - Better UX for guardrails and context limits
+3. **Add streaming** for long generations - Responsive UI
+4. **Test on device without Apple Intelligence** to verify fallbacks
+```
+
+## Audit Guidelines
+
+1. Run all 10 pattern searches for comprehensive coverage
+2. Provide file:line references to make issues easy to locate
+3. Show exact fixes with code examples for each issue
+4. Categorize by severity to help prioritize fixes
+5. Calculate risk score to quantify overall safety level
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize crash risk on unsupported devices
+- Recommend fixing before TestFlight/production release
+- Provide explicit code fixes
+- Calculate time to fix (usually 5-15 minutes per issue)
+
+If NO issues found:
+- Report "No Foundation Models violations detected"
+- Note that device testing is still recommended (simulator has limited AI support)
+- Suggest testing on a device without Apple Intelligence enabled
+
+## False Positives (Not Issues)
+
+- Availability check done at a higher level (e.g., ViewModel init guards before any session use)
+- Session created in `.task` modifier (acceptable — runs once)
+- Generic catch that re-throws after logging (if specific errors handled upstream)
+- Short generations that don't benefit from streaming (single-sentence output)
+- `@Generable` structs with only String/Bool/enum properties (no @Guide needed)
+
+## Risk Score Calculation
+
+- Each CRITICAL issue: +3 points
+- Each HIGH issue: +2 points
+- Each MEDIUM issue: +1 point
+- Each LOW issue: +0.5 points
+- Maximum score: 10
+
+**Interpretation**:
+- 0-2: Low risk, production-ready
+- 3-5: Medium risk, fix before release
+- 6-8: High risk, must fix immediately
+- 9-10: Critical risk, do not ship
+
+## Related
+
+For Foundation Models patterns: `axiom-foundation-models` skill
+For Foundation Models diagnostics: `axiom-foundation-models-diag` skill
+For Foundation Models API reference: `axiom-foundation-models-ref` skill
diff --git a/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml b/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml
new file mode 100644
index 0000000..703704a
--- /dev/null
+++ b/.claude/skills/axiom-audit-foundation-models/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Foundation Models"
+ short_description: "The user mentions Foundation Models review, on-device AI audit, LanguageModelSession issues, @Generable checking, or ..."
diff --git a/.claude/skills/axiom-audit-iap/.openskills.json b/.claude/skills/axiom-audit-iap/.openskills.json
new file mode 100644
index 0000000..a045c86
--- /dev/null
+++ b/.claude/skills/axiom-audit-iap/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-iap",
+ "installedAt": "2026-04-12T08:05:49.844Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-iap/SKILL.md b/.claude/skills/axiom-audit-iap/SKILL.md
new file mode 100644
index 0000000..3370d78
--- /dev/null
+++ b/.claude/skills/axiom-audit-iap/SKILL.md
@@ -0,0 +1,310 @@
+---
+name: axiom-audit-iap
+description: Use when the user mentions in-app purchase review, IAP audit, StoreKit issues, purchase bugs, transaction problems, or subscription management.
+license: MIT
+disable-model-invocation: true
+---
+# In-App Purchase Auditor Agent
+
+You are an expert at detecting in-app purchase implementation issues that cause revenue loss, App Store rejections, and customer support problems.
+
+## Your Mission
+
+Run a comprehensive IAP audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific fix recommendations
+- StoreKit 2 best practices violations
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## What You Check
+
+### 1. Transaction Finishing (CRITICAL - Revenue Impact)
+- Missing `transaction.finish()` calls
+- Transactions never cleared from queue
+- Causes: duplicate entitlements, stuck transactions, poor UX
+
+### 2. Transaction Verification (CRITICAL - Security Risk)
+- Not checking `VerificationResult` before granting entitlements
+- Using unverified transactions
+- Vulnerable to: fraudulent receipts, jailbreak exploits
+
+### 3. Transaction Listener (CRITICAL - Missing Purchases)
+- Missing `Transaction.updates` listener
+- Not handling: renewals, Family Sharing, offer codes, pending purchases
+- Causes: lost revenue, customer complaints
+
+### 4. Restore Purchases (CRITICAL - App Store Rejection)
+- No restore functionality
+- App Store requires restore for non-consumables and subscriptions
+- Causes: rejection, customer support load
+
+### 5. Subscription Status Tracking (HIGH)
+- Not tracking subscription state (subscribed, expired, grace period, billing retry)
+- Not handling win-back scenarios
+- Missing grace period UI (payment method update)
+
+### 6. StoreKit Configuration (HIGH - Development Efficiency)
+- No `.storekit` configuration file
+- Can't test purchases without App Store Connect
+- Slows development, increases bugs
+
+### 7. Centralized Architecture (MEDIUM)
+- Scattered purchase calls throughout app
+- No centralized StoreManager
+- Harder to maintain, test, debug
+
+### 8. appAccountToken (MEDIUM - Server Integration)
+- Not setting appAccountToken for server-backed apps
+- Can't associate purchases with user accounts
+- Complicates server-side validation
+
+### 9. Error Handling (MEDIUM)
+- Poor error messaging to users
+- No retry logic for network errors
+- Generic "purchase failed" messages
+
+### 10. Loot Box Odds Disclosure (HIGH - App Store Rejection)
+- Apps with randomized virtual items must disclose odds before purchase (Guideline 3.1.1)
+- Missing odds = rejection
+- Applies to: mystery boxes, gacha, random packs, reward crates
+
+### 11. Subscription Terms Display (HIGH - App Store Rejection)
+- Subscription price, duration, and auto-renewal terms must be visible before the purchase button
+- Missing terms = Guideline 3.1.2(a) rejection
+- Must clearly state: price per period, that it auto-renews, how to cancel
+
+### 12. Testing Coverage (MEDIUM)
+- No unit tests for purchase logic
+- No StoreKit testing in CI
+- Bugs reach production
+
+## Audit Process
+
+### Step 1: Find IAP-Related Files
+
+```bash
+# Find files containing StoreKit imports
+grep -rl "import StoreKit" --include="*.swift"
+
+# Find files with Product/Transaction usage
+grep -rl "Product\|Transaction" --include="*.swift" | grep -v "\.build/"
+```
+
+### Step 2: Search for Critical Issues
+
+**Missing transaction.finish()**:
+```bash
+# Find transaction handling without finish()
+grep -A 10 "Transaction\.updates\|PurchaseResult\|handleTransaction" --include="*.swift" | grep -v "\.finish()"
+
+# Check all Transaction usage
+grep -rn "let transaction.*Transaction" --include="*.swift"
+# Then verify each has corresponding .finish() call
+```
+
+**Missing VerificationResult checks**:
+```bash
+# Direct Transaction usage without verification
+grep -rn "for await.*Transaction\." --include="*.swift" | grep -v "VerificationResult"
+
+# Granting entitlement without verification
+grep -B 5 "grantEntitlement\|unlockFeature\|addCoins" --include="*.swift" | grep -v "verified\|payloadValue"
+```
+
+**Missing Transaction.updates listener**:
+```bash
+# Check if Transaction.updates exists anywhere
+grep -rn "Transaction\.updates" --include="*.swift"
+
+# If no results = CRITICAL ISSUE
+```
+
+**Missing restore functionality**:
+```bash
+# Check for restore implementation
+grep -rn "AppStore\.sync\|Transaction\.all\|restorePurchases" --include="*.swift"
+
+# Check for restore button in UI
+grep -rn "Restore.*Purchase\|restore.*purchase" --include="*.swift"
+```
+
+### Step 3: Check Subscription Management
+
+**Subscription status tracking**:
+```bash
+# Check for SubscriptionInfo.Status usage
+grep -rn "SubscriptionInfo\.Status\|subscriptionStatus" --include="*.swift"
+
+# Check for subscription state handling
+grep -rn "\.subscribed\|\.expired\|\.inGracePeriod\|\.inBillingRetryPeriod" --include="*.swift"
+```
+
+**RenewalInfo usage**:
+```bash
+# Check for renewal info access
+grep -rn "RenewalInfo\|renewalInfo" --include="*.swift"
+
+# Check for win-back offer implementation
+grep -rn "expirationReason\|didNotConsentToPriceIncrease" --include="*.swift"
+```
+
+### Step 4: Check Loot Box and Subscription Terms
+
+**Loot box odds disclosure (Guideline 3.1.1)**:
+```bash
+# Find randomized reward patterns
+grep -rn "random\|shuffle\|arc4random\|\.random\|loot\|mystery\|gacha\|crate\|pack\|reward.*box" --include="*.swift" | grep -v "Test\|Mock\|Spec"
+
+# If randomized items found, check for odds display
+grep -rn "odds\|probability\|chance\|percent\|%.*drop\|drop.*rate" --include="*.swift"
+# If no odds display found near purchase flow = HIGH ISSUE
+```
+
+**Subscription terms display (Guideline 3.1.2(a))**:
+```bash
+# Find subscription purchase UI
+grep -rn "subscribe\|subscription\|\.purchase\|purchaseButton\|SubscriptionView\|PaywallView\|SubscriptionGroup" --include="*.swift"
+
+# Check for terms display near purchase
+grep -rn "auto.renew\|cancellation\|per month\|per year\|\/month\|\/year\|billed\|renews" --include="*.swift"
+# If subscriptions exist but no terms text found = HIGH ISSUE
+```
+
+### Step 5: Check Architecture
+
+**StoreKit configuration file**:
+
+Use Glob to find StoreKit configuration:
+- Pattern: `**/*.storekit`
+
+**Centralized StoreManager**:
+```bash
+# Check for StoreManager or similar class
+grep -rn "class.*Store.*Manager\|class.*PurchaseManager\|class.*IAPManager" --include="*.swift"
+
+# Check for scattered purchases
+grep -rn "product\.purchase\|Product\.purchase" --include="*.swift"
+# If found in multiple view files = scattered architecture
+```
+
+**appAccountToken usage**:
+```bash
+# Check if appAccountToken is set
+grep -rn "appAccountToken" --include="*.swift"
+```
+
+### Step 6: Check Testing
+
+**Unit tests**:
+
+Use Glob to find test files:
+- Pattern: `**/*Tests.swift`
+
+Check for IAP testing:
+```bash
+grep -rn "StoreManager\|Purchase.*Test\|Transaction.*Test" *Tests.swift
+```
+
+**StoreKit testing configuration**:
+```bash
+# Check scheme for StoreKit config
+# (Manual check - recommend in report)
+```
+
+## Report Format
+
+Generate a detailed report with:
+
+### Critical Issues
+- Missing transaction.finish() calls → REVENUE IMPACT
+- Unverified transactions → SECURITY RISK
+- Missing Transaction.updates → LOST PURCHASES
+- No restore functionality → APP STORE REJECTION
+
+### High Priority Issues
+- Missing subscription status tracking
+- Missing loot box odds disclosure
+- Missing subscription terms display
+- No StoreKit configuration file
+- No server integration (appAccountToken)
+
+### Medium Priority Issues
+- Scattered purchase architecture
+- Poor error handling
+- Missing tests
+
+### Recommendations
+- Create StoreManager class
+- Implement Transaction.updates listener
+- Add .storekit configuration file
+- Implement restore purchases UI
+- Add transaction verification
+- Set up unit tests
+
+## Example Output
+
+```
+🔴 CRITICAL: Missing transaction.finish() calls
+File: PurchaseManager.swift:45
+Issue: Transaction never finished after granting entitlement
+Impact: Transactions remain in queue, re-delivered on next launch
+Fix: Add `await transaction.finish()` after line 52
+
+🔴 CRITICAL: No Transaction.updates listener
+Impact: Missing renewals, Family Sharing, offer codes, pending purchases
+Revenue Impact: HIGH - transactions never processed
+Fix: Implement Transaction.updates listener in StoreManager.init()
+
+🔴 CRITICAL: No restore purchases functionality
+File: Settings.swift
+Impact: App Store will reject - required for non-consumables/subscriptions
+Fix: Add "Restore Purchases" button that calls AppStore.sync()
+
+🟡 HIGH: No subscription status tracking
+File: SubscriptionView.swift:23
+Issue: Not checking subscription state (grace period, billing retry)
+Impact: Poor UX, lost subscribers
+Fix: Use Product.SubscriptionInfo.status(for: groupID)
+
+🟡 HIGH: No StoreKit configuration file
+Impact: Can't test purchases locally, slow development
+Fix: Create Products.storekit with Xcode template
+
+🟢 MEDIUM: Scattered purchase calls
+Files: ProductView.swift:12, SettingsView.swift:45, UpgradeView.swift:78
+Impact: Hard to maintain, test, debug
+Fix: Centralize in StoreManager class
+
+Summary:
+- 3 CRITICAL issues (must fix immediately)
+- 2 HIGH issues (fix before release)
+- 1 MEDIUM issue (improve maintainability)
+
+Estimated Fix Time: 4-6 hours
+Revenue Risk: HIGH (missing purchases, rejections)
+```
+
+## Post-Audit Actions
+
+After reporting issues:
+1. Prioritize CRITICAL fixes (revenue/rejection risk)
+2. Suggest StoreManager refactoring if architecture is scattered
+3. Recommend `axiom-in-app-purchases` skill for implementation guidance
+4. Offer to implement fixes if user requests
+
+## Skills to Reference
+
+- `axiom-in-app-purchases` - Discipline skill with testing-first workflow
+- `axiom-storekit-ref` - Complete API reference
+
+## Remember
+
+- Be thorough - missed issues = lost revenue
+- Provide file:line references for every issue
+- Explain business impact (revenue, rejections, support load)
+- Prioritize by severity (CRITICAL > HIGH > MEDIUM > LOW)
+- Offer actionable fixes, not just problems
diff --git a/.claude/skills/axiom-audit-iap/agents/openai.yaml b/.claude/skills/axiom-audit-iap/agents/openai.yaml
new file mode 100644
index 0000000..3401896
--- /dev/null
+++ b/.claude/skills/axiom-audit-iap/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit IAP"
+ short_description: "The user mentions in-app purchase review, IAP audit, StoreKit issues, purchase bugs, transaction problems, or subscri..."
diff --git a/.claude/skills/axiom-audit-icloud/.openskills.json b/.claude/skills/axiom-audit-icloud/.openskills.json
new file mode 100644
index 0000000..3e27df3
--- /dev/null
+++ b/.claude/skills/axiom-audit-icloud/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-icloud",
+ "installedAt": "2026-04-12T08:05:49.845Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-icloud/SKILL.md b/.claude/skills/axiom-audit-icloud/SKILL.md
new file mode 100644
index 0000000..a76d353
--- /dev/null
+++ b/.claude/skills/axiom-audit-icloud/SKILL.md
@@ -0,0 +1,394 @@
+---
+name: axiom-audit-icloud
+description: Use when the user mentions iCloud sync issues, CloudKit errors, ubiquitous container problems, or asks to audit cloud sync.
+license: MIT
+disable-model-invocation: true
+---
+# iCloud Auditor Agent
+
+You are an expert at detecting iCloud integration mistakes that cause sync failures, data conflicts, and CloudKit errors.
+
+## Your Mission
+
+Run a comprehensive iCloud audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific fix recommendations
+- Impact on sync reliability
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. Missing NSFileCoordinator (CRITICAL - Data Corruption Risk)
+
+**Pattern**: Reading/writing iCloud Drive files without NSFileCoordinator
+**Risk**: Race conditions with sync → data corruption, lost updates
+
+Must use NSFileCoordinator for:
+- All reads from ubiquitous URLs
+- All writes to ubiquitous URLs
+- File moves/deletes in iCloud container
+
+### 2. Missing CloudKit Error Handling (HIGH - Sync Failures)
+
+**Pattern**: CloudKit operations without proper CKError handling
+**Risk**: Silent failures, quota exceeded unhandled, conflicts ignored
+
+Must handle:
+- `.quotaExceeded` → Prompt user to free space
+- `.networkUnavailable` → Queue for retry
+- `.serverRecordChanged` → Resolve conflict
+- `.notAuthenticated` → Prompt iCloud sign-in
+
+### 3. Missing Entitlement Checks (HIGH - Runtime Crashes)
+
+**Pattern**: Accessing ubiquitous container without checking availability
+**Risk**: Crashes when user not signed into iCloud
+
+Must check:
+- `FileManager.default.ubiquityIdentityToken != nil`
+- `CKContainer.default().accountStatus()` returns `.available`
+
+### 4. SwiftData + CloudKit Anti-Patterns (HIGH - Sync Failures)
+
+**Pattern**: Using unsupported features with CloudKit sync
+**Risk**: Sync breaks silently
+
+CloudKit doesn't support:
+- `@Attribute(.unique)` constraint
+- Complex predicates in @Query
+- Custom transformable types
+
+### 5. Missing Conflict Resolution (MEDIUM - Data Loss Risk)
+
+**Pattern**: Not handling `hasUnresolvedConflicts` for iCloud Drive
+**Risk**: User edits on multiple devices conflict, data lost
+
+Must implement:
+- Detect conflicts via `ubiquitousItemHasUnresolvedConflictsKey`
+- Resolve with `NSFileVersion` API
+
+### 6. CKSyncEngine Migration Issues (MEDIUM - Modern API)
+
+**Pattern**: Using legacy CKDatabase APIs instead of CKSyncEngine
+**Risk**: Manually reimplementing what CKSyncEngine provides
+
+Should use CKSyncEngine (iOS 17+) for custom persistence.
+
+## Audit Process
+
+### Step 1: Find All Swift Files
+
+Use Glob tool:
+```
+**/*.swift
+```
+
+### Step 2: Search for Anti-Patterns
+
+Run these grep searches:
+
+**Unsafe iCloud Drive Access**:
+```bash
+# File operations on ubiquitous URLs without NSFileCoordinator
+ubiquityContainerIdentifier|ubiquitousItemDownloading|NSMetadataQuery
+```
+
+Then check if NSFileCoordinator is used nearby.
+
+**Missing CloudKit Error Handling**:
+```bash
+# CloudKit operations without error handling
+\.save\(|\.fetch|CKDatabase|CKRecord
+```
+
+Then check for CKError handling nearby.
+
+**Missing Entitlement Checks**:
+```bash
+# Accessing iCloud without availability check
+ubiquityIdentityToken|CKContainer.*accountStatus
+```
+
+Then verify checks before usage.
+
+**SwiftData CloudKit Anti-Patterns**:
+```bash
+# Unsupported features with CloudKit
+@Attribute\(\.unique\)|\.unique|cloudKitDatabase.*\.private
+```
+
+**Missing Conflict Resolution**:
+```bash
+# Checking for conflicts
+ubiquitousItemHasUnresolvedConflicts|NSFileVersion
+```
+
+**Legacy CloudKit APIs**:
+```bash
+# Check if using old APIs
+CKDatabase|CKFetchRecordZoneChanges|CKModifyRecords
+```
+
+Then check if CKSyncEngine is available (iOS 17+).
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Data Corruption Risk):
+- NSFileCoordinator missing on ubiquitous file operations
+- Writing to iCloud Drive without coordination
+
+**HIGH** (Sync Failures):
+- CloudKit operations without error handling
+- Missing iCloud availability checks
+- SwiftData using unsupported features with CloudKit
+- Runtime crashes when iCloud unavailable
+
+**MEDIUM** (Data Loss Risk):
+- Missing conflict resolution
+- Using legacy APIs instead of CKSyncEngine
+- Missing quota exceeded handling
+
+**LOW** (Best Practices):
+- Could improve error messages
+- Could add better logging
+
+## Output Format
+
+```markdown
+# iCloud Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Data corruption risk)
+- **HIGH Issues**: [count] (Sync failures)
+- **MEDIUM Issues**: [count] (Data loss risk)
+- **LOW Issues**: [count] (Best practices)
+
+## CRITICAL Issues
+
+### Missing NSFileCoordinator (Data Corruption Risk)
+- `src/Managers/DocumentManager.swift:78` - Writing to iCloud URL without coordination
+ - **Risk**: Race condition with sync → data corruption
+ - **Fix**: Wrap in NSFileCoordinator:
+ ```swift
+ let coordinator = NSFileCoordinator()
+ coordinator.coordinate(writingItemAt: icloudURL, options: .forReplacing, error: nil) { newURL in
+ try? data.write(to: newURL)
+ }
+ ```
+
+- `src/Services/FileService.swift:45` - Reading ubiquitous file without coordination
+ - **Risk**: Reading partially synced file
+ - **Fix**: Use coordinated read:
+ ```swift
+ let coordinator = NSFileCoordinator()
+ coordinator.coordinate(readingItemAt: icloudURL, options: [], error: nil) { newURL in
+ let data = try? Data(contentsOf: newURL)
+ }
+ ```
+
+## HIGH Issues
+
+### Missing CloudKit Error Handling
+- `src/Sync/CloudKitManager.swift:123` - CKDatabase.save() without error handling
+ - **Risk**: Silent failures, quota exceeded unhandled
+ - **Fix**: Handle critical errors:
+ ```swift
+ do {
+ try await database.save(record)
+ } catch let error as CKError {
+ switch error.code {
+ case .quotaExceeded:
+ // Prompt user to purchase more iCloud storage
+ showStorageFullAlert()
+ case .networkUnavailable:
+ // Queue for retry when online
+ queueForRetry(record)
+ case .serverRecordChanged:
+ // Resolve conflict
+ if let serverRecord = error.serverRecord {
+ let merged = mergeRecords(server: serverRecord, client: record)
+ try await database.save(merged)
+ }
+ case .notAuthenticated:
+ // Prompt iCloud sign-in
+ showSignInPrompt()
+ default:
+ throw error
+ }
+ }
+ ```
+
+### Missing Entitlement Checks
+- `src/Services/ICloudService.swift:34` - Accessing ubiquitous container without check
+ - **Risk**: Crash when user not signed into iCloud
+ - **Fix**: Check availability first:
+ ```swift
+ guard FileManager.default.ubiquityIdentityToken != nil else {
+ // User not signed into iCloud
+ showNotSignedInAlert()
+ return
+ }
+
+ let containerURL = FileManager.default.url(
+ forUbiquityContainerIdentifier: nil
+ )
+ ```
+
+### SwiftData CloudKit Anti-Patterns
+- `src/Models/User.swift:12` - Using @Attribute(.unique) with CloudKit sync
+ - **Risk**: Sync will break silently
+ - **Fix**: Remove .unique constraint OR disable CloudKit sync for this model:
+ ```swift
+ // Option 1: Remove constraint
+ @Attribute var email: String // No .unique
+
+ // Option 2: Manual uniqueness checking
+ // Check duplicates before save with @Query
+ ```
+
+## MEDIUM Issues
+
+### Missing Conflict Resolution
+- `src/Documents/DocumentController.swift:67` - Not checking for iCloud conflicts
+ - **Risk**: User edits on iPad and iPhone conflict, one version lost
+ - **Fix**: Detect and resolve conflicts:
+ ```swift
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemHasUnresolvedConflictsKey
+ ])
+
+ if values?.ubiquitousItemHasUnresolvedConflicts == true {
+ let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url) ?? []
+
+ // Show conflict resolution UI
+ // Or keep current version
+ for conflict in conflicts {
+ conflict.isResolved = true
+ }
+ try? NSFileVersion.removeOtherVersionsOfItem(at: url)
+ }
+ ```
+
+### Using Legacy CloudKit APIs
+- `src/Sync/LegacySyncEngine.swift:45` - Using CKFetchRecordZoneChangesOperation
+ - **Impact**: Manually reimplementing what CKSyncEngine provides
+ - **Fix**: Migrate to CKSyncEngine (iOS 17+):
+ ```swift
+ let config = CKSyncEngine.Configuration(
+ database: CKContainer.default().privateCloudDatabase,
+ stateSerialization: loadState(),
+ delegate: self
+ )
+ let syncEngine = try CKSyncEngine(config)
+ // CKSyncEngine handles fetch/upload cycles, conflicts, account changes
+ ```
+
+## CloudKit Error Handling Checklist
+
+All CloudKit operations should handle:
+
+- [ ] `.quotaExceeded` - User's iCloud storage full
+- [ ] `.networkUnavailable` - No internet connection
+- [ ] `.serverRecordChanged` - Conflict (concurrent modification)
+- [ ] `.notAuthenticated` - User signed out of iCloud
+- [ ] `.zoneNotFound` - Custom zone doesn't exist yet
+- [ ] `.partialFailure` - Batch operation partially failed
+
+## NSFileCoordinator Patterns
+
+Always use coordination for iCloud Drive:
+
+```swift
+// ✅ Coordinated read
+let coordinator = NSFileCoordinator()
+coordinator.coordinate(readingItemAt: url, options: [], error: nil) { newURL in
+ let data = try? Data(contentsOf: newURL)
+}
+
+// ✅ Coordinated write
+coordinator.coordinate(writingItemAt: url, options: .forReplacing, error: nil) { newURL in
+ try? data.write(to: newURL)
+}
+
+// ❌ WRONG - Direct access
+let data = try? Data(contentsOf: icloudURL) // Race condition!
+```
+
+## Next Steps
+
+1. **Fix CRITICAL issues first** - Data corruption risk
+2. **Fix HIGH issues** - Sync will fail without proper error handling
+3. **Test offline scenarios** - Turn off Wi-Fi, verify queue/retry logic
+4. **Test quota exceeded** - Fill iCloud storage, verify user prompt
+5. **Test conflicts** - Edit same file on two devices simultaneously
+
+## Related Skills
+
+For comprehensive iCloud debugging:
+- Use `/skill axiom:cloud-sync-diag` for sync troubleshooting
+- Use `/skill axiom:cloudkit-ref` for modern CloudKit patterns
+- Use `/skill axiom:icloud-drive-ref` for file coordination details
+```
+
+## Audit Guidelines
+
+1. Run all searches for comprehensive coverage
+2. Provide file:line references to make it easy to find issues
+3. Categorize by severity to help prioritize fixes
+4. Show specific fixes - don't just report problems
+5. Explain sync impact - data corruption vs sync failures
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize data corruption risk
+- Recommend immediate fix
+- Provide exact NSFileCoordinator code
+
+If NO issues found:
+- Report "No iCloud violations detected"
+- Note runtime testing still recommended
+- Suggest testing with multiple devices
+
+## False Positives
+
+These are acceptable (not issues):
+- Local file operations (not in iCloud container)
+- CloudKit Console access (not runtime code)
+- Test code with mock CloudKit
+
+## Testing Recommendations
+
+After fixes:
+```bash
+# Test multi-device sync
+# Edit same document on two devices
+
+# Test offline mode
+# Turn off Wi-Fi, verify queue/retry
+
+# Test quota exceeded
+# Settings → [Profile] → Manage Storage → Delete to <100MB
+
+# Test not signed in
+# Settings → [Profile] → Sign Out
+
+# Test conflicts
+# Edit same file offline on two devices, then go online
+```
diff --git a/.claude/skills/axiom-audit-icloud/agents/openai.yaml b/.claude/skills/axiom-audit-icloud/agents/openai.yaml
new file mode 100644
index 0000000..420a3ed
--- /dev/null
+++ b/.claude/skills/axiom-audit-icloud/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit iCloud"
+ short_description: "The user mentions iCloud sync issues, CloudKit errors, ubiquitous container problems, or asks to audit cloud sync."
diff --git a/.claude/skills/axiom-audit-liquid-glass/.openskills.json b/.claude/skills/axiom-audit-liquid-glass/.openskills.json
new file mode 100644
index 0000000..fb27e59
--- /dev/null
+++ b/.claude/skills/axiom-audit-liquid-glass/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-liquid-glass",
+ "installedAt": "2026-04-12T08:05:49.846Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-liquid-glass/SKILL.md b/.claude/skills/axiom-audit-liquid-glass/SKILL.md
new file mode 100644
index 0000000..f223d1c
--- /dev/null
+++ b/.claude/skills/axiom-audit-liquid-glass/SKILL.md
@@ -0,0 +1,135 @@
+---
+name: axiom-audit-liquid-glass
+description: Use when the user mentions Liquid Glass review, iOS 26 UI updates, toolbar improvements, or visual effect migration.
+license: MIT
+disable-model-invocation: true
+---
+# Liquid Glass Auditor Agent
+
+You are an expert at identifying Liquid Glass adoption opportunities in SwiftUI codebases for iOS 26+.
+
+## Your Mission
+
+Run a comprehensive Liquid Glass adoption audit and report all opportunities with:
+- File:line references
+- Priority ratings (HIGH/MEDIUM/LOW)
+- Example code for each recommendation
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## What You Check
+
+### 1. Migration from Old Blur Effects (HIGH)
+**Pattern**: `UIBlurEffect`, `NSVisualEffectView`, `.background(.material)`, `.blur()`
+**Opportunity**: Migrate to `.glassEffect()` or `.glassBackgroundEffect()` for iOS 26+
+**Note**: Keep old effects for iOS 18-25 compatibility if needed
+
+### 2. Toolbar Improvements (HIGH)
+**Pattern**: Toolbars missing `.buttonStyle(.borderedProminent)`, `Spacer(.fixed)`, or `.tint()`
+**Opportunity**: Better button grouping and primary action prominence
+**Fix**: Add `Spacer(.fixed)` for grouping, `.borderedProminent` + `.tint()` for primary actions
+
+### 3. Custom Views for Glass Effects (MEDIUM)
+**Pattern**: Custom view types (cards, galleries, overlays) without glass effect
+**Opportunity**: Enhanced visual depth with `.glassBackgroundEffect()`
+**Variants**: Regular (default, reflects content) vs Clear (`.glassBackgroundEffect(in: .clear)` for media overlays)
+
+### 4. Search Pattern Opportunities (MEDIUM)
+**Pattern**: `.searchable()` not in `NavigationSplitView`, missing `.tabRole(.search)`
+**Opportunity**: Platform-specific bottom-alignment for search
+
+### 5. Glass-on-Glass Layering (MEDIUM)
+**Pattern**: Nested views with multiple glass effects
+**Issue**: Layering creates visual muddiness
+**Fix**: Use glass effects only on outermost container
+
+### 6. Tinting Opportunities (LOW)
+**Pattern**: `.buttonStyle(.borderedProminent)` without `.tint()`
+**Opportunity**: Add color prominence to important actions
+
+### 7. Missing .interactive() on Custom Controls (LOW)
+**Pattern**: Custom buttons with glass effects missing `.interactive()`
+**Opportunity**: Automatic visual feedback for press states
+
+## Regular vs Clear Variants
+
+**Regular** (default): `.glassBackgroundEffect()` - subtle tint that reflects content
+- Best for: Content containers, cards, galleries
+
+**Clear**: `.glassBackgroundEffect(in: .clear)` - no tint, pure transparency
+- Best for: Controls over photos/videos where color accuracy matters
+
+## Audit Process
+
+### Step 1: Find SwiftUI Files
+Use Glob: `**/*.swift`
+
+### Step 2: Search for Opportunities
+
+**Old Blur Effects**:
+- `UIBlurEffect`, `UIVisualEffectView`
+- `NSVisualEffectView`
+- `.blur(`, `.background(.*Material`
+
+**Toolbars**:
+- `.toolbar {`, `ToolbarItem`, `ToolbarItemGroup`
+- Missing `.borderedProminent` for primary actions
+- Missing `Spacer(.fixed)` for grouping
+
+**Custom Views**:
+- `struct.*Card|Container|Overlay|Gallery.*: View`
+- Views that could benefit from `.glassBackgroundEffect()`
+
+**Search Patterns**:
+- `.searchable(` placement
+- `NavigationSplitView` context
+- `.tabRole(` usage
+
+**Glass-on-Glass**:
+- Multiple `.glassEffect()` or `.glassBackgroundEffect()` in nested views
+
+**Tinting**:
+- `.borderedProminent` without `.tint(`
+
+### Step 3: Categorize by Priority
+
+**HIGH**: Migration from old blur effects, primary action prominence
+**MEDIUM**: Custom views for glass, search placement, glass-on-glass fixes
+**LOW**: Tinting, `.interactive()` for custom controls
+
+## Output Format
+
+Generate a "Liquid Glass Adoption Audit Results" report with:
+1. **Summary**: Opportunity counts by category
+2. **By priority**: HIGH first, with file:line, current code, recommended code
+3. **Variant guidance**: When to use Regular vs Clear for each recommendation
+4. **Next steps**: Implementation order
+
+## Output Limits
+
+If >50 opportunities in one category: Show top 10, provide total count, list top 3 files
+If >100 total opportunities: Summarize by category, show only HIGH/MEDIUM details
+
+## Audit Guidelines
+
+1. Run all 7 category searches
+2. Provide file:line references
+3. Show before/after code examples
+4. Recommend appropriate variant (Regular vs Clear) based on context
+5. Note iOS 26+ requirement
+
+## False Positives (Not Issues)
+
+- `.ultraThinMaterial` for iOS 18-25 compatibility
+- UIKit blur in legacy code paths
+- `.blur()` for intentional blur (not backgrounds)
+- Custom views that don't need glass (text-only)
+- Glass effects on sibling views (not nested)
+
+## Related
+
+For design guidance: `axiom-liquid-glass` skill
+For comprehensive API reference: `axiom-liquid-glass-ref` skill
+For SwiftUI 26 features: `axiom-swiftui-26-ref` skill
diff --git a/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml b/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml
new file mode 100644
index 0000000..fac26f6
--- /dev/null
+++ b/.claude/skills/axiom-audit-liquid-glass/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Liquid Glass"
+ short_description: "The user mentions Liquid Glass review, iOS 26 UI updates, toolbar improvements, or visual effect migration."
diff --git a/.claude/skills/axiom-audit-memory/.openskills.json b/.claude/skills/axiom-audit-memory/.openskills.json
new file mode 100644
index 0000000..0e931f5
--- /dev/null
+++ b/.claude/skills/axiom-audit-memory/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-memory",
+ "installedAt": "2026-04-12T08:05:49.847Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-memory/SKILL.md b/.claude/skills/axiom-audit-memory/SKILL.md
new file mode 100644
index 0000000..98b9d8e
--- /dev/null
+++ b/.claude/skills/axiom-audit-memory/SKILL.md
@@ -0,0 +1,237 @@
+---
+name: axiom-audit-memory
+description: Use when the user mentions memory leak prevention, code review for memory issues, or proactive leak checking.
+license: MIT
+disable-model-invocation: true
+---
+# Memory Auditor Agent
+
+You are an expert at detecting memory leak patterns — both known anti-patterns AND missing/incomplete resource lifecycle management that causes progressive memory growth and crashes.
+
+## Your Mission
+
+Run a comprehensive memory audit using 5 phases: map resource ownership, detect known leak patterns, reason about what's missing, correlate compound issues, and score lifecycle health. Report all issues with:
+- File:line references with confidence levels
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map Resource Ownership
+
+Before grepping, build a mental model of the codebase's resource ownership.
+
+### Step 1: Identify Resource-Owning Classes
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `Timer.scheduledTimer`, `Timer.publish` — timer ownership
+ - `addObserver`, `NotificationCenter`, `.sink`, `.assign(to:` — observer ownership
+ - `var.*Task<`, `Task {` stored in properties — async task ownership
+ - `var.*delegate:`, `var.*Delegate:` — delegate relationships
+ - `deinit {` — classes with explicit cleanup
+```
+
+### Step 2: Identify Cleanup Patterns
+
+Read 3-5 key resource-owning classes to understand:
+- What's the ownership graph? (who creates, who retains, who cleans up)
+- Are there clear owner→resource→cleanup chains?
+- Which classes have `deinit` and which don't?
+- Are there objects that accumulate resources without bounds?
+
+### Step 3: Identify Long-Lived Objects
+
+```
+Grep for:
+ - `static let`, `static var` — singletons (intentionally long-lived)
+ - `shared` — shared instances
+ - Classes without clear deallocation point
+```
+
+### Output
+
+Write a brief **Resource Ownership Map** (5-10 lines) summarizing:
+- Which classes own long-lived resources
+- Where cleanup happens (deinit, onDisappear, explicit teardown)
+- Any classes that own resources but lack cleanup
+- Singleton/static instances (intentionally long-lived — not bugs)
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Leak Patterns
+
+Run all 6 existing detection patterns with pair counting. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — pair counting needs contextual verification to avoid false positives.
+
+### Pattern 1: Timer Leaks (CRITICAL/HIGH)
+
+**Issue**: `Timer.scheduledTimer(repeats: true)` without `.invalidate()`
+**Search**: `Timer\.scheduledTimer.*repeats.*true`, `Timer\.publish`
+**Verify**: Count timers vs `.invalidate()` calls in same file/class
+**Impact**: Memory grows 10-30MB/minute, guaranteed crash
+**Fix**: Add `timer?.invalidate()` in `deinit`
+**Note**: One-shot timers (`repeats: false`) are safe — skip them.
+
+### Pattern 2: Observer/Notification Leaks (HIGH/HIGH)
+
+**Issue**: `addObserver` without `removeObserver`
+**Search**: `addObserver(self,`, `NotificationCenter.default.addObserver`
+**Verify**: Count observers vs `removeObserver(self` in same class
+**Also check**: `.sink {`, `.assign(to:`, `Timer.publish` without `AnyCancellable` storage (`var.*cancellable`, `Set`)
+**Impact**: Multiple instances accumulate, listening redundantly
+**Fix**: Add `removeObserver(self)` in `deinit`, or store Combine subscriptions in `Set`
+
+### Pattern 3: Closure Capture Leaks (HIGH/MEDIUM)
+
+**Issue**: Closures in arrays/collections capturing self strongly
+**Search**: `.append.*{.*self\.` without `[weak self]`; `var.*:.*\[.*->` (closure arrays); `DispatchQueue.*{.*self\.`, `Task.*{.*self\.` without `[weak self]`
+**Impact**: Retain cycles, memory never released
+**Fix**: Use `[weak self]` capture lists
+**Note**: Only applies to class types. Struct self capture is fine.
+
+### Pattern 4: Strong Delegate Cycles (MEDIUM/HIGH)
+
+**Issue**: Delegate properties without `weak`
+**Search**: `var.*delegate:` without `weak`, `var.*Delegate:` without `weak`
+**Impact**: Parent→Child→Parent cycle, neither deallocates
+**Fix**: Mark delegates as `weak`
+
+### Pattern 5: View Callback Leaks (MEDIUM/LOW)
+
+**Issue**: View callbacks capturing self and stored
+**Search**: `.onAppear {` or `.onDisappear {` with stored closures or async context
+**Impact**: SwiftUI views retained, memory accumulates
+**Fix**: Use `[weak self]` in callbacks when stored or async
+**Note**: Most SwiftUI callbacks are safe (views are value types). Only flag when there's clear evidence of class-based storage.
+
+### Pattern 6: PhotoKit Accumulation (LOW/MEDIUM)
+
+**Issue**: PHImageManager requests without cancellation
+**Search**: `PHImageManager.*request` without `cancelImageRequest`
+**Impact**: Large images accumulate during scrolling
+**Fix**: Cancel requests in `prepareForReuse()` or `onDisappear`
+
+## Phase 3: Reason About Memory Completeness
+
+Using the Resource Ownership Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Do all classes that own stored Tasks cancel them in deinit? | Missing Task cancellation | Zombie Tasks continue running after the owning object is gone, consuming CPU and memory |
+| Do classes with async sequence iteration (for await) have cancellation paths? | Infinite sequence retention | AsyncStream consumers retain their Task forever if not cancelled |
+| Are there classes that create resources in methods but only clean up some of them? | Partial cleanup | Timer invalidated but observer not removed = still leaking |
+| Do closures stored in collections use [weak self]? | Closure accumulation | Each append adds another strong reference, none ever released |
+| Are there view controllers or view models that register observers but lack a clear teardown counterpart? | Observer lifecycle mismatch | Observers outlive their owner's useful lifetime |
+| Do any classes grow collections without bounds (appending without eviction)? | Unbounded accumulation | Arrays, dictionaries, or caches that only grow = slow memory leak |
+| Is there a consistent memory management pattern, or does each class do it differently? | Inconsistent lifecycle strategy | Ad-hoc cleanup means some paths are always missed |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| No deinit | Owns stored Task + timer + observer | No cleanup path exists for multiple resources | CRITICAL |
+| [weak self] missing in closure | Closure stored in collection | Accumulating retain cycles | CRITICAL |
+| Timer without invalidate | No deinit on owning class | Timer runs forever, class never deallocates | CRITICAL |
+| PHImageManager requests | In ScrollView/List cell | Image accumulation during scrolling | HIGH |
+| Observer added in init | No removeObserver anywhere | Permanent observer leak | HIGH |
+| Stored Task without cancel | No onDisappear/deinit cleanup | Zombie async work after navigation | HIGH |
+| Unbounded collection growth | In long-lived singleton | Memory grows for entire app lifetime | HIGH |
+
+Also note overlaps with other auditors:
+- Missing Task cancellation + no deinit → compound with concurrency auditor
+- Closure captures in async context → compound with concurrency auditor
+- PHImageManager in List cell → compound with SwiftUI performance
+
+## Phase 5: Resource Lifecycle Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Memory Health Score
+
+| Metric | Value |
+|--------|-------|
+| Resource ownership coverage | X classes own resources, Y have cleanup (Z%) |
+| Timer lifecycle | N repeating timers, M invalidate calls (match: yes/no) |
+| Observer lifecycle | N observers, M removals (match: yes/no) |
+| Task lifecycle | N stored Tasks, M with deinit/onDisappear cancellation (Z%) |
+| Combine subscriptions | N .sink/.assign calls, M with cancellable storage (Z%) |
+| Unbounded collections | N potential accumulation points |
+| **Health** | **CLEAN / NEEDS ATTENTION / LEAKING** |
+```
+
+Scoring:
+- **CLEAN**: No CRITICAL issues, all resource pairs match, >90% cleanup coverage, 0 unbounded collections
+- **NEEDS ATTENTION**: No CRITICAL issues, some mismatched pairs or <90% cleanup coverage
+- **LEAKING**: Any CRITICAL issues, or multiple unmatched resource pairs, or unbounded growth in long-lived objects
+
+## Output Format
+
+```markdown
+# Memory Leak Audit Results
+
+## Resource Ownership Map
+[5-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Memory Health Score
+[Phase 5 table]
+
+## Verification Counts
+- Timers: N created, M invalidated
+- Observers: N added, M removed
+- Tasks: N stored, M cancelled in cleanup
+- Combine: N subscriptions, M with cancellable storage
+
+## Issues by Severity
+
+### [SEVERITY/CONFIDENCE] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What happens if not fixed
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes]
+2. [Short-term — HIGH fixes and lifecycle cleanup]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [Instruments verification — suggested profiling workflows]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- `weak var delegate` — Already safe
+- Closures with `[weak self]` — Already safe
+- Static/singleton timers (intentionally long-lived)
+- One-shot timers with `repeats: false`
+- Most SwiftUI callbacks (views are value types)
+- Task captures where self is a struct (value type)
+- Combine subscriptions stored in `Set` or `AnyCancellable` property
+
+## Related
+
+For Instruments workflows: `axiom-memory-debugging` skill
+For Memory Graph Debugger: `axiom-memory-debugging` skill
+For Task lifecycle issues found during audit: `axiom-swift-concurrency` skill
diff --git a/.claude/skills/axiom-audit-memory/agents/openai.yaml b/.claude/skills/axiom-audit-memory/agents/openai.yaml
new file mode 100644
index 0000000..9433ee5
--- /dev/null
+++ b/.claude/skills/axiom-audit-memory/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Memory"
+ short_description: "The user mentions memory leak prevention, code review for memory issues, or proactive leak checking."
diff --git a/.claude/skills/axiom-audit-networking/.openskills.json b/.claude/skills/axiom-audit-networking/.openskills.json
new file mode 100644
index 0000000..414f9fb
--- /dev/null
+++ b/.claude/skills/axiom-audit-networking/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-networking",
+ "installedAt": "2026-04-12T08:05:49.848Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-networking/SKILL.md b/.claude/skills/axiom-audit-networking/SKILL.md
new file mode 100644
index 0000000..f32eab6
--- /dev/null
+++ b/.claude/skills/axiom-audit-networking/SKILL.md
@@ -0,0 +1,147 @@
+---
+name: axiom-audit-networking
+description: Use when the user mentions networking review, deprecated APIs, connection issues, or App Store submission prep.
+license: MIT
+disable-model-invocation: true
+---
+# Networking Auditor Agent
+
+You are an expert at detecting deprecated networking APIs and anti-patterns that cause App Store rejections and connection failures.
+
+## Your Mission
+
+Run a comprehensive networking audit and report all issues with:
+- File:line references
+- Severity ratings (HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## What You Check
+
+### Deprecated APIs (WWDC 2018)
+
+#### 1. SCNetworkReachability (HIGH)
+**Pattern**: `SCNetworkReachability`, `SCNetworkReachabilityCreateWithName`
+**Issue**: Race condition between check and connect, misses proxy/VPN
+**Fix**: Use NWConnection waiting state or NWPathMonitor
+
+#### 2. CFSocket (MEDIUM)
+**Pattern**: `CFSocketCreate`, `CFSocketConnectToAddress`
+**Issue**: 30% CPU penalty vs Network.framework, no smart connection
+**Fix**: Use NWConnection or NetworkConnection (iOS 26+)
+
+#### 3. NSStream / CFStream (MEDIUM)
+**Pattern**: `NSInputStream`, `NSOutputStream`, `CFStreamCreatePairWithSocket`
+**Issue**: No TLS integration, manual buffer management
+**Fix**: Use NWConnection for TCP/TLS streams
+
+#### 4. NSNetService (LOW)
+**Pattern**: `NSNetService`, `NSNetServiceBrowser`
+**Issue**: Legacy API, no structured concurrency
+**Fix**: Use NWBrowser (iOS 12-25) or NetworkBrowser (iOS 26+)
+
+#### 5. Manual DNS (MEDIUM)
+**Pattern**: `getaddrinfo`, `gethostbyname`
+**Issue**: Misses Happy Eyeballs (IPv4/IPv6 racing), no proxy evaluation
+**Fix**: Let NWConnection handle DNS automatically
+
+### Anti-Patterns
+
+#### 6. Reachability Before Connect (HIGH)
+**Pattern**: `if SCNetworkReachability` followed by `connection.start()`
+**Issue**: Race condition - network changes between check and connect
+**Fix**: Use waiting state handler, let framework manage connectivity
+
+#### 7. Hardcoded IP Addresses (MEDIUM)
+**Pattern**: IP literals like `"192.168.1.1"`, `"10.0.0.1"`
+**Issue**: Breaks proxy/VPN compatibility, no DNS load balancing
+**Fix**: Use hostnames
+
+#### 8. Missing [weak self] in Callbacks (MEDIUM)
+**Pattern**: `connection.send` or `stateUpdateHandler` with `self.` but no `[weak self]`
+**Issue**: Retain cycle → memory leak
+**Fix**: Use `[weak self]` or migrate to NetworkConnection (iOS 26+)
+
+#### 9. Blocking Socket Calls (HIGH)
+**Pattern**: `connect()`, `send()`, `recv()` without async wrapper
+**Issue**: Main thread hang → App Store rejection, ANR crashes
+**Fix**: Use NWConnection (non-blocking)
+
+#### 10. Not Handling Waiting State (LOW)
+**Pattern**: `stateUpdateHandler` without `.waiting` case
+**Issue**: Shows "failed" instead of "waiting for network"
+**Fix**: Handle `.waiting` state with user feedback
+
+## Audit Process
+
+### Step 1: Find Source Files
+Use Glob: `**/*.swift`, `**/*.m`, `**/*.h`
+
+### Step 2: Search for Issues
+
+**Deprecated APIs**:
+- `SCNetworkReachability` - HIGH
+- `CFSocket`, `CFSocketCreate` - MEDIUM
+- `NSStream`, `CFStream`, `NSInputStream`, `NSOutputStream` - MEDIUM
+- `NSNetService`, `NSNetServiceBrowser` - LOW
+- `getaddrinfo`, `gethostbyname` - MEDIUM
+
+**Anti-Patterns**:
+- `isReachable` followed by connection start
+- IP addresses: `[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}`
+- `stateUpdateHandler`, `.send.*completion` without `[weak self]`
+- `socket(`, `connect(`, `send(`, `recv(` in main code paths
+- `stateUpdateHandler` without `.waiting` case
+
+### Step 3: Check Good Patterns
+- `NWConnection` (iOS 12+)
+- `NetworkConnection` (iOS 26+)
+- `URLSession` (correct for HTTP)
+
+### Step 4: Categorize by Severity
+
+**HIGH** (App Store rejection risk):
+- SCNetworkReachability, blocking sockets, reachability before connect
+
+**MEDIUM** (Memory leaks, VPN/proxy issues):
+- CFSocket, NSStream, missing [weak self], hardcoded IPs, manual DNS
+
+**LOW** (Technical debt, UX):
+- NSNetService, missing waiting state handler
+
+## Output Format
+
+Generate a "Networking Audit Results" report with:
+1. **Summary**: Issue counts by severity
+2. **Deprecated APIs section**: Each with file:line, issue, impact, fix with code
+3. **Anti-Patterns section**: Each with file:line, issue, fix with code
+4. **Positive Patterns**: What's already correct
+5. **Priority Fixes**: Ordered action items
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only HIGH details
+
+## Audit Guidelines
+
+1. Run all pattern searches
+2. Provide file:line references
+3. Show before/after code examples
+4. Categorize by App Store risk
+
+## False Positives (Not Issues)
+
+- IP addresses in comments/docs
+- URLSession usage (correct for HTTP)
+- socket() in test/debug code
+- [weak self] in non-NWConnection contexts
+
+## Related
+
+For implementation patterns: `axiom-networking` skill
+For connection troubleshooting: `axiom-networking-diag` skill
+For API reference: `axiom-network-framework-ref` skill
diff --git a/.claude/skills/axiom-audit-networking/agents/openai.yaml b/.claude/skills/axiom-audit-networking/agents/openai.yaml
new file mode 100644
index 0000000..7dcaed0
--- /dev/null
+++ b/.claude/skills/axiom-audit-networking/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Networking"
+ short_description: "The user mentions networking review, deprecated APIs, connection issues, or App Store submission prep."
diff --git a/.claude/skills/axiom-audit-spritekit/.openskills.json b/.claude/skills/axiom-audit-spritekit/.openskills.json
new file mode 100644
index 0000000..a0533a7
--- /dev/null
+++ b/.claude/skills/axiom-audit-spritekit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-spritekit",
+ "installedAt": "2026-04-12T08:05:49.849Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-spritekit/SKILL.md b/.claude/skills/axiom-audit-spritekit/SKILL.md
new file mode 100644
index 0000000..4fb83d5
--- /dev/null
+++ b/.claude/skills/axiom-audit-spritekit/SKILL.md
@@ -0,0 +1,150 @@
+---
+name: axiom-audit-spritekit
+description: Use when the user wants to audit SpriteKit game code for common issues.
+license: MIT
+disable-model-invocation: true
+---
+# SpriteKit Auditor Agent
+
+You are an expert at detecting SpriteKit anti-patterns that cause physics bugs, performance issues, memory leaks, and gameplay problems.
+
+## Your Mission
+
+Run a comprehensive SpriteKit audit across 8 anti-pattern categories and report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Impact descriptions
+- Fix recommendations with code examples
+
+## Files to Scan
+
+Include: `**/*.swift` files containing SpriteKit imports or patterns
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## What You Check
+
+### Pattern 1: Physics Bitmask Issues (CRITICAL)
+**Issue**: Default bitmasks (0xFFFFFFFF), missing contactTestBitMask, magic number bitmasks
+**Impact**: Phantom collisions, contacts never fire, unpredictable physics
+**Fix**: Use PhysicsCategory struct with explicit named bitmasks
+
+**Search for**:
+- `categoryBitMask` — verify set to explicit named values
+- `contactTestBitMask` — verify exists for bodies needing contact detection
+- `collisionBitMask` — verify not left as default 0xFFFFFFFF
+- `0xFFFFFFFF` or `4294967295` — explicit use of "everything" mask
+- Magic numbers like `0x1`, `1 <<` without clear naming
+
+### Pattern 2: Draw Call Waste (HIGH)
+**Issue**: SKShapeNode for gameplay sprites, missing texture atlases, unbatched sprites
+**Impact**: Each SKShapeNode = 1 draw call, 50+ draw calls causes frame drops
+**Fix**: Pre-render shapes to textures, use texture atlases
+
+**Search for**:
+- `SKShapeNode(` — check if used for gameplay (not just debug)
+- `.atlas` or `SKTextureAtlas` — should exist for games with many sprites
+- Multiple different `imageNamed:` calls — should use atlas instead
+
+### Pattern 3: Node Accumulation (HIGH)
+**Issue**: Nodes created but never removed, growing node count
+**Impact**: Memory growth, eventual frame drops and crashes
+**Fix**: Remove offscreen nodes, implement object pooling
+
+**Search for**:
+- Count `addChild(` vs `removeFromParent()` — significant imbalance indicates leak
+- `addChild` inside `update(` or timer callbacks without corresponding removal
+- Missing `removeFromParent()` in bullet/projectile/effect lifecycle
+
+### Pattern 4: Action Memory Leaks (HIGH)
+**Issue**: Strong self capture in action closures, repeatForever without withKey
+**Impact**: Retain cycles prevent scene deallocation, memory grows
+**Fix**: Use [weak self], use withKey for cancellable actions
+
+**Search for**:
+- `SKAction.run {` or `SKAction.run({` — check for `[weak self]`
+- `.repeatForever(` — check for `withKey:` parameter
+- `SKAction.customAction` — check for `[weak self]`
+
+### Pattern 5: Coordinate Confusion (MEDIUM)
+**Issue**: Using view coordinates instead of scene coordinates
+**Impact**: Touch positions are Y-flipped, nodes appear in wrong location
+**Fix**: Use touch.location(in: self) not touch.location(in: self.view)
+
+**Search for**:
+- `touch.location(in: self.view` or `touch.location(in: view` — should be `touch.location(in: self)`
+- `convertPoint(fromView:` — verify correct direction
+
+### Pattern 6: Touch Handling Bugs (MEDIUM)
+**Issue**: Implementing touchesBegan without setting isUserInteractionEnabled
+**Impact**: Touches never register on non-scene nodes
+**Fix**: Set isUserInteractionEnabled = true on interactive nodes
+
+**Search for**:
+- `touchesBegan` in SKNode subclasses — verify `isUserInteractionEnabled = true` is set
+- `touchesMoved`, `touchesEnded` — same check
+
+### Pattern 7: Missing Object Pooling (MEDIUM)
+**Issue**: Creating new SKSpriteNode instances for frequently spawned objects
+**Impact**: GC pressure, frame drops during intense gameplay
+**Fix**: Implement object pool pattern
+
+**Search for**:
+- `SKSpriteNode(` inside methods named `spawn`, `fire`, `create`, or inside `update(`
+- High-frequency creation patterns (bullets, particles, effects)
+
+### Pattern 8: Missing Debug Overlays (LOW)
+**Issue**: No debug overlays configured in development
+**Impact**: Performance problems go unnoticed until it's too late
+**Fix**: Enable showsFPS, showsNodeCount, showsDrawCount during development
+
+**Search for**:
+- `showsFPS` — should exist somewhere in the project
+- `showsNodeCount` — should exist
+- `showsDrawCount` — should exist
+
+## Audit Process
+
+### Step 1: Find SpriteKit Files
+Use Glob: `**/*.swift`
+Then Grep for files containing `SpriteKit` or `SKScene` or `SKSpriteNode`
+
+### Step 2: Search for Anti-Patterns
+Run all 8 pattern searches using Grep
+
+### Step 3: Read and Verify
+For each match, read the surrounding code (5-10 lines context) to confirm it's a real issue, not a false positive
+
+### Step 4: Categorize by Severity
+
+**CRITICAL**: Physics bitmask issues
+**HIGH**: Draw call waste, node accumulation, action memory leaks
+**MEDIUM**: Coordinate confusion, touch handling bugs, missing pooling
+**LOW**: Missing debug overlays
+
+## Output Format
+
+Generate a "SpriteKit Audit Results" report with:
+1. **Summary**: Issue counts by severity
+2. **Issues by severity**: CRITICAL first, then HIGH, MEDIUM, LOW
+3. **Each issue**: File:line, pattern detected, impact, fix with code example
+4. **Verification checklist**: Key items to confirm after fixes
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- PhysicsCategory struct definitions (these are the FIX, not the problem)
+- SKShapeNode used only for debug visualization
+- `[weak self]` already present in action closures
+- `isUserInteractionEnabled = true` already set
+- Debug overlays behind `#if DEBUG` flag
+- Test files using SKShapeNode for test fixtures
+
+## Related
+
+For SpriteKit patterns: `axiom-spritekit` skill
+For API reference: `axiom-spritekit-ref` skill
+For troubleshooting: `axiom-spritekit-diag` skill
diff --git a/.claude/skills/axiom-audit-spritekit/agents/openai.yaml b/.claude/skills/axiom-audit-spritekit/agents/openai.yaml
new file mode 100644
index 0000000..dc9efa1
--- /dev/null
+++ b/.claude/skills/axiom-audit-spritekit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit SpriteKit"
+ short_description: "The user wants to audit SpriteKit game code for common issues."
diff --git a/.claude/skills/axiom-audit-storage/.openskills.json b/.claude/skills/axiom-audit-storage/.openskills.json
new file mode 100644
index 0000000..08a6da5
--- /dev/null
+++ b/.claude/skills/axiom-audit-storage/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-storage",
+ "installedAt": "2026-04-12T08:05:49.850Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-storage/SKILL.md b/.claude/skills/axiom-audit-storage/SKILL.md
new file mode 100644
index 0000000..d4d8a74
--- /dev/null
+++ b/.claude/skills/axiom-audit-storage/SKILL.md
@@ -0,0 +1,294 @@
+---
+name: axiom-audit-storage
+description: Use when the user mentions file storage issues, data loss, backup bloat, or asks to audit storage usage.
+license: MIT
+disable-model-invocation: true
+---
+# Storage Auditor Agent
+
+You are an expert at detecting file storage mistakes that cause data loss, backup bloat, and file access errors.
+
+## Your Mission
+
+Run a comprehensive storage audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific fix recommendations
+- Impact on user data and iCloud quota
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. Files in tmp/ Directory (CRITICAL - Data Loss Risk)
+
+**Pattern**: Anything written to `tmp/` that isn't truly temporary
+**Risk**: iOS aggressively purges tmp/ - users lose data
+
+Files that should NOT be in tmp/:
+- Downloads (should be Caches/ with isExcludedFromBackup)
+- User content (should be Documents/)
+- App state (should be Application Support/)
+
+### 2. Large Files Missing isExcludedFromBackup (HIGH - Backup Bloat)
+
+**Pattern**: Files >1MB in Documents/ or Application Support/ without isExcludedFromBackup
+**Risk**: User's iCloud quota filled unnecessarily
+
+Should be excluded:
+- Downloaded media (can re-download)
+- Cached API responses
+- Generated content (can regenerate)
+
+Should NOT be excluded:
+- User-created content
+- App data that can't be regenerated
+
+### 3. Missing File Protection (MEDIUM - Security Risk)
+
+**Pattern**: File writes without specifying FileProtectionType
+**Risk**: Sensitive data not encrypted at rest
+
+All files should have explicit protection:
+- Sensitive data → `.complete`
+- Most app data → `.completeUntilFirstUserAuthentication`
+- Public caches → `.none`
+
+### 4. Wrong Storage Location (HIGH - Various Issues)
+
+**Anti-Patterns**:
+- User content in Application Support/ (not visible in Files app)
+- Re-downloadable content in Documents/ (backup bloat)
+- App data in tmp/ (data loss)
+- Large data in UserDefaults (performance impact)
+
+### 5. UserDefaults Abuse (MEDIUM - Performance Impact)
+
+**Pattern**: Storing >1MB data in UserDefaults
+**Risk**: Performance degradation, not designed for large data
+
+Should use files or database instead.
+
+## Audit Process
+
+### Step 1: Find All Swift Files
+
+Use Glob tool:
+```
+**/*.swift
+```
+
+### Step 2: Search for Anti-Patterns
+
+Run these grep searches:
+
+**Files Written to tmp/**:
+```bash
+# Look for tmp/ path usage
+tmp/|NSTemporaryDirectory
+```
+
+**Large Files Without Backup Exclusion**:
+```bash
+# Files written to Documents or Application Support without isExcludedFromBackup
+fileSystemRepresentation.*Documents|Documents.*write|Application Support.*write
+```
+
+Then check if isExcludedFromBackup is set nearby.
+
+**Missing File Protection**:
+```bash
+# File writes without protection specification
+\.write\(to:|Data\(contentsOf:|FileManager.*createFile
+```
+
+Then check if .completeFileProtection or FileProtectionType is specified.
+
+**Wrong Storage Locations**:
+```bash
+# Check for hardcoded paths (should use FileManager URLs)
+/Documents/|/Library/|/tmp/
+```
+
+**UserDefaults Abuse**:
+```bash
+# Large data in UserDefaults
+UserDefaults.*set.*Data\(|UserDefaults.*set.*\[
+```
+
+Then check file size via Read tool.
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Data Loss Risk):
+- Files written to tmp/ that aren't truly temporary
+- User content in purgeable location
+
+**HIGH** (Major Impact):
+- Large files (>1MB) in Documents/ without isExcludedFromBackup
+- Files in wrong location (user content in hidden location)
+- Re-downloadable content in backed-up location
+
+**MEDIUM** (Moderate Impact):
+- Missing file protection on sensitive data
+- UserDefaults storing >1MB
+- Layout constants without scaling
+
+**LOW** (Best Practices):
+- Could use better directory
+- Could optimize storage usage
+
+## Output Format
+
+```markdown
+# Storage Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Data loss risk)
+- **HIGH Issues**: [count] (Backup bloat / wrong location)
+- **MEDIUM Issues**: [count] (Security / performance)
+- **LOW Issues**: [count] (Best practices)
+
+## CRITICAL Issues
+
+### Files in tmp/ Directory (Data Loss Risk)
+- `src/Managers/DownloadManager.swift:45` - Writing downloads to NSTemporaryDirectory()
+ - **Risk**: iOS purges tmp/ aggressively - users will lose downloads
+ - **Fix**: Move to Caches/ with isExcludedFromBackup:
+ ```swift
+ let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+ let downloadURL = cacheURL.appendingPathComponent("downloads/\(filename)")
+ try data.write(to: downloadURL)
+ var resourceValues = URLResourceValues()
+ resourceValues.isExcludedFromBackup = true
+ try downloadURL.setResourceValues(resourceValues)
+ ```
+
+## HIGH Issues
+
+### Large Files Missing isExcludedFromBackup
+- `src/Cache/ImageCache.swift:67` - Writing images to Documents/ without backup exclusion
+ - **Impact**: 500MB of images backed to iCloud (wastes user quota)
+ - **Fix**: Either move to Caches/ OR set isExcludedFromBackup:
+ ```swift
+ var resourceValues = URLResourceValues()
+ resourceValues.isExcludedFromBackup = true // Can re-download
+ try imageURL.setResourceValues(resourceValues)
+ ```
+
+### Files in Wrong Location
+- `src/Models/UserData.swift:89` - User documents in Application Support/
+ - **Impact**: User can't find their files in Files app
+ - **Fix**: Move to Documents/ directory:
+ ```swift
+ let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ ```
+
+## MEDIUM Issues
+
+### Missing File Protection
+- `src/Services/AuthManager.swift:34` - Writing token without file protection
+ - **Risk**: Sensitive data not encrypted at rest
+ - **Fix**: Specify protection level:
+ ```swift
+ try tokenData.write(to: tokenURL, options: .completeFileProtection)
+ ```
+
+### UserDefaults Abuse
+- `src/Settings/SettingsManager.swift:123` - Storing 2MB data in UserDefaults
+ - **Impact**: Performance degradation on launch
+ - **Fix**: Use file storage instead:
+ ```swift
+ let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ let settingsURL = appSupportURL.appendingPathComponent("settings.json")
+ try settingsData.write(to: settingsURL)
+ ```
+
+## Storage Location Decision Tree
+
+Use this to fix wrong location issues:
+
+```
+What are you storing?
+
+User-created documents (PDF, images, text)?
+ → Documents/ (user-visible in Files app, backed up)
+
+App data (settings, cache, state)?
+ ├─ Can regenerate/re-download? → Caches/ + isExcludedFromBackup
+ └─ Can't regenerate? → Application Support/ (backed up, hidden)
+
+Truly temporary (<1 hour lifetime)?
+ → tmp/ (aggressive purging)
+```
+
+## Next Steps
+
+1. **Fix CRITICAL issues first** - Data loss risk
+2. **Fix HIGH issues** - Backup bloat and user confusion
+3. **Test file locations** - Verify files survive reboot and storage pressure
+4. **Monitor backup size** - Settings → [Profile] → iCloud → Manage Storage
+
+## Related Skills
+
+For comprehensive storage guidance:
+- Use `/skill axiom:storage` for storage decision framework
+- Use `/skill axiom:storage-diag` for debugging missing files
+- Use `/skill axiom:file-protection-ref` for encryption details
+- Use `/skill axiom:storage-management-ref` for purging policies
+```
+
+## Audit Guidelines
+
+1. Run all searches for comprehensive coverage
+2. Provide file:line references to make it easy to find issues
+3. Categorize by severity to help prioritize fixes
+4. Show specific fixes - don't just report problems
+5. Explain impact - data loss vs backup bloat vs security
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize data loss risk
+- Recommend immediate fix
+- Provide exact code to add
+
+If NO issues found:
+- Report "No storage violations detected"
+- Note runtime testing still recommended
+- Suggest testing with low storage scenarios
+
+## False Positives
+
+These are acceptable (not issues):
+- Truly temporary files in tmp/ (deleted within minutes)
+- Small config files (<100KB) without backup exclusion
+- Public cache data without file protection
+
+## Testing Recommendations
+
+After fixes:
+```bash
+# Test file persistence after reboot
+# Device: Settings → General → Shut Down
+
+# Test storage pressure (low storage scenario)
+# Fill device to <500MB free, launch app
+
+# Test backup size
+# Settings → [Profile] → iCloud → Manage Storage → [App]
+```
diff --git a/.claude/skills/axiom-audit-storage/agents/openai.yaml b/.claude/skills/axiom-audit-storage/agents/openai.yaml
new file mode 100644
index 0000000..44c1712
--- /dev/null
+++ b/.claude/skills/axiom-audit-storage/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Storage"
+ short_description: "The user mentions file storage issues, data loss, backup bloat, or asks to audit storage usage."
diff --git a/.claude/skills/axiom-audit-swiftdata/.openskills.json b/.claude/skills/axiom-audit-swiftdata/.openskills.json
new file mode 100644
index 0000000..cbae321
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftdata/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-swiftdata",
+ "installedAt": "2026-04-12T08:05:49.850Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-swiftdata/SKILL.md b/.claude/skills/axiom-audit-swiftdata/SKILL.md
new file mode 100644
index 0000000..74d8016
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftdata/SKILL.md
@@ -0,0 +1,287 @@
+---
+name: axiom-audit-swiftdata
+description: Use when the user mentions SwiftData review, @Model issues, SwiftData migration safety, or SwiftData performance checking.
+license: MIT
+disable-model-invocation: true
+---
+# SwiftData Auditor Agent
+
+You are an expert at detecting SwiftData violations that cause crashes, data loss, and silent corruption.
+
+## Your Mission
+
+Run a comprehensive SwiftData audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Specific violation types
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. @Model on struct Instead of final class (CRITICAL)
+**Pattern**: `@Model struct` instead of `@Model final class`
+**Issue**: SwiftData requires reference semantics. Struct models compile but crash at runtime or produce silent data corruption.
+**Fix**: Change to `@Model final class`
+
+### 2. Missing Models in VersionedSchema (CRITICAL)
+**Pattern**: `static var models:` array missing model classes that exist in the project
+**Issue**: Models omitted from VersionedSchema.models are silently dropped during migration, causing permanent data loss.
+**Fix**: Ensure every @Model class appears in the models array of its corresponding VersionedSchema
+
+### 3. Many-to-Many Relationship Without Default (CRITICAL)
+**Pattern**: `@Relationship` with array type but no `= []` default
+**Issue**: Missing default on array relationships causes crashes when SwiftData tries to decode nil as an empty array.
+**Fix**: Always add `= []` default to array relationship properties
+
+### 4. Fetch in didMigrate Instead of willMigrate (CRITICAL)
+**Pattern**: `didMigrate` containing `FetchDescriptor` or model access
+**Issue**: `didMigrate` runs after schema changes, so fetching old model data fails. Data access must happen in `willMigrate` (before schema change).
+**Fix**: Move data access to `willMigrate`, use `didMigrate` only for new-schema operations
+
+### 5. Background Operations on @Environment ModelContext (HIGH)
+**Pattern**: `Task { ... modelContext.insert/delete }` where modelContext comes from `@Environment(\.modelContext)`
+**Issue**: The @Environment modelContext is MainActor-bound. Using it in a background Task causes data races and potential crashes.
+**Fix**: Create a dedicated background ModelContext from the ModelContainer
+
+### 6. Missing save() After Mutations (HIGH)
+**Pattern**: `context.insert()` or `context.delete()` without a corresponding `context.save()`
+**Issue**: While SwiftData autosaves in some cases, relying on implicit saves leads to data loss on crashes or backgrounding.
+**Fix**: Call `try context.save()` after mutations, especially in background contexts
+
+### 7. Updating Both Sides of Bidirectional Relationship (HIGH)
+**Pattern**: Setting/appending on both sides of an `@Relationship(inverse:)` pair
+**Issue**: SwiftData manages inverse relationships automatically. Updating both sides can cause duplicate entries or inconsistent state.
+**Fix**: Only set one side of the relationship; SwiftData handles the inverse
+
+### 8. N+1 in Relationship Loops (MEDIUM)
+**Pattern**: Accessing relationship properties inside `for` loops (e.g., `item.tags`, `song.album`)
+**Issue**: Each relationship access may trigger a separate fetch. With 1000 items, this means 1000 extra queries.
+**Fix**: Use `#Predicate` with relationship filtering, or restructure to batch-fetch related objects
+
+### 9. Over-Indexing (MEDIUM)
+**Pattern**: 5 or more `@Attribute(.indexed)` on a single model
+**Issue**: Each index slows writes and increases storage. Over-indexing degrades performance on insert-heavy models.
+**Fix**: Index only properties used in predicates and sort descriptors. 2-3 indexes per model is typical.
+
+### 10. Batch Insert Without Chunking (MEDIUM)
+**Pattern**: `for item in items { context.insert(item) }` then `save()` with large datasets
+**Issue**: Inserting thousands of objects without chunking causes memory spikes and UI freezes.
+**Fix**: Chunk inserts into batches of 100-500, saving after each chunk
+
+## Audit Process
+
+### Step 1: Find All SwiftData Files
+
+Use Glob tool to find Swift files:
+- `**/*.swift`
+
+Then use Grep to find files containing SwiftData patterns:
+- `import SwiftData`
+- `@Model`
+- `ModelContainer`
+- `ModelContext`
+- `VersionedSchema`
+- `SchemaMigrationPlan`
+
+### Step 2: Search for Violations
+
+**Pattern 1: @Model on struct**:
+```
+Grep: @Model\s+struct
+```
+
+**Pattern 2: Missing models in VersionedSchema**:
+```
+# Find all @Model class definitions
+Grep: @Model\s+(final\s+)?class\s+\w+
+
+# Find VersionedSchema.models arrays
+Grep: static\s+var\s+models:
+
+# Compare: every @Model class should appear in at least one models array
+```
+
+**Pattern 3: Array relationship without default**:
+```
+# Find @Relationship with array types
+Grep: @Relationship.*\[.*\]
+
+# Check for = [] default on same or next line
+# Flag any array relationship property without = []
+```
+
+**Pattern 4: Fetch in didMigrate**:
+```
+Grep: didMigrate.*FetchDescriptor
+Grep: didMigrate[^}]*context\.fetch
+```
+Read matching files to verify the fetch is inside didMigrate (not willMigrate).
+
+**Pattern 5: Background ops on @Environment context**:
+```
+Grep: Task\s*\{[^}]*modelContext\.(insert|delete|save)
+```
+Read matching files to check if modelContext comes from @Environment.
+
+**Pattern 6: Missing save() after mutations**:
+```
+# Find insert/delete calls
+Grep: context\.(insert|delete)\(
+
+# Find save calls
+Grep: context\.save\(\)
+
+# Compare counts — significantly more mutations than saves is a flag
+```
+
+**Pattern 7: Updating both sides of relationship**:
+```
+# Find @Relationship(inverse:) definitions
+Grep: @Relationship\(.*inverse:
+
+# Read those files and check for manual updates on both sides
+```
+
+**Pattern 8: N+1 in relationship loops**:
+```
+# Find for-in loops accessing relationship properties
+Grep: for\s+\w+\s+in\s+\w+\s*\{
+```
+Read matching files and check for relationship property access inside the loop body.
+
+**Pattern 9: Over-indexing**:
+```
+Grep: @Attribute\(\.indexed\)
+```
+Count per file — flag files with 5+ indexed attributes.
+
+**Pattern 10: Batch insert without chunking**:
+```
+# Find loops with insert
+Grep: for\s+.*\{[^}]*\.insert\(
+```
+Read matching files to check dataset size and chunking.
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Crash or data loss):
+- @Model struct (runtime crash/corruption)
+- Missing VersionedSchema models (silent data loss)
+- Array relationship without default (decode crash)
+- Fetch in didMigrate (migration failure)
+
+**HIGH** (Data races or silent bugs):
+- Background ops on @Environment context
+- Missing save() after mutations
+- Updating both sides of bidirectional relationship
+
+**MEDIUM** (Performance degradation):
+- N+1 in relationship loops
+- Over-indexing
+- Batch insert without chunking
+
+## Output Format
+
+```markdown
+# SwiftData Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (Crash/data loss risk)
+- **HIGH Issues**: [count] (Data race/silent bug risk)
+- **MEDIUM Issues**: [count] (Performance degradation)
+
+## Risk Score: [0-10]
+(Each CRITICAL = +3 points, HIGH = +2 points, MEDIUM = +1 point, cap at 10)
+
+## CRITICAL Issues
+
+### @Model on struct
+- `Models/Song.swift:12` - `@Model struct Song` should be `@Model final class Song`
+ - **Risk**: Runtime crash or silent data corruption
+ - **Fix**:
+ ```swift
+ // WRONG
+ @Model struct Song { ... }
+
+ // CORRECT
+ @Model final class Song { ... }
+ ```
+
+### Missing Models in VersionedSchema
+- `Migration/SchemaV2.swift:8` - `static var models` missing `Tag` class
+ - **Risk**: Tag data silently dropped during migration
+ - **Fix**: Add `Tag.self` to the models array
+
+[...continue for each issue found...]
+
+## Next Steps
+
+1. **Fix CRITICAL issues immediately** - Crash and data loss risk
+2. **Fix HIGH issues before release** - Data integrity risk
+3. **Address MEDIUM issues in next sprint** - Performance improvement
+4. **Test migration on real device** with production-size data
+```
+
+## Audit Guidelines
+
+1. Run all 10 pattern searches for comprehensive coverage
+2. Provide file:line references to make issues easy to locate
+3. Show exact fixes with code examples for each issue
+4. Categorize by severity to help prioritize fixes
+5. Calculate risk score to quantify overall safety level
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize crash risk and data loss
+- Recommend fixing before production release
+- Provide explicit code fixes
+- Calculate time to fix (usually 2-10 minutes per issue)
+
+If NO issues found:
+- Report "No SwiftData violations detected"
+- Note that runtime testing is still recommended
+- Suggest migration testing checklist
+
+## False Positives (Not Issues)
+
+- `@Model struct` in comments or documentation
+- Array properties that aren't relationships (plain `[String]` etc.)
+- `context.insert` in test files
+- Single-item inserts (no chunking needed)
+- Explicit `autosave: true` container configuration (save() less critical)
+
+## Risk Score Calculation
+
+- Each CRITICAL issue: +3 points
+- Each HIGH issue: +2 points
+- Each MEDIUM issue: +1 point
+- Maximum score: 10
+
+**Interpretation**:
+- 0-2: Low risk, production-ready
+- 3-5: Medium risk, fix before release
+- 6-8: High risk, must fix immediately
+- 9-10: Critical risk, do not ship
+
+## Related
+
+For SwiftData patterns: `axiom-swiftdata` skill
+For migration diagnostics: `axiom-swiftdata-migration-diag` skill
+For schema migration: `axiom-swiftdata-migration` skill
diff --git a/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml b/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml
new file mode 100644
index 0000000..87a5936
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftdata/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit SwiftData"
+ short_description: "The user mentions SwiftData review, @Model issues, SwiftData migration safety, or SwiftData performance checking."
diff --git a/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json b/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json
new file mode 100644
index 0000000..8968d6f
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-architecture/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-swiftui-architecture",
+ "installedAt": "2026-04-12T08:05:49.851Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md b/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md
new file mode 100644
index 0000000..d933b13
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-architecture/SKILL.md
@@ -0,0 +1,225 @@
+---
+name: axiom-audit-swiftui-architecture
+description: Use when the user mentions SwiftUI architecture review, separation of concerns, testability issues, or "logic in view" problems.
+license: MIT
+disable-model-invocation: true
+---
+# SwiftUI Architecture Auditor Agent
+
+You are an expert at reviewing SwiftUI architecture — both known anti-patterns AND missing/incomplete separation of concerns that makes code untestable, unmaintainable, and fragile.
+
+## Your Mission
+
+Run a comprehensive architecture audit using 5 phases: map view/model boundaries, detect known anti-patterns, reason about what's untestable or poorly separated, correlate compound issues, and score architecture health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations that align with `axiom-swiftui-architecture` skill
+
+Do NOT focus on micro-performance (formatters/sorting) unless they also represent architectural violations (logic in view). For performance issues, link to `swiftui-performance-analyzer`. Fix recommendations must name the specific extraction target (model, computed property, service) — not just "refactor."
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map View/Model Boundaries
+
+Before grepping for violations, build a mental model of how the app separates views from logic.
+
+### Step 1: Identify Architecture Pattern
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `struct.*:.*View` — SwiftUI views
+ - `@Observable class` — modern observable models
+ - `ObservableObject` — legacy observable models
+ - `@State`, `@Binding`, `@Bindable` — state ownership
+ - `@Environment` — environment injection
+ - `import SwiftUI` in non-View files — potential coupling
+```
+
+### Step 2: Identify Logic Locations
+
+```
+Grep for:
+ - `Task {` in files with `var body` — async work in views
+ - `withAnimation.*await` — async boundary violations
+ - `URLSession`, `FileManager`, `try await` in view files — side effects in views
+ - `.filter(`, `.sorted(`, `.map(` in view files — data transforms in views
+```
+
+### Step 3: Understand Architecture Strategy
+
+Read 3-5 key files (main view, a model/viewmodel, a service) to understand:
+- Is there a consistent architecture pattern? (vanilla SwiftUI, MVVM, TCA, coordinator)
+- Where does business logic live? (views, models, services)
+- How are dependencies injected? (environment, init, singleton)
+- Is the code testable without UI? (can you test logic without importing SwiftUI)
+
+### Output
+
+Write a brief **Architecture Boundary Map** (8-12 lines) summarizing:
+- Architecture pattern used (or mixed/none)
+- View count vs model/viewmodel count (ratio indicates separation)
+- Logic location (views, models, or mixed)
+- Dependency injection strategy
+- State management pattern (@State/@Observable/@Environment usage)
+- Testability assessment (what percentage of logic requires SwiftUI to test)
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 5 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Logic in View Body (HIGH)
+
+**Pattern**: Non-trivial logic inside `var body` or View methods
+**Search**: `DateFormatter()`, `NumberFormatter()` in files with `var body`; `.filter(`, `.sorted(`, `.map(`, `.reduce(` near `var body`; if/else chains with business logic in body
+**Issue**: Untestable logic, violates separation of concerns (also hurts performance)
+**Fix**: Extract to `@Observable` model or computed property
+
+### 2. Async Boundary Violations (CRITICAL)
+
+**Pattern**: `Task { }` performing multi-step business logic in views; `withAnimation` wrapping `await` calls
+**Search**: `Task {` in view files — read context, check for `URLSession`, `FileManager`, `try await`, multi-step logic; `withAnimation` followed by `await` within 5 lines
+**Issue**: State-as-Bridge violation, unpredictable animation timing, untestable side effects
+**Fix**: Synchronous state mutation in view, async work in model
+
+### 3. Property Wrapper Misuse (HIGH)
+
+**Pattern**: `@State var item: Item` (non-private) where Item is passed in from parent
+**Search**: `@State var` without `private` — read context to check if value comes from parent
+**Issue**: Creates a local copy that loses updates from the parent source of truth
+**Fix**: Use `let item: Item` (read-only) or `@Bindable var item: Item` (read-write)
+
+### 4. God ViewModel (MEDIUM)
+
+**Pattern**: `@Observable class` or `ObservableObject` class with >20 stored properties or mixing unrelated domains
+**Search**: `@Observable class`, `ObservableObject` — read the class, count stored properties, check domain coherence
+**Issue**: SRP violation, hard to test, unnecessary view updates when unrelated state changes
+**Fix**: Split into smaller, focused models
+
+### 5. Testability Boundary Violations (MEDIUM)
+
+**Pattern**: Non-View types importing SwiftUI
+**Search**: `import SwiftUI` in all files — for each match, read the file. Skip if it conforms to View (has `var body`). Also skip files that import SwiftUI only for value types (`Color`, `Font`, `Image`) — this is a common pattern for design systems, theme definitions, and semantic color/typography mappings. Only flag files with no `View` conformances, no `body` properties, and no view-building code, but that use SwiftUI for business logic or model types.
+**Issue**: Business logic coupled to UI framework, can't unit test without SwiftUI
+**Fix**: Remove `import SwiftUI` from models; use Foundation types
+
+## Phase 3: Reason About Architecture Completeness
+
+Using the Architecture Boundary Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Is there business logic in view bodies that has no corresponding unit tests? | Untestable logic | Logic in views can only be tested via UI tests (100x slower) or not at all |
+| Are there views with >100 lines of body that should be decomposed? | Monolithic views | Large views are hard to understand, impossible to preview in isolation, and resist refactoring |
+| Is the architecture pattern consistent across the app? (some views use MVVM, others don't) | Inconsistent architecture | Developers can't predict where to find logic, where to add features, or how to test |
+| Do @Observable models expose internal state that views shouldn't mutate directly? | Missing access control | Views directly mutating model internals bypasses validation and business rules |
+| Are there dependency chains where views create their own models instead of receiving them? | View-owned dependencies | Views creating their own dependencies are untestable and resist composition |
+| Is navigation logic separated from business logic, or are they entangled? | Navigation/business entanglement | Changing navigation requires modifying business logic and vice versa |
+| Are there views that duplicate logic present in another view? | Cross-view duplication | Same business rule implemented differently in two views = divergent behavior |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Logic in view body | No unit tests for that logic | Untested business logic | CRITICAL |
+| Async boundary violation | In critical flow (purchase, auth) | Untestable, timing-sensitive critical transaction | CRITICAL |
+| @State copying parent data | Parent updates the data | Source-of-truth bug — UI shows stale data | CRITICAL |
+| God ViewModel | Holds strong references to closures/delegates | Retain cycles across a large dependency surface | HIGH |
+| import SwiftUI in model | Model has complex business logic | Core logic untestable without UI framework | HIGH |
+| Inconsistent architecture | New developer joins team | No predictable pattern to follow, accelerates tech debt | HIGH |
+| View-owned dependencies | In reusable component | Component can't be tested or composed differently | MEDIUM |
+| Duplicate logic across views | Logic involves validation | Validation rules diverge silently over time | HIGH |
+
+Also note overlaps with other auditors:
+- Logic in view body (formatters, processing) → compound with swiftui-performance-analyzer
+- Async Task in view → compound with concurrency-auditor
+- Navigation logic in views → compound with swiftui-nav-auditor
+- God ViewModel holding closures/delegates → compound with memory-auditor (retain cycle surface area)
+
+## Phase 5: Architecture Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Architecture Health Score
+
+| Metric | Value |
+|--------|-------|
+| View/model ratio | N views, M models/viewmodels (ratio X:1) |
+| Logic separation | N views with business logic in body, M with logic in models (Z% clean) |
+| Async boundary | N Task blocks in views, M delegating to models (Z% clean) |
+| Property wrapper correctness | N @State usages, M potentially copying parent data |
+| Testability | N non-View types importing SwiftUI, M total non-View types (Z% testable) |
+| Architecture consistency | Pattern: [consistent/mixed/none] |
+| **Health** | **CLEAN / TANGLED / MONOLITHIC** |
+```
+
+Scoring:
+- **CLEAN**: No CRITICAL issues, >80% logic in models, consistent architecture pattern, <3 views with business logic in body, 0 non-View SwiftUI imports
+- **TANGLED**: No CRITICAL issues, but logic split between views and models, or inconsistent patterns, or some async boundary violations
+- **MONOLITHIC**: Any CRITICAL issues, or >50% of logic in views, or no model layer, or pervasive async boundary violations
+
+## Output Format
+
+```markdown
+# SwiftUI Architecture Audit Results
+
+## Architecture Boundary Map
+[8-12 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues (correctness bugs)
+- HIGH: [N] issues (testability/separation)
+- MEDIUM: [N] issues (maintainability)
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Architecture Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What happens if not fixed
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (async boundaries, property wrapper bugs)]
+2. [Short-term — HIGH fixes (extract logic from views, fix testability)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [If performance concerns: run `/axiom:audit swiftui-performance`]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- `Task { await viewModel.load() }` — simple delegation to model is fine
+- `@State` on private properties initialized with literals
+- Small views (<30 lines) with inline formatting logic
+- `import SwiftUI` in files that only use value types (Color, Font, Image) for design system
+- God ViewModel in very small apps (3-5 screens, single domain)
+- `.filter`/`.sorted` on small, known-size collections in simple views
+
+## Related
+
+For architecture patterns: `axiom-swiftui-architecture` skill
+For performance issues: `swiftui-performance-analyzer` agent
+For navigation architecture: `swiftui-nav-auditor` agent
diff --git a/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml
new file mode 100644
index 0000000..44c81d0
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-architecture/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit SwiftUI Architecture"
+ short_description: "The user mentions SwiftUI architecture review, separation of concerns, testability issues, or \"logic in view\" problems."
diff --git a/.claude/skills/axiom-audit-swiftui-layout/.openskills.json b/.claude/skills/axiom-audit-swiftui-layout/.openskills.json
new file mode 100644
index 0000000..fb941ac
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-layout/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-swiftui-layout",
+ "installedAt": "2026-04-12T08:05:49.852Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-swiftui-layout/SKILL.md b/.claude/skills/axiom-audit-swiftui-layout/SKILL.md
new file mode 100644
index 0000000..88347ea
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-layout/SKILL.md
@@ -0,0 +1,261 @@
+---
+name: axiom-audit-swiftui-layout
+description: Use when the user mentions SwiftUI layout review, adaptive layout issues, GeometryReader problems, or multi-device layout checking.
+license: MIT
+disable-model-invocation: true
+---
+# SwiftUI Layout Auditor Agent
+
+You are an expert at detecting SwiftUI layout issues — both known anti-patterns AND missing/incomplete adaptive layout strategies that cause broken layouts across device sizes, orientations, and multitasking modes.
+
+## Your Mission
+
+Run a comprehensive layout audit using 5 phases: map the layout strategy, detect known anti-patterns, reason about what breaks on different devices, correlate compound issues, and score layout health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map Layout Strategy
+
+Before grepping for violations, build a mental model of how the app handles different screen sizes.
+
+### Step 1: Identify Layout Approach
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `GeometryReader` — manual size reading
+ - `onGeometryChange` — modern geometry observation (iOS 16+)
+ - `ViewThatFits` — content-driven adaptation
+ - `AnyLayout` — dynamic layout switching
+ - `containerRelativeFrame` — relative sizing (iOS 17+)
+ - `horizontalSizeClass`, `verticalSizeClass` — size class adaptation
+```
+
+### Step 2: Identify Fixed Dimensions and Breakpoints
+
+```
+Grep for:
+ - `.frame(width:`, `.frame(height:` — fixed dimensions
+ - `UIScreen.main`, `UIDevice.current.orientation` — deprecated APIs
+ - `.width >`, `.width <`, `.height >` — numeric breakpoints
+ - `UIRequiresFullScreen` in plist files
+```
+
+### Step 3: Understand Adaptivity Strategy
+
+Read 3-5 key view files (root view, main content view, a detail view) to understand:
+- Does the app adapt to different screen sizes, or assume one device class?
+- Is GeometryReader used for sizing, or do views use flexible layouts?
+- Are there device-specific code paths (iPad vs iPhone)?
+- Does the app support multitasking (Split View, Stage Manager)?
+
+### Output
+
+Write a brief **Layout Strategy Map** (8-10 lines) summarizing:
+- Layout approach (flexible/fixed/mixed)
+- GeometryReader usage count and pattern (sizing vs observation)
+- Size class usage (present/absent, correct/misused)
+- Fixed dimension count and range
+- Adaptivity level (single-device, size-class-aware, fully adaptive)
+- Deprecated API usage
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. GeometryReader in Stacks Without .frame() (CRITICAL)
+
+**Pattern**: GeometryReader inside VStack/HStack/ZStack without explicit `.frame()` constraint
+**Search**: `GeometryReader` — read context, check if inside a stack without `.frame()` on the GeometryReader
+**Issue**: GeometryReader expands to fill all available space, collapsing sibling views in stacks
+**Fix**: Constrain with `.frame(height:)` or use `onGeometryChange` (iOS 16+)
+
+### 2. Deprecated Screen/Device APIs (CRITICAL)
+
+**Pattern**: UIScreen.main or UIDevice.current.orientation in SwiftUI code
+**Search**: `UIDevice\.current\.orientation`, `UIScreen\.main\.bounds`, `UIScreen\.main\.nativeBounds`, `UIScreen\.main\.scale`
+**Issue**: These APIs don't account for multitasking, Stage Manager, or window resizing. They return stale values.
+**Fix**: Use `GeometryReader`, `onGeometryChange`, `horizontalSizeClass`, or `ViewThatFits`
+
+### 3. UIRequiresFullScreen (CRITICAL)
+
+**Pattern**: UIRequiresFullScreen set to true in Info.plist
+**Search**: `UIRequiresFullScreen` in `*.plist` files
+**Issue**: Disables all multitasking on iPad. Apple rejects apps that use this unnecessarily.
+**Fix**: Remove and support adaptive layouts with size classes
+
+### 4. Size Class as Orientation Proxy (HIGH)
+
+**Pattern**: horizontalSizeClass used to determine portrait vs landscape
+**Search**: `horizontalSizeClass.*==.*\.regular`, `horizontalSizeClass.*==.*\.compact` — read context to check if used to infer orientation
+**Issue**: Size class doesn't map to orientation. iPad is `.regular` in both orientations. iPhone 15 Pro Max is `.regular` in landscape.
+**Fix**: Use `ViewThatFits` for content-driven adaptation, or `onGeometryChange` for dimension-driven decisions
+
+### 5. Conditional HStack/VStack (Identity Loss) (HIGH)
+
+**Pattern**: if/else switching between VStack and HStack
+**Search**: `if.*\{` near `VStack` and `HStack` in same scope — read context to check for if/else switching
+**Issue**: Switching stack types destroys and recreates all child views, losing scroll position, text field focus, and animation state
+**Fix**: Use `AnyLayout` with `HStackLayout`/`VStackLayout`, or `ViewThatFits`
+
+### 6. Nested GeometryReaders (HIGH)
+
+**Pattern**: Multiple GeometryReader blocks in same file, especially nested
+**Search**: `GeometryReader` — count per file, flag files with 2+
+**Issue**: Nested GeometryReaders create confusing size propagation — usually indicates over-reliance on manual sizing
+**Fix**: Use one GeometryReader at a high level, or prefer `onGeometryChange` (iOS 16+)
+
+### 7. Hardcoded Width/Height Breakpoints (MEDIUM)
+
+**Pattern**: Numeric comparisons against geometry dimensions
+**Search**: `\.width\s*[<>]=?\s*\d{3}`, `\.height\s*[<>]=?\s*\d{3}`, `size\.width\s*[<>]=?\s*\d{3}`
+**Issue**: Hardcoded breakpoints break on new device sizes. iPhone and iPad dimensions change every year.
+**Fix**: Use `horizontalSizeClass`/`verticalSizeClass` for broad adaptation, `ViewThatFits` for content-driven decisions
+
+### 8. Large Fixed Frames (300+ px) (MEDIUM)
+
+**Pattern**: .frame with width or height of 300 or more
+**Search**: `\.frame\(width:\s*\d{3,}`, `\.frame\(height:\s*\d{3,}` — flag values >= 300
+**Issue**: Fixed frames >300pt clip on smaller devices (iPhone SE: 320pt wide) and waste space on larger ones
+**Fix**: Use `.frame(maxWidth:)`, `containerRelativeFrame` (iOS 17+), or flexible layouts
+
+### 9. Non-Lazy ForEach in Stacks (MEDIUM)
+
+**Pattern**: VStack or HStack with ForEach (non-lazy)
+**Search**: `VStack` or `HStack` followed by `ForEach` — verify not `LazyVStack`/`LazyHStack`
+**Issue**: Non-lazy stacks instantiate ALL views upfront. With 100+ items, this causes launch lag and high memory.
+**Fix**: Use `LazyVStack`/`LazyHStack` inside `ScrollView`
+**Note**: VStack with <20 items is fine.
+
+### 10. GeometryReader for Relative Sizing (LOW)
+
+**Pattern**: GeometryReader used solely for percentage-based sizing
+**Search**: `GeometryReader.*size\.width\s*\*`, `GeometryReader.*size\.height\s*\*`
+**Issue**: `containerRelativeFrame` (iOS 17+) handles relative sizing more cleanly with proper layout participation
+**Fix**: Replace `GeometryReader { geo in view.frame(width: geo.size.width * 0.5) }` with `.containerRelativeFrame(.horizontal) { w, _ in w * 0.5 }`
+
+## Phase 3: Reason About Layout Completeness
+
+Using the Layout Strategy Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Do layouts work in iPad Split View and Slide Over (roughly half screen width)? | Missing multitasking support | iPad users in Split View see layouts designed for full-width — text truncates, images clip, buttons stack wrong |
+| Are there views that use fixed widths close to the smallest device width (320pt iPhone SE)? | Near-edge fixed sizing | A 300pt fixed frame on a 320pt screen leaves 10pt margins — one Dynamic Type bump and content clips |
+| Do adaptive layouts preserve view identity when switching between compact and regular size classes? | Identity loss on adaptation | if/else between VStack and HStack destroys child state — user loses scroll position mid-interaction |
+| Is GeometryReader used inside ScrollView or List cells? | GeometryReader in scrolling context | GeometryReader proposes infinite height in a scroll context, causing layout loops or zero-height rendering |
+| Are there layouts that assume a single window size (no Stage Manager, no free-form windows)? | Missing iOS 26 free-form window support | iOS 26 introduces resizable windows — layouts that assume fixed dimensions will break |
+| Does the app handle landscape orientation on iPhone, or only portrait? | Missing landscape support | Users who rotate their phone see a broken layout if the app only considered portrait |
+| Are there views with many fixed `.frame()` calls that could use flexible alternatives? | Over-constrained layout | Fixed dimensions fight SwiftUI's flexible layout system — harder to maintain, more breakage |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| GeometryReader in stack | Inside ScrollView/List | Layout loop or zero-height rendering | CRITICAL |
+| UIScreen.main.bounds | Used for layout decisions | Stale values break multitasking | CRITICAL |
+| Conditional VStack/HStack | In main content view | User loses state on rotation/resize | CRITICAL |
+| Large fixed frame (>300pt) | No size class checking | Clips on iPhone SE and iPad Split View | HIGH |
+| Hardcoded breakpoints | Different values in different files | Inconsistent adaptation thresholds | HIGH |
+| Nested GeometryReaders | In frequently visited screen | Confusing layout on the most-seen view | HIGH |
+| No size class usage | iPad target in deployment info | iPad users get phone-style layout | HIGH |
+| Size class as orientation proxy | iPhone Pro Max user | Wrong layout in landscape on large iPhone | MEDIUM |
+
+Also note overlaps with other auditors:
+- Non-lazy ForEach → compound with swiftui-performance-analyzer (launch lag)
+- GeometryReader in List cells → compound with swiftui-performance-analyzer (double layout pass)
+- Fixed dimensions + Dynamic Type → compound with accessibility-auditor (text clipping)
+- Missing adaptivity + iPad → compound with ux-flow-auditor (broken user journey on iPad)
+
+## Phase 5: Layout Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Layout Health Score
+
+| Metric | Value |
+|--------|-------|
+| Adaptivity coverage | Size class usage: yes/no, ViewThatFits: N usages, AnyLayout: N usages |
+| GeometryReader discipline | N total, M constrained with .frame() (Z%), nested: N files |
+| Fixed dimension risk | N fixed frames >300pt, M hardcoded breakpoints |
+| Deprecated API usage | N UIScreen/UIDevice references |
+| Identity safety | N conditional stack switches, M using AnyLayout (Z% safe) |
+| Device coverage | Smallest supported width: Xpt, multitasking support: yes/no |
+| **Health** | **ADAPTIVE / RIGID / BROKEN** |
+```
+
+Scoring:
+- **ADAPTIVE**: No CRITICAL issues, size class or ViewThatFits used for adaptation, 0 deprecated APIs, no identity-losing conditional stacks, supports multitasking
+- **RIGID**: No CRITICAL issues, but missing adaptivity (no size class usage), or some fixed dimensions that risk clipping, or conditional stacks without AnyLayout
+- **BROKEN**: Any CRITICAL issues (GeometryReader in stacks, deprecated APIs, UIRequiresFullScreen), or layouts that clip on common device sizes
+
+## Output Format
+
+```markdown
+# SwiftUI Layout Audit Results
+
+## Layout Strategy Map
+[8-10 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Layout Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What breaks and on which devices
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (GeometryReader, deprecated APIs)]
+2. [Short-term — HIGH fixes (identity loss, adaptivity)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+4. [Test on: iPhone SE (320pt), iPad Split View (~half width), iPad Stage Manager]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- GeometryReader as root view of a screen (no siblings to collapse)
+- `UIScreen.main` used only for one-time setup (e.g., launch screen)
+- `UIRequiresFullScreen` for camera-only or AR apps (legitimate use)
+- Small fixed frames (<100pt) for icons/badges
+- `VStack { ForEach }` with <20 items (lazy overhead not worth it)
+- Size class checks that genuinely adapt layout (not inferring orientation)
+- GeometryReader with `.frame()` constraint (already safe)
+- Large fixed frames for full-screen backgrounds/images (intentional)
+
+## Related
+
+For SwiftUI layout patterns: `axiom-swiftui-layout` skill
+For SwiftUI layout reference: `axiom-swiftui-layout-ref` skill
+For SwiftUI containers: `axiom-swiftui-containers-ref` skill
diff --git a/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml
new file mode 100644
index 0000000..60fea62
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-layout/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit SwiftUI Layout"
+ short_description: "The user mentions SwiftUI layout review, adaptive layout issues, GeometryReader problems, or multi-device layout chec..."
diff --git a/.claude/skills/axiom-audit-swiftui-nav/.openskills.json b/.claude/skills/axiom-audit-swiftui-nav/.openskills.json
new file mode 100644
index 0000000..5337730
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-nav/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-swiftui-nav",
+ "installedAt": "2026-04-12T08:05:49.853Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-swiftui-nav/SKILL.md b/.claude/skills/axiom-audit-swiftui-nav/SKILL.md
new file mode 100644
index 0000000..7d42374
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-nav/SKILL.md
@@ -0,0 +1,256 @@
+---
+name: axiom-audit-swiftui-nav
+description: Use when the user mentions SwiftUI navigation issues, deep linking problems, state restoration bugs, or navigation architecture review.
+license: MIT
+disable-model-invocation: true
+---
+# SwiftUI Navigation Auditor Agent
+
+You are an expert at detecting SwiftUI navigation issues — both known anti-patterns AND missing/incomplete navigation architecture that causes deep link failures, state loss, and broken user journeys.
+
+## Your Mission
+
+Run a comprehensive navigation audit using 5 phases: map the navigation architecture, detect known anti-patterns, reason about what's missing, correlate compound issues, and score navigation health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+
+**Note**: This agent checks navigation **architecture and correctness**. For **performance** issues, use `swiftui-performance-analyzer`.
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map Navigation Architecture
+
+Before grepping for issues, build a mental model of the app's navigation structure.
+
+### Step 1: Identify Navigation Containers
+
+```
+Glob: **/*.swift (excluding test/vendor paths)
+Grep for:
+ - `NavigationStack` — stack-based navigation
+ - `NavigationSplitView` — master-detail navigation
+ - `TabView` — tab structure
+ - `UINavigationController`, `UITabBarController` — UIKit navigation
+```
+
+### Step 2: Map Navigation Paths and Destinations
+
+```
+Grep for:
+ - `NavigationPath`, `@State.*path` — programmatic navigation state
+ - `.navigationDestination(for:` — type-based routing
+ - `NavigationLink` — static navigation links
+ - `.sheet`, `.fullScreenCover` — modal presentations
+ - `.onOpenURL` — deep link handlers
+ - `@SceneStorage` — state preservation
+```
+
+### Step 3: Understand Navigation Strategy
+
+Read 2-3 key navigation files to understand:
+- Is there a central navigation coordinator, or is navigation distributed across views?
+- What types are used in NavigationPath? Are they registered with .navigationDestination?
+- How are deep links routed from .onOpenURL to the correct destination?
+- Is navigation state preserved across app termination?
+
+### Output
+
+Write a brief **Navigation Architecture Map** (8-12 lines) summarizing:
+- Navigation container types and count (Stack vs SplitView)
+- NavigationPath usage (present/absent, centralized/distributed)
+- Destination registration count vs path type count
+- Deep link handling (present/absent, routing strategy)
+- State preservation strategy (SceneStorage, manual, none)
+- Tab/navigation integration pattern
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 10 existing detection patterns. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Missing NavigationPath (HIGH)
+
+**Pattern**: NavigationStack without path binding
+**Search**: `NavigationStack {` or `NavigationStack()` without `path:` parameter — compare against `@State.*NavigationPath` count
+**Issue**: Can't navigate programmatically or handle deep links
+**Fix**: Add `@State private var path = NavigationPath()` and bind with `NavigationStack(path: $path)`
+
+### 2. Deep Link Gaps (CRITICAL)
+
+**Pattern**: Missing deep link handling
+**Search**: Check for `.onOpenURL` handler; check Info.plist for URL scheme registration
+**Issue**: Deep links fail silently, external navigation broken
+**Fix**: Implement `.onOpenURL` handler that routes to correct NavigationPath destination
+
+### 3. State Restoration Issues (HIGH)
+
+**Pattern**: Missing `.navigationDestination(for:)` for path types
+**Search**: `.navigationDestination(for:` — count registrations vs types pushed onto path
+**Issue**: Navigation state lost when types aren't registered
+**Fix**: Add `.navigationDestination(for:)` for every type used in NavigationPath
+
+### 4. Wrong Container (MEDIUM)
+
+**Pattern**: Wrong navigation container for the use case
+**Search**: `NavigationStack` in master-detail contexts (iPad apps); `NavigationSplitView` for linear flows
+**Issue**: Poor iPad/Mac experience, wasted screen space
+**Fix**: Use NavigationSplitView for master-detail, NavigationStack for linear flows
+
+### 5. Type Safety Issues (HIGH)
+
+**Pattern**: Multiple `.navigationDestination` with same type
+**Search**: Multiple `.navigationDestination(for:` with the same type parameter
+**Issue**: Undefined behavior — wrong view shown, navigation breaks
+**Fix**: Use unique types or wrapper enum with associated values
+
+### 6. Tab/Nav Integration (MEDIUM)
+
+**Pattern**: Missing sidebar adaptable style (iOS 18+)
+**Search**: `TabView` with `NavigationStack` but no `.tabViewStyle(.sidebarAdaptable)`
+**Issue**: Tab bar doesn't unify with sidebar on iPad
+**Fix**: Add `.tabViewStyle(.sidebarAdaptable)`
+
+### 7. Missing State Preservation (HIGH)
+
+**Pattern**: No persistence for navigation path
+**Search**: Absence of `@SceneStorage` for navigation path data
+**Issue**: User loses their place when app is terminated by system
+**Fix**: Store NavigationPath data in `@SceneStorage` with Codable encoding
+
+### 8. Deprecated NavigationLink APIs (MEDIUM)
+
+**Pattern**: Using deprecated iOS 16+ APIs
+**Search**: `NavigationLink.*isActive:` or `NavigationLink.*tag:.*selection:`
+**Issue**: Deprecated, will be removed in future iOS versions
+**Fix**: Migrate to NavigationStack + NavigationPath pattern
+
+### 9. Coordinator Pattern Violations (LOW)
+
+**Pattern**: Navigation logic scattered across views
+**Search**: Multiple files with `path.append(`, navigation logic in leaf views
+**Issue**: Hard to reason about navigation flow, difficult to add deep links
+**Fix**: Centralize in coordinator/router
+
+### 10. Missing NavigationSplitViewVisibility (LOW)
+
+**Pattern**: No explicit sidebar visibility management
+**Search**: `NavigationSplitView` without `@State var visibility: NavigationSplitViewVisibility`
+**Issue**: Can't programmatically control sidebar visibility
+**Fix**: Add `@State var visibility: NavigationSplitViewVisibility` and bind
+
+## Phase 3: Reason About Navigation Completeness
+
+Using the Navigation Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are there .navigationDestination registrations for every type that could be pushed onto the NavigationPath? | Orphan path types | Pushing an unregistered type silently fails — the view never appears, no error |
+| Do deep link handlers cover all screens that should be externally reachable? | Incomplete deep link coverage | Marketing, notifications, and widgets link to screens that have no URL handler |
+| Is NavigationPath data preserved and restored across app termination? | State restoration gap | User navigates 3 levels deep, app is killed, relaunches to root — lost context |
+| Are there navigation destinations that receive IDs but don't validate the entity exists? | Missing data validation on navigation | Deep link to deleted item shows empty/crash screen |
+| Is navigation state consistent across tabs? (e.g., switching tabs doesn't corrupt other tab's path) | Cross-tab state corruption | NavigationPath shared across tabs causes one tab's navigation to affect another |
+| Are there sheets/covers presented from within NavigationStack that also try to navigate the stack? | Modal/stack conflict | Sheet tries to push onto parent stack, causes undefined behavior |
+| Does the app handle universal links and custom URL schemes consistently? | Inconsistent link handling | Universal links work but custom scheme doesn't, or vice versa |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Missing NavigationPath | Deep link handler exists | Deep links received but can't navigate programmatically | CRITICAL |
+| Orphan .navigationDestination type | Type pushed in deep link handler | Deep link silently fails to show destination | CRITICAL |
+| No state preservation | Deep navigation depth possible | User loses complex navigation state on app kill | HIGH |
+| Duplicate .navigationDestination type | Used in different tabs | Type collision causes wrong tab's view to appear | HIGH |
+| Deprecated NavigationLink | In core navigation flow | Migration debt in critical path | HIGH |
+| Wrong container (Stack on iPad) | Deep link to detail view | Deep link shows phone-style navigation on iPad | MEDIUM |
+| Modal presented from NavigationStack | Modal tries to push onto stack | Modal/stack navigation conflict | HIGH |
+
+Also note overlaps with other auditors:
+- Missing deep link validation → compound with ux-flow-auditor (dead end after deep link)
+- Navigation state not preserved → compound with ux-flow-auditor (lost user context)
+- NavigationPath recreation in body → compound with swiftui-performance-analyzer
+
+## Phase 5: Navigation Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Navigation Health Score
+
+| Metric | Value |
+|--------|-------|
+| Path coverage | N NavigationStacks, M with NavigationPath binding (Z%) |
+| Destination coverage | N types pushed, M registered with .navigationDestination (Z%) |
+| Deep link coverage | N screens, M reachable via deep link (Z%) |
+| State preservation | NavigationPath persisted: yes/no |
+| Deprecated APIs | N deprecated NavigationLink usages |
+| Container correctness | NavigationStack/SplitView used appropriately: yes/no |
+| **Health** | **SOLID / FRAGILE / BROKEN** |
+```
+
+Scoring:
+- **SOLID**: No CRITICAL issues, all destination types registered, deep links handled, state preserved, 0 deprecated APIs
+- **FRAGILE**: No CRITICAL issues, but missing state preservation, or incomplete destination registration, or some deprecated APIs
+- **BROKEN**: Any CRITICAL issues (deep link gaps, type collisions), or destination types pushed but never registered
+
+## Output Format
+
+```markdown
+# SwiftUI Navigation Audit Results
+
+## Navigation Architecture Map
+[8-12 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Navigation Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What users experience
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (deep link gaps, type collisions)]
+2. [Short-term — HIGH fixes (state preservation, missing destinations)]
+3. [Long-term — architectural improvements from Phase 3 findings]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- NavigationStack without path for purely static navigation (no deep links, no programmatic nav)
+- No @SceneStorage if app doesn't support state restoration by design
+- No coordinator in small apps (over-engineering)
+- NavigationStack on iPad if truly linear flow
+- .navigationDestination types that are only used with NavigationLink (not pushed programmatically)
+
+## Related
+
+For navigation patterns: `axiom-swiftui-nav` skill
+For debugging: `axiom-swiftui-nav-diag` skill
+For API reference: `axiom-swiftui-nav-ref` skill
diff --git a/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml b/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml
new file mode 100644
index 0000000..ee5f50e
--- /dev/null
+++ b/.claude/skills/axiom-audit-swiftui-nav/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit SwiftUI Nav"
+ short_description: "The user mentions SwiftUI navigation issues, deep linking problems, state restoration bugs, or navigation architectur..."
diff --git a/.claude/skills/axiom-audit-testing/.openskills.json b/.claude/skills/axiom-audit-testing/.openskills.json
new file mode 100644
index 0000000..4920293
--- /dev/null
+++ b/.claude/skills/axiom-audit-testing/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-testing",
+ "installedAt": "2026-04-12T08:05:49.854Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-testing/SKILL.md b/.claude/skills/axiom-audit-testing/SKILL.md
new file mode 100644
index 0000000..0047908
--- /dev/null
+++ b/.claude/skills/axiom-audit-testing/SKILL.md
@@ -0,0 +1,328 @@
+---
+name: axiom-audit-testing
+description: Use when the user wants to audit test quality, find flaky test patterns, speed up test execution, or prepare for Swift Testing migration.
+license: MIT
+disable-model-invocation: true
+---
+# Testing Auditor Agent
+
+You are an expert at detecting test quality issues — both known anti-patterns AND missing/incomplete test coverage that leaves critical paths unverified.
+
+## Your Mission
+
+Run a comprehensive test quality audit using 5 phases: map test coverage shape, detect known anti-patterns, reason about what's untested, correlate compound risks, and score test health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Issue category and phase
+- Fix recommendations
+
+## Files to Scan
+
+**Test files**: `*Tests.swift`, `*Test.swift`, `*Spec.swift`
+**Production files**: `**/*.swift` (for coverage shape mapping in Phase 1)
+Skip: `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map Test Coverage Shape
+
+Before checking test quality, understand *what's tested and what isn't*.
+
+### Step 1: Inventory Production and Test Code
+
+```
+Glob: **/*.swift (production code — excluding test/vendor paths)
+Glob: **/*Tests.swift, **/*Test.swift, **/*Spec.swift (test code)
+
+For each test file, grep for:
+ - `@testable import` — which production modules are tested
+ - `import XCTest` vs `import Testing` — which framework
+ - `XCUIApplication` — UI test vs unit test
+```
+
+### Step 2: Identify Critical Production Paths
+
+Read key production files to identify:
+- **Auth/Security**: login, token management, keychain access, biometric auth
+- **Payments/IAP**: StoreKit, purchase flows, receipt validation
+- **Data persistence**: SwiftData/CoreData models, migrations, save/load operations
+- **Networking**: API clients, request building, response parsing, error handling
+- **Error handling**: error enums, catch blocks, failure states
+
+### Step 3: Cross-Reference
+
+Match production modules/directories against test files:
+- Which production modules have corresponding test files?
+- Which have NO test files at all?
+- Which critical paths (auth, payments, persistence) are tested vs untested?
+
+### Output
+
+Write a brief **Coverage Shape Map** (8-12 lines) summarizing:
+- Total production modules vs modules with tests
+- Which critical paths are tested
+- Which critical paths are untested
+- Test framework split (XCTest vs Swift Testing)
+- Test type split (unit vs UI)
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known Anti-Patterns
+
+Run all 5 existing detection categories. These are fast and reliable. For each potential match, read surrounding context to verify it's a real issue before reporting.
+
+### Grep Patterns by Category
+
+**Flaky patterns**:
+```
+sleep\(
+Thread\.sleep
+usleep\(
+static var.*=
+class var.*=
+```
+
+**Speed indicators**:
+```
+import XCTest
+import UIKit|SwiftUI (in unit test files — may not need simulator)
+XCUIApplication
+@testable import
+```
+
+**Migration candidates**:
+```
+XCTestCase
+XCTAssertEqual|XCTAssertTrue|XCTAssertNil
+func test.*\(\).*\{
+```
+
+**Swift 6 issues**:
+```
+@MainActor.*class|struct
+class.*XCTestCase
+```
+
+**Quality issues**:
+```
+func test.*\{ (check for missing assertions in body)
+try!|as!
+setUp\(|setUpWithError\( (check line count)
+```
+
+### Category 1: Flaky Test Patterns (CRITICAL)
+
+#### 1.1 Sleep Calls
+**Search**: `sleep(`, `Thread.sleep`, `usleep(`
+**Issue**: Arbitrary waits cause timing-dependent failures, especially in CI
+**Fix**: Use condition-based waiting:
+
+```swift
+// ✅ Swift Testing
+await confirmation { confirm in
+ observer.onComplete = { confirm() }
+ triggerAction()
+}
+
+// ✅ XCTest
+let element = app.buttons["Submit"]
+XCTAssertTrue(element.waitForExistence(timeout: 5))
+```
+
+#### 1.2 Shared Mutable State
+**Search**: `static var` or `class var` in test classes
+**Issue**: Parallel test execution causes race conditions
+**Fix**: Use instance properties, fresh setup per test
+
+#### 1.3 Order-Dependent Tests
+**Detection**: Tests that reference results from other test methods, or setUp that depends on test order
+**Issue**: Swift Testing and XCTest randomize order
+**Fix**: Make each test independent
+
+### Category 2: Test Speed Issues (HIGH)
+
+#### 2.1 Host Application Not Needed
+**Detection**: Unit tests with no UIKit/SwiftUI imports, no XCUIApplication usage
+**Issue**: Launching app adds 20-60 seconds per run
+**Fix**: Set Host Application to "None" for pure unit tests
+
+#### 2.2 Tests in App Target
+**Detection**: Test files using `@testable import MyApp` that only test models/services/utilities
+**Issue**: App tests require simulator launch — 60x slower than package tests
+**Fix**: Extract testable logic into Swift Package, test with `swift test`
+
+#### 2.3 Unnecessary UI Test Overhead
+**Detection**: Unit-style tests in UI test target
+**Issue**: UI tests have heavy setup/teardown
+**Fix**: Move to unit test target
+
+### Category 3: Swift Testing Migration (MEDIUM)
+
+#### 3.1 XCTestCase Migration Candidates
+**Search**: `XCTestCase` with only basic `XCTAssert*` calls
+**Issue**: Missing modern testing features (parallelism, async, parameterization)
+**Fix**: Migrate to `@Suite` struct with `@Test` functions
+
+#### 3.2 Parameterized Test Opportunities
+**Detection**: Multiple similar test functions (`testParseValid`, `testParseInvalid`, `testParseEmpty`)
+**Issue**: Repetitive tests that could be consolidated
+**Fix**: Use `@Test(arguments:)` parameterization
+
+### Category 4: Swift 6 Concurrency Issues (HIGH)
+
+#### 4.1 XCTestCase with MainActor Default
+**Search**: `class.*XCTestCase` in projects using `default-actor-isolation = MainActor`
+**Issue**: XCTestCase is Objective-C, initializers are nonisolated — compiler error in Swift 6.2+
+**Fix**:
+
+```swift
+// ❌ Error with MainActor default
+final class MyTests: XCTestCase { }
+
+// ✅ Works
+nonisolated final class MyTests: XCTestCase {
+ @MainActor func testSomething() async { }
+}
+```
+
+#### 4.2 Missing @MainActor on UI Tests
+**Detection**: Tests accessing @MainActor types without isolation
+**Issue**: Swift 6 strict concurrency requires explicit isolation
+**Fix**: Add `@MainActor` to test function
+
+### Category 5: Test Quality Issues (MEDIUM/LOW)
+
+#### 5.1 Tests Without Assertions
+**Search**: Test functions with no `XCTAssert*`, `#expect`, or `#require`
+**Issue**: Tests that don't assert don't verify behavior — false confidence
+**Fix**: Add meaningful assertions
+
+#### 5.2 Overly Long Setup
+**Detection**: `setUp()` or `setUpWithError()` methods longer than 20 lines
+**Issue**: Complex setup makes tests hard to understand and maintain
+**Fix**: Extract to helper methods, use factory patterns
+
+#### 5.3 Force Unwrapping in Tests
+**Search**: `try!`, `as!`, `!.` on values from system under test
+**Issue**: Crashes obscure actual test failures
+**Fix**: Use `XCTUnwrap` or `try #require`
+**Note**: Do NOT flag force unwraps in `setUp()`, `setUpWithError()`, fixture factories, or known-valid literals (`URL(string: "...")!`, `UUID(uuidString: "...")!`, `NSRegularExpression(pattern: "...")!`).
+
+## Phase 3: Reason About Test Completeness
+
+Using the Coverage Shape Map from Phase 1 and your domain knowledge, check for what's *untested* — not just what's wrong with existing tests.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Are critical paths (auth, payments, persistence) tested? | Missing critical coverage | Bugs in auth/payments/persistence have the highest user impact and business cost |
+| Do async tests use proper confirmation/expectation patterns? | Unreliable async tests | Async tests without proper waiting are inherently flaky |
+| Are error paths tested? (catch blocks, failure states, error enums) | Missing negative tests | Happy-path-only testing misses the failures users actually experience |
+| Is there test code for the public API surface? | Missing contract tests | Public API changes break consumers silently without contract tests |
+| Do tests with network calls use mocks/stubs, or hit real servers? | Fragile external dependencies | Real server tests are slow, flaky, and fail offline |
+| Are there test files that only test happy paths with no edge cases? | Shallow coverage | Nominal coverage without edge cases gives false confidence |
+| Do production error enums have corresponding test assertions? | Untested error variants | Every error case that can happen in production should be verified in tests |
+
+For each finding, explain what's untested and why it matters. Require evidence from the Phase 1 map — don't speculate about modules you haven't examined.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| No tests for auth module | Auth uses @MainActor + async | Untested concurrency in security-critical code | CRITICAL |
+| Missing error path tests | `try!` in production code | Crash on unhandled error | CRITICAL |
+| Test uses sleep() | Tests auth flow | Flaky test on critical path | CRITICAL |
+| No tests for persistence layer | Database migration code present | Untested migrations risk data loss | HIGH |
+| Tests exist but no assertions | `@testable import` of payment module | False confidence in payment code | HIGH |
+| XCTestCase with shared mutable state | Swift 6 strict concurrency enabled | Data races in test infrastructure | HIGH |
+| No mock/stub for network layer | Tests import networking module | Fragile tests dependent on external servers | MEDIUM |
+
+Also note overlaps with other auditors:
+- Untested @MainActor code → compound with concurrency auditor
+- Untested persistence migrations → compound with data auditor
+- Tests with sleep() in async context → compound with concurrency auditor
+
+## Phase 5: Test Health Score
+
+Calculate and present a health score:
+
+```markdown
+## Test Health Score
+
+| Metric | Value |
+|--------|-------|
+| Module coverage | X/Y production modules have tests (Z%) |
+| Critical path coverage | auth (yes/no), payments (yes/no), persistence (yes/no), networking (yes/no) |
+| Error path coverage | N error enums, M with test assertions (Z%) |
+| Test reliability | N sleep() calls, M shared mutable state instances |
+| Test speed | N tests requiring simulator, M pure unit tests |
+| Test framework | N XCTest, M Swift Testing (migration %) |
+| **Health** | **WELL TESTED / GAPS / UNDERTESTED** |
+```
+
+Scoring:
+- **WELL TESTED**: All critical paths tested, <3 flaky patterns, >70% module coverage, error paths covered
+- **GAPS**: Most critical paths tested, some flaky patterns or missing error coverage, or 40-70% module coverage
+- **UNDERTESTED**: Critical paths untested, or >5 flaky patterns, or <40% module coverage
+
+## Output Format
+
+```markdown
+# Test Quality Audit Results
+
+## Coverage Shape Map
+[8-12 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (anti-pattern detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## Test Health Score
+[Phase 5 table]
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line (or module name for coverage gaps)
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What happens if not fixed
+**Fix**: Code example or recommended action
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Quick Wins
+1. [Fastest impact fix]
+2. [Biggest speedup]
+3. [Easiest migration]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (flaky tests, untested critical paths)]
+2. [Short-term — HIGH fixes (speed improvements, Swift 6 compliance)]
+3. [Long-term — coverage expansion from Phase 3 findings]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- `sleep()` in test helpers for rate limiting (check context)
+- `static let` constants (immutable is fine)
+- UI tests that legitimately need XCUIApplication
+- Performance tests using XCTMetric
+- Tests intentionally using XCTest for Objective-C interop
+- Force unwraps in `setUp()` / fixture setup on known-valid literals
+- Modules with no tests that are pure UI (better tested via UI tests or previews)
+
+## Related
+
+For unit test patterns: `axiom-swift-testing` skill
+For UI test patterns: `axiom-ui-testing` skill
+For async test patterns: `axiom-testing-async` skill
+For flaky test diagnosis: `axiom-test-failure-analyzer` agent
diff --git a/.claude/skills/axiom-audit-testing/agents/openai.yaml b/.claude/skills/axiom-audit-testing/agents/openai.yaml
new file mode 100644
index 0000000..0394be9
--- /dev/null
+++ b/.claude/skills/axiom-audit-testing/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit Testing"
+ short_description: "The user wants to audit test quality, find flaky test patterns, speed up test execution, or prepare for Swift Testing..."
diff --git a/.claude/skills/axiom-audit-textkit/.openskills.json b/.claude/skills/axiom-audit-textkit/.openskills.json
new file mode 100644
index 0000000..819ebfb
--- /dev/null
+++ b/.claude/skills/axiom-audit-textkit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-textkit",
+ "installedAt": "2026-04-12T08:05:49.855Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-textkit/SKILL.md b/.claude/skills/axiom-audit-textkit/SKILL.md
new file mode 100644
index 0000000..7387979
--- /dev/null
+++ b/.claude/skills/axiom-audit-textkit/SKILL.md
@@ -0,0 +1,361 @@
+---
+name: axiom-audit-textkit
+description: Use when the user mentions TextKit review, text layout issues, Writing Tools integration, or UITextView/NSTextView code review.
+license: MIT
+disable-model-invocation: true
+---
+# TextKit Auditor Agent
+
+You are an expert at detecting TextKit 1 fallback triggers and deprecated text layout patterns that prevent Writing Tools integration and cause incorrect behavior with complex scripts.
+
+## Your Mission
+
+Run a comprehensive TextKit audit and report all issues with:
+- File:line references for easy fixing
+- Severity ratings (CRITICAL/HIGH/MEDIUM)
+- Specific violation types
+- Fix recommendations with code examples
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Output Limits
+
+If >50 issues in one category:
+- Show top 10 examples
+- Provide total count
+- List top 3 files with most issues
+
+If >100 total issues:
+- Summarize by category
+- Show only CRITICAL/HIGH details
+- Always show: Severity counts, top 3 files by issue count
+
+## What You Check
+
+### 1. TextKit 1 Fallback Triggers (CRITICAL)
+**Pattern**: Direct `.layoutManager` access without checking `.textLayoutManager` first
+**Issue**: One-way fallback to TextKit 1, loses Writing Tools, incorrect complex script handling
+**Fix**: Check `textLayoutManager` first, only fall back for old OS versions
+
+### 2. NSLayoutManager Usage (CRITICAL)
+**Pattern**: Using `NSLayoutManager` class or delegate
+**Issue**: TextKit 1 only, no Writing Tools, deprecated paradigm
+**Fix**: Migrate to `NSTextLayoutManager` (TextKit 2)
+
+### 3. Glyph API Usage (CRITICAL)
+**Pattern**: `numberOfGlyphs`, `glyphRange`, `glyphIndex`, `rectForGlyph`, `characterIndex(forGlyphAt:)`
+**Issue**: Incorrect for complex scripts (Arabic, Kannada, Thai), data corruption risk
+**Fix**: Use `NSTextLayoutFragment` and `NSTextLineFragment` for measurement
+
+### 4. NSRange with TextKit 2 (HIGH)
+**Pattern**: Using NSRange instead of NSTextRange/NSTextLocation with TextKit 2 APIs
+**Issue**: Wrong paradigm, breaks with structured documents
+**Fix**: Use `NSTextLocation` and `NSTextRange` for TextKit 2
+
+### 5. Missing Writing Tools Integration (MEDIUM)
+**Pattern**: UITextView/NSTextView without `writingToolsBehavior` property
+**Issue**: No Writing Tools support (iOS 18+)
+**Fix**: Set `.writingToolsBehavior = .default` for full experience
+
+### 6. Missing Writing Tools State Checks (MEDIUM)
+**Pattern**: Text mutations without checking `isWritingToolsActive`
+**Issue**: Can interfere with Writing Tools operation
+**Fix**: Check `isWritingToolsActive` before modifying text
+
+## Audit Process
+
+### Step 1: Find All Swift Files
+
+Use Glob tool to find Swift files:
+- Pattern: `**/*.swift`
+
+### Step 2: Search for TextKit Anti-Patterns
+
+**Direct layoutManager Access** (Fallback Trigger):
+```bash
+# Direct access without textLayoutManager check
+grep -rn "\.layoutManager\b" --include="*.swift" | grep -v "textLayoutManager"
+grep -rn "textView\.layoutManager" --include="*.swift"
+
+# Look for proper TextKit 2 checks (should be common)
+grep -rn "textLayoutManager" --include="*.swift"
+```
+
+**NSLayoutManager Usage** (TextKit 1):
+```bash
+# NSLayoutManager class usage
+grep -rn "NSLayoutManager" --include="*.swift"
+
+# NSLayoutManagerDelegate conformance
+grep -rn ": NSLayoutManagerDelegate" --include="*.swift"
+```
+
+**Glyph APIs** (Deprecated):
+```bash
+# Glyph count queries
+grep -rn "numberOfGlyphs" --include="*.swift"
+
+# Glyph range queries
+grep -rn "glyphRange" --include="*.swift"
+
+# Glyph index queries
+grep -rn "glyphIndex" --include="*.swift"
+
+# Character-to-glyph mapping (broken for complex scripts)
+grep -rn "characterIndex(forGlyphAt:" --include="*.swift"
+grep -rn "glyphIndexForCharacter" --include="*.swift"
+
+# Glyph rect queries
+grep -rn "rectForGlyph" --include="*.swift"
+grep -rn "boundingRectForGlyphRange" --include="*.swift"
+```
+
+**NSRange with TextKit 2**:
+```bash
+# NSRange used with TextKit 2 APIs
+grep -rn "NSTextLayoutManager.*NSRange" --include="*.swift"
+grep -rn "textLayoutManager.*NSRange" --include="*.swift"
+```
+
+**Missing Writing Tools Integration**:
+```bash
+# Find text views
+grep -rn "UITextView\|NSTextView" --include="*.swift"
+
+# Check for Writing Tools configuration (should match text view count)
+grep -rn "writingToolsBehavior" --include="*.swift"
+
+# Check for Writing Tools state awareness
+grep -rn "isWritingToolsActive" --include="*.swift"
+```
+
+### Step 3: Categorize by Severity
+
+**CRITICAL** (Breaks Writing Tools, incorrect complex script handling):
+- Direct `.layoutManager` access (fallback trigger)
+- NSLayoutManager usage (TextKit 1 only)
+- Glyph APIs (data corruption with Arabic, Kannada, etc.)
+
+**HIGH** (Wrong paradigm):
+- NSRange with TextKit 2 APIs (should use NSTextRange)
+
+**MEDIUM** (Missing modern features):
+- Missing `writingToolsBehavior` property
+- Missing `isWritingToolsActive` checks
+
+## Output Format
+
+```markdown
+# TextKit Audit Results
+
+## Summary
+- **CRITICAL Issues**: [count] (TextKit 1 fallback, data corruption risk)
+- **HIGH Issues**: [count] (Wrong paradigm)
+- **MEDIUM Issues**: [count] (Missing modern features)
+
+## TextKit Version: [TextKit 1 / TextKit 2 / Mixed]
+
+## CRITICAL Issues
+
+### TextKit 1 Fallback Triggers
+- `src/Views/EditorView.swift:42` - `textView.layoutManager` accessed directly
+ - **Risk**: One-way fallback to TextKit 1, loses Writing Tools support
+ - **Fix**: Check `textLayoutManager` first
+ ```swift
+ // ❌ BAD: Immediate fallback to TextKit 1
+ if let layoutManager = textView.layoutManager {
+ // TextKit 1 code
+ }
+
+ // ✅ GOOD: Use TextKit 2 when available
+ if let textLayoutManager = textView.textLayoutManager {
+ // TextKit 2 code
+ } else if let layoutManager = textView.layoutManager {
+ // TextKit 1 fallback only for old OS
+ }
+ ```
+
+### NSLayoutManager Usage (TextKit 1 Only)
+- `src/Helpers/TextMeasure.swift:67` - NSLayoutManager class used
+ - **Risk**: No Writing Tools, incorrect handling of Arabic/Kannada text
+ - **Fix**: Migrate to NSTextLayoutManager
+ ```swift
+ // TextKit 2 replacement for line counting
+ var lineCount = 0
+ textLayoutManager.enumerateTextLayoutFragments(
+ from: textLayoutManager.documentRange.location,
+ options: [.ensuresLayout]
+ ) { fragment in
+ lineCount += fragment.textLineFragments.count
+ return true
+ }
+ ```
+
+### Glyph API Usage (Data Corruption Risk)
+- `src/Helpers/LineCounter.swift:89` - `numberOfGlyphs` used
+ - **Risk**: Incorrect count for complex scripts (Arabic: 1 char = 2+ glyphs, Kannada: 1 char splits)
+ - **Why broken**: Glyph ≠ character for ligatures, combining marks, right-to-left text
+ - **Fix**: Use TextKit 2 fragment enumeration
+ ```swift
+ // TextKit 2 - no glyph APIs
+ textLayoutManager.enumerateTextLayoutFragments(...) { fragment in
+ // Use fragment.textLineFragments for measurement
+ }
+ ```
+
+## HIGH Issues
+
+### NSRange with TextKit 2 APIs
+- `src/Views/SelectionHandler.swift:123` - NSRange used with NSTextLayoutManager
+ - **Risk**: Wrong paradigm, breaks with structured documents
+ - **Fix**: Convert to NSTextRange via NSTextContentManager
+ ```swift
+ // Convert NSRange → NSTextRange
+ let startLocation = textContentManager.location(
+ textContentManager.documentRange.location,
+ offsetBy: nsRange.location
+ )!
+ let endLocation = textContentManager.location(
+ startLocation,
+ offsetBy: nsRange.length
+ )!
+ let textRange = NSTextRange(location: startLocation, end: endLocation)
+ ```
+
+## MEDIUM Issues
+
+### Missing Writing Tools Integration
+- `src/Views/NotesEditor.swift:34` - UITextView without writingToolsBehavior property
+ - **Impact**: No Writing Tools support (iOS 18+)
+ - **Fix**: Add Writing Tools configuration
+ ```swift
+ textView.writingToolsBehavior = .default // Full experience
+ textView.writingToolsResultOptions = [.richText, .list]
+ ```
+
+### Missing Writing Tools State Checks
+- `src/Services/SyncService.swift:201` - Text mutations without isWritingToolsActive check
+ - **Impact**: Can interfere with Writing Tools operation
+ - **Fix**: Check before modifying text
+ ```swift
+ func syncChanges() {
+ guard !textView.isWritingToolsActive else { return }
+ // Sync logic
+ }
+ ```
+
+## TextKit Version Assessment
+
+**Current State**: [Describe which version is in use]
+- TextKit 2: [List TextKit 2 usage]
+- TextKit 1: [List TextKit 1 usage]
+- Mixed: [Describe if both are used]
+
+**Recommendation**:
+- If iOS 16+ only: Migrate fully to TextKit 2
+- If supporting iOS 15-: Use TextKit 2 with TextKit 1 fallback pattern
+- Writing Tools requires TextKit 2 (iOS 18+)
+
+## Next Steps
+
+1. **Fix CRITICAL issues first** - Prevents data corruption with complex scripts
+2. **Migrate to TextKit 2** - Required for Writing Tools (iOS 18+)
+3. **Test with complex scripts** - Arabic, Hebrew, Thai, Hindi, Kannada
+4. **Test Writing Tools** - iOS 18+ only
+
+## Testing Recommendations
+
+After fixes:
+```bash
+# Test with complex scripts
+1. Enter Arabic text: "مرحبا"
+2. Enter Kannada text: "ಅಕ್ಟೋಬರ್"
+3. Verify: No crashes, correct rendering
+
+# Test Writing Tools (iOS 18+)
+1. Select text in UITextView
+2. Tap "Writing Tools" in context menu
+3. Verify: Full inline experience (not panel-only)
+
+# Debug TextKit 1 fallback
+1. Set breakpoint on _UITextViewEnablingCompatibilityMode (UIKit)
+2. Subscribe to willSwitchToNSLayoutManagerNotification (AppKit)
+3. Run app and check if fallback occurs
+```
+
+## For Detailed TextKit 2 Guidance
+
+Use `/skill axiom:textkit-ref` for complete TextKit 2 architecture reference, migration patterns from TextKit 1, Writing Tools integration guide, and SwiftUI TextEditor + AttributedString patterns.
+```
+
+## Audit Guidelines
+
+1. Run all searches for comprehensive coverage
+2. Provide file:line references to make it easy to find issues
+3. Include code examples showing both wrong and correct patterns
+4. Categorize by severity to help prioritize fixes
+5. Assess TextKit version to determine migration path
+
+## When Issues Found
+
+If CRITICAL issues found:
+- Emphasize data corruption risk with complex scripts
+- Warn about Writing Tools loss
+- Recommend TextKit 2 migration
+- Provide exact fix code
+
+If NO issues found:
+- Report "No TextKit violations detected"
+- Note current TextKit version in use
+- Suggest Writing Tools integration if iOS 18+
+
+## False Positives
+
+These are acceptable (not issues):
+- TextKit 1 code behind OS version checks (iOS 15 fallback)
+- `layoutManager` mentioned in comments
+- TextKit 1 in migration code with proper guards
+
+## Migration Priority
+
+**High Priority** (if targeting iOS 18+):
+1. Fix fallback triggers (`.layoutManager` access)
+2. Remove glyph APIs (data corruption risk)
+3. Integrate Writing Tools
+
+**Medium Priority** (if supporting iOS 16-17):
+1. Use TextKit 2 with TextKit 1 fallback
+2. Plan migration to TextKit 2 when dropping iOS 15
+
+**Low Priority** (if iOS 15 only):
+- Stay on TextKit 1 until dropping iOS 15 support
+
+## Complex Script Examples
+
+**Why glyph APIs are dangerous:**
+
+**Arabic** (right-to-left):
+- Visual: "مرحبا" (5 characters)
+- Glyphs: 7+ glyphs (ligatures, position forms)
+- Glyph index ≠ character index
+
+**Kannada** ("October"):
+- Character 4: single vowel
+- Glyphs: 2 glyphs (split vowel)
+- Glyphs reorder during shaping
+- No 1:1 mapping
+
+**TextKit 2 Solution**: Abstracts glyphs away, uses NSTextLocation for positions.
+
+## Summary
+
+This audit scans for:
+- **3 CRITICAL patterns** that break Writing Tools and complex scripts
+- **1 HIGH pattern** using wrong paradigm
+- **2 MEDIUM patterns** missing modern features
+
+**Fix time**: TextKit 2 migration typically 2-4 hours for simple editors, 1-2 days for complex implementations.
+
+**When to run**: Before iOS 18 release, after adding text editing features, quarterly for technical debt tracking.
diff --git a/.claude/skills/axiom-audit-textkit/agents/openai.yaml b/.claude/skills/axiom-audit-textkit/agents/openai.yaml
new file mode 100644
index 0000000..66a54d0
--- /dev/null
+++ b/.claude/skills/axiom-audit-textkit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit TextKit"
+ short_description: "The user mentions TextKit review, text layout issues, Writing Tools integration, or UITextView/NSTextView code review."
diff --git a/.claude/skills/axiom-audit-ux-flow/.openskills.json b/.claude/skills/axiom-audit-ux-flow/.openskills.json
new file mode 100644
index 0000000..63d9586
--- /dev/null
+++ b/.claude/skills/axiom-audit-ux-flow/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-audit-ux-flow",
+ "installedAt": "2026-04-12T08:05:49.856Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-audit-ux-flow/SKILL.md b/.claude/skills/axiom-audit-ux-flow/SKILL.md
new file mode 100644
index 0000000..5ffe7d2
--- /dev/null
+++ b/.claude/skills/axiom-audit-ux-flow/SKILL.md
@@ -0,0 +1,272 @@
+---
+name: axiom-audit-ux-flow
+description: Use when the user mentions UX flow issues, dead-end views, dismiss traps, missing empty states, broken user journeys, or wants a UX audit of their iOS app.
+license: MIT
+disable-model-invocation: true
+---
+# UX Flow Auditor Agent
+
+You are an expert at detecting user journey defects in iOS apps (SwiftUI and UIKit) — both known anti-patterns AND missing/incomplete flows that cause user frustration, support tickets, and abandonment.
+
+## Your Mission
+
+Run a comprehensive UX flow audit using 5 phases: map the user journey architecture, detect known UX defects, reason about what flows are missing or incomplete, correlate compound issues, and score journey health. Report all issues with:
+- File:line references
+- Severity ratings (CRITICAL/HIGH/MEDIUM/LOW)
+- Fix recommendations with code examples
+- Cross-auditor correlation notes
+
+**This agent checks user journeys, not code patterns.** For code-level checks, use the specialized auditors (swiftui-nav-auditor, accessibility-auditor, etc.).
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Map User Journey Architecture
+
+Before checking individual patterns, build a mental model of the app's user journey surface.
+
+### Step 1: Identify Entry Points
+
+```
+Glob: **/App.swift, **/*App.swift, **/SceneDelegate.swift, **/AppDelegate.swift
+Grep for:
+ - `.onOpenURL` — deep link entry points
+ - `widgetURL` — widget entry points
+ - `UNUserNotificationCenter` — notification entry points
+ - `application(_:open:`, `application(_:continue:` — URL/activity entry points
+```
+
+### Step 2: Map Navigation Structure
+
+```
+Grep for:
+ - `NavigationStack`, `NavigationSplitView` — navigation containers
+ - `TabView`, `UITabBarController` — tab structure
+ - `.sheet`, `.fullScreenCover` — modal presentations
+ - `.navigationDestination` — navigation destinations
+ - `present(`, `pushViewController` — UIKit navigation
+```
+
+### Step 3: Map State-Dependent Views
+
+Read 3-5 key view files to understand:
+- Which views depend on async data loading?
+- Which views have empty/error/loading state handling?
+- Where are the critical user flows? (onboarding, purchase, settings, content creation)
+
+### Output
+
+Write a brief **Journey Architecture Map** (8-12 lines) summarizing:
+- App entry points (main, deep links, widgets, notifications)
+- Navigation structure (tabs, stacks, modals)
+- Critical user flows identified
+- State-dependent views (async data, conditional content)
+
+Present this map in the output before proceeding.
+
+## Phase 2: Detect Known UX Defects
+
+Run all 11 existing detection categories. These are fast and reliable. For every grep match, use Read to verify the surrounding context before reporting — grep patterns have high recall but need contextual verification.
+
+### 1. Dead-End Views (CRITICAL)
+
+**Pattern**: Views that are navigation destinations but have no actions, navigation, or completion state
+**Search**: Views in `.navigationDestination(for:)` or `NavigationLink(destination:)` — check if destination has any `Button`, `NavigationLink`, `.sheet`, `.fullScreenCover`, or dismiss action. UIKit: View controllers with no `IBAction`, no `addTarget`, no `pushViewController`/`present` calls
+**Issue**: Users land on a screen with nothing to do
+**Fix**: Add clear next action or completion path
+
+### 2. Dismiss Traps (CRITICAL)
+
+**Pattern**: Modal presentations without escape
+**Search**: `.fullScreenCover` without `@Environment(\.dismiss)` or dismiss button; `.sheet` with `.interactiveDismissDisabled(true)` without alternative dismiss; `.alert`/`.confirmationDialog` without cancel action. UIKit: `present(_:animated:)` with `.fullScreen` where presented VC has no close button; `isModalInPresentation = true` without dismiss path
+**Issue**: Users are trapped in a modal with no way out
+**Fix**: Add dismiss button or cancel action
+
+### 3. Buried CTAs (HIGH)
+
+**Pattern**: Primary actions hidden or hard to find
+**Search**: Root tab views — check if first visible content has a clear primary action; `ScrollView` content — check if primary `Button` is near top vs below fold; `.toolbar` items using `.secondaryAction` placement for primary functionality; Actions only inside `DisclosureGroup` or `Menu`
+**Issue**: Users can't find the main action
+**Fix**: Surface primary action prominently
+
+### 4. Promise-Scope Mismatch (HIGH)
+
+**Pattern**: Labels/titles that don't match content
+**Search**: `.navigationTitle()` text vs view content; `NavigationLink` label vs destination content; `TabView` tab labels vs tab content
+**Issue**: Users expect one thing, get another
+**Fix**: Align title/label with actual content
+
+### 5. Deep Link Dead Ends (HIGH)
+
+**Pattern**: URLs that open to broken/empty views
+**Search**: `.onOpenURL` handlers — check if destination view validates the linked entity exists; deep link routes that push views without checking data availability; no fallback view when linked content is unavailable
+**Issue**: External link opens app to blank/broken screen
+**Fix**: Validate linked content, show fallback for missing data
+
+### 6. Missing Empty States (HIGH)
+
+**Pattern**: Data views with no empty handling
+**Search**: `List` or `ForEach` over arrays/queries without empty check; `@Query` results used in `ForEach` without `if results.isEmpty` guard; search results without "no results" UI; `LazyVGrid`/`LazyVStack` without empty state overlay
+**Issue**: Users see a blank screen with no guidance
+**Fix**: Add ContentUnavailableView or empty state overlay
+
+### 7. Missing Loading/Error States (HIGH)
+
+**Pattern**: Async operations without user feedback
+**Search**: `.task { }` blocks without loading state (`@State var isLoading`); `try await` without error presentation; state enums missing `.loading`/`.error` cases. UIKit: `URLSession` calls without `UIActivityIndicatorView`; completion handlers that don't update UI on error
+**Issue**: Users don't know if something is loading or broken
+**Fix**: Add loading indicator and error presentation
+
+### 8. Accessibility Dead Ends (HIGH)
+
+**Pattern**: Flows unreachable via assistive technology
+**Search**: `.onLongPressGesture` / `DragGesture` without `.accessibilityAction` equivalent; custom controls without `.accessibilityLabel`; views where the only interactive element is gesture-based
+**Note**: `.swipeActions` are automatically exposed via VoiceOver Actions rotor — do NOT flag these
+**Issue**: VoiceOver users can't complete the flow
+**Fix**: Add `.accessibilityAction` equivalents for gesture-only interactions
+
+### 9. Onboarding Gaps (MEDIUM)
+
+**Pattern**: First-launch experience issues
+**Search**: `@AppStorage` for first-launch flag — check the gated view for completeness; onboarding flows with more than 5 screens; onboarding requiring sign-up before showing app value
+**Issue**: Users abandon onboarding before seeing value
+**Fix**: Show value early, keep onboarding under 5 screens
+
+### 10. Broken Data Paths (MEDIUM)
+
+**Pattern**: State/binding wiring issues
+**Search**: `@Binding` parameters initialized with `.constant()` in non-preview production code; `@Environment` keys used but not provided in view hierarchy; `@Observable` objects created with `@State` when they should be passed via environment
+**Note**: Read 3-5 lines above and below. If there's a comment explaining intent (e.g., `// Staged refactor`, `// Intentional`), downgrade to LOW or skip.
+**Issue**: User actions don't propagate, UI is disconnected
+**Fix**: Wire bindings correctly, inject environment objects
+
+### 11. Platform Parity Gaps (MEDIUM)
+
+**Pattern**: Missing iPad/landscape/Mac adaptivity
+**Search**: `NavigationStack` without `NavigationSplitView` for iPad; no `.horizontalSizeClass` usage in adaptive layouts; fixed heights that break in landscape
+**Issue**: iPad/landscape users have degraded experience
+**Fix**: Use NavigationSplitView, check size classes
+
+**Scan systematically**: When you find a pattern in one file, grep the entire codebase for the same pattern. A single instance usually indicates a codebase-wide habit. Report the full count and list all affected files.
+
+## Phase 3: Reason About Journey Completeness
+
+Using the Journey Architecture Map from Phase 1 and your domain knowledge, check for what's *missing* — not just what's wrong with individual screens.
+
+| Question | What it detects | Why it matters |
+|----------|----------------|----------------|
+| Can users complete every critical flow (onboarding, purchase, content creation) from start to finish without dead ends? | Incomplete critical flows | Users abandon the app at dead ends in core journeys |
+| Does every modal presentation have a clear exit path, including when async operations fail mid-flow? | Missing error recovery in modals | Users get stuck in sheets when network calls fail |
+| Are there screens that load async data but have no way to retry on failure? | Missing retry affordance | Users must kill and restart the app to try again |
+| Do deep links, widgets, and notifications all land on screens that validate their data? | Unvalidated entry points | External entry points assume data exists, show broken state |
+| Is there a consistent state pattern (loading/content/empty/error) applied to all data-dependent views? | Inconsistent state handling | Some screens handle empty gracefully, others show blank |
+| Can VoiceOver users complete every flow that sighted users can? | Inaccessible critical paths | Gesture-only features exclude assistive technology users |
+| Do destructive actions (delete, cancel subscription, sign out) have confirmation and undo paths? | Missing safety nets | Users lose data/state with no way to recover |
+| Are there flows where the back button or swipe-to-dismiss loses user input? | Data loss on navigation | Users lose form data or draft content when navigating away |
+
+For each finding, explain what's missing and why it matters. Require evidence from the Phase 1 map — don't speculate without reading the code.
+
+## Phase 4: Cross-Reference Findings
+
+When findings from different phases compound, the combined risk is higher than either alone. Bump the severity when you find these combinations:
+
+| Finding A | + Finding B | = Compound | Severity |
+|-----------|------------|-----------|----------|
+| Dead-end view | No NavigationPath management | User trapped with no programmatic exit | CRITICAL |
+| Gesture-only action | No .accessibilityAction | Flow unreachable for VoiceOver users | CRITICAL |
+| Missing loading state | Unhandled async error | User sees blank screen on failure | CRITICAL |
+| Missing empty state | Deep link to list view | Deep link opens to blank screen | CRITICAL |
+| Dismiss trap in sheet | Async operation in progress | User stuck while operation runs | HIGH |
+| Missing error state | No retry button | User must kill app to retry | HIGH |
+| Buried CTA | Onboarding flow | New users never find primary action | HIGH |
+| Broken data path | Critical flow (purchase, auth) | Core transaction silently broken | HIGH |
+
+Also note overlaps with other auditors:
+- Dead end + no NavigationPath → compound with swiftui-nav-auditor
+- Gesture-only + no accessibilityAction → compound with accessibility-auditor
+- Missing loading + unhandled error → compound with concurrency-auditor
+
+## Phase 5: UX Journey Health Score
+
+Calculate and present a health score:
+
+```markdown
+## UX Journey Health Score
+
+| Metric | Value |
+|--------|-------|
+| Critical flow coverage | N critical flows identified, M complete start-to-finish (Z%) |
+| State handling | N data-dependent views, M with loading/empty/error states (Z%) |
+| Modal safety | N modal presentations, M with clear dismiss path (Z%) |
+| Entry point validation | N external entries (deep link, widget, notification), M validate data (Z%) |
+| Accessibility reach | N interactive flows, M reachable via VoiceOver (Z%) |
+| **Health** | **SMOOTH / ROUGH EDGES / BROKEN JOURNEYS** |
+```
+
+Scoring:
+- **SMOOTH**: No CRITICAL issues, all critical flows complete, >80% state handling coverage, all modals have dismiss paths
+- **ROUGH EDGES**: No CRITICAL issues, most critical flows complete, some missing states or entry point validation gaps
+- **BROKEN JOURNEYS**: Any CRITICAL issues (dead ends, dismiss traps), or critical flows incomplete, or <50% state handling
+
+## Output Format
+
+```markdown
+# UX Flow Audit Results
+
+## Journey Architecture Map
+[8-12 line summary from Phase 1]
+
+## Summary
+- CRITICAL: [N] issues
+- HIGH: [N] issues
+- MEDIUM: [N] issues
+- LOW: [N] issues
+- Phase 2 (defect detection): [N] issues
+- Phase 3 (completeness reasoning): [N] issues
+- Phase 4 (compound findings): [N] issues
+
+## UX Journey Health Score
+[Phase 5 table]
+
+## Enhanced Rating Table (CRITICAL and HIGH only)
+
+| Finding | Urgency | Blast Radius | Fix Effort | ROI |
+|---------|---------|-------------|-----------|-----|
+| [description] | Ship-blocker/Next release/Backlog | All users/Specific flow/Edge case | [time] | Critical/High/Medium |
+
+## Issues by Severity
+
+### [SEVERITY] [Category]: [Description]
+**File**: path/to/file.swift:line
+**Phase**: [2: Detection | 3: Completeness | 4: Compound]
+**Issue**: What's wrong or missing
+**Impact**: What users experience
+**Fix**: Code example showing the fix
+**Cross-Auditor Notes**: [if overlapping with another auditor]
+
+## Recommendations
+1. [Immediate actions — CRITICAL fixes (dead ends, dismiss traps)]
+2. [Short-term — HIGH fixes (missing states, entry point validation)]
+3. [Long-term — journey improvements from Phase 3 findings]
+```
+
+## Output Limits
+
+If >50 issues in one category: Show top 10, provide total count, list top 3 files
+If >100 total issues: Summarize by category, show only CRITICAL/HIGH details
+
+## False Positives (Not Issues)
+
+- Views intentionally designed as static informational screens (About, Legal, Licenses)
+- `.fullScreenCover` with dismiss handled by parent view callback
+- Empty states handled by a shared container/wrapper view
+- Deep links not implemented by design choice (documented)
+- iPad-only or iPhone-only apps (no platform parity expected)
+- `.swipeActions` on List rows (automatically exposed via VoiceOver Actions rotor)
+
+## Related
+
+For navigation architecture: `axiom-swiftui-nav` skill
+For accessibility compliance: `axiom-accessibility-diag` skill
+For UX principles: `axiom-ux-flow-audit` skill
diff --git a/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml b/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml
new file mode 100644
index 0000000..3c92f1e
--- /dev/null
+++ b/.claude/skills/axiom-audit-ux-flow/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Audit UX Flow"
+ short_description: "The user mentions UX flow issues, dead-end views, dismiss traps, missing empty states, broken user journeys, or wants..."
diff --git a/.claude/skills/axiom-auto-layout-debugging/.openskills.json b/.claude/skills/axiom-auto-layout-debugging/.openskills.json
new file mode 100644
index 0000000..de48504
--- /dev/null
+++ b/.claude/skills/axiom-auto-layout-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-auto-layout-debugging",
+ "installedAt": "2026-04-12T08:05:52.943Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-auto-layout-debugging/SKILL.md b/.claude/skills/axiom-auto-layout-debugging/SKILL.md
new file mode 100644
index 0000000..12bb09e
--- /dev/null
+++ b/.claude/skills/axiom-auto-layout-debugging/SKILL.md
@@ -0,0 +1,630 @@
+---
+name: axiom-auto-layout-debugging
+description: Use when encountering "Unable to simultaneously satisfy constraints" errors, constraint conflicts, ambiguous layout warnings, or views positioned incorrectly - systematic debugging workflow for Auto Layout issues in iOS
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Auto Layout Debugging
+
+## When to Use This Skill
+
+Use when:
+- Seeing "Unable to simultaneously satisfy constraints" errors in console
+- Views positioned incorrectly or not appearing
+- Constraint warnings during app launch or navigation
+- Ambiguous layout errors
+- Views appearing at unexpected sizes
+- Debug View Hierarchy shows misaligned views
+- Storyboard/XIB constraints behaving differently at runtime
+
+## Overview
+
+**Core Principle**: Auto Layout constraint errors follow predictable patterns. Systematic debugging with proper tools identifies issues in minutes instead of hours.
+
+**Time Savings**: Typical constraint debugging without this workflow: 30-60 minutes. With systematic approach: 5-10 minutes.
+
+---
+
+## Quick Decision Tree
+
+```
+Constraint error in console?
+├─ Can't identify which views?
+│ └─ Use Symbolic Breakpoint + Memory Address Identification
+├─ Constraint conflicts shown?
+│ └─ Use Constraint Priority Resolution
+├─ Ambiguous layout (multiple solutions)?
+│ └─ Use _autolayoutTrace to find missing constraints
+└─ Views positioned incorrectly but no errors?
+ └─ Use Debug View Hierarchy + Show Constraints
+```
+
+---
+
+## Understanding Constraint Error Messages
+
+### Anatomy of Error Message
+
+```
+Unable to simultaneously satisfy constraints.
+Probably at least one of the constraints in the following list you don't need.
+
+(
+ "",
+ "",
+ "",
+ ""
+)
+
+Will attempt to recover by breaking constraint
+
+```
+
+**Key Components**:
+1. **Memory addresses** — `0x7f8b9c4...` identifies views and constraints
+2. **Visual Format** — Human-readable constraint description
+3. **`(active)` status** — Constraint is currently enforced
+4. **Recovery action** — Which constraint system will break (usually lowest priority)
+
+### System-Generated Constraints
+
+**UIView-Encapsulated-Layout-Width/Height**:
+- Created by UIKit for cells, system views
+- Often source of conflicts
+- Usually correct; your constraints are the problem
+
+**Autoresizing Mask Constraints**:
+- Format: `h=--&` or `v=&--`
+- `-` = fixed dimension
+- `&` = flexible dimension
+- Example: `h=--&` = fixed left margin and width, flexible right margin
+
+---
+
+## Debugging Workflow
+
+### Step 1: Set Up Symbolic Breakpoint (One-Time Setup)
+
+**Purpose**: Break when constraint conflict occurs, before system breaks constraint.
+
+**Setup**:
+1. Open Breakpoint Navigator (⌘+7 or ⌘+8)
+2. Click `+` → "Symbolic Breakpoint"
+3. **Symbol**: `UIViewAlertForUnsatisfiableConstraints`
+4. (Optional) Add **Action** → "Sound" → select sound
+5. (Optional) Check "Automatically continue after evaluating actions"
+
+**Why this works**: Pauses execution at exact moment of constraint conflict, giving you debugger access to all views and constraints.
+
+---
+
+### Step 2: Identify Views from Memory Addresses
+
+When breakpoint hits, console shows memory addresses like `UILabel:0x7f8b9c4...`
+
+#### Technique 1: Use %rbx Register (When Breakpoint Hits)
+
+```lldb
+# Print all involved views and constraints
+po $arg1
+
+# Or on older Xcode versions
+po $rbx
+```
+
+**Output**: NSArray containing all conflicting constraints and affected views.
+
+#### Technique 2: Set View Background Color
+
+```lldb
+# Set background color on suspected view
+expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor]
+
+# Continue execution to see which view turned red
+```
+
+**Result**: Visually identifies which view corresponds to memory address.
+
+#### Technique 3: Print View Hierarchy
+
+**Objective-C projects**:
+```lldb
+po [[UIWindow keyWindow] _autolayoutTrace]
+```
+
+**Swift projects**:
+```lldb
+expr -l objc++ -O -- [[UIWindow keyWindow] _autolayoutTrace]
+```
+
+**Output**: Entire view hierarchy with `*` marking ambiguous layouts.
+
+**Example**:
+```
+*
+|
+```
+
+The `*` indicates this UIView has ambiguous constraints.
+
+#### Technique 4: Print Constraints for Specific View
+
+```lldb
+# Horizontal constraints (axis: 0)
+po [0x7f8b9c4... constraintsAffectingLayoutForAxis:0]
+
+# Vertical constraints (axis: 1)
+po [0x7f8b9c4... constraintsAffectingLayoutForAxis:1]
+```
+
+**Output**: All constraints affecting that view's layout.
+
+---
+
+### Step 3: Use Debug View Hierarchy
+
+**When to use**: Views positioned incorrectly, constraints not visible in code.
+
+**Workflow**:
+1. **Trigger the issue** — Navigate to screen with constraint problems
+2. **Pause execution** — Click "Debug View Hierarchy" button in debug bar (or Debug → View Debugging → Capture View Hierarchy)
+3. **Inspect 3D view** — Rotate view hierarchy to see layering
+4. **Enable "Show Constraints"** — Shows all constraints as lines
+5. **Select view** — Right panel shows all constraints affecting selected view
+
+**Key Features**:
+- **Show Clipped Content** — Reveals views positioned off-screen
+- **Show Constraints** — Visualizes constraint relationships
+- **Filter Bar** — Search for specific views by class or memory address
+
+**Finding Issues**:
+- Purple constraints = satisfied
+- Orange/red constraints = conflicts
+- Select constraint → see both views it connects
+
+---
+
+### Step 4: Name Your Constraints (Prevention)
+
+**Why**: Makes error messages readable instead of cryptic memory addresses.
+
+#### In Interface Builder (Storyboards/XIBs)
+
+1. Select constraint in Document Outline
+2. Open Attributes Inspector
+3. Set **Identifier** field (e.g., "ProfileImageWidthConstraint")
+
+**Before**:
+```
+
+```
+
+**After**:
+```
+
+```
+
+#### Programmatically
+
+```swift
+let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 100)
+widthConstraint.identifier = "ProfileImageWidthConstraint"
+widthConstraint.isActive = true
+```
+
+**Impact**: Instantly know which constraint is breaking without hunting through code.
+
+---
+
+### Step 5: Name Your Views (Prevention)
+
+**Why**: Error messages show view class AND your custom label.
+
+#### In Interface Builder
+
+1. Select view in Document Outline
+2. Open Identity Inspector
+3. Set **Label** field (e.g., "Profile Image View")
+
+**Before**:
+```
+
+```
+
+**After**:
+```
+
+```
+
+#### Programmatically
+
+```swift
+imageView.accessibilityIdentifier = "ProfileImageView"
+```
+
+**Note**: Xcode automatically uses textual components (UILabel text, UIButton titles) as identifiers when available.
+
+---
+
+## Common Constraint Conflict Patterns
+
+### Pattern 1: Conflicting Fixed Widths
+
+**Symptom**:
+```
+Container width: 375
+Child width: 300
+Child leading: 20
+Child trailing: 20
+// 20 + 300 + 20 = 340 ≠ 375
+```
+
+**❌ WRONG**:
+```swift
+// Conflicting constraints
+imageView.widthAnchor.constraint(equalToConstant: 300).isActive = true
+imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
+imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
+// Over-constrained: width + leading + trailing = 3 horizontal constraints (only need 2)
+```
+
+**✅ CORRECT Option 1** (Remove fixed width):
+```swift
+// Let width be calculated from leading + trailing
+imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
+imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
+// Width will be container width - 40
+```
+
+**✅ CORRECT Option 2** (Use priorities):
+```swift
+let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 300)
+widthConstraint.priority = .defaultHigh // 750 (can be broken if needed)
+widthConstraint.isActive = true
+
+imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20).isActive = true
+imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20).isActive = true
+// Required constraints (1000) will break lower-priority width constraint if needed
+```
+
+---
+
+### Pattern 2: UIView-Encapsulated-Layout Conflicts
+
+**Symptom**: Table cells or collection view cells conflicting with `UIView-Encapsulated-Layout-Width`.
+
+**Why it happens**: System sets cell width based on table/collection view. Your constraints fight it.
+
+**❌ WRONG**:
+```swift
+// In UITableViewCell
+contentLabel.widthAnchor.constraint(equalToConstant: 320).isActive = true
+// Conflicts with system-determined cell width
+```
+
+**✅ CORRECT**:
+```swift
+// Use relative constraints, not fixed widths
+contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16).isActive = true
+contentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16).isActive = true
+// Width adapts to cell width automatically
+```
+
+---
+
+### Pattern 3: Autoresizing Mask Conflicts
+
+**Symptom**: Mixing Auto Layout with `autoresizingMask` or not setting `translatesAutoresizingMaskIntoConstraints = false`.
+
+**❌ WRONG**:
+```swift
+let imageView = UIImageView()
+view.addSubview(imageView)
+
+// Forgot to disable autoresizing mask
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
+// Conflicts with autoresizing mask constraints
+```
+
+**✅ CORRECT**:
+```swift
+let imageView = UIImageView()
+imageView.translatesAutoresizingMaskIntoConstraints = false // ← CRITICAL
+view.addSubview(imageView)
+
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
+```
+
+**Why**: `translatesAutoresizingMaskIntoConstraints = true` creates automatic constraints that conflict with your explicit constraints.
+
+---
+
+### Pattern 4: Ambiguous Layout (Missing Constraints)
+
+**Symptom**: View appears, but position shifts unexpectedly or `_autolayoutTrace` shows `*` (ambiguous).
+
+**Problem**: Not enough constraints to determine unique position/size.
+
+**❌ WRONG** (Ambiguous X position):
+```swift
+imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
+imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true
+// Missing: horizontal position (leading/trailing/centerX)
+```
+
+**✅ CORRECT**:
+```swift
+imageView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
+imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true // ← Added
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
+imageView.heightAnchor.constraint(equalToConstant: 100).isActive = true
+```
+
+**Rule**: Every view needs:
+- **Horizontal**: 2 constraints (e.g., leading + width, OR leading + trailing, OR centerX + width)
+- **Vertical**: 2 constraints (e.g., top + height, OR top + bottom, OR centerY + height)
+
+---
+
+### Pattern 5: Priority Conflicts
+
+**Symptom**: Unexpected constraint breaks, but all constraints seem correct.
+
+**Problem**: Multiple constraints at same priority competing.
+
+**❌ WRONG**:
+```swift
+// Both required (priority 1000)
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true
+imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150).isActive = true
+// Impossible: width can't be 100 AND >= 150
+```
+
+**✅ CORRECT**:
+```swift
+let preferredWidth = imageView.widthAnchor.constraint(equalToConstant: 100)
+preferredWidth.priority = .defaultHigh // 750
+preferredWidth.isActive = true
+
+let minWidth = imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 150)
+minWidth.priority = .required // 1000
+minWidth.isActive = true
+
+// Result: width will be 150 (required constraint wins)
+```
+
+**Priority levels** (higher = stronger):
+- `.required` (1000) — Must be satisfied
+- `.defaultHigh` (750) — Strong preference
+- `.defaultLow` (250) — Weak preference
+- Custom: any value 1-999
+
+---
+
+## Debugging Checklist
+
+### Before Debugging
+- [ ] Read full error message in console (don't ignore it)
+- [ ] Note which constraints are listed as conflicting
+- [ ] Check if error is consistent or intermittent
+
+### During Debugging
+- [ ] Set symbolic breakpoint at UIViewAlertForUnsatisfiableConstraints
+- [ ] Identify views using memory addresses (background color technique)
+- [ ] Use Debug View Hierarchy to visualize constraints
+- [ ] Check _autolayoutTrace for ambiguous layouts
+- [ ] Verify translatesAutoresizingMaskIntoConstraints = false for programmatic views
+
+### After Fixing
+- [ ] Test on multiple device sizes (iPhone SE, iPhone Pro Max)
+- [ ] Test orientation changes (portrait/landscape)
+- [ ] Test with Dynamic Type sizes
+- [ ] Verify no console warnings during transitions
+- [ ] Add constraint identifiers for future debugging
+
+---
+
+## Advanced Techniques
+
+### Constraint Priority Strategy
+
+**Use case**: View that should be certain size, but can shrink if needed.
+
+```swift
+// Preferred size: 200x200
+let widthConstraint = imageView.widthAnchor.constraint(equalToConstant: 200)
+widthConstraint.priority = .defaultHigh // 750
+widthConstraint.isActive = true
+
+let heightConstraint = imageView.heightAnchor.constraint(equalToConstant: 200)
+heightConstraint.priority = .defaultHigh // 750
+heightConstraint.isActive = true
+
+// But never smaller than 100x100
+imageView.widthAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true
+imageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true
+
+// And never larger than container
+imageView.widthAnchor.constraint(lessThanOrEqualTo: containerView.widthAnchor).isActive = true
+imageView.heightAnchor.constraint(lessThanOrEqualTo: containerView.heightAnchor).isActive = true
+```
+
+**Result**: Image is 200x200 when space available, shrinks to fit container (min 100x100).
+
+---
+
+### Content Hugging and Compression Resistance
+
+**Content Hugging** (resist expanding):
+```swift
+// Label should not stretch beyond its text width
+label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+```
+
+**Compression Resistance** (resist shrinking):
+```swift
+// Label should not truncate if possible
+label.setContentCompressionResistancePriority(.required, for: .horizontal)
+```
+
+**Common pattern**:
+```swift
+// In horizontal stack: priorityLabel (hugs) + spacer + valueLabel (hugs)
+priorityLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+valueLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
+
+// Spacer fills remaining space (low hugging priority)
+spacerView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+```
+
+---
+
+### Debugging Transformed Views
+
+**Problem**: View transformations (rotate, scale) don't affect Auto Layout.
+
+**Gotcha**:
+```swift
+imageView.transform = CGAffineTransform(rotationAngle: .pi / 4) // 45° rotation
+// Auto Layout still uses original (un-rotated) frame for calculations
+```
+
+**Solution**: Auto Layout works correctly, but visual debugging can be confusing. Use original frame for constraint debugging.
+
+---
+
+## Troubleshooting
+
+### Issue: Breakpoint Never Hits
+
+**Check**:
+1. Symbolic breakpoint symbol is exactly `UIViewAlertForUnsatisfiableConstraints`
+2. Breakpoint is enabled (checkmark visible)
+3. Constraint conflict actually exists (check console for error message)
+
+---
+
+### Issue: Can't Identify View from Memory Address
+
+**Solution 1**: Use background color technique
+```lldb
+expr ((UIView *)0x7f8b9c4...).backgroundColor = [UIColor redColor]
+continue
+```
+
+**Solution 2**: Print recursive description
+```lldb
+po [0x7f8b9c4... recursiveDescription]
+```
+
+**Solution 3**: Check view's class
+```lldb
+po [0x7f8b9c4... class]
+```
+
+---
+
+### Issue: Debug View Hierarchy Shows No Constraints
+
+**Check**:
+1. Click "Show Constraints" button in debug bar (looks like constraint icon)
+2. Select specific view to see its constraints in right panel
+3. Constraints may be satisfied (purple) vs conflicting (orange/red)
+
+---
+
+### Issue: Constraints Change at Runtime
+
+**Check**:
+1. UIKit system constraints (UIView-Encapsulated-Layout) added for cells/system views
+2. Dynamic Type changes (font size changes = size invalidation)
+3. Orientation changes triggering new constraints
+4. View controller lifecycle (viewDidLoad vs viewWillLayoutSubviews)
+
+---
+
+## Common Mistakes
+
+### ❌ Ignoring Console Warnings
+
+**Wrong**: Seeing constraint warning, continuing anyway.
+
+**Correct**: Fix every constraint warning immediately. They compound and cause unpredictable layout later.
+
+---
+
+### ❌ Not Setting Identifiers
+
+**Wrong**: Debugging constraints by memory address.
+
+**Correct**: Always set constraint identifiers. 30 seconds now saves 30 minutes later.
+
+---
+
+### ❌ Over-Constraining
+
+**Wrong**: Setting leading + trailing + width.
+
+**Correct**: Use 2 of 3 (leading + trailing, OR leading + width, OR trailing + width).
+
+---
+
+### ❌ Mixing Auto Layout and Frames
+
+**Wrong**:
+```swift
+imageView.frame = CGRect(x: 50, y: 50, width: 100, height: 100) // Manual frame
+imageView.widthAnchor.constraint(equalToConstant: 100).isActive = true // Auto Layout
+```
+
+**Correct**: Choose one approach. If using Auto Layout, set `translatesAutoresizingMaskIntoConstraints = false` and let constraints determine position/size.
+
+---
+
+## Real-World Impact
+
+**Before** (no systematic approach):
+- 30-60 minutes per constraint conflict
+- Trial-and-error constraint changes
+- Frustration from cryptic error messages
+- Breaking working constraints to fix new ones
+
+**After** (systematic debugging):
+- 5-10 minutes per constraint conflict
+- Targeted fixes with Debug View Hierarchy
+- Named constraints = instant identification
+- Symbolic breakpoint catches issues immediately
+
+---
+
+## Related Skills
+
+- For Xcode environment issues: See `axiom-xcode-debugging` skill
+- For SwiftUI layout issues: See `axiom-swiftui-performance` skill
+- For testing UI: See `axiom-ui-testing` skill
+
+---
+
+## Resources
+
+**Docs**: /library/archive/documentation/userexperience/conceptual/autolayoutpg/debuggingtricksandtips
+
+---
+
+## Key Takeaways
+
+1. **Name everything** — Constraints and views with identifiers save hours of debugging
+2. **Use symbolic breakpoint** — Catch constraint conflicts at source, not after recovery
+3. **Debug View Hierarchy** — Visualize constraints instead of guessing
+4. **Memory address → View** — Background color technique instantly identifies mystery views
+5. **Two constraints per axis** — Avoid over-constraining (leading + trailing + width = conflict)
+6. **Priorities matter** — Use .required (1000) for must-haves, .defaultHigh (750) for preferences
+7. **Systematic wins** — Following workflow saves 30-50 minutes per conflict
+
+---
+
+**Last Updated**: 2024
+**Minimum Requirements**: Xcode 12+, iOS 11+ (symbolic breakpoints work on all versions)
diff --git a/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml b/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml
new file mode 100644
index 0000000..7a65416
--- /dev/null
+++ b/.claude/skills/axiom-auto-layout-debugging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Auto Layout Debugging"
+ short_description: "Encountering \"Unable to simultaneously satisfy constraints\" errors, constraint conflicts, ambiguous layout warnings, ..."
diff --git a/.claude/skills/axiom-avfoundation-ref/.openskills.json b/.claude/skills/axiom-avfoundation-ref/.openskills.json
new file mode 100644
index 0000000..123344b
--- /dev/null
+++ b/.claude/skills/axiom-avfoundation-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-avfoundation-ref",
+ "installedAt": "2026-04-12T08:05:53.813Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-avfoundation-ref/SKILL.md b/.claude/skills/axiom-avfoundation-ref/SKILL.md
new file mode 100644
index 0000000..9843355
--- /dev/null
+++ b/.claude/skills/axiom-avfoundation-ref/SKILL.md
@@ -0,0 +1,643 @@
+---
+name: axiom-avfoundation-ref
+description: Reference — AVFoundation audio APIs, AVAudioSession categories/modes, AVAudioEngine pipelines, bit-perfect DAC output, iOS 26+ spatial audio capture, ASAF/APAC, Audio Mix with Cinematic framework
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# AVFoundation Audio Reference
+
+## Quick Reference
+
+```swift
+// AUDIO SESSION SETUP
+import AVFoundation
+
+try AVAudioSession.sharedInstance().setCategory(
+ .playback, // or .playAndRecord, .ambient
+ mode: .default, // or .voiceChat, .measurement
+ options: [.mixWithOthers, .allowBluetooth]
+)
+try AVAudioSession.sharedInstance().setActive(true)
+
+// AUDIO ENGINE PIPELINE
+let engine = AVAudioEngine()
+let player = AVAudioPlayerNode()
+engine.attach(player)
+engine.connect(player, to: engine.mainMixerNode, format: nil)
+try engine.start()
+player.scheduleFile(audioFile, at: nil)
+player.play()
+
+// INPUT PICKER (iOS 26+)
+import AVKit
+let picker = AVInputPickerInteraction()
+picker.delegate = self
+myButton.addInteraction(picker)
+// In button action: picker.present()
+
+// AIRPODS HIGH QUALITY (iOS 26+)
+try AVAudioSession.sharedInstance().setCategory(
+ .playAndRecord,
+ options: [.bluetoothHighQualityRecording, .allowBluetoothA2DP]
+)
+```
+
+---
+
+## AVAudioSession
+
+### Categories
+
+| Category | Use Case | Silent Switch | Background |
+|----------|----------|---------------|------------|
+| `.ambient` | Game sounds, not primary | Silences | No |
+| `.soloAmbient` | Default, interrupts others | Silences | No |
+| `.playback` | Music player, podcast | Ignores | Yes |
+| `.record` | Voice recorder | — | Yes |
+| `.playAndRecord` | VoIP, voice chat | Ignores | Yes |
+| `.multiRoute` | DJ apps, multiple outputs | Ignores | Yes |
+
+### Modes
+
+| Mode | Use Case |
+|------|----------|
+| `.default` | General audio |
+| `.voiceChat` | VoIP, reduces echo |
+| `.videoChat` | FaceTime-style |
+| `.gameChat` | Voice chat in games |
+| `.videoRecording` | Camera recording |
+| `.measurement` | Flat response, no processing |
+| `.moviePlayback` | Video playback |
+| `.spokenAudio` | Podcasts, audiobooks |
+
+### Options
+
+```swift
+// Mixing
+.mixWithOthers // Play with other apps
+.duckOthers // Lower other audio while playing
+.interruptSpokenAudioAndMixWithOthers // Pause podcasts, mix music
+
+// Bluetooth
+.allowBluetooth // HFP (calls)
+.allowBluetoothA2DP // High quality stereo
+.bluetoothHighQualityRecording // iOS 26+ AirPods recording
+
+// Routing
+.defaultToSpeaker // Route to speaker (not receiver)
+.allowAirPlay // Enable AirPlay
+```
+
+### Interruption Handling
+
+```swift
+NotificationCenter.default.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: nil,
+ queue: .main
+) { notification in
+ guard let userInfo = notification.userInfo,
+ let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
+ return
+ }
+
+ switch type {
+ case .began:
+ // Pause playback
+ player.pause()
+
+ case .ended:
+ guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
+ let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
+ if options.contains(.shouldResume) {
+ player.play()
+ }
+
+ @unknown default:
+ break
+ }
+}
+```
+
+### Route Change Handling
+
+```swift
+NotificationCenter.default.addObserver(
+ forName: AVAudioSession.routeChangeNotification,
+ object: nil,
+ queue: .main
+) { notification in
+ guard let userInfo = notification.userInfo,
+ let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
+ return
+ }
+
+ switch reason {
+ case .oldDeviceUnavailable:
+ // Headphones unplugged — pause playback
+ player.pause()
+
+ case .newDeviceAvailable:
+ // New device connected
+ break
+
+ case .categoryChange:
+ // Category changed by system or another app
+ break
+
+ default:
+ break
+ }
+}
+```
+
+---
+
+## AVAudioEngine
+
+### Basic Pipeline
+
+```swift
+let engine = AVAudioEngine()
+
+// Create nodes
+let player = AVAudioPlayerNode()
+let reverb = AVAudioUnitReverb()
+reverb.loadFactoryPreset(.largeHall)
+reverb.wetDryMix = 50
+
+// Attach to engine
+engine.attach(player)
+engine.attach(reverb)
+
+// Connect: player → reverb → mixer → output
+engine.connect(player, to: reverb, format: nil)
+engine.connect(reverb, to: engine.mainMixerNode, format: nil)
+
+// Start
+engine.prepare()
+try engine.start()
+
+// Play file
+let url = Bundle.main.url(forResource: "audio", withExtension: "m4a")!
+let file = try AVAudioFile(forReading: url)
+player.scheduleFile(file, at: nil)
+player.play()
+```
+
+### Node Types
+
+| Node | Purpose |
+|------|---------|
+| `AVAudioPlayerNode` | Plays audio files/buffers |
+| `AVAudioInputNode` | Mic input (engine.inputNode) |
+| `AVAudioOutputNode` | Speaker output (engine.outputNode) |
+| `AVAudioMixerNode` | Mix multiple inputs |
+| `AVAudioUnitEQ` | Equalizer |
+| `AVAudioUnitReverb` | Reverb effect |
+| `AVAudioUnitDelay` | Delay effect |
+| `AVAudioUnitDistortion` | Distortion effect |
+| `AVAudioUnitTimePitch` | Time stretch / pitch shift |
+
+### Installing Taps (Audio Analysis)
+
+```swift
+let inputNode = engine.inputNode
+let format = inputNode.outputFormat(forBus: 0)
+
+inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, time in
+ // Process audio buffer
+ guard let channelData = buffer.floatChannelData?[0] else { return }
+ let frameLength = Int(buffer.frameLength)
+
+ // Calculate RMS level
+ var sum: Float = 0
+ for i in 0..UIBackgroundModes
+// audio
+
+// 3. Set Now Playing info (recommended)
+let nowPlayingInfo: [String: Any] = [
+ MPMediaItemPropertyTitle: "Song Title",
+ MPMediaItemPropertyArtist: "Artist",
+ MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime,
+ MPMediaItemPropertyPlaybackDuration: duration
+]
+MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+```
+
+### Ducking Other Audio
+
+```swift
+try AVAudioSession.sharedInstance().setCategory(
+ .playback,
+ options: .duckOthers
+)
+
+// When done, restore others
+try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+```
+
+### Bluetooth Device Handling
+
+```swift
+// Allow all Bluetooth
+try AVAudioSession.sharedInstance().setCategory(
+ .playAndRecord,
+ options: [.allowBluetooth, .allowBluetoothA2DP]
+)
+
+// Check current Bluetooth route
+let route = AVAudioSession.sharedInstance().currentRoute
+let hasBluetoothOutput = route.outputs.contains {
+ $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
+}
+```
+
+---
+
+## Anti-Patterns
+
+### Wrong Category
+
+```swift
+// WRONG — music player using ambient (silenced by switch)
+try AVAudioSession.sharedInstance().setCategory(.ambient)
+
+// CORRECT — music needs .playback
+try AVAudioSession.sharedInstance().setCategory(.playback)
+```
+
+### Missing Interruption Handling
+
+```swift
+// WRONG — no interruption observer
+// Audio stops on phone call and never resumes
+
+// CORRECT — always handle interruptions
+NotificationCenter.default.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ // ... handle began/ended
+)
+```
+
+### Tap Memory Leaks
+
+```swift
+// WRONG — tap installed, never removed
+engine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { ... }
+
+// CORRECT — remove tap when done
+deinit {
+ engine.inputNode.removeTap(onBus: 0)
+}
+```
+
+### Format Mismatch Crashes
+
+```swift
+// WRONG — connecting nodes with incompatible formats
+engine.connect(playerNode, to: mixerNode, format: wrongFormat) // Crash!
+
+// CORRECT — use nil for automatic format negotiation, or match exactly
+engine.connect(playerNode, to: mixerNode, format: nil)
+```
+
+### Forgetting to Activate Session
+
+```swift
+// WRONG — configure but don't activate
+try AVAudioSession.sharedInstance().setCategory(.playback)
+// Audio doesn't work!
+
+// CORRECT — always activate
+try AVAudioSession.sharedInstance().setCategory(.playback)
+try AVAudioSession.sharedInstance().setActive(true)
+```
+
+---
+
+## Resources
+
+**WWDC**: 2025-251, 2025-403, 2019-510
+
+**Docs**: /avfoundation, /avkit, /cinematic
+
+---
+
+**Targets:** iOS 12+ (core), iOS 26+ (spatial features)
+**Frameworks:** AVFoundation, AVKit, Cinematic (iOS 26+)
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml b/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml
new file mode 100644
index 0000000..3aa11e1
--- /dev/null
+++ b/.claude/skills/axiom-avfoundation-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "AVFoundation Reference"
+ short_description: "Reference — AVFoundation audio APIs, AVAudioSession categories/modes, AVAudioEngine pipelines, bit-perfect DAC output..."
diff --git a/.claude/skills/axiom-axe-ref/.openskills.json b/.claude/skills/axiom-axe-ref/.openskills.json
new file mode 100644
index 0000000..f0a3479
--- /dev/null
+++ b/.claude/skills/axiom-axe-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-axe-ref",
+ "installedAt": "2026-04-12T08:05:54.694Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-axe-ref/SKILL.md b/.claude/skills/axiom-axe-ref/SKILL.md
new file mode 100644
index 0000000..47e18c8
--- /dev/null
+++ b/.claude/skills/axiom-axe-ref/SKILL.md
@@ -0,0 +1,409 @@
+---
+name: axiom-axe-ref
+description: Use when automating iOS Simulator UI interactions beyond simctl capabilities. Reference for AXe CLI covering accessibility-based tapping, gestures, text input, screenshots, video recording, and UI tree inspection.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# AXe Reference (iOS Simulator UI Automation)
+
+AXe is a CLI tool for interacting with iOS Simulators using Apple's Accessibility APIs and HID functionality. Single binary, no daemon required.
+
+## Installation
+
+```bash
+brew install cameroncooke/axe/axe
+
+# Verify installation
+axe --version
+```
+
+## Critical Best Practice: describe_ui First
+
+**ALWAYS run `describe_ui` before UI interactions.** Never guess coordinates from screenshots.
+
+**Best practice:** Use describe-ui to get precise element coordinates prior to using x/y parameters (don't guess from screenshots).
+
+```bash
+# 1. FIRST: Get the UI tree with frame coordinates
+axe describe-ui --udid $UDID
+
+# 2. THEN: Tap by accessibility ID (preferred)
+axe tap --id "loginButton" --udid $UDID
+
+# 3. OR: Tap by label
+axe tap --label "Login" --udid $UDID
+
+# 4. LAST RESORT: Tap by coordinates from describe-ui output
+axe tap -x 200 -y 400 --udid $UDID
+```
+
+**Priority order for targeting elements:**
+1. `--id` (accessibilityIdentifier) - most stable
+2. `--label` (accessibility label) - stable but may change with localization
+3. `-x -y` coordinates from `describe-ui` - fragile, use only when no identifier
+
+## Core Concept: Accessibility-First
+
+**AXe's key advantage**: Tap elements by accessibility identifier or label, not just coordinates.
+
+```bash
+# Coordinate-based (fragile - breaks with layout changes)
+axe tap -x 200 -y 400 --udid $UDID
+
+# Accessibility-based (stable - survives UI changes)
+axe tap --id "loginButton" --udid $UDID
+axe tap --label "Login" --udid $UDID
+```
+
+**Always prefer `--id` or `--label` over coordinates.**
+
+## Getting the Simulator UDID
+
+AXe requires the simulator UDID for most commands:
+
+```bash
+# Get booted simulator UDID
+UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
+
+# List all simulators
+axe list-simulators
+```
+
+## Touch & Tap Commands
+
+### Tap by Accessibility Identifier (Recommended)
+
+```bash
+# Tap element with accessibilityIdentifier
+axe tap --id "loginButton" --udid $UDID
+
+# Tap element with accessibility label
+axe tap --label "Submit" --udid $UDID
+```
+
+### Tap by Coordinates
+
+```bash
+# Basic tap
+axe tap -x 200 -y 400 --udid $UDID
+
+# Tap with timing controls
+axe tap -x 200 -y 400 --pre-delay 0.5 --post-delay 0.3 --udid $UDID
+
+# Long press (hold duration in seconds)
+axe tap -x 200 -y 400 --duration 1.0 --udid $UDID
+```
+
+### Low-Level Touch Events
+
+```bash
+# Touch down (finger press)
+axe touch down -x 200 -y 400 --udid $UDID
+
+# Touch up (finger release)
+axe touch up -x 200 -y 400 --udid $UDID
+```
+
+## Swipe & Gesture Commands
+
+### Custom Swipe
+
+```bash
+# Swipe from point A to point B
+axe swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 --udid $UDID
+
+# Swipe with duration (slower = more visible)
+axe swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 --duration 0.5 --udid $UDID
+```
+
+### Gesture Presets
+
+```bash
+# Scrolling
+axe gesture scroll-up --udid $UDID # Scroll content up (swipe down)
+axe gesture scroll-down --udid $UDID # Scroll content down (swipe up)
+axe gesture scroll-left --udid $UDID
+axe gesture scroll-right --udid $UDID
+
+# Edge swipes (navigation)
+axe gesture swipe-from-left-edge --udid $UDID # Back navigation
+axe gesture swipe-from-right-edge --udid $UDID
+axe gesture swipe-from-top-edge --udid $UDID # Notification Center
+axe gesture swipe-from-bottom-edge --udid $UDID # Home indicator/Control Center
+```
+
+## Text Input
+
+### Type Text
+
+```bash
+# Type text (element must be focused)
+axe type "user@example.com" --udid $UDID
+
+# Type with delay between characters
+axe type "password123" --char-delay 0.1 --udid $UDID
+
+# Type from stdin
+echo "Hello World" | axe type --stdin --udid $UDID
+
+# Type from file
+axe type --file /tmp/input.txt --udid $UDID
+```
+
+### Keyboard Keys
+
+```bash
+# Press specific key by HID keycode
+axe key 40 --udid $UDID # Return/Enter
+
+# Common keycodes:
+# 40 = Return/Enter
+# 41 = Escape
+# 42 = Backspace/Delete
+# 43 = Tab
+# 44 = Space
+# 79 = Right Arrow
+# 80 = Left Arrow
+# 81 = Down Arrow
+# 82 = Up Arrow
+
+# Key sequence with timing
+axe key-sequence 40 43 40 --delay 0.2 --udid $UDID
+```
+
+## Hardware Buttons
+
+```bash
+# Home button
+axe button home --udid $UDID
+
+# Lock/Power button
+axe button lock --udid $UDID
+
+# Long press power (shutdown dialog)
+axe button lock --duration 3.0 --udid $UDID
+
+# Side button (iPhone X+)
+axe button side-button --udid $UDID
+
+# Siri
+axe button siri --udid $UDID
+
+# Apple Pay
+axe button apple-pay --udid $UDID
+```
+
+## Screenshots
+
+```bash
+# Screenshot to auto-named file
+axe screenshot --udid $UDID
+# Output: screenshot_2026-01-11_143052.png
+
+# Screenshot to specific file
+axe screenshot --output /tmp/my-screenshot.png --udid $UDID
+
+# Screenshot to stdout (for piping)
+axe screenshot --stdout --udid $UDID > screenshot.png
+```
+
+## Video Recording & Streaming
+
+### Record Video
+
+```bash
+# Start recording (Ctrl+C to stop)
+axe record-video --output /tmp/recording.mp4 --udid $UDID
+
+# Record with quality settings
+axe record-video --output /tmp/recording.mp4 --quality high --udid $UDID
+
+# Record with scale (reduce file size)
+axe record-video --output /tmp/recording.mp4 --scale 0.5 --udid $UDID
+```
+
+### Stream Video
+
+```bash
+# Stream at 10 FPS (default)
+axe stream-video --udid $UDID
+
+# Stream at specific framerate (1-30 FPS)
+axe stream-video --fps 30 --udid $UDID
+
+# Stream formats
+axe stream-video --format mjpeg --udid $UDID # MJPEG (default)
+axe stream-video --format jpeg --udid $UDID # Individual JPEGs
+axe stream-video --format ffmpeg --udid $UDID # FFmpeg compatible
+axe stream-video --format bgra --udid $UDID # Raw BGRA
+```
+
+## UI Inspection (describe-ui)
+
+**Critical for finding accessibility identifiers and labels.**
+
+### Full Screen UI Tree
+
+```bash
+# Get complete accessibility tree
+axe describe-ui --udid $UDID
+
+# Output includes:
+# - Element type (Button, TextField, StaticText, etc.)
+# - Accessibility identifier
+# - Accessibility label
+# - Frame (position and size)
+# - Enabled/disabled state
+```
+
+### Point-Specific UI Info
+
+```bash
+# Get element at specific coordinates
+axe describe-ui --point 200,400 --udid $UDID
+```
+
+### Example Output
+
+```json
+{
+ "type": "Button",
+ "identifier": "loginButton",
+ "label": "Login",
+ "frame": {"x": 150, "y": 380, "width": 100, "height": 44},
+ "enabled": true,
+ "focused": false
+}
+```
+
+## Common Workflows
+
+### Login Flow
+
+```bash
+UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
+
+# Tap email field and type
+axe tap --id "emailTextField" --udid $UDID
+axe type "user@example.com" --udid $UDID
+
+# Tap password field and type
+axe tap --id "passwordTextField" --udid $UDID
+axe type "password123" --udid $UDID
+
+# Tap login button
+axe tap --id "loginButton" --udid $UDID
+
+# Wait and screenshot
+sleep 2
+axe screenshot --output /tmp/login-result.png --udid $UDID
+```
+
+### Discover Elements Before Automating
+
+```bash
+# 1. Get the UI tree
+axe describe-ui --udid $UDID > /tmp/ui-tree.json
+
+# 2. Find elements (search for identifiers)
+cat /tmp/ui-tree.json | jq '.[] | select(.identifier != null) | {identifier, label, type}'
+
+# 3. Use discovered identifiers in automation
+axe tap --id "discoveredIdentifier" --udid $UDID
+```
+
+### Scroll to Find Element
+
+```bash
+# Scroll down until element appears (pseudo-code pattern)
+for i in {1..5}; do
+ if axe describe-ui --udid $UDID | grep -q "targetElement"; then
+ axe tap --id "targetElement" --udid $UDID
+ break
+ fi
+ axe gesture scroll-down --udid $UDID
+ sleep 0.5
+done
+```
+
+### Screenshot on Error
+
+```bash
+# Automation with error capture
+if ! axe tap --id "submitButton" --udid $UDID; then
+ axe screenshot --output /tmp/error-state.png --udid $UDID
+ axe describe-ui --udid $UDID > /tmp/error-ui-tree.json
+ echo "Failed to tap submitButton - see error-state.png"
+fi
+```
+
+## Timing Controls
+
+Most commands support timing options:
+
+| Option | Description |
+|--------|-------------|
+| `--pre-delay` | Wait before action (seconds) |
+| `--post-delay` | Wait after action (seconds) |
+| `--duration` | Action duration (for taps, button presses) |
+| `--char-delay` | Delay between characters (for type) |
+
+```bash
+# Example with full timing control
+axe tap --id "button" --pre-delay 0.5 --post-delay 0.3 --udid $UDID
+```
+
+## AXe vs simctl
+
+| Capability | simctl | AXe |
+|------------|--------|-----|
+| Device lifecycle | ✅ | ❌ |
+| Permissions | ✅ | ❌ |
+| Push notifications | ✅ | ❌ |
+| Status bar | ✅ | ❌ |
+| Deep links | ✅ | ❌ |
+| Screenshots | ✅ | ✅ (PNG) |
+| Video recording | ✅ | ✅ (H.264) |
+| Video streaming | ❌ | ✅ |
+| UI tap/swipe | ❌ | ✅ |
+| Type text | ❌ | ✅ |
+| Hardware buttons | ❌ | ✅ |
+| Accessibility tree | ❌ | ✅ |
+
+**Use both together**: simctl for device control, AXe for UI automation.
+
+## Troubleshooting
+
+### Element Not Found
+
+1. Run `axe describe-ui` to see available elements
+2. Check element has `accessibilityIdentifier` set in code
+3. Ensure element is visible (not off-screen)
+
+### Tap Doesn't Work
+
+1. Check element is enabled (`"enabled": true` in describe-ui)
+2. Try adding `--pre-delay 0.5` for slow-loading UI
+3. Verify correct UDID with `axe list-simulators`
+
+### Type Not Working
+
+1. Ensure text field is focused first: `axe tap --id "textField"`
+2. Check keyboard is visible
+3. Try `--char-delay 0.05` for reliability
+
+### Permission Denied
+
+AXe uses private APIs - ensure you're running on a Mac with Xcode installed and proper entitlements.
+
+## Resources
+
+**GitHub**: https://github.com/cameroncooke/AXe
+
+**Related**: xcsentinel (build orchestration)
+
+**Skills**: axiom-xctest-automation, axiom-ui-testing
+
+**Agents**: simulator-tester, test-runner
diff --git a/.claude/skills/axiom-axe-ref/agents/openai.yaml b/.claude/skills/axiom-axe-ref/agents/openai.yaml
new file mode 100644
index 0000000..ead26a6
--- /dev/null
+++ b/.claude/skills/axiom-axe-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Axe Reference"
+ short_description: "Automating iOS Simulator UI interactions beyond simctl capabilities"
diff --git a/.claude/skills/axiom-background-processing-diag/.openskills.json b/.claude/skills/axiom-background-processing-diag/.openskills.json
new file mode 100644
index 0000000..505d47c
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-background-processing-diag",
+ "installedAt": "2026-04-12T08:05:56.179Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-background-processing-diag/SKILL.md b/.claude/skills/axiom-background-processing-diag/SKILL.md
new file mode 100644
index 0000000..9f522be
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-diag/SKILL.md
@@ -0,0 +1,441 @@
+---
+name: axiom-background-processing-diag
+description: Symptom-based background task troubleshooting - decision trees for 'task never runs', 'task terminates early', 'works in dev not prod', 'handler not called', with time-cost analysis for each diagnosis path
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Background Processing Diagnostics
+
+Symptom-based troubleshooting for background task issues.
+
+**Related skills**: `axiom-background-processing` (patterns, checklists), `axiom-background-processing-ref` (API reference)
+
+---
+
+## Symptom 1: Task Never Runs
+
+Handler never called despite successful `submit()`.
+
+### Quick Diagnosis (5 minutes)
+
+```
+Task never runs?
+│
+├─ Step 1: Check Info.plist (2 min)
+│ ├─ BGTaskSchedulerPermittedIdentifiers contains EXACT identifier?
+│ │ └─ NO → Add identifier, rebuild
+│ ├─ UIBackgroundModes includes "fetch" or "processing"?
+│ │ └─ NO → Add required mode
+│ └─ Identifiers case-sensitive match code?
+│ └─ NO → Fix typo, rebuild
+│
+├─ Step 2: Check registration timing (2 min)
+│ ├─ Registered in didFinishLaunchingWithOptions?
+│ │ └─ NO → Move registration before return true
+│ └─ Registration before first submit()?
+│ └─ NO → Ensure register() precedes submit()
+│
+└─ Step 3: Check app state (1 min)
+ ├─ App swiped away from App Switcher?
+ │ └─ YES → No background until user opens app
+ └─ Background App Refresh disabled in Settings?
+ └─ YES → Enable or inform user
+```
+
+### Time-Cost Analysis
+
+| Approach | Time | Success Rate |
+|----------|------|--------------|
+| Check Info.plist + registration | 5 min | 70% (catches most issues) |
+| Add console logging | 15 min | 90% |
+| LLDB simulate launch | 5 min | 95% (confirms handler works) |
+| Random code changes | 2+ hours | Low |
+
+### LLDB Quick Test
+
+Verify handler is correctly registered:
+
+```lldb
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
+```
+
+If breakpoint hits → Registration correct, issue is scheduling/system factors.
+If nothing happens → Registration broken.
+
+---
+
+## Symptom 2: Task Terminates Unexpectedly
+
+Handler called but work doesn't complete before termination.
+
+### Quick Diagnosis (5 minutes)
+
+```
+Task terminates early?
+│
+├─ Step 1: Check expiration handler (1 min)
+│ ├─ Expiration handler set FIRST in handler?
+│ │ └─ NO → Move to very first line
+│ └─ Expiration handler actually cancels work?
+│ └─ NO → Add cancellation logic
+│
+├─ Step 2: Check setTaskCompleted (2 min)
+│ ├─ Called in success path?
+│ ├─ Called in failure path?
+│ ├─ Called after expiration?
+│ └─ ANY path missing → Task never signals completion
+│
+├─ Step 3: Check work duration (2 min)
+│ ├─ BGAppRefreshTask work > 30 seconds?
+│ │ └─ YES → Chunk work or use BGProcessingTask
+│ └─ BGProcessingTask work > system limit?
+│ └─ YES → Save progress, resume on next launch
+```
+
+### Common Causes
+
+| Cause | Fix |
+|-------|-----|
+| Missing expiration handler | Set handler as first line |
+| setTaskCompleted not called | Add to ALL code paths |
+| Work takes too long | Chunk and checkpoint |
+| Network timeout > task time | Use background URLSession |
+| Async callback after expiration | Check shouldContinue flag |
+
+### Test Expiration Handling
+
+```lldb
+// First simulate launch
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
+
+// Then force expiration
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
+```
+
+Verify expiration handler runs and work stops gracefully.
+
+---
+
+## Symptom 3: Background URLSession Delegate Not Called
+
+Download completes but `didFinishDownloadingTo` never fires.
+
+### Quick Diagnosis (5 minutes)
+
+```
+URLSession delegate not called?
+│
+├─ Step 1: Check session configuration (2 min)
+│ ├─ Using URLSessionConfiguration.background()?
+│ │ └─ NO → Must use background config
+│ ├─ Session identifier unique?
+│ │ └─ NO → Use unique bundle-prefixed ID
+│ └─ sessionSendsLaunchEvents = true?
+│ └─ NO → Set for app relaunch on completion
+│
+├─ Step 2: Check AppDelegate handler (2 min)
+│ ├─ handleEventsForBackgroundURLSession implemented?
+│ │ └─ NO → Required for session events
+│ └─ Completion handler stored and called later?
+│ └─ NO → Store handler, call after events processed
+│
+└─ Step 3: Check delegate assignment (1 min)
+ ├─ Session created with delegate?
+ └─ Delegate not nil when task completes?
+```
+
+### Required AppDelegate Code
+
+```swift
+// Store completion handler
+var backgroundSessionCompletionHandler: (() -> Void)?
+
+func application(_ application: UIApplication,
+ handleEventsForBackgroundURLSession identifier: String,
+ completionHandler: @escaping () -> Void) {
+ backgroundSessionCompletionHandler = completionHandler
+}
+
+// Call after all events processed
+func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ DispatchQueue.main.async {
+ self.backgroundSessionCompletionHandler?()
+ self.backgroundSessionCompletionHandler = nil
+ }
+}
+```
+
+---
+
+## Symptom 4: Works in Development, Not Production
+
+Task runs with debugger but fails in release builds or for users.
+
+### Quick Diagnosis (10 minutes)
+
+```
+Works in dev, not prod?
+│
+├─ Step 1: Check system constraints (3 min)
+│ ├─ Low Power Mode enabled?
+│ │ └─ Check ProcessInfo.isLowPowerModeEnabled
+│ ├─ Background App Refresh disabled?
+│ │ └─ Check UIApplication.backgroundRefreshStatus
+│ └─ Battery < 20%?
+│ └─ System pauses discretionary work
+│
+├─ Step 2: Check app state (2 min)
+│ ├─ App force-quit from App Switcher?
+│ │ └─ YES → No background until foreground launch
+│ └─ App recently used?
+│ └─ Rarely used apps get lower priority
+│
+├─ Step 3: Check build differences (3 min)
+│ ├─ Debug vs Release optimization differences?
+│ ├─ #if DEBUG code excluding production?
+│ └─ Different bundle identifier in release?
+│
+└─ Step 4: Add production logging (2 min)
+ └─ Log task schedule/launch/complete to analytics
+```
+
+### The 7 Scheduling Factors
+
+All affect task execution in production:
+
+| Factor | Check |
+|--------|-------|
+| Critically Low Battery | Battery < 20%? |
+| Low Power Mode | ProcessInfo.isLowPowerModeEnabled |
+| App Usage | User opens app frequently? |
+| App Switcher | App NOT swiped away? |
+| Background App Refresh | Settings enabled? |
+| System Budgets | Many recent background launches? |
+| Rate Limiting | Requests too frequent? |
+
+### Production Debugging
+
+Add logging to track what's happening:
+
+```swift
+func scheduleRefresh() {
+ let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ Analytics.log("background_task_scheduled")
+ } catch {
+ Analytics.log("background_task_schedule_failed", error: error)
+ }
+}
+
+func handleRefresh(task: BGAppRefreshTask) {
+ Analytics.log("background_task_started")
+ // ... work ...
+ Analytics.log("background_task_completed")
+ task.setTaskCompleted(success: true)
+}
+```
+
+---
+
+## Symptom 5: Inconsistent Task Scheduling
+
+Task runs sometimes but not predictably.
+
+### Quick Diagnosis (5 minutes)
+
+```
+Inconsistent scheduling?
+│
+├─ Step 1: Understand earliestBeginDate (2 min)
+│ ├─ This is MINIMUM delay, not scheduled time
+│ │ └─ System runs when convenient AFTER this date
+│ └─ Set too far in future (> 1 week)?
+│ └─ System may skip task entirely
+│
+├─ Step 2: Check scheduling pattern (2 min)
+│ ├─ Scheduling same task multiple times?
+│ │ └─ Call getPendingTaskRequests to check
+│ └─ Scheduling in handler for continuity?
+│ └─ Required for continuous refresh
+│
+└─ Step 3: Understand system behavior (1 min)
+ ├─ BGAppRefreshTask runs based on USER patterns
+ │ └─ User rarely opens app = rare runs
+ └─ BGProcessingTask runs when charging
+ └─ User doesn't charge overnight = no runs
+```
+
+### Expected Behavior
+
+| Task Type | Scheduling Behavior |
+|-----------|---------------------|
+| BGAppRefreshTask | Runs before predicted app usage times |
+| BGProcessingTask | Runs when charging + idle (typically overnight) |
+| Silent Push | Rate-limited; 14 pushes may = 7 launches |
+
+**Key insight**: You request a time window. System decides when (or if) to run.
+
+---
+
+## Symptom 6: App Crashes on Background Launch
+
+App crashes when launched by system for background task.
+
+### Quick Diagnosis (5 minutes)
+
+```
+Crash on background launch?
+│
+├─ Step 1: Check launch initialization (2 min)
+│ ├─ UI setup before task handler?
+│ │ └─ Background launch may not have UI context
+│ ├─ Accessing files before first unlock?
+│ │ └─ Use completeUntilFirstUserAuthentication protection
+│ └─ Force unwrapping optionals that may be nil?
+│ └─ Guard against nil in background context
+│
+├─ Step 2: Check handler safety (2 min)
+│ ├─ Handler captures self strongly?
+│ │ └─ Use [weak self] to prevent retain cycles
+│ └─ Handler accesses UI on non-main thread?
+│ └─ Dispatch UI work to main queue
+│
+└─ Step 3: Check data protection (1 min)
+ └─ Files accessible when device locked?
+ └─ Use .completeUnlessOpen or .completeUntilFirstUserAuthentication
+```
+
+### File Protection for Background Tasks
+
+```swift
+// Set appropriate protection when creating files
+try data.write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication)
+
+// Or configure in entitlements for entire app
+```
+
+### Safe Handler Pattern
+
+```swift
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.app.refresh",
+ using: nil
+) { [weak self] task in
+ guard let self = self else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+
+ // Don't access UI
+ // Use background-safe APIs only
+ self.performBackgroundWork(task: task)
+}
+```
+
+---
+
+## Symptom 7: Task Runs Multiple Times
+
+Same task appears to run repeatedly or in parallel.
+
+### Quick Diagnosis (5 minutes)
+
+```
+Task runs multiple times?
+│
+├─ Step 1: Check scheduling logic (2 min)
+│ ├─ Scheduling on every app launch?
+│ │ └─ Check getPendingTaskRequests first
+│ ├─ Scheduling in handler AND elsewhere?
+│ │ └─ Consolidate to single location
+│ └─ Using same identifier for different purposes?
+│ └─ Use unique identifiers per task type
+│
+├─ Step 2: Check for duplicate submissions (2 min)
+│ └─ Multiple submit() calls queued?
+│ └─ System may batch into single execution
+│
+└─ Step 3: Check handler execution (1 min)
+ └─ setTaskCompleted called promptly?
+ └─ Delay may cause system to think task hung
+```
+
+### Prevent Duplicate Scheduling
+
+```swift
+func scheduleRefreshIfNeeded() {
+ BGTaskScheduler.shared.getPendingTaskRequests { requests in
+ let alreadyScheduled = requests.contains {
+ $0.identifier == "com.app.refresh"
+ }
+
+ if !alreadyScheduled {
+ self.scheduleRefresh()
+ }
+ }
+}
+```
+
+---
+
+## Quick Diagnostic Checklist
+
+### 30-Second Check
+
+- [ ] Info.plist has identifier?
+- [ ] Registration in didFinishLaunchingWithOptions?
+- [ ] App not swiped away?
+
+### 5-Minute Check
+
+- [ ] Identifiers exactly match (case-sensitive)?
+- [ ] Background mode enabled (fetch/processing)?
+- [ ] setTaskCompleted called in all paths?
+- [ ] Expiration handler set first?
+
+### 15-Minute Investigation
+
+- [ ] LLDB simulate launch works?
+- [ ] LLDB simulate expiration handled?
+- [ ] Console shows registration/scheduling logs?
+- [ ] Real device (not just simulator)?
+- [ ] Release build (not just debug)?
+- [ ] Background App Refresh enabled in Settings?
+
+---
+
+## Console Log Filters
+
+```
+// All background task events
+subsystem:com.apple.backgroundtaskscheduler
+
+// Specific to your app
+subsystem:com.apple.backgroundtaskscheduler message:"com.yourapp"
+```
+
+### Expected Log Sequence
+
+1. "Registered handler for task with identifier"
+2. "Scheduling task with identifier"
+3. "Starting task with identifier"
+4. (your work executes)
+5. "Task completed with identifier"
+
+Missing any step = issue at that stage.
+
+---
+
+## Resources
+
+**WWDC**: 2019-707 (debugging commands), 2020-10063 (7 factors)
+
+**Skills**: axiom-background-processing, axiom-background-processing-ref
+
+---
+
+**Last Updated**: 2025-12-31
+**Platforms**: iOS 13+
diff --git a/.claude/skills/axiom-background-processing-diag/agents/openai.yaml b/.claude/skills/axiom-background-processing-diag/agents/openai.yaml
new file mode 100644
index 0000000..b083e1d
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Background Processing Diagnostics"
+ short_description: "Symptom-based background task troubleshooting"
diff --git a/.claude/skills/axiom-background-processing-ref/.openskills.json b/.claude/skills/axiom-background-processing-ref/.openskills.json
new file mode 100644
index 0000000..d75499a
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-background-processing-ref",
+ "installedAt": "2026-04-12T08:05:56.911Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-background-processing-ref/SKILL.md b/.claude/skills/axiom-background-processing-ref/SKILL.md
new file mode 100644
index 0000000..071b2bb
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-ref/SKILL.md
@@ -0,0 +1,778 @@
+---
+name: axiom-background-processing-ref
+description: Complete background task API reference - BGTaskScheduler, BGAppRefreshTask, BGProcessingTask, BGContinuedProcessingTask (iOS 26), beginBackgroundTask, background URLSession, with all WWDC code examples
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Background Processing Reference
+
+Complete API reference for iOS background execution, with code examples from WWDC sessions.
+
+**Related skills**: `axiom-background-processing` (decision trees, patterns), `axiom-background-processing-diag` (troubleshooting)
+
+---
+
+## Part 1: BGTaskScheduler Registration
+
+### Info.plist Configuration
+
+```xml
+
+BGTaskSchedulerPermittedIdentifiers
+
+ com.yourapp.refresh
+ com.yourapp.maintenance
+
+ com.yourapp.export.*
+
+
+
+UIBackgroundModes
+
+
+ fetch
+
+ processing
+
+```
+
+### Register Handler
+
+```swift
+import BackgroundTasks
+
+func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+) -> Bool {
+
+ // Register BEFORE returning from didFinishLaunching
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.refresh",
+ using: nil // nil = system creates serial background queue
+ ) { task in
+ self.handleAppRefresh(task: task as! BGAppRefreshTask)
+ }
+
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.maintenance",
+ using: nil
+ ) { task in
+ self.handleMaintenance(task: task as! BGProcessingTask)
+ }
+
+ return true
+}
+```
+
+**Parameters**:
+- `forTaskWithIdentifier`: Must match Info.plist exactly (case-sensitive)
+- `using`: DispatchQueue for handler callback; nil = system creates one
+- `launchHandler`: Called when task is launched; receives BGTask subclass
+
+### Registration Timing
+
+From WWDC 2019-707:
+> "You do this by registering a launch handler **before your application finishes launching**"
+
+Register in:
+- ✅ `application(_:didFinishLaunchingWithOptions:)` before `return true`
+- ❌ Not in viewDidLoad, button handlers, or async callbacks
+
+---
+
+## Part 2: BGAppRefreshTask
+
+### Purpose
+
+Keep app content fresh throughout the day. System launches app based on **user usage patterns**.
+
+### Runtime
+
+~30 seconds (same as legacy background fetch)
+
+### Scheduling
+
+```swift
+func scheduleAppRefresh() {
+ let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
+
+ // earliestBeginDate = MINIMUM delay (not exact time)
+ // System decides actual time based on usage patterns
+ request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch BGTaskScheduler.Error.notPermitted {
+ // Background App Refresh disabled in Settings
+ } catch BGTaskScheduler.Error.tooManyPendingTaskRequests {
+ // Too many pending requests for this identifier
+ } catch BGTaskScheduler.Error.unavailable {
+ // Background tasks not available (Simulator, etc.)
+ } catch {
+ print("Schedule failed: \(error)")
+ }
+}
+
+// Schedule when app enters background
+func applicationDidEnterBackground(_ application: UIApplication) {
+ scheduleAppRefresh()
+}
+```
+
+### Handler
+
+```swift
+func handleAppRefresh(task: BGAppRefreshTask) {
+ // 1. Set expiration handler FIRST
+ task.expirationHandler = { [weak self] in
+ self?.currentOperation?.cancel()
+ }
+
+ // 2. Schedule NEXT refresh (continuous pattern)
+ scheduleAppRefresh()
+
+ // 3. Perform work
+ let operation = fetchLatestContentOperation()
+ currentOperation = operation
+
+ operation.completionBlock = {
+ // 4. Signal completion (REQUIRED)
+ task.setTaskCompleted(success: !operation.isCancelled)
+ }
+
+ operationQueue.addOperation(operation)
+}
+```
+
+### BGAppRefreshTaskRequest Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `identifier` | String | Must match Info.plist |
+| `earliestBeginDate` | Date? | Minimum delay before execution |
+
+---
+
+## Part 3: BGProcessingTask
+
+### Purpose
+
+Deferrable maintenance work (database cleanup, ML training, backups). Runs at **system-friendly times**, typically overnight when charging.
+
+### Runtime
+
+Several minutes (significantly longer than refresh tasks)
+
+### Scheduling with Constraints
+
+```swift
+func scheduleMaintenanceIfNeeded() {
+ // Only schedule if work is needed
+ guard Date().timeIntervalSince(lastMaintenanceDate) > 7 * 24 * 3600 else {
+ return
+ }
+
+ let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance")
+
+ // CRITICAL for CPU-intensive work
+ request.requiresExternalPower = true
+
+ // Optional: Need network for cloud sync
+ request.requiresNetworkConnectivity = true
+
+ // Keep within 1 week — longer may be skipped
+ // request.earliestBeginDate = ...
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ print("Schedule failed: \(error)")
+ }
+}
+```
+
+### Handler with Progress Checkpointing
+
+```swift
+func handleMaintenance(task: BGProcessingTask) {
+ var shouldContinue = true
+
+ task.expirationHandler = {
+ shouldContinue = false
+ }
+
+ Task {
+ for item in workItems {
+ guard shouldContinue else {
+ // Expiration called — save progress and exit
+ saveProgress()
+ break
+ }
+
+ await processItem(item)
+ saveProgress() // Checkpoint after each item
+ }
+
+ task.setTaskCompleted(success: shouldContinue)
+ }
+}
+```
+
+### BGProcessingTaskRequest Properties
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `identifier` | String | — | Must match Info.plist |
+| `earliestBeginDate` | Date? | nil | Minimum delay |
+| `requiresNetworkConnectivity` | Bool | false | Wait for network |
+| `requiresExternalPower` | Bool | false | Wait for charging |
+
+### CPU Monitor Disabling
+
+> "For the first time ever, we're giving you the ability to turn that off for the duration of your processing task so you can take full advantage of the hardware while the device is plugged in."
+
+When `requiresExternalPower = true`, CPU Monitor (which normally terminates CPU-heavy background apps) is disabled.
+
+---
+
+## Part 4: BGContinuedProcessingTask (iOS 26+)
+
+### Purpose
+
+Continue **user-initiated work** after app backgrounds, with system UI showing progress. From WWDC 2025-227.
+
+**NOT for**: Automatic tasks, maintenance, syncing — user must explicitly initiate.
+
+### Use Cases
+
+- Photo/video export
+- Publishing content
+- Updating connected accessories
+- File compression
+
+### Info.plist (Wildcard Pattern)
+
+```xml
+BGTaskSchedulerPermittedIdentifiers
+
+
+ com.yourapp.export.*
+
+```
+
+### Dynamic Registration
+
+Unlike other tasks, register **when user initiates action**:
+
+```swift
+func userTappedExportButton() {
+ // Register dynamically
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.export.photos"
+ ) { task in
+ let continuedTask = task as! BGContinuedProcessingTask
+ self.handleExport(task: continuedTask)
+ }
+
+ // Submit immediately
+ submitExportRequest()
+}
+```
+
+### Submission with Progress UI
+
+```swift
+func submitExportRequest() {
+ let request = BGContinuedProcessingTaskRequest(
+ identifier: "com.yourapp.export.photos",
+ title: "Exporting Photos", // Shown in system UI
+ subtitle: "0 of 100 photos complete" // Shown in system UI
+ )
+
+ // Strategy: .fail = reject if can't start now; .enqueue = queue (default)
+ request.strategy = .fail
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ // Show error — can't run in background now
+ showError("Cannot export in background right now")
+ }
+}
+```
+
+### Handler with Mandatory Progress Reporting
+
+```swift
+func handleExport(task: BGContinuedProcessingTask) {
+ var shouldContinue = true
+
+ task.expirationHandler = {
+ shouldContinue = false
+ }
+
+ // MANDATORY: Report progress
+ // Tasks with no progress updates are AUTO-EXPIRED
+ task.progress.totalUnitCount = Int64(photos.count)
+ task.progress.completedUnitCount = 0
+
+ Task {
+ for (index, photo) in photos.enumerated() {
+ guard shouldContinue else { break }
+
+ await exportPhoto(photo)
+
+ // Update progress — system displays to user
+ task.progress.completedUnitCount = Int64(index + 1)
+ }
+
+ task.setTaskCompleted(success: shouldContinue)
+ }
+}
+```
+
+### BGContinuedProcessingTaskRequest Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `identifier` | String | With wildcard, can have dynamic suffix |
+| `title` | String | Shown in system progress UI |
+| `subtitle` | String | Shown in system progress UI |
+| `strategy` | Strategy | `.fail` or `.enqueue` (default) |
+
+### Strategy Options
+
+```swift
+// .fail — Reject if can't start immediately
+request.strategy = .fail
+
+// .enqueue — Queue if can't start (default)
+// Task may run later
+```
+
+### GPU Access (iOS 26+)
+
+```swift
+// Check if GPU available for background task
+let supportedResources = BGTaskScheduler.shared.supportedResources
+if supportedResources.contains(.gpu) {
+ // GPU is available
+}
+```
+
+---
+
+## Part 5: beginBackgroundTask
+
+### Purpose
+
+Finish critical work (~30 seconds) when app transitions to background. For state saving, completing uploads.
+
+### Basic Pattern
+
+```swift
+var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
+
+func applicationDidEnterBackground(_ application: UIApplication) {
+ backgroundTaskID = application.beginBackgroundTask(withName: "Save State") {
+ // Expiration handler — clean up immediately
+ self.saveProgress()
+ application.endBackgroundTask(self.backgroundTaskID)
+ self.backgroundTaskID = .invalid
+ }
+
+ // Do critical work
+ saveEssentialState { [weak self] in
+ guard let self = self,
+ self.backgroundTaskID != .invalid else { return }
+
+ // End task AS SOON AS work completes
+ UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
+ self.backgroundTaskID = .invalid
+ }
+}
+```
+
+### Key Points
+
+- Call `endBackgroundTask` immediately when done — don't wait for expiration
+- Failing to end may cause system to terminate app
+- ~30 seconds max, not guaranteed
+- Use for finalization, not ongoing work
+
+### SwiftUI / SceneDelegate
+
+```swift
+.onChange(of: scenePhase) { newPhase in
+ if newPhase == .background {
+ startBackgroundTask()
+ }
+}
+```
+
+---
+
+## Part 6: Background URLSession
+
+### Purpose
+
+Large downloads/uploads that continue **even after app termination**. Work handed off to system daemon.
+
+### Configuration
+
+```swift
+lazy var backgroundSession: URLSession = {
+ let config = URLSessionConfiguration.background(
+ withIdentifier: "com.yourapp.downloads"
+ )
+
+ // App relaunched when task completes
+ config.sessionSendsLaunchEvents = true
+
+ // System chooses optimal time (WiFi, charging)
+ config.isDiscretionary = true
+
+ // Timeout for requests (not the download itself)
+ config.timeoutIntervalForRequest = 60
+
+ return URLSession(configuration: config, delegate: self, delegateQueue: nil)
+}()
+```
+
+### Starting Download
+
+```swift
+func downloadFile(from url: URL) {
+ let task = backgroundSession.downloadTask(with: url)
+ task.resume()
+ // Work continues even if app terminates
+}
+```
+
+### AppDelegate Handler
+
+```swift
+var backgroundSessionCompletionHandler: (() -> Void)?
+
+func application(
+ _ application: UIApplication,
+ handleEventsForBackgroundURLSession identifier: String,
+ completionHandler: @escaping () -> Void
+) {
+ // Store — call after all events processed
+ backgroundSessionCompletionHandler = completionHandler
+}
+```
+
+### URLSessionDelegate Implementation
+
+```swift
+extension AppDelegate: URLSessionDelegate, URLSessionDownloadDelegate {
+
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ // MUST move file immediately — temp location deleted after return
+ let destination = getDestinationURL(for: downloadTask)
+ try? FileManager.default.moveItem(at: location, to: destination)
+ }
+
+ func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ // All events processed — call stored completion handler
+ DispatchQueue.main.async {
+ self.backgroundSessionCompletionHandler?()
+ self.backgroundSessionCompletionHandler = nil
+ }
+ }
+}
+```
+
+### Configuration Properties
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `sessionSendsLaunchEvents` | false | Relaunch app on completion |
+| `isDiscretionary` | false | Wait for optimal conditions |
+| `allowsCellularAccess` | true | Allow cellular network |
+| `allowsExpensiveNetworkAccess` | true | Allow expensive networks |
+| `allowsConstrainedNetworkAccess` | true | Allow Low Data Mode |
+
+---
+
+## Part 7: Testing Background Tasks
+
+### LLDB Debugging Commands
+
+Pause app in debugger, then execute:
+
+```lldb
+// Trigger task launch
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
+
+// Trigger task expiration
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
+```
+
+### Testing Workflow
+
+1. Set breakpoint in task handler
+2. Run app, let it enter background
+3. Pause execution (Debug → Pause)
+4. Execute simulate launch command
+5. Resume — breakpoint should hit
+6. Test expiration handling with simulate expiration
+
+### Console Logging
+
+Filter Console.app:
+
+```
+subsystem:com.apple.backgroundtaskscheduler
+```
+
+### getPendingTaskRequests
+
+Check what's scheduled:
+
+```swift
+BGTaskScheduler.shared.getPendingTaskRequests { requests in
+ for request in requests {
+ print("Pending: \(request.identifier)")
+ print(" Earliest: \(request.earliestBeginDate ?? Date())")
+ }
+}
+```
+
+---
+
+## Part 8: Throttling & System Constraints
+
+### The 7 Scheduling Factors
+
+| Factor | How to Check | Impact |
+|--------|--------------|--------|
+| Critically Low Battery | Battery < ~20% | Discretionary work paused |
+| Low Power Mode | `ProcessInfo.isLowPowerModeEnabled` | Limited activity |
+| App Usage | User opens app frequently? | Higher priority |
+| App Switcher | Not swiped away? | Swiped = no background |
+| Background App Refresh | `backgroundRefreshStatus` | Off = no BGAppRefresh |
+| System Budgets | Many recent launches? | Budget depletes, refills daily |
+| Rate Limiting | Requests too frequent? | System spaces launches |
+
+### Checking Constraints
+
+```swift
+// Low Power Mode
+if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ // Reduce background work
+}
+
+// Listen for changes
+NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
+ .sink { _ in
+ // Adapt behavior
+ }
+
+// Background App Refresh status
+switch UIApplication.shared.backgroundRefreshStatus {
+case .available:
+ // Can schedule tasks
+case .denied:
+ // User disabled — prompt in Settings
+case .restricted:
+ // MDM or parental controls — cannot enable
+@unknown default:
+ break
+}
+```
+
+### Thermal State
+
+```swift
+switch ProcessInfo.processInfo.thermalState {
+case .nominal:
+ break // Normal operation
+case .fair:
+ // Reduce intensive work
+case .serious:
+ // Minimize all background activity
+case .critical:
+ // Stop non-essential work immediately
+@unknown default:
+ break
+}
+
+NotificationCenter.default.publisher(for: ProcessInfo.thermalStateDidChangeNotification)
+ .sink { _ in
+ // Respond to thermal changes
+ }
+```
+
+---
+
+## Part 9: Push Notifications for Background
+
+### Silent Push Payload
+
+```json
+{
+ "aps": {
+ "content-available": 1
+ },
+ "custom-data": "your-payload"
+}
+```
+
+### APNS Priority
+
+```
+apns-priority: 5 // Discretionary — energy efficient (recommended)
+apns-priority: 10 // Immediate — only for time-sensitive
+```
+
+### Handler
+
+```swift
+func application(
+ _ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+) {
+ guard userInfo["aps"] as? [String: Any] != nil else {
+ completionHandler(.noData)
+ return
+ }
+
+ Task {
+ do {
+ let hasNewData = try await fetchLatestData()
+ completionHandler(hasNewData ? .newData : .noData)
+ } catch {
+ completionHandler(.failed)
+ }
+ }
+}
+```
+
+### Rate Limiting Behavior
+
+> "Receiving 14 pushes in a window may result in only 7 launches, maintaining a ~15-minute interval."
+
+Silent pushes are rate-limited. Don't expect launch on every push.
+
+---
+
+## Part 10: SwiftUI Integration
+
+### backgroundTask Modifier
+
+```swift
+@main
+struct MyApp: App {
+ @Environment(\.scenePhase) var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ .onChange(of: scenePhase) { newPhase in
+ if newPhase == .background {
+ scheduleAppRefresh()
+ }
+ }
+ // App refresh handler
+ .backgroundTask(.appRefresh("com.yourapp.refresh")) {
+ scheduleAppRefresh() // Schedule next
+ await fetchLatestContent()
+ // Task completes when closure returns (no setTaskCompleted needed)
+ }
+ // Background URLSession handler
+ .backgroundTask(.urlSession("com.yourapp.downloads")) {
+ await processDownloadedFiles()
+ }
+ }
+}
+```
+
+### Cancellation with Swift Concurrency
+
+```swift
+.backgroundTask(.appRefresh("com.yourapp.refresh")) {
+ await withTaskCancellationHandler {
+ // Normal work
+ try await fetchData()
+ } onCancel: {
+ // Called when task expires
+ // Keep lightweight — runs synchronously
+ }
+}
+```
+
+### Background URLSession with SwiftUI
+
+```swift
+.backgroundTask(.urlSession("com.yourapp.weather")) {
+ // Called when background URLSession completes
+ // Handle completed downloads
+}
+```
+
+---
+
+## Quick Reference
+
+### Task Types
+
+| Type | Runtime | API | Use Case |
+|------|---------|-----|----------|
+| BGAppRefreshTask | ~30s | submit(BGAppRefreshTaskRequest) | Fresh content |
+| BGProcessingTask | Minutes | submit(BGProcessingTaskRequest) | Maintenance |
+| BGContinuedProcessingTask | Extended | submit(BGContinuedProcessingTaskRequest) | User-initiated |
+| beginBackgroundTask | ~30s | beginBackgroundTask(withName:) | State saving |
+| Background URLSession | As needed | URLSessionConfiguration.background | Downloads |
+| Silent Push | ~30s | didReceiveRemoteNotification | Server trigger |
+
+### Required Info.plist
+
+```xml
+BGTaskSchedulerPermittedIdentifiers
+
+ your.identifiers.here
+
+
+UIBackgroundModes
+
+ fetch
+ processing
+
+```
+
+### LLDB Commands
+
+```lldb
+// Launch
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"ID"]
+
+// Expire
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"ID"]
+```
+
+---
+
+## Resources
+
+**WWDC**: 2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227
+
+**Docs**: /backgroundtasks, /backgroundtasks/bgtaskscheduler, /foundation/urlsessionconfiguration
+
+**Skills**: axiom-background-processing, axiom-background-processing-diag
+
+---
+
+**Last Updated**: 2025-12-31
+**Platforms**: iOS 13+, iOS 26+ (BGContinuedProcessingTask)
diff --git a/.claude/skills/axiom-background-processing-ref/agents/openai.yaml b/.claude/skills/axiom-background-processing-ref/agents/openai.yaml
new file mode 100644
index 0000000..e62ecb4
--- /dev/null
+++ b/.claude/skills/axiom-background-processing-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Background Processing Reference"
+ short_description: "Complete background task API reference"
diff --git a/.claude/skills/axiom-background-processing/.openskills.json b/.claude/skills/axiom-background-processing/.openskills.json
new file mode 100644
index 0000000..9c53601
--- /dev/null
+++ b/.claude/skills/axiom-background-processing/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-background-processing",
+ "installedAt": "2026-04-12T08:05:55.443Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-background-processing/SKILL.md b/.claude/skills/axiom-background-processing/SKILL.md
new file mode 100644
index 0000000..de885d6
--- /dev/null
+++ b/.claude/skills/axiom-background-processing/SKILL.md
@@ -0,0 +1,1011 @@
+---
+name: axiom-background-processing
+description: Use when implementing BGTaskScheduler, debugging background tasks that never run, understanding why tasks terminate early, or testing background execution - systematic task lifecycle management with proper registration, expiration handling, and Swift 6 cancellation patterns
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Background Processing
+
+## Overview
+
+Background execution is a **privilege**, not a right. iOS actively limits background work to protect battery life and user experience. **Core principle**: Treat background tasks as discretionary jobs — you request a time window, the system decides when (or if) to run your code.
+
+**Key insight**: Most "my task never runs" issues stem from registration mistakes or misunderstanding the 7 scheduling factors that govern execution. This skill provides systematic debugging, not guesswork.
+
+**Energy optimization**: For reducing battery impact of background tasks, see `axiom-energy` skill. This skill focuses on task **mechanics** — making tasks run correctly and complete reliably.
+
+**Requirements**: iOS 13+ (BGTaskScheduler), iOS 26+ (BGContinuedProcessingTask), Xcode 15+
+
+## Example Prompts
+
+Real questions developers ask that this skill answers:
+
+#### 1. "My background task never runs. I register it, schedule it, but nothing happens."
+→ The skill covers the registration checklist and debugging decision tree for "task never runs" issues
+
+#### 2. "How do I test background tasks? They don't seem to trigger in the simulator."
+→ The skill covers LLDB debugging commands and simulator limitations
+
+#### 3. "My task gets terminated before it completes. How do I extend the time?"
+→ The skill covers task types (BGAppRefresh 30s vs BGProcessing minutes), expiration handlers, and incremental progress saving
+
+#### 4. "Should I use BGAppRefreshTask or BGProcessingTask? What's the difference?"
+→ The skill provides decision tree for choosing the correct task type based on work duration and system requirements
+
+#### 5. "How do I integrate Swift 6 concurrency with background task expiration?"
+→ The skill covers withTaskCancellationHandler patterns for bridging BGTask expiration to structured concurrency
+
+#### 6. "My background task works in development but not in production."
+→ The skill covers the 7 scheduling factors, throttling behavior, and production debugging
+
+---
+
+## Red Flags — Task Won't Run or Terminates
+
+If you see ANY of these, suspect registration or scheduling issues:
+
+- **Task never runs**: Handler never called despite successful `submit()`
+- **Task terminates immediately**: Handler called but work doesn't complete
+- **Works in dev, not prod**: Task runs with debugger but not in release builds
+- **Console shows no launch**: No "BackgroundTask" entries in unified logging
+- **Identifier mismatch errors**: Task identifier not matching Info.plist
+- **"No handler registered"**: Handler not registered before first scheduling
+
+#### Difference from energy issues
+- **Energy issue**: Task runs but drains battery (see `axiom-energy` skill)
+- **This skill**: Task doesn't run, or terminates before completing work
+
+---
+
+## Mandatory First Steps
+
+**ALWAYS verify these before debugging code**:
+
+### Step 1: Verify Info.plist Configuration (2 minutes)
+
+```xml
+
+BGTaskSchedulerPermittedIdentifiers
+
+ com.yourapp.refresh
+ com.yourapp.processing
+
+
+
+UIBackgroundModes
+
+ fetch
+
+
+
+
+ fetch
+ processing
+
+```
+
+**Common mistake**: Identifier in code doesn't EXACTLY match Info.plist. Check for typos, case sensitivity.
+
+### Step 2: Verify Registration Timing (2 minutes)
+
+Registration MUST happen before app finishes launching:
+
+```swift
+// ✅ CORRECT: Register in didFinishLaunchingWithOptions
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.refresh",
+ using: nil
+ ) { task in
+ // Safe force cast: identifier guarantees BGAppRefreshTask type
+ self.handleAppRefresh(task: task as! BGAppRefreshTask)
+ }
+
+ return true // Register BEFORE returning
+}
+
+// ❌ WRONG: Registering after launch or on-demand
+func someButtonTapped() {
+ // TOO LATE - registration won't work
+ BGTaskScheduler.shared.register(...)
+}
+```
+
+**Exception**: BGContinuedProcessingTask (iOS 26+) uses dynamic registration when user initiates the action.
+
+### Step 3: Check Console Logs (5 minutes)
+
+Filter Console.app for background task events:
+
+```
+subsystem:com.apple.backgroundtaskscheduler
+```
+
+Look for:
+- "Registered handler for task with identifier"
+- "Scheduling task with identifier"
+- "Starting task with identifier"
+- "Task completed with identifier"
+- Error messages about missing handlers or identifiers
+
+### Step 4: Verify App Not Swiped Away (1 minute)
+
+**Critical**: If user force-quits app from App Switcher, NO background tasks will run.
+
+Check in App Switcher: Is your app still visible? Swiping away = no background execution until user launches again.
+
+---
+
+## Background Task Decision Tree
+
+```
+Need to run code in the background?
+│
+├─ User initiated the action explicitly (button tap)?
+│ ├─ iOS 26+? → BGContinuedProcessingTask (Pattern 4)
+│ └─ iOS 13-25? → beginBackgroundTask + save progress (Pattern 5)
+│
+├─ Keep content fresh throughout the day?
+│ ├─ Runtime needed ≤ 30 seconds? → BGAppRefreshTask (Pattern 1)
+│ └─ Need several minutes? → BGProcessingTask with constraints (Pattern 2)
+│
+├─ Deferrable maintenance work (DB cleanup, ML training)?
+│ └─ BGProcessingTask with requiresExternalPower (Pattern 2)
+│
+├─ Large downloads/uploads?
+│ └─ Background URLSession (Pattern 6)
+│
+├─ Triggered by server data changes?
+│ └─ Silent push notification → fetch data → complete handler (Pattern 7)
+│
+└─ Short critical work when app backgrounds?
+ └─ beginBackgroundTask (Pattern 5)
+```
+
+### Task Type Comparison
+
+| Type | Runtime | When Runs | Use Case |
+|------|---------|-----------|----------|
+| BGAppRefreshTask | ~30 seconds | Based on user app usage patterns | Fetch latest content |
+| BGProcessingTask | Several minutes | Device charging, idle (typically overnight) | Maintenance, ML training |
+| BGContinuedProcessingTask | Extended | System-managed with progress UI | User-initiated export/publish |
+| beginBackgroundTask | ~30 seconds | Immediately when backgrounding | Save state, finish upload |
+| Background URLSession | As needed | System-friendly time, even after termination | Large transfers |
+
+---
+
+## Common Patterns
+
+### Pattern 1: BGAppRefreshTask — Keep Content Fresh
+
+**Use when**: You need to fetch new content so app feels fresh when user opens it.
+
+**Runtime**: ~30 seconds
+
+**When system runs it**: Predicted based on user's app usage patterns. If user opens app every morning, system learns and refreshes before then.
+
+#### Registration (at app launch)
+
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.refresh",
+ using: nil
+ ) { task in
+ self.handleAppRefresh(task: task as! BGAppRefreshTask)
+ }
+
+ return true
+}
+```
+
+#### Scheduling (when app backgrounds)
+
+```swift
+func scheduleAppRefresh() {
+ let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.refresh")
+
+ // earliestBeginDate = MINIMUM delay, not exact time
+ // System may run hours later based on usage patterns
+ request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // At least 15 min
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ print("Failed to schedule refresh: \(error)")
+ }
+}
+
+// Call when app enters background
+func applicationDidEnterBackground(_ application: UIApplication) {
+ scheduleAppRefresh()
+}
+
+// Or with SceneDelegate / SwiftUI
+.onChange(of: scenePhase) { newPhase in
+ if newPhase == .background {
+ scheduleAppRefresh()
+ }
+}
+```
+
+#### Handler
+
+```swift
+func handleAppRefresh(task: BGAppRefreshTask) {
+ // 1. IMMEDIATELY set expiration handler
+ task.expirationHandler = { [weak self] in
+ // Cancel any in-progress work
+ self?.currentOperation?.cancel()
+ }
+
+ // 2. Schedule NEXT refresh (continuous refresh pattern)
+ scheduleAppRefresh()
+
+ // 3. Do the work
+ fetchLatestContent { [weak self] result in
+ switch result {
+ case .success:
+ task.setTaskCompleted(success: true)
+ case .failure:
+ task.setTaskCompleted(success: false)
+ }
+ }
+}
+```
+
+**Key points**:
+- Set expiration handler FIRST
+- Schedule next refresh inside handler (continuous pattern)
+- Call `setTaskCompleted` in ALL code paths (success AND failure)
+- Keep work under 30 seconds
+
+---
+
+### Pattern 2: BGProcessingTask — Deferrable Maintenance
+
+**Use when**: Maintenance work that can wait for optimal system conditions (charging, WiFi, idle).
+
+**Runtime**: Several minutes
+
+**When system runs it**: Typically overnight when device is charging. May not run daily.
+
+#### Registration
+
+```swift
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.maintenance",
+ using: nil
+) { task in
+ self.handleMaintenance(task: task as! BGProcessingTask)
+}
+```
+
+#### Scheduling with Constraints
+
+```swift
+func scheduleMaintenanceIfNeeded() {
+ // Be conscientious — only schedule when work is actually needed
+ guard needsMaintenance() else { return }
+
+ let request = BGProcessingTaskRequest(identifier: "com.yourapp.maintenance")
+
+ // CRITICAL: Set requiresExternalPower for CPU-intensive work
+ request.requiresExternalPower = true
+
+ // Optional: Require network for cloud sync
+ request.requiresNetworkConnectivity = true
+
+ // Don't set earliestBeginDate too far — max ~1 week
+ // If user doesn't return to app, task won't run
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch BGTaskScheduler.Error.unavailable {
+ print("Background processing not available")
+ } catch {
+ print("Failed to schedule: \(error)")
+ }
+}
+```
+
+#### Handler with Progress Checkpointing
+
+```swift
+func handleMaintenance(task: BGProcessingTask) {
+ var shouldContinue = true
+
+ task.expirationHandler = { [weak self] in
+ shouldContinue = false
+ self?.saveProgress() // Save partial progress!
+ }
+
+ Task {
+ do {
+ // Process in chunks, checking for expiration
+ for chunk in workChunks {
+ guard shouldContinue else {
+ // Expiration called — stop gracefully
+ break
+ }
+
+ try await processChunk(chunk)
+ saveProgress() // Checkpoint after each chunk
+ }
+
+ task.setTaskCompleted(success: true)
+ } catch {
+ task.setTaskCompleted(success: false)
+ }
+ }
+}
+```
+
+**Key points**:
+- Set `requiresExternalPower = true` for CPU-intensive work (prevents battery drain)
+- Save progress incrementally — task may be interrupted
+- Work may never run if user doesn't charge device
+- Don't set `earliestBeginDate` more than a week ahead
+
+---
+
+### Pattern 3: SwiftUI backgroundTask Modifier
+
+**Use when**: SwiftUI app using modern async/await patterns.
+
+```swift
+@main
+struct MyApp: App {
+ @Environment(\.scenePhase) var scenePhase
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ .onChange(of: scenePhase) { newPhase in
+ if newPhase == .background {
+ scheduleAppRefresh()
+ }
+ }
+ // Handle app refresh
+ .backgroundTask(.appRefresh("com.yourapp.refresh")) {
+ // Schedule next refresh
+ scheduleAppRefresh()
+
+ // Async work — task completes when closure returns
+ await fetchLatestContent()
+ }
+ // Handle background URLSession events
+ .backgroundTask(.urlSession("com.yourapp.downloads")) {
+ // Called when background URLSession completes
+ await processDownloadedFiles()
+ }
+ }
+}
+```
+
+**SwiftUI advantages**:
+- Implicit task completion when closure returns (no `setTaskCompleted` needed)
+- Native Swift Concurrency support
+- Task automatically cancelled on expiration
+
+---
+
+### Pattern 4: BGContinuedProcessingTask (iOS 26+)
+
+**Use when**: User explicitly initiates work (button tap) that should continue after backgrounding, with visible progress.
+
+**NOT for**: Automatic tasks, maintenance, syncing
+
+```swift
+// 1. Info.plist — use wildcard for dynamic suffix
+// BGTaskSchedulerPermittedIdentifiers:
+// "com.yourapp.export.*"
+
+// 2. Register WHEN user initiates action (not at launch)
+func userTappedExportButton() {
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.export.photos"
+ ) { task in
+ let continuedTask = task as! BGContinuedProcessingTask
+ self.handleExport(task: continuedTask)
+ }
+
+ // Submit immediately
+ let request = BGContinuedProcessingTaskRequest(
+ identifier: "com.yourapp.export.photos",
+ title: "Exporting Photos",
+ subtitle: "0 of 100 photos"
+ )
+
+ // Optional: Fail if can't start immediately
+ request.strategy = .fail // or .enqueue (default)
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ showError("Cannot export in background right now")
+ }
+}
+
+// 3. Handler with mandatory progress reporting
+func handleExport(task: BGContinuedProcessingTask) {
+ var shouldContinue = true
+
+ task.expirationHandler = {
+ shouldContinue = false
+ }
+
+ // MANDATORY: Report progress (tasks with no progress auto-expire)
+ task.progress.totalUnitCount = 100
+ task.progress.completedUnitCount = 0
+
+ Task {
+ for (index, photo) in photos.enumerated() {
+ guard shouldContinue else { break }
+
+ await exportPhoto(photo)
+
+ // Update progress — system shows this to user
+ task.progress.completedUnitCount = Int64(index + 1)
+ }
+
+ task.setTaskCompleted(success: shouldContinue)
+ }
+}
+```
+
+**Key points**:
+- Dynamic registration (when user acts, not at launch)
+- Progress reporting is MANDATORY — tasks with no updates auto-expire
+- User can monitor and cancel from system UI
+- Use `.fail` strategy when work is only useful if it starts immediately
+
+---
+
+### Pattern 5: beginBackgroundTask — Short Critical Work
+
+**Use when**: App is backgrounding and you need ~30 seconds to finish critical work (save state, complete upload).
+
+```swift
+var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
+
+func applicationDidEnterBackground(_ application: UIApplication) {
+ // Start background task
+ backgroundTaskID = application.beginBackgroundTask(withName: "Save State") { [weak self] in
+ // Expiration handler — clean up and end task
+ self?.saveProgress()
+ if let taskID = self?.backgroundTaskID {
+ application.endBackgroundTask(taskID)
+ }
+ self?.backgroundTaskID = .invalid
+ }
+
+ // Do critical work
+ saveEssentialState { [weak self] in
+ // End task as soon as done — DON'T wait for expiration
+ if let taskID = self?.backgroundTaskID, taskID != .invalid {
+ UIApplication.shared.endBackgroundTask(taskID)
+ self?.backgroundTaskID = .invalid
+ }
+ }
+}
+```
+
+**Key points**:
+- Call `endBackgroundTask` AS SOON as work completes (not just in expiration handler)
+- Failing to end task may cause system to terminate your app and impact future launches
+- ~30 seconds max, not guaranteed
+- Use for state saving, not ongoing work
+
+---
+
+### Pattern 6: Background URLSession
+
+**Use when**: Large downloads/uploads that should continue even if app terminates.
+
+```swift
+// 1. Create background configuration
+lazy var backgroundSession: URLSession = {
+ let config = URLSessionConfiguration.background(
+ withIdentifier: "com.yourapp.downloads"
+ )
+ config.sessionSendsLaunchEvents = true // App relaunched when complete
+ config.isDiscretionary = true // System chooses optimal time
+
+ return URLSession(configuration: config, delegate: self, delegateQueue: nil)
+}()
+
+// 2. Start download
+func downloadFile(from url: URL) {
+ let task = backgroundSession.downloadTask(with: url)
+ task.resume()
+}
+
+// 3. Handle app relaunch for session events (AppDelegate)
+func application(_ application: UIApplication,
+ handleEventsForBackgroundURLSession identifier: String,
+ completionHandler: @escaping () -> Void) {
+
+ // Store completion handler — call after processing events
+ backgroundSessionCompletionHandler = completionHandler
+
+ // Session delegate methods will be called
+}
+
+// 4. URLSessionDelegate
+func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
+ // All events processed — call stored completion handler
+ DispatchQueue.main.async {
+ self.backgroundSessionCompletionHandler?()
+ self.backgroundSessionCompletionHandler = nil
+ }
+}
+
+func urlSession(_ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL) {
+ // Move file from temp location before returning
+ let destinationURL = getDestinationURL(for: downloadTask)
+ try? FileManager.default.moveItem(at: location, to: destinationURL)
+}
+```
+
+**Key points**:
+- Work handed off to system daemon (`nsurlsessiond`) — continues after app termination
+- `isDiscretionary = true` for non-urgent (system waits for WiFi, charging)
+- Must handle `handleEventsForBackgroundURLSession` for app relaunch
+- Move downloaded files immediately — temp location deleted after delegate returns
+
+---
+
+### Pattern 7: Silent Push Notification Trigger
+
+**Use when**: Server needs to wake app to fetch new data.
+
+#### Server Payload
+
+```json
+{
+ "aps": {
+ "content-available": 1
+ },
+ "custom-data": "fetch-new-messages"
+}
+```
+
+Use `apns-priority: 5` (not 10) for energy efficiency.
+
+#### App Handler
+
+```swift
+func application(_ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+
+ Task {
+ do {
+ let hasNewData = try await fetchLatestData()
+ completionHandler(hasNewData ? .newData : .noData)
+ } catch {
+ completionHandler(.failed)
+ }
+ }
+}
+```
+
+**Key points**:
+- Silent pushes are rate-limited — don't expect launch on every push
+- System coalesces multiple pushes (14 pushes may result in 7 launches)
+- Budget depletes with each launch and refills throughout day
+- ~30 seconds runtime per launch
+
+> For silent push notification patterns (content-available payload, throttling limits, APNs setup), see axiom-push-notifications.
+
+---
+
+## Swift 6 Cancellation Integration
+
+When using structured concurrency, bridge BGTask expiration to task cancellation:
+
+```swift
+func handleAppRefresh(task: BGAppRefreshTask) {
+ // Create a Task that respects expiration
+ let workTask = Task {
+ try await withTaskCancellationHandler {
+ // Your async work
+ try await fetchAndProcessData()
+ task.setTaskCompleted(success: true)
+ } onCancel: {
+ // Called synchronously when task.cancel() is invoked
+ // Note: Runs on arbitrary thread, keep lightweight
+ }
+ }
+
+ // Bridge expiration to cancellation
+ task.expirationHandler = {
+ workTask.cancel() // Triggers onCancel block
+ }
+}
+
+// Checking cancellation in your work
+func fetchAndProcessData() async throws {
+ for item in items {
+ // Check if we should stop
+ try Task.checkCancellation()
+
+ // Or non-throwing check
+ guard !Task.isCancelled else {
+ saveProgress()
+ return
+ }
+
+ try await process(item)
+ }
+}
+```
+
+**Key points**:
+- `withTaskCancellationHandler` handles cancellation while task is suspended
+- `Task.checkCancellation()` throws `CancellationError` if cancelled
+- `Task.isCancelled` for non-throwing check
+- Cancellation is cooperative — your code must check and respond
+
+---
+
+## Testing Background Tasks
+
+### Simulator Limitations
+
+Background tasks **do not run automatically** in simulator. You must manually trigger them.
+
+### LLDB Debugging Commands
+
+While app is running with debugger attached, pause execution and run:
+
+```lldb
+// Trigger task launch
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.yourapp.refresh"]
+
+// Trigger task expiration (test expiration handler)
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"com.yourapp.refresh"]
+```
+
+### Testing Workflow
+
+1. Set breakpoint in task handler
+2. Run app, let it background
+3. Pause in debugger
+4. Run `_simulateLaunchForTaskWithIdentifier` command
+5. Resume — breakpoint should hit
+6. Test expiration with `_simulateExpirationForTaskWithIdentifier`
+
+### Testing Checklist
+
+- [ ] Task handler breakpoint hits when simulated?
+- [ ] Expiration handler called when simulated?
+- [ ] `setTaskCompleted` called in all code paths?
+- [ ] Works on real device (not just simulator)?
+- [ ] Works in release build (not just debug)?
+- [ ] App not swiped away from App Switcher?
+
+---
+
+## The 7 Scheduling Factors
+
+From WWDC 2020-10063 "Background execution demystified":
+
+| Factor | Description | Impact |
+|--------|-------------|--------|
+| **Critically Low Battery** | <20% battery | All discretionary work paused |
+| **Low Power Mode** | User-enabled | Background activity limited |
+| **App Usage** | How often user launches app | More usage = higher priority |
+| **App Switcher** | App still visible? | Swiped away = no background |
+| **Background App Refresh** | System setting | Off = no BGAppRefresh tasks |
+| **System Budgets** | Energy/data budgets | Deplete with launches, refill over day |
+| **Rate Limiting** | System spacing | Prevents too-frequent launches |
+
+### Responding to System Constraints
+
+```swift
+// Check Low Power Mode
+if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ // Reduce background work
+}
+
+// Listen for changes
+NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
+ .sink { _ in
+ // Adapt behavior
+ }
+
+// Check Background App Refresh status
+let status = UIApplication.shared.backgroundRefreshStatus
+switch status {
+case .available:
+ break // Good to schedule
+case .denied:
+ // User disabled — prompt to enable in Settings
+case .restricted:
+ // Parental controls or MDM — can't enable
+}
+```
+
+---
+
+## Audit Checklists
+
+### Registration Checklist
+
+- [ ] Identifier in Info.plist exactly matches code (case-sensitive)?
+- [ ] Correct background mode enabled (`fetch`, `processing`)?
+- [ ] Registration happens in `didFinishLaunchingWithOptions` BEFORE return?
+- [ ] Not registering same identifier multiple times?
+- [ ] Handler closure doesn't capture self strongly?
+
+### Scheduling Checklist
+
+- [ ] Scheduling on main queue or background queue (if performance sensitive)?
+- [ ] `earliestBeginDate` not too far in future (max ~1 week)?
+- [ ] Handling `submit()` errors?
+- [ ] Not scheduling duplicate tasks (check `getPendingTaskRequests`)?
+
+### Handler Checklist
+
+- [ ] Expiration handler set IMMEDIATELY at start of handler?
+- [ ] `setTaskCompleted(success:)` called in ALL code paths?
+- [ ] Next task scheduled (for continuous patterns)?
+- [ ] Progress saved incrementally for long operations?
+- [ ] Expiration handler actually cancels ongoing work?
+
+### Production Readiness
+
+- [ ] Tested on real device, not just simulator?
+- [ ] Tested in release build, not just debug?
+- [ ] Tested with Low Power Mode enabled?
+- [ ] Tested after force-quit from App Switcher (should NOT run)?
+- [ ] Console logs show expected "Task completed" messages?
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Just poll the server every 30 seconds in background"
+
+**The temptation**: "Polling is simpler than push notifications. We need real-time updates."
+
+**The reality**:
+- iOS will NOT give you 30-second background intervals
+- BGAppRefreshTask runs based on USER behavior patterns, not your schedule
+- If user rarely opens app, task may run once per day or less
+- Polling burns budget quickly — fewer total launches
+
+**Time cost comparison**:
+- Implement polling: 30 minutes (won't work as expected)
+- Understand why it doesn't work: 2-4 hours debugging
+- Implement proper push notifications: 3-4 hours
+
+**What actually works**:
+- Silent push notifications (server triggers, not polling)
+- BGAppRefreshTask for predicted user behavior (not real-time)
+- BGProcessingTask for deferrable work (overnight)
+
+**Pushback template**: "iOS background execution doesn't support polling intervals. BGAppRefreshTask runs based on when iOS predicts the user will open our app, not on a fixed schedule. For real-time updates, we need server-side push notifications. Let me show you Apple's documentation on this."
+
+---
+
+### Scenario 2: "My task needs 5 minutes, not 30 seconds"
+
+**The temptation**: "I'll just use beginBackgroundTask and do all my work."
+
+**The reality**:
+- beginBackgroundTask: ~30 seconds max
+- BGAppRefreshTask: ~30 seconds
+- BGProcessingTask: Several minutes, but only when charging
+- No API gives you guaranteed 5-minute foreground-quality runtime
+
+**What actually works**:
+1. **Chunk your work** — Break into 30-second pieces, save progress
+2. **Use BGProcessingTask** with `requiresExternalPower = true` (runs overnight)
+3. **iOS 26+**: Use BGContinuedProcessingTask for user-initiated work
+
+**Pushback template**: "iOS limits background runtime to protect battery. For work that needs several minutes, we have two options: (1) BGProcessingTask runs overnight when charging — great for maintenance, (2) Break work into chunks that complete in 30 seconds, saving progress between runs. Which fits our use case better?"
+
+---
+
+### Scenario 3: "It works on my device but not for users"
+
+**The temptation**: "The code is correct — it must be a user device issue."
+
+**The reality**: Debug builds with Xcode attached behave differently than release builds in the wild.
+
+**Common causes**:
+1. **Low Power Mode enabled** — limits background activity
+2. **Background App Refresh disabled** — user or parental controls
+3. **App swiped away** — kills all background tasks
+4. **Budget exhausted** — too many recent launches
+5. **Rarely used app** — system deprioritizes
+
+**Debugging steps**:
+1. Check `backgroundRefreshStatus` at launch, log it
+2. Log when tasks are scheduled and completed
+3. Use MetricKit to monitor background launches in production
+4. Ask users: "Did you force-quit the app from App Switcher?"
+
+**Pushback template**: "Background execution depends on 7 system factors including battery level, user app usage patterns, and whether they force-quit the app. Let me add logging to understand what's happening for affected users."
+
+---
+
+### Scenario 4: "Ship now, add background tasks later"
+
+**The temptation**: "Background work is a nice-to-have feature."
+
+**The reality**:
+- Users expect content to be fresh when they open the app
+- Competing apps that refresh in background feel more responsive
+- Adding background tasks later requires careful registration timing
+- First impression of stale content drives retention
+
+**Time cost comparison**:
+- Add BGAppRefreshTask now: 1-2 hours
+- Retrofit later with proper testing: 4-6 hours
+- Debug "why doesn't it work" issues: Additional hours
+
+**Minimum viable background**:
+```swift
+// In didFinishLaunchingWithOptions
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.yourapp.refresh",
+ using: nil
+) { task in
+ task.setTaskCompleted(success: true) // Placeholder
+ self.scheduleRefresh()
+}
+```
+
+**Pushback template**: "Background refresh is a core expectation for [type of app]. The minimum implementation is 20 lines of code. If we ship without it and add later, we risk registration timing bugs. Let me add the scaffolding now so we can enhance it post-launch."
+
+---
+
+## Real-World Examples
+
+### Example 1: Task Never Runs — Identifier Mismatch
+
+**Symptom**: `submit()` succeeds but handler never called.
+
+**Diagnosis**:
+```swift
+// Code uses:
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.myapp.Refresh", // Capital R
+ ...
+)
+
+// Info.plist has:
+// "com.myapp.refresh" // lowercase r
+```
+
+**Fix**: Identifiers must EXACTLY match (case-sensitive).
+
+**Time wasted**: 2 hours debugging code logic when issue was typo.
+
+---
+
+### Example 2: Task Terminates — Missing setTaskCompleted
+
+**Symptom**: Handler runs, work appears to complete, but next scheduled task never runs.
+
+**Diagnosis**:
+```swift
+func handleRefresh(task: BGAppRefreshTask) {
+ fetchData { result in
+ switch result {
+ case .success:
+ task.setTaskCompleted(success: true) // ✅ Called
+ case .failure:
+ // ❌ Missing setTaskCompleted!
+ print("Failed")
+ }
+ }
+}
+```
+
+**Fix**: Call `setTaskCompleted` in ALL code paths including errors.
+
+```swift
+case .failure:
+ task.setTaskCompleted(success: false) // ✅ Now called
+```
+
+**Impact**: Failing to call setTaskCompleted may cause system to penalize app's background budget.
+
+---
+
+### Example 3: Works in Dev, Not Production — Force Quit
+
+**Symptom**: Users report background sync doesn't work. Developer can't reproduce.
+
+**Diagnosis**:
+```
+User: "I close my apps every night to save battery."
+Developer: "How do you close them?"
+User: "Swipe up in the app switcher."
+```
+
+**Reality**: Swiping away from App Switcher = force quit = no background tasks until user opens app again.
+
+**Fix**:
+1. Educate users (not ideal)
+2. Accept this is iOS behavior
+3. Ensure good first-launch experience when app reopens
+
+---
+
+### Example 4: BGProcessingTask Never Runs — Missing Power Requirement
+
+**Symptom**: BGProcessingTask scheduled but never executes.
+
+**Diagnosis**: User has phone plugged in at night, but task has `requiresExternalPower = true` and user uses wireless charger.
+
+Wait, that's not the issue. Real issue:
+```swift
+let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
+// Missing: request.requiresExternalPower = true
+```
+
+Without `requiresExternalPower`, system STILL waits for charging but has less certainty. Setting it explicitly gives system clear signal.
+
+Also: User must have launched app in foreground within ~2 weeks for processing tasks to be eligible.
+
+---
+
+## Quick Reference
+
+### LLDB Debugging Commands
+
+```lldb
+// Trigger task
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"IDENTIFIER"]
+
+// Trigger expiration
+e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"IDENTIFIER"]
+```
+
+### Console Filter
+
+```
+subsystem:com.apple.backgroundtaskscheduler
+```
+
+### Task Type Summary
+
+| Need | Use | Runtime |
+|------|-----|---------|
+| Keep content fresh | BGAppRefreshTask | ~30s |
+| Heavy maintenance | BGProcessingTask + requiresExternalPower | Minutes |
+| User-initiated continuation | BGContinuedProcessingTask (iOS 26) | Extended |
+| Finish on background | beginBackgroundTask | ~30s |
+| Large downloads | Background URLSession | As needed |
+| Server-triggered | Silent push notification | ~30s |
+
+---
+
+## Resources
+
+**WWDC**: 2019-707, 2020-10063, 2022-10142, 2023-10170, 2025-227
+
+**Docs**: /backgroundtasks/bgtaskscheduler, /backgroundtasks/starting-and-terminating-tasks-during-development
+
+**Skills**: axiom-background-processing-ref, axiom-background-processing-diag, axiom-energy, axiom-push-notifications
+
+---
+
+**Last Updated**: 2025-12-31
+**Platforms**: iOS 13+, iOS 26+ (BGContinuedProcessingTask)
+**Status**: Production-ready background task patterns
diff --git a/.claude/skills/axiom-background-processing/agents/openai.yaml b/.claude/skills/axiom-background-processing/agents/openai.yaml
new file mode 100644
index 0000000..42aa2ac
--- /dev/null
+++ b/.claude/skills/axiom-background-processing/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Background Processing"
+ short_description: "Implementing BGTaskScheduler, debugging background tasks that never run, understanding why tasks terminate early, or ..."
diff --git a/.claude/skills/axiom-build-debugging/.openskills.json b/.claude/skills/axiom-build-debugging/.openskills.json
new file mode 100644
index 0000000..df58545
--- /dev/null
+++ b/.claude/skills/axiom-build-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-build-debugging",
+ "installedAt": "2026-04-12T08:05:57.598Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-build-debugging/SKILL.md b/.claude/skills/axiom-build-debugging/SKILL.md
new file mode 100644
index 0000000..763430e
--- /dev/null
+++ b/.claude/skills/axiom-build-debugging/SKILL.md
@@ -0,0 +1,514 @@
+---
+name: axiom-build-debugging
+description: Use when encountering dependency conflicts, CocoaPods/SPM resolution failures, "Multiple commands produce" errors, or framework version mismatches - systematic dependency and build configuration debugging for iOS projects. Includes pressure scenario guidance for resisting quick fixes under time constraints
+license: MIT
+metadata:
+ version: "1.1.0"
+ last-updated: "TDD-tested with production crisis scenarios"
+---
+
+# Build Debugging
+
+## Overview
+
+Check dependencies BEFORE blaming code. **Core principle** 80% of persistent build failures are dependency resolution issues (CocoaPods, SPM, framework conflicts), not code bugs.
+
+## Example Prompts
+
+These are real questions developers ask that this skill is designed to answer:
+
+#### 1. "I added a Swift Package but I'm getting 'No such module' errors. The package is in my Xcode project but won't compile."
+→ The skill covers SPM resolution workflows, package cache clearing, and framework search path diagnostics
+
+#### 2. "The build is failing with 'Multiple commands produce' the same output file. How do I figure out which files are duplicated?"
+→ The skill shows how to identify duplicate target membership and resolve file conflicts in build settings
+
+#### 3. "CocoaPods installed dependencies successfully but the build still fails. How do I debug CocoaPods issues?"
+→ The skill covers Podfile.lock conflict resolution, linking errors, and version constraint debugging
+
+#### 4. "My build works on my Mac but fails on the CI server. Both machines have the latest Xcode. What's different?"
+→ The skill explains dependency caching differences, environment-specific paths, and reproducible build strategies
+
+#### 5. "I'm getting framework version conflicts and I don't know which dependency is causing it. How do I resolve this?"
+→ The skill demonstrates dependency graph analysis and version constraint resolution strategies for complex dependency trees
+
+---
+
+## Red Flags — Dependency/Build Issues
+
+If you see ANY of these, suspect dependency problem:
+- "No such module" after adding package
+- "Multiple commands produce" same output file
+- Build succeeds on one machine, fails on another
+- CocoaPods install succeeds but build fails
+- SPM resolution takes forever or times out
+- Framework version conflicts in error logs
+
+## Quick Decision Tree
+
+```
+Build failing?
+├─ "No such module XYZ"?
+│ ├─ After adding SPM package?
+│ │ └─ Clean build folder + reset package caches
+│ ├─ After pod install?
+│ │ └─ Check Podfile.lock conflicts
+│ └─ Framework not found?
+│ └─ Check FRAMEWORK_SEARCH_PATHS
+├─ "Multiple commands produce"?
+│ └─ Duplicate files in target membership
+├─ SPM resolution hangs?
+│ └─ Clear package caches + derived data
+└─ Version conflicts?
+ └─ Use dependency resolution strategies below
+```
+
+## Common Build Issues
+
+### Issue 1: SPM Package Not Found
+
+**Symptom**: "No such module PackageName" after adding Swift Package
+
+**❌ WRONG**:
+```bash
+# Rebuilding without cleaning
+xcodebuild build
+```
+
+**✅ CORRECT**:
+```bash
+# Reset package caches first
+rm -rf ~/Library/Developer/Xcode/DerivedData
+rm -rf ~/Library/Caches/org.swift.swiftpm
+
+# Reset packages in project
+xcodebuild -resolvePackageDependencies
+
+# Clean build
+xcodebuild clean build -scheme YourScheme
+```
+
+### Issue 2: CocoaPods Conflicts
+
+**Symptom**: Pod install succeeds but build fails with framework errors
+
+**Check Podfile.lock**:
+```bash
+# See what versions were actually installed
+cat Podfile.lock | grep -A 2 "PODS:"
+
+# Compare with Podfile requirements
+cat Podfile | grep "pod "
+```
+
+**Fix version conflicts**:
+```ruby
+# Podfile - be explicit about versions
+pod 'Alamofire', '~> 5.8.0' # Not just 'Alamofire'
+pod 'SwiftyJSON', '5.0.1' # Exact version if needed
+```
+
+**Clean reinstall**:
+```bash
+# Remove all pods
+rm -rf Pods/
+rm Podfile.lock
+
+# Reinstall
+pod install
+
+# Open workspace (not project!)
+open YourApp.xcworkspace
+```
+
+### Issue 3: Multiple Commands Produce Error
+
+**Symptom**: "Multiple commands produce '/path/to/file'"
+
+**Cause**: Same file added to multiple targets or build phases
+
+**Fix**:
+1. Open Xcode
+2. Select file in navigator
+3. File Inspector → Target Membership
+4. Uncheck duplicate targets
+5. Or: Build Phases → Copy Bundle Resources → remove duplicates
+
+### Issue 4: Framework Search Paths
+
+**Symptom**: "Framework not found" or "Linker command failed"
+
+**Check build settings**:
+```bash
+# Show all build settings
+xcodebuild -showBuildSettings -scheme YourScheme | grep FRAMEWORK_SEARCH_PATHS
+```
+
+**Fix in Xcode**:
+1. Target → Build Settings
+2. Search "Framework Search Paths"
+3. Add path: `$(PROJECT_DIR)/Frameworks` (recursive)
+4. Or: `$(inherited)` to inherit from project
+
+### Issue 5: SPM Version Conflicts
+
+**Symptom**: Package resolution fails with version conflicts
+
+**See dependency graph**:
+```bash
+# In project directory
+swift package show-dependencies
+
+# Or see resolved versions
+cat Package.resolved
+```
+
+**Fix conflicts**:
+```swift
+// Package.swift - be explicit
+.package(url: "https://github.com/owner/repo", exact: "1.2.3") // Exact version
+.package(url: "https://github.com/owner/repo", from: "1.2.0") // Minimum version
+.package(url: "https://github.com/owner/repo", .upToNextMajor(from: "1.0.0")) // SemVer
+```
+
+**Reset resolution**:
+```bash
+# Clear package caches
+rm -rf .build
+rm Package.resolved
+
+# Re-resolve
+swift package resolve
+```
+
+## Dependency Resolution Strategies
+
+### Strategy 1: Lock to Specific Versions
+
+When stability matters more than latest features:
+
+**CocoaPods**:
+```ruby
+pod 'Alamofire', '5.8.0' # Exact version
+pod 'SwiftyJSON', '~> 5.0.0' # Any 5.0.x
+```
+
+**SPM**:
+```swift
+.package(url: "...", exact: "1.2.3")
+```
+
+### Strategy 2: Use Version Ranges
+
+When you want bug fixes but not breaking changes:
+
+**CocoaPods**:
+```ruby
+pod 'Alamofire', '~> 5.8' # 5.8.x but not 5.9
+pod 'SwiftyJSON', '>= 5.0', '< 6.0' # Range
+```
+
+**SPM**:
+```swift
+.package(url: "...", from: "1.2.0") // 1.2.0 and higher
+.package(url: "...", .upToNextMajor(from: "1.0.0")) // 1.x.x but not 2.0.0
+```
+
+### Strategy 3: Fork and Pin
+
+When you need custom modifications:
+
+```bash
+# Fork repo on GitHub
+# Clone your fork
+git clone https://github.com/yourname/package.git
+
+# In Package.swift, use your fork
+.package(url: "https://github.com/yourname/package", branch: "custom-fixes")
+```
+
+### Strategy 4: Exclude Transitive Dependencies
+
+When a dependency's dependency conflicts:
+
+**SPM (not directly supported, use workarounds)**:
+```swift
+// Instead of this:
+.package(url: "https://github.com/problematic/package")
+
+// Fork it and remove the conflicting dependency from its Package.swift
+```
+
+**CocoaPods**:
+```ruby
+# Exclude specific subspecs
+pod 'Firebase/Core' # Not all of Firebase
+pod 'Firebase/Analytics'
+```
+
+## Build Configuration Issues
+
+### Debug vs Release Differences
+
+**Symptom**: Builds in Debug, fails in Release (or vice versa)
+
+**Check optimization settings**:
+```bash
+# Compare Debug and Release settings
+xcodebuild -showBuildSettings -configuration Debug > debug.txt
+xcodebuild -showBuildSettings -configuration Release > release.txt
+diff debug.txt release.txt
+```
+
+**Common culprits**:
+- SWIFT_OPTIMIZATION_LEVEL (-Onone vs -O)
+- ENABLE_TESTABILITY (YES in Debug, NO in Release)
+- DEBUG preprocessor flag
+- Code signing settings
+
+### Workspace vs Project
+
+**Always open workspace with CocoaPods**:
+```bash
+# ❌ WRONG
+open YourApp.xcodeproj
+
+# ✅ CORRECT
+open YourApp.xcworkspace
+```
+
+**Check which you're building**:
+```bash
+# For workspace
+xcodebuild -workspace YourApp.xcworkspace -scheme YourScheme build
+
+# For project only (no CocoaPods)
+xcodebuild -project YourApp.xcodeproj -scheme YourScheme build
+```
+
+## Pressure Scenarios: When to Resist "Quick Fix" Advice
+
+### The Problem
+
+Under deadline pressure, senior engineers and teammates provide "quick fixes" based on pattern-matching:
+- "Just regenerate the lock file"
+- "Increment the build number"
+- "Delete DerivedData and rebuild"
+
+These feel safe because they come from experience. **But if the diagnosis is wrong, the fix wastes time you don't have.**
+
+**Critical insight** Time pressure makes authority bias STRONGER. You're more likely to trust advice when stressed.
+
+### Red Flags — STOP Before Acting
+
+If you hear ANY of these, pause 5 minutes before executing:
+
+- ❌ **"This smells like..."** (pattern-matching, not diagnosis)
+- ❌ **"Just..."** (underestimating complexity)
+- ❌ **"This usually fixes it"** (worked once ≠ works always)
+- ❌ **"You have plenty of time"** (overconfidence about 24-hour turnaround)
+- ❌ **"This is safe"** (regenerating lock files CAN break things)
+
+**Your brain under pressure** Trusts these phrases because they sound confident. Doesn't ask "but do they have evidence THIS is the root cause?"
+
+### Mandatory Diagnosis Before "Quick Fix"
+
+When someone senior suggests a fix under time pressure:
+
+#### Step 1: Ask (Don't argue)
+```
+"I understand the pressure. Before we regenerate lock files,
+can we spend 5 minutes comparing the broken build to our
+working build? I want to know what we're fixing."
+```
+
+#### Step 2: Demand Evidence
+- "What makes you think it's a lock file issue?"
+- "What changed between our last successful build and this failure?"
+- "Can we see the actual error from App Store build vs our build?"
+
+#### Step 3: Document the Gamble
+```
+If we try "pod install":
+- Time to execute: 10 minutes
+- Time to learn it failed: 24 hours (next submission cycle)
+- Remaining time if it fails: 6 days
+- Alternative: Spend 1-2 hours diagnosing first
+
+Cost of being wrong with quick fix: High
+Cost of spending 1 hour on diagnosis: Low
+```
+
+#### Step 4: Push Back Professionally
+```
+"I want to move fast too. A 1-hour diagnosis now means we
+won't waste another 24-hour cycle. Let's document what we're
+testing before we submit."
+```
+
+#### Why this works
+- You're not questioning their expertise
+- You're asking for evidence (legitimate request)
+- You're showing you understand the pressure
+- You're making the time math visible
+
+### Real-World Example: App Store Review Blocker
+
+**Scenario** App rejected in App Store build, passes locally.
+
+**Senior says** "Regenerate lock file and resubmit (7 days buffer)"
+
+#### What you do
+1. ❌ WRONG: Execute immediately, fail after 24 hours, now 6 days left
+2. ✅ RIGHT: Spend 1 hour comparing builds first
+
+#### Comparison checklist
+```
+Local build that works:
+- Pod versions in Podfile.lock: [list them]
+- Xcode version: [version]
+- Derived Data: [timestamp]
+- CocoaPods version: [version]
+
+App Store build that fails:
+- Pod versions used: [from error message]
+- Build system: [App Store's environment]
+- Differences: [explicitly document]
+```
+
+#### After comparison
+- If versions match: Lock file isn't the issue. Skip the quick fix.
+- If versions differ: Now you understand what to fix.
+
+**Time saved** 24 hours of wasted iteration.
+
+### When to Trust Quick Fixes (Rare)
+
+Quick fixes are safe ONLY when:
+
+- [ ] You've seen this EXACT error before (not "similar")
+- [ ] You know the root cause (not "this usually works")
+- [ ] You can reproduce it locally (so you know if fix worked)
+- [ ] You have >48 hours buffer (so failure costs less)
+- [ ] You documented the fix in case you need to explain it later
+
+#### In production crises, NONE of these are usually true.
+
+---
+
+## Testing Checklist
+
+### When Adding Dependencies
+- [ ] Specify exact versions or ranges (not just latest)
+- [ ] Check for known conflicts with existing deps
+- [ ] Test clean build after adding
+- [ ] Commit lockfile (Podfile.lock or Package.resolved)
+
+### When Builds Fail
+- [ ] Run mandatory environment checks (xcode-debugging skill)
+- [ ] Check dependency lockfiles for changes
+- [ ] Verify using correct workspace/project file
+- [ ] Compare working vs broken build settings
+
+### Before Shipping
+- [ ] Test both Debug and Release builds
+- [ ] Verify all dependencies have compatible licenses
+- [ ] Check binary size impact of dependencies
+- [ ] Test on clean machine or CI
+
+## Common Mistakes
+
+### ❌ Not Committing Lockfiles
+```bash
+# ❌ BAD: .gitignore includes lockfiles
+Podfile.lock
+Package.resolved
+```
+
+**Why**: Team members get different versions, builds differ
+
+### ❌ Using "Latest" Version
+```ruby
+# ❌ BAD: No version specified
+pod 'Alamofire'
+```
+
+**Why**: Breaking changes when dependency updates
+
+### ❌ Mixing Package Managers
+```
+Project uses both:
+- CocoaPods (Podfile)
+- Carthage (Cartfile)
+- SPM (Package.swift)
+```
+
+**Why**: Conflicts are inevitable, pick one primary manager
+
+### ❌ Not Cleaning After Dependency Changes
+```bash
+# ❌ BAD: Just rebuild
+xcodebuild build
+
+# ✅ GOOD: Clean first
+xcodebuild clean build
+```
+
+### ❌ Opening Project Instead of Workspace
+When using CocoaPods, always open .xcworkspace not .xcodeproj
+
+## Command Reference
+
+```bash
+# CocoaPods
+pod install # Install dependencies
+pod update # Update to latest versions
+pod update PodName # Update specific pod
+pod outdated # Check for updates
+pod deintegrate # Remove CocoaPods from project
+
+# Swift Package Manager
+swift package resolve # Resolve dependencies
+swift package update # Update dependencies
+swift package show-dependencies # Show dependency tree
+swift package reset # Reset package cache
+xcodebuild -resolvePackageDependencies # Xcode's SPM resolve
+
+# Carthage
+carthage update # Update dependencies
+carthage bootstrap # Download pre-built frameworks
+carthage build --platform iOS # Build for specific platform
+
+# Xcode Build
+xcodebuild clean # Clean build folder
+xcodebuild -list # List schemes and targets
+xcodebuild -showBuildSettings # Show all build settings
+```
+
+## Real-World Impact
+
+**Before** (trial-and-error with dependencies):
+- Dependency issue: 2-4 hours debugging
+- Clean builds not run consistently
+- Version conflicts surprise team
+- CI failures from dependency mismatches
+
+**After** (systematic dependency management):
+- Dependency issue: 15-30 minutes (check lockfile → resolve)
+- Clean builds mandatory after dep changes
+- Explicit version constraints prevent surprises
+- CI matches local builds (committed lockfiles)
+
+**Key insight** Lock down dependency versions early. Flexibility causes more problems than it solves.
+
+## Resources
+
+**Docs**: swift.org/package-manager, /xcode/build-system
+
+**GitHub**: Carthage/Carthage
+
+**Skills**: axiom-xcode-debugging
+
+---
+
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-build-debugging/agents/openai.yaml b/.claude/skills/axiom-build-debugging/agents/openai.yaml
new file mode 100644
index 0000000..1f77047
--- /dev/null
+++ b/.claude/skills/axiom-build-debugging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Build Debugging"
+ short_description: "Encountering dependency conflicts, CocoaPods/SPM resolution failures, \"Multiple commands produce\" errors, or framewor..."
diff --git a/.claude/skills/axiom-build-performance/.openskills.json b/.claude/skills/axiom-build-performance/.openskills.json
new file mode 100644
index 0000000..afa617c
--- /dev/null
+++ b/.claude/skills/axiom-build-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-build-performance",
+ "installedAt": "2026-04-12T08:05:58.249Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-build-performance/SKILL.md b/.claude/skills/axiom-build-performance/SKILL.md
new file mode 100644
index 0000000..17fc914
--- /dev/null
+++ b/.claude/skills/axiom-build-performance/SKILL.md
@@ -0,0 +1,769 @@
+---
+name: axiom-build-performance
+description: Use when build times are slow, investigating build performance, analyzing Build Timeline, identifying type checking bottlenecks, enabling compilation caching, or optimizing incremental builds - comprehensive build optimization workflows including Xcode 26 compilation caching
+license: MIT
+compatibility: iOS 14+, macOS 11+, iPadOS 14+, tvOS 14+, watchOS 7+, axiom-visionOS 1.0+. Xcode 14+ (Xcode 26+ for compilation caching and explicit modules)
+metadata:
+ version: "2.0"
+ last-updated: "2026-01-01"
+ wwdc-sessions: "[2018-408, 2022-110364, 2024-10171, 2025-247]"
+---
+
+# Build Performance Optimization
+
+## Overview
+
+Systematic Xcode build performance analysis and optimization. **Core principle**: Measure before optimizing, then optimize the critical path first.
+
+## When to Use This Skill
+
+- Build times have increased significantly
+- Incremental builds taking too long
+- Want to analyze Build Timeline
+- Need to identify slow-compiling Swift code
+- Optimizing CI/CD build times
+- Build performance regression investigation
+- Enabling Xcode 26 compilation caching
+- Reducing module variants in explicitly built modules
+- Understanding the three-phase build process (scan → modules → compile)
+
+## Quick Win: Run the Agent First
+
+For automated scanning and quick wins:
+```bash
+/axiom:optimize-build
+```
+
+The build-optimizer agent scans for common issues and provides immediate fixes. Use this skill for deep analysis.
+
+## The Build Performance Workflow
+
+### Step 1: Measure Baseline (Required)
+
+**Why**: You can't improve what you don't measure. Baseline prevents placebo optimizations.
+
+```bash
+# Clean build (eliminates all caching)
+xcodebuild clean build -scheme YourScheme
+
+# Measure time
+time xcodebuild build -scheme YourScheme
+
+# Or use Xcode UI
+Product → Perform Action → Build with Timing Summary
+```
+
+**Record**:
+- Total build time
+- Incremental build time (change one file, rebuild)
+- Which phase takes longest (compilation vs linking vs scripts)
+
+**Example baseline**:
+```
+Clean build: 247 seconds
+Incremental (1 file change): 12 seconds
+Longest phase: Compile Swift sources (189s)
+```
+
+### Step 2: Analyze Build Timeline (Xcode 14+)
+
+**Access**:
+1. Build your project (Cmd+B)
+2. Open Report Navigator (Cmd+9)
+3. Select latest build
+4. Show Assistant Editor (Cmd+Option+Return)
+5. Build Timeline appears alongside build log
+
+**What to look for**:
+
+#### Critical Path (The Build's Speed Limit)
+The **critical path** is the shortest possible build time with unlimited CPU cores. It's defined by the longest chain of dependent tasks.
+
+```
+┌─────────────────────────────────────────┐
+│ Critical Path: A → B → C → D (120s) │
+│ │
+│ Task A: 30s ─────────┐ │
+│ Task B: 40s ├─→ D: 20s │
+│ Task C: 30s ─────────┘ │
+│ │
+│ Even with 100 CPUs, build takes 120s │
+└─────────────────────────────────────────┘
+```
+
+**Goal**: Shorten the critical path by breaking dependencies.
+
+#### Timeline Red Flags
+
+**Empty vertical space**: Tasks waiting for inputs
+```
+Timeline:
+████████░░░░░░░░████████ ← Bad: idle cores waiting
+████████████████████████ ← Good: continuous work
+```
+
+**Long horizontal bars**: Slow individual tasks
+```
+Task A: ████████████████████ (45 seconds) ← Investigate
+Task B: ███ (3 seconds) ← Fine
+```
+
+**Serial target builds**: Targets waiting unnecessarily
+```
+Framework: ████████░░░░░░░░░░ ← Waiting
+App: ░░░░░░░░░░████████ ← Delayed
+
+Better (parallel):
+Framework: ████████
+App: ░░░░████████████
+```
+
+### Step 3: Identify Bottlenecks (Decision Tree)
+
+**Is compilation the slowest phase?**
+├─ YES → Check type checking performance (Step 4)
+└─ NO → Is linking slow?
+ ├─ YES → Check link dependencies (Step 5)
+ └─ NO → Are scripts slow?
+ ├─ YES → Optimize build phase scripts (Step 6)
+ └─ NO → Check parallelization (Step 7)
+
+## Optimization Patterns
+
+### Pattern 1: Type Checking Performance (MEDIUM-HIGH IMPACT)
+
+**Symptom**: "Compile Swift sources" takes >50% of build time.
+
+**Diagnosis**:
+
+Enable compiler warnings to find slow functions:
+
+```swift
+// Add to Debug build settings → Other Swift Flags
+-warn-long-function-bodies 100
+-warn-long-expression-type-checking 100
+```
+
+Build → Xcode shows warnings:
+```
+MyView.swift:42: Function body took 247ms to type-check (limit: 100ms)
+LoginViewModel.swift:18: Expression took 156ms to type-check (limit: 100ms)
+```
+
+**Fix slow type checking**:
+
+```swift
+// ❌ SLOW - Complex type inference (247ms)
+func calculateTotal(items: [Item]) -> Double {
+ return items
+ .filter { $0.isActive }
+ .map { $0.price * $0.quantity }
+ .reduce(0, +)
+}
+
+// ✅ FAST - Explicit types (12ms)
+func calculateTotal(items: [Item]) -> Double {
+ let activeItems: [Item] = items.filter { $0.isActive }
+ let prices: [Double] = activeItems.map { $0.price * $0.quantity }
+ let total: Double = prices.reduce(0, +)
+ return total
+}
+```
+
+**Common slow patterns**:
+- Complex chained operations without intermediate types
+- Deeply nested closures
+- Large literals (dictionaries, arrays)
+- Operator overloading in complex expressions
+
+**Expected impact**: 10-30% faster compilation for affected files.
+
+---
+
+### Pattern 2: Build Phase Script Optimization (HIGH IMPACT)
+
+**Symptom**: Build Timeline shows long script phases in Debug builds.
+
+**Common culprits**:
+- dSYM/Crashlytics uploads running in Debug
+- Asset processing on every build
+- Code generation scripts without caching
+
+**Fix**: Make scripts conditional
+
+```bash
+# ❌ BAD - Runs in ALL configurations (adds 6+ seconds to debug builds)
+#!/bin/bash
+firebase crashlytics upload-symbols
+
+# ✅ GOOD - Skip in Debug
+#!/bin/bash
+if [ "${CONFIGURATION}" = "Release" ]; then
+ firebase crashlytics upload-symbols
+fi
+
+# Example savings: 6.3 seconds per incremental debug build
+```
+
+**Script Phase Sandboxing** (Xcode 14+)
+
+Enable to prevent data races and improve parallelization:
+
+```
+Build Settings → User Script Sandboxing → YES
+```
+
+**Why**: Forces you to declare inputs/outputs explicitly, enabling parallel execution.
+
+```bash
+# Script phase with proper inputs/outputs
+Input Files:
+ $(SRCROOT)/input.txt
+ $(DERIVED_FILE_DIR)/checksum.txt
+
+Output Files:
+ $(DERIVED_FILE_DIR)/output.html
+
+# Now Xcode knows dependencies and can parallelize safely
+```
+
+**Parallel Script Execution**:
+
+```
+Build Settings → FUSE_BUILD_SCRIPT_PHASES → YES
+```
+
+**⚠️ WARNING**: Only enable if ALL scripts have correct inputs/outputs declared. Otherwise you'll get data races.
+
+**Expected impact**: 5-10 seconds saved per incremental debug build.
+
+---
+
+### Pattern 3: Compilation Mode Settings (CRITICAL)
+
+**Symptom**: Incremental builds recompile entire modules.
+
+**Check current settings**:
+
+```bash
+# In project.pbxproj
+grep "SWIFT_COMPILATION_MODE" project.pbxproj
+```
+
+**Optimal configuration**:
+
+| Configuration | Setting | Why |
+|---|---|---|
+| **Debug** | `singlefile` (Incremental) | Only recompiles changed files |
+| **Release** | `wholemodule` | Maximum optimization |
+
+```swift
+// ❌ BAD - Whole module in Debug
+SWIFT_COMPILATION_MODE = wholemodule; // ALL configs
+
+// ✅ GOOD - Incremental for Debug
+Debug: SWIFT_COMPILATION_MODE = singlefile;
+Release: SWIFT_COMPILATION_MODE = wholemodule;
+```
+
+**How to fix**:
+1. Project → Build Settings
+2. Filter: "Compilation Mode"
+3. Set Debug to "Incremental"
+4. Set Release to "Whole Module"
+
+**Expected impact**: 40-60% faster incremental debug builds.
+
+---
+
+### Pattern 4: Build Active Architecture Only (HIGH IMPACT)
+
+**Symptom**: Debug builds compile for multiple architectures (x86_64 + arm64).
+
+**Check**:
+```bash
+grep "ONLY_ACTIVE_ARCH" project.pbxproj
+```
+
+**Fix**:
+
+| Configuration | Setting | Why |
+|---|---|---|
+| **Debug** | `YES` | Only build for current device (arm64 OR x86_64) |
+| **Release** | `NO` | Build universal binary |
+
+**How to fix**:
+1. Build Settings → "Build Active Architecture Only"
+2. Set Debug to YES
+3. Keep Release as NO
+
+**Expected impact**: 40-50% faster debug builds (half the architectures).
+
+---
+
+### Pattern 5: Debug Information Format (MEDIUM IMPACT)
+
+**Symptom**: Debug builds generating dSYMs unnecessarily.
+
+**Optimal configuration**:
+
+| Configuration | Setting | Why |
+|---|---|---|
+| **Debug** | `dwarf` | Embedded debug info, faster |
+| **Release** | `dwarf-with-dsym` | Separate dSYM for crash reporting |
+
+```bash
+# Check current
+grep "DEBUG_INFORMATION_FORMAT" project.pbxproj
+```
+
+**How to fix**:
+1. Build Settings → "Debug Information Format"
+2. Set Debug to "DWARF"
+3. Set Release to "DWARF with dSYM File"
+
+**Expected impact**: 3-5 seconds saved per debug build.
+
+---
+
+### Pattern 6: Target Parallelization (WWDC 2018-408)
+
+**Symptom**: Build Timeline shows targets building sequentially when they could be parallel.
+
+**Check scheme configuration**:
+1. Product → Scheme → Edit Scheme
+2. Build tab
+3. Check "Parallelize Build" checkbox
+4. Verify target order allows parallelization
+
+**Dependency graph example**:
+
+```
+App ──┬──→ Framework A
+ └──→ Framework B
+
+Framework A ──→ Utilities
+Framework B ──→ Utilities
+```
+
+**Timeline (bad - serial)**:
+```
+Utilities: ████████░░░░░░░░░░░░░░
+Framework A: ░░░░░░░░████████░░░░░░
+Framework B: ░░░░░░░░░░░░░░░░████████
+App: ░░░░░░░░░░░░░░░░░░░░░░████
+```
+
+**Timeline (good - parallel)**:
+```
+Utilities: ████████
+Framework A: ░░░░░░░░████████
+Framework B: ░░░░░░░░████████
+App: ░░░░░░░░░░░░░░░░████
+```
+
+**Expected impact**: Proportional to number of independent targets (e.g., 2 parallel targets = ~2x faster).
+
+---
+
+### Pattern 7: Emit Module Optimization (Xcode 14+, Swift 5.7+)
+
+**What it is**: Swift modules are produced separately from compilation, unblocking downstream targets faster.
+
+**Before (Xcode 13)**:
+```
+Framework: Compile ████████████ → Emit Module █
+App: ░░░░░░░░░░░░░░░░░░░░░░░░░█████████
+ ↑
+ Waiting for Framework compilation to finish
+```
+
+**After (Xcode 14+)**:
+```
+Framework: Compile ████████████
+ Emit Module ███
+App: ░░░░░░███████████
+ ↑
+ Starts as soon as module emitted
+```
+
+**Automatic**: No configuration needed, works in Xcode 14+ with Swift 5.7+.
+
+**Expected impact**: Reduces idle time in multi-target builds by 20-40%.
+
+---
+
+### Pattern 8: Eager Linking (Xcode 14+)
+
+**What it is**: Linking can start before all compilation finishes if the module is ready.
+
+**Impact**: Further reduces critical path in dependency chains.
+
+**Automatic**: Works in Xcode 14+ automatically.
+
+---
+
+### Pattern 9: Compilation Caching (Xcode 26+, CRITICAL)
+
+**What it is**: Xcode 26 introduces compilation caching that reuses previously compiled artifacts across clean builds.
+
+**Build Settings**:
+
+```
+Build Settings → COMPILATION_CACHE_ENABLE_CACHING → YES
+```
+
+**How it works**:
+- Caches compilation results based on input file content and compiler flags
+- Works across clean builds — even after `xcodebuild clean`, cached artifacts can be reused
+- Significantly reduces CI/CD build times where clean builds are common
+
+**When to enable**:
+- CI/CD pipelines with frequent clean builds
+- Teams sharing build artifacts
+- Projects with stable dependencies
+
+**Verification**:
+```bash
+# Build with caching enabled
+xcodebuild build -scheme YourScheme \
+ COMPILATION_CACHE_ENABLE_CACHING=YES
+
+# Check build log for cache information
+```
+
+**Current limitations** (Xcode 26):
+- Swift Package Manager dependencies not yet cacheable
+- CompileStoryboard, CompileXIB, DataModelCompile, Ld tasks not cacheable
+- Cache requires time to populate on first run
+
+**Expected impact**: 20-40% faster clean builds after initial cache population (up to 70%+ for favorable projects).
+
+---
+
+### Pattern 10: Explicitly Built Modules (Xcode 16+, HIGH IMPACT)
+
+**What it is**: Xcode splits module compilation into explicit build tasks instead of implicit on-demand compilation. **Enabled by default for Swift in Xcode 26.**
+
+**The Problem with Implicit Modules (Pre-Xcode 16)**:
+
+When a compiler encounters an import, it builds the module on-demand:
+```
+Compile A.swift ─── needs UIKit ───→ (builds UIKit.pcm) ───→ continues
+Compile B.swift ─── needs UIKit ───→ (waits for A to finish) ───→ uses cached
+Compile C.swift ─── needs UIKit ───→ (waits) ───→ uses cached
+```
+
+Problems:
+- One task blocks others waiting for the same module
+- Non-deterministic: whoever gets there first builds it
+- Build failures hard to reproduce (depends on task order)
+
+**Explicitly Built Modules Solution**:
+
+Xcode now separates compilation into three phases:
+
+```
+Phase 1: SCAN Phase 2: BUILD MODULES Phase 3: COMPILE
+┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
+│ Scan A.swift │ │ Build UIKit.pcm │ │ Compile A.swift │
+│ Scan B.swift │ → │ Build Foundation.pcm │ → │ Compile B.swift │
+│ Scan C.swift │ │ Build SwiftUI.pcm │ │ Compile C.swift │
+└──────────────────┘ └──────────────────────┘ └──────────────────┘
+ (fast) (parallel) (parallel)
+```
+
+**Benefits**:
+- **More reliable builds**: Precise dependencies, deterministic build graphs
+- **More efficient scheduling**: Build system knows exactly what's needed
+- **Better debugging**: Debugger reuses built modules (no separate rebuild)
+- **Visible module tasks**: See "Compile Clang Module" and "Compile Swift Module" in build log
+
+**Enable/Disable** (if needed):
+```
+Build Settings → Explicitly Built Modules → YES (default in Xcode 26 for Swift)
+```
+
+**Module Variants** (WWDC 2024-10171)
+
+The same module may be built multiple times with different settings:
+
+```
+Build Log:
+ Compile Clang module 'UIKit' (hash: abc123) ← Variant 1
+ Compile Clang module 'UIKit' (hash: def456) ← Variant 2
+ Compile Swift module 'UIKit' (hash: ghi789) ← Variant 3
+```
+
+**Common causes of variants**:
+- Different preprocessor macros between targets
+- Mixed C and Objective-C language modes
+- Different C language versions (C11 vs C17)
+- Disabling ARC on some targets
+
+**Diagnose variants**:
+1. Build with Timing Summary: `Product → Perform Action → Build with Timing Summary`
+2. Filter build log: Type "modules report" in filter box
+3. View Clang and Swift module reports showing variant counts
+
+**Reduce variants** (unify settings at project/workspace level):
+```bash
+# Check for macro differences
+grep "GCC_PREPROCESSOR_DEFINITIONS" project.pbxproj
+
+# Move target-specific macros to project level where possible
+Project → Build Settings → Preprocessor Macros → [unify here]
+```
+
+**Example** (from WWDC 2024-10171):
+```
+Before: 4 UIKit variants (2 Swift × 2 Clang)
+After: 2 UIKit variants (unified settings)
+Impact: Fewer module builds = faster incremental builds
+```
+
+**Expected impact**: 10-30% faster builds by reducing duplicate module compilation.
+
+**Note: Swift Build** (Xcode 26+): Xcode now uses Swift Build, Apple's open-source build engine. This provides more predictable builds, better SPM integration, and cross-platform support (Linux, Windows, Android). No configuration needed.
+
+---
+
+## Measurement & Verification
+
+### Before and After Comparison
+
+**Required steps**:
+
+1. **Baseline** (before changes):
+ ```bash
+ xcodebuild clean build -scheme YourScheme 2>&1 | tee baseline.log
+ ```
+
+2. **Apply ONE optimization at a time**
+
+3. **Measure improvement**:
+ ```bash
+ xcodebuild clean build -scheme YourScheme 2>&1 | tee optimized.log
+ ```
+
+4. **Compare**:
+ ```bash
+ # Extract build time from logs
+ grep "Build succeeded" baseline.log
+ grep "Build succeeded" optimized.log
+ ```
+
+**Example**:
+```
+Baseline: Build succeeded (247.3 seconds)
+Optimized: Build succeeded (156.8 seconds)
+Improvement: 90.5 seconds (36.6% faster)
+```
+
+### Build Timeline Visual Verification
+
+**Before optimization**:
+- Look for empty vertical space (idle cores)
+- Long horizontal bars (slow tasks)
+- Serial target builds
+
+**After optimization**:
+- Timeline should be more "filled"
+- Shorter horizontal bars
+- Parallel target builds
+
+**Critical path**: Should be visibly shorter.
+
+---
+
+## Real-World Optimization Examples
+
+### Example 1: Large iOS App (50+ source files)
+
+**Baseline**:
+- Clean build: 247 seconds
+- Incremental (1 file): 12 seconds
+
+**Optimizations applied**:
+1. Debug compilation mode: singlefile (saved 89s)
+2. Build Active Architecture: YES (saved 45s)
+3. Conditional dSYM upload script (saved 6.3s per incremental)
+
+**Result**:
+- Clean build: 156 seconds (36% faster)
+- Incremental: 5.7 seconds (52% faster)
+
+---
+
+### Example 2: Multi-Framework Project
+
+**Baseline**:
+- 5 frameworks built serially
+- Total: 189 seconds
+
+**Optimizations applied**:
+1. Enabled parallel builds in scheme
+2. Fixed unnecessary dependencies
+3. Emit module optimization (automatic in Xcode 14)
+
+**Result**:
+- Total: 94 seconds (50% faster)
+- Critical path reduced from 189s to 94s
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Optimizing Without Measuring
+
+**Mistake**: "I think this will help" → make change → no measurement.
+
+**Why bad**: Placebo improvements, wasted time, actual regressions unnoticed.
+
+**Fix**: Always measure before → change one thing → measure after.
+
+---
+
+### Pitfall 2: Optimizing Release Builds for Speed
+
+**Mistake**: Set Release to incremental compilation for "faster builds".
+
+**Why bad**: Release builds should optimize for runtime performance, not build speed. You ship Release builds to users.
+
+**Fix**: Only optimize Debug builds for speed. Keep Release optimized for runtime.
+
+---
+
+### Pitfall 3: Breaking Dependencies for Parallelization
+
+**Mistake**: Remove legitimate dependencies to "make builds parallel".
+
+**Why bad**: Build errors, undefined behavior, race conditions.
+
+**Fix**: Only parallelize truly independent targets. Use Build Timeline to identify safe opportunities.
+
+---
+
+### Pitfall 4: Enabling FUSE_BUILD_SCRIPT_PHASES Without Sandboxing
+
+**Mistake**: Enable parallel scripts but don't declare inputs/outputs.
+
+**Why bad**: Data races, non-deterministic build failures, incorrect builds.
+
+**Fix**: First enable `ENABLE_USER_SCRIPT_SANDBOXING = YES`, fix all errors, THEN enable `FUSE_BUILD_SCRIPT_PHASES`.
+
+---
+
+## Troubleshooting
+
+### Problem: Builds Still Slow After Optimizations
+
+**Check**:
+1. Did you clean before measuring? (`xcodebuild clean`)
+2. Are you measuring the right build? (Debug vs Release)
+3. Is your machine thermal throttling? (Activity Monitor → CPU tab)
+4. Are other apps using CPU? (Quit Xcode, Docker, VMs during measurement)
+
+---
+
+### Problem: Build Timeline Shows No Parallelization
+
+**Check**:
+1. Scheme → Parallelize Build checked?
+2. Are targets actually independent? (Check dependency graph)
+3. Do targets have unnecessary explicit dependencies?
+
+---
+
+### Problem: Type Checking Warnings Don't Appear
+
+**Check**:
+1. Added flags to correct configuration? (Debug, not Release)
+2. Syntax correct? `-warn-long-function-bodies 100` (with hyphen)
+3. Building the right scheme?
+4. Clean build to force recompilation
+
+---
+
+## Advanced: Analyzing Build Logs
+
+### Extract Compilation Times
+
+```bash
+# Find slowest files to compile
+xcodebuild -workspace YourApp.xcworkspace \
+ -scheme YourScheme \
+ clean build \
+ OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" 2>&1 | \
+ grep ".[0-9]ms" | \
+ sort -nr | \
+ head -20
+```
+
+**Output**:
+```
+247.3ms MyViewModel.swift:42:1 func calculateTotal
+156.8ms LoginView.swift:18:3 var body
+89.2ms NetworkManager.swift:67:1 func handleResponse
+...
+```
+
+**Action**: Add explicit types to slowest functions.
+
+---
+
+### Extract Build Phase Times
+
+```bash
+# From build log
+Build target 'MyApp' (project 'MyApp')
+ Compile Swift source files (128.4 seconds)
+ Link MyApp (12.3 seconds)
+ Run custom shell script (6.7 seconds)
+```
+
+**Action**: Optimize the longest phase first.
+
+---
+
+## Checklist: Build Performance Audit
+
+Before considering your build optimized:
+
+**Measurement**
+- [ ] Measured baseline (clean + incremental)
+- [ ] Verified improvement in Build Timeline
+- [ ] Documented baseline → optimized comparison
+
+**Compilation Settings**
+- [ ] Debug uses incremental compilation
+- [ ] Build Active Architecture = YES (Debug only)
+- [ ] Debug uses DWARF (not dSYM)
+- [ ] Type checking warnings enabled
+- [ ] Fixed slow type-checking functions (>100ms)
+
+**Parallelization**
+- [ ] Parallelize Build enabled in scheme
+- [ ] No unnecessary target dependencies
+- [ ] Build phase scripts are conditional (skip in Debug when possible)
+- [ ] Enabled script sandboxing if using parallel scripts
+
+**Xcode 26+ (if applicable)**
+- [ ] Compilation caching enabled for CI/CD (`COMPILATION_CACHE_ENABLE_CACHING`)
+- [ ] Checked module variants (Modules Report in build log, see Pattern 10)
+- [ ] Unified build settings at project level to reduce module variants
+- [ ] Explicitly Built Modules enabled (default for Swift in Xcode 26)
+
+---
+
+## Resources
+
+**WWDC**: 2018-408, 2022-110364, 2024-10171, 2025-247
+
+**Docs**: /xcode/improving-the-speed-of-incremental-builds, /xcode/building-your-project-with-explicit-module-dependencies
+
+**Tools**: Xcode Build Timeline (Xcode 14+), Build with Timing Summary (Product → Perform Action), Modules Report (Xcode 16+), Instruments Time Profiler
+
+---
+
+**Remember**: Build performance optimization is about systematic measurement and targeted improvements. Optimize the critical path first, measure everything, and verify improvements in the Build Timeline.
diff --git a/.claude/skills/axiom-build-performance/agents/openai.yaml b/.claude/skills/axiom-build-performance/agents/openai.yaml
new file mode 100644
index 0000000..6a16aa4
--- /dev/null
+++ b/.claude/skills/axiom-build-performance/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Build Performance"
+ short_description: "Build times are slow, investigating build performance, analyzing Build Timeline, identifying type checking bottleneck..."
diff --git a/.claude/skills/axiom-camera-capture-diag/.openskills.json b/.claude/skills/axiom-camera-capture-diag/.openskills.json
new file mode 100644
index 0000000..b60f459
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-camera-capture-diag",
+ "installedAt": "2026-04-12T08:05:59.537Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-camera-capture-diag/SKILL.md b/.claude/skills/axiom-camera-capture-diag/SKILL.md
new file mode 100644
index 0000000..3587c52
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-diag/SKILL.md
@@ -0,0 +1,587 @@
+---
+name: axiom-camera-capture-diag
+description: camera freezes, preview rotated wrong, capture slow, session interrupted, black preview, front camera mirrored, camera not starting, AVCaptureSession errors, startRunning blocks, phone call interrupts camera
+license: MIT
+compatibility: iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-01-03"
+---
+
+# Camera Capture Diagnostics
+
+Systematic troubleshooting for AVFoundation camera issues: frozen preview, wrong rotation, slow capture, session interruptions, and permission problems.
+
+## Overview
+
+**Core Principle**: When camera doesn't work, the problem is usually:
+1. **Threading** (session work on main thread) - 35%
+2. **Session lifecycle** (not started, interrupted, not configured) - 25%
+3. **Rotation** (deprecated APIs, missing coordinator) - 20%
+4. **Permissions** (denied, not requested) - 15%
+5. **Configuration** (wrong preset, missing input/output) - 5%
+
+**Always check threading and session state BEFORE debugging capture logic.**
+
+## Red Flags
+
+Symptoms that indicate camera-specific issues:
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| Preview shows black screen | Session not started, permission denied, no camera input |
+| UI freezes when opening camera | `startRunning()` called on main thread |
+| Camera freezes on phone call | No interruption handling |
+| Preview rotated 90° wrong | Not using RotationCoordinator (iOS 17+) |
+| Captured photo rotated wrong | Rotation angle not applied to output connection |
+| Front camera photo not mirrored | This is correct! (preview mirrors, photo does not) |
+| "Camera in use by another app" | Another app has exclusive access |
+| Capture takes 2+ seconds | `photoQualityPrioritization` set to `.quality` |
+| Session won't start on iPad | Split View - camera unavailable |
+| Crash on older iOS | Using iOS 17+ APIs without availability check |
+
+## Mandatory First Steps
+
+Before investigating code, run these diagnostics:
+
+### Step 1: Check Session State
+
+```swift
+print("📷 Session state:")
+print(" isRunning: \(session.isRunning)")
+print(" inputs: \(session.inputs.count)")
+print(" outputs: \(session.outputs.count)")
+
+for input in session.inputs {
+ if let deviceInput = input as? AVCaptureDeviceInput {
+ print(" Input: \(deviceInput.device.localizedName)")
+ }
+}
+
+for output in session.outputs {
+ print(" Output: \(type(of: output))")
+}
+```
+
+**Expected output**:
+- ✅ isRunning: true, inputs ≥ 1, outputs ≥ 1 → Session working
+- ⚠️ isRunning: false → Session not started or interrupted
+- ❌ inputs: 0 → Camera not added (permission? configuration?)
+
+### Step 2: Check Threading
+
+```swift
+print("🧵 Thread check:")
+
+// When setting up session
+sessionQueue.async {
+ print(" Setup thread: \(Thread.isMainThread ? "❌ MAIN" : "✅ Background")")
+}
+
+// When starting session
+sessionQueue.async {
+ print(" Start thread: \(Thread.isMainThread ? "❌ MAIN" : "✅ Background")")
+}
+```
+
+**Expected output**:
+- ✅ All background → Correct
+- ❌ Any main thread → UI will freeze
+
+### Step 3: Check Permissions
+
+```swift
+let status = AVCaptureDevice.authorizationStatus(for: .video)
+print("🔐 Camera permission: \(status.rawValue)")
+
+switch status {
+case .authorized: print(" ✅ Authorized")
+case .notDetermined: print(" ⚠️ Not yet requested")
+case .denied: print(" ❌ Denied by user")
+case .restricted: print(" ❌ Restricted (parental controls?)")
+@unknown default: print(" ❓ Unknown")
+}
+```
+
+### Step 4: Check for Interruptions
+
+```swift
+// Add temporary observer to see interruptions
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionWasInterrupted,
+ object: session,
+ queue: .main
+) { notification in
+ if let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int {
+ print("🚨 Interrupted: reason \(reason)")
+ }
+}
+```
+
+## Decision Tree
+
+```
+Camera not working as expected?
+│
+├─ Black/frozen preview?
+│ ├─ Check Step 1 (session state)
+│ │ ├─ isRunning = false → See Pattern 1 (session not started)
+│ │ ├─ inputs = 0 → See Pattern 2 (no camera input)
+│ │ └─ isRunning = true, inputs > 0 → See Pattern 3 (preview layer)
+│
+├─ UI freezes when opening camera?
+│ └─ Check Step 2 (threading)
+│ └─ Main thread → See Pattern 4 (move to session queue)
+│
+├─ Camera freezes during use?
+│ ├─ After phone call → See Pattern 5 (interruption handling)
+│ ├─ In Split View (iPad) → See Pattern 6 (multitasking)
+│ └─ Random freezes → See Pattern 7 (thermal pressure)
+│
+├─ Preview/photo rotated wrong?
+│ ├─ Preview rotated → See Pattern 8 (RotationCoordinator preview)
+│ ├─ Captured photo rotated → See Pattern 9 (capture rotation)
+│ └─ Front camera "wrong" → See Pattern 10 (mirroring expected)
+│
+├─ Capture too slow?
+│ ├─ 2+ seconds delay → See Pattern 11 (quality prioritization)
+│ └─ Slight delay → See Pattern 12 (deferred processing)
+│
+├─ Permission issues?
+│ ├─ Status: notDetermined → See Pattern 13 (request permission)
+│ └─ Status: denied → See Pattern 14 (settings prompt)
+│
+└─ Crash on some devices?
+ └─ See Pattern 15 (API availability)
+```
+
+## Diagnostic Patterns
+
+### Pattern 1: Session Not Started
+
+**Symptom**: Black preview, `isRunning = false`
+
+**Common causes**:
+1. `startRunning()` never called
+2. `startRunning()` called but session has no inputs
+3. Session stopped and never restarted
+
+**Diagnostic**:
+```swift
+// Check if startRunning was called
+print("isRunning before start: \(session.isRunning)")
+session.startRunning()
+print("isRunning after start: \(session.isRunning)")
+```
+
+**Fix**:
+```swift
+// Ensure session is started on session queue
+func startSession() {
+ sessionQueue.async { [self] in
+ guard !session.isRunning else { return }
+
+ // Verify we have inputs before starting
+ guard !session.inputs.isEmpty else {
+ print("❌ Cannot start - no inputs configured")
+ return
+ }
+
+ session.startRunning()
+ }
+}
+```
+
+**Time to fix**: 10 min
+
+### Pattern 2: No Camera Input
+
+**Symptom**: `session.inputs.count = 0`
+
+**Common causes**:
+1. Camera permission denied
+2. `AVCaptureDeviceInput` creation failed
+3. `canAddInput()` returned false
+4. Configuration not committed
+
+**Diagnostic**:
+```swift
+// Step through input setup
+guard let camera = AVCaptureDevice.default(for: .video) else {
+ print("❌ No camera device found")
+ return
+}
+print("✅ Camera: \(camera.localizedName)")
+
+do {
+ let input = try AVCaptureDeviceInput(device: camera)
+ print("✅ Input created")
+
+ if session.canAddInput(input) {
+ print("✅ Can add input")
+ } else {
+ print("❌ Cannot add input - check session preset compatibility")
+ }
+} catch {
+ print("❌ Input creation failed: \(error)")
+}
+```
+
+**Fix**: Ensure permission is granted BEFORE creating input, and wrap in configuration block:
+```swift
+session.beginConfiguration()
+// Add input here
+session.commitConfiguration()
+```
+
+**Time to fix**: 15 min
+
+### Pattern 3: Preview Layer Not Connected
+
+**Symptom**: `isRunning = true`, inputs configured, but preview is black
+
+**Common causes**:
+1. Preview layer session not set
+2. Preview layer not in view hierarchy
+3. Preview layer frame is zero
+
+**Diagnostic**:
+```swift
+print("Preview layer session: \(previewLayer.session != nil)")
+print("Preview layer superlayer: \(previewLayer.superlayer != nil)")
+print("Preview layer frame: \(previewLayer.frame)")
+print("Preview layer connection: \(previewLayer.connection != nil)")
+```
+
+**Fix**:
+```swift
+// Ensure preview layer is properly configured
+previewLayer.session = session
+previewLayer.videoGravity = .resizeAspectFill
+
+// Ensure frame is set (common in SwiftUI)
+previewLayer.frame = view.bounds
+```
+
+**Time to fix**: 10 min
+
+### Pattern 4: Main Thread Blocking
+
+**Symptom**: UI freezes for 1-3 seconds when camera opens
+
+**Root cause**: `startRunning()` is a blocking call executed on main thread
+
+**Diagnostic**:
+```swift
+// If this prints on main thread, that's the problem
+print("startRunning on thread: \(Thread.current)")
+session.startRunning()
+```
+
+**Fix**:
+```swift
+// Create dedicated serial queue
+private let sessionQueue = DispatchQueue(label: "camera.session")
+
+func startSession() {
+ sessionQueue.async { [self] in
+ session.startRunning()
+ }
+}
+```
+
+**Time to fix**: 15 min
+
+### Pattern 5: Phone Call Interruption
+
+**Symptom**: Camera works, then freezes when phone call comes in
+
+**Root cause**: Session interrupted but no handling/UI feedback
+
+**Diagnostic**:
+```swift
+// Check if session is still running after returning from call
+print("Session running: \(session.isRunning)")
+// Will be false during active call, true after call ends
+```
+
+**Fix**: Add interruption observers (see camera-capture skill Pattern 5)
+
+**Key point**: Session AUTOMATICALLY resumes after interruption ends. You don't need to call `startRunning()` again. Just update your UI.
+
+**Time to fix**: 30 min
+
+### Pattern 6: Split View Camera Unavailable
+
+**Symptom**: Camera stops working when iPad enters Split View
+
+**Root cause**: Camera not available with multiple foreground apps
+
+**Diagnostic**:
+```swift
+// Check interruption reason
+// InterruptionReason.videoDeviceNotAvailableWithMultipleForegroundApps
+```
+
+**Fix**: Show appropriate UI message and resume when user exits Split View:
+```swift
+case .videoDeviceNotAvailableWithMultipleForegroundApps:
+ showMessage("Camera unavailable in Split View. Use full screen.")
+```
+
+**Time to fix**: 15 min
+
+### Pattern 7: Thermal Pressure
+
+**Symptom**: Camera stops randomly, especially after prolonged use
+
+**Root cause**: Device getting hot, system reducing resources
+
+**Diagnostic**:
+```swift
+// Check thermal state
+print("Thermal state: \(ProcessInfo.processInfo.thermalState.rawValue)")
+// 0 = nominal, 1 = fair, 2 = serious, 3 = critical
+```
+
+**Fix**: Reduce quality or show cooling message:
+```swift
+case .videoDeviceNotAvailableDueToSystemPressure:
+ // Reduce quality
+ session.sessionPreset = .medium
+ showMessage("Camera quality reduced due to device temperature")
+```
+
+**Time to fix**: 20 min
+
+### Pattern 8: Preview Rotation Wrong
+
+**Symptom**: Preview is rotated 90° from expected
+
+**Root cause**: Not using RotationCoordinator (iOS 17+) or not observing updates
+
+**Diagnostic**:
+```swift
+print("Preview connection rotation: \(previewLayer.connection?.videoRotationAngle ?? -1)")
+```
+
+**Fix**:
+```swift
+// Create and observe RotationCoordinator
+let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: previewLayer)
+
+// Set initial rotation
+previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
+
+// Observe changes
+observation = coordinator.observe(\.videoRotationAngleForHorizonLevelPreview) { [weak previewLayer] coord, _ in
+ DispatchQueue.main.async {
+ previewLayer?.connection?.videoRotationAngle = coord.videoRotationAngleForHorizonLevelPreview
+ }
+}
+```
+
+**Time to fix**: 30 min
+
+### Pattern 9: Captured Photo Rotation Wrong
+
+**Symptom**: Preview looks correct, but captured photo is rotated
+
+**Root cause**: Rotation angle not applied to photo output connection
+
+**Diagnostic**:
+```swift
+if let connection = photoOutput.connection(with: .video) {
+ print("Photo connection rotation: \(connection.videoRotationAngle)")
+}
+```
+
+**Fix**:
+```swift
+func capturePhoto() {
+ // Apply current rotation to capture
+ if let connection = photoOutput.connection(with: .video) {
+ connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture
+ }
+
+ photoOutput.capturePhoto(with: settings, delegate: self)
+}
+```
+
+**Time to fix**: 15 min
+
+### Pattern 10: Front Camera Mirroring
+
+**Symptom**: Designer says "front camera photo doesn't match preview"
+
+**Reality**: This is CORRECT behavior, not a bug.
+
+**Explanation**:
+- Preview is mirrored (like looking in a mirror - user expectation)
+- Captured photo is NOT mirrored (text reads correctly when shared)
+- This matches the system Camera app behavior
+
+**If business requires mirrored photos** (selfie apps):
+```swift
+func mirrorImage(_ image: UIImage) -> UIImage? {
+ guard let cgImage = image.cgImage else { return nil }
+ return UIImage(cgImage: cgImage, scale: image.scale, orientation: .upMirrored)
+}
+```
+
+**Time to fix**: 5 min (explanation) or 15 min (if mirroring required)
+
+### Pattern 11: Slow Capture (Quality Priority)
+
+**Symptom**: Photo capture takes 2+ seconds
+
+**Root cause**: `photoQualityPrioritization = .quality` (default for some devices)
+
+**Diagnostic**:
+```swift
+print("Max quality prioritization: \(photoOutput.maxPhotoQualityPrioritization.rawValue)")
+// Check what you're requesting in AVCapturePhotoSettings
+```
+
+**Fix**:
+```swift
+var settings = AVCapturePhotoSettings()
+
+// For fast capture (social/sharing)
+settings.photoQualityPrioritization = .speed
+
+// For balanced (general use)
+settings.photoQualityPrioritization = .balanced
+
+// Only use .quality when image quality is critical
+```
+
+**Time to fix**: 5 min
+
+### Pattern 12: Deferred Processing
+
+**Symptom**: Want maximum responsiveness (zero-shutter-lag)
+
+**Solution**: Enable deferred processing (iOS 17+)
+```swift
+photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
+
+// Then handle proxy in delegate:
+// - didFinishProcessingPhoto gives proxy for immediate display
+// - didFinishCapturingDeferredPhotoProxy gives final image later
+```
+
+**Time to fix**: 30 min
+
+### Pattern 13: Permission Not Requested
+
+**Symptom**: `authorizationStatus = .notDetermined`
+
+**Fix**:
+```swift
+// Must request before setting up session
+Task {
+ let granted = await AVCaptureDevice.requestAccess(for: .video)
+ if granted {
+ setupSession()
+ }
+}
+```
+
+**Time to fix**: 10 min
+
+### Pattern 14: Permission Denied
+
+**Symptom**: `authorizationStatus = .denied`
+
+**Fix**: Show settings prompt
+```swift
+func showSettingsPrompt() {
+ let alert = UIAlertController(
+ title: "Camera Access Required",
+ message: "Please enable camera access in Settings to use this feature.",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ })
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ present(alert, animated: true)
+}
+```
+
+**Time to fix**: 15 min
+
+### Pattern 15: API Availability Crash
+
+**Symptom**: Crash on iOS 16 or earlier
+
+**Root cause**: Using iOS 17+ APIs without availability check
+
+**Fix**:
+```swift
+if #available(iOS 17.0, *) {
+ // Use RotationCoordinator
+ let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview)
+} else {
+ // Fallback to deprecated videoOrientation
+ if let connection = previewLayer.connection {
+ connection.videoOrientation = .portrait
+ }
+}
+```
+
+**Time to fix**: 20 min
+
+## Quick Reference Table
+
+| Symptom | Check First | Likely Pattern |
+|---------|-------------|----------------|
+| Black preview | Step 1 (session state) | 1, 2, or 3 |
+| UI freezes | Step 2 (threading) | 4 |
+| Freezes on call | Step 4 (interruptions) | 5 |
+| Wrong rotation | Print rotation angle | 8 or 9 |
+| Slow capture | Print quality setting | 11 |
+| Denied access | Step 3 (permissions) | 14 |
+| Crash on old iOS | Check @available | 15 |
+
+## Checklist
+
+Before escalating camera issues:
+
+**Basics**:
+- ☑ Session has at least one input
+- ☑ Session has at least one output
+- ☑ Session isRunning = true
+- ☑ Preview layer connected to session
+- ☑ Preview layer has non-zero frame
+
+**Threading**:
+- ☑ All session work on sessionQueue
+- ☑ startRunning() on background thread
+- ☑ UI updates on main thread
+
+**Permissions**:
+- ☑ Authorization status checked
+- ☑ Permission requested if notDetermined
+- ☑ Graceful UI for denied state
+
+**Rotation**:
+- ☑ RotationCoordinator created with device AND previewLayer
+- ☑ Observation set up for preview angle changes
+- ☑ Capture angle applied when taking photos
+
+**Interruptions**:
+- ☑ Interruption observer registered
+- ☑ UI feedback for interrupted state
+- ☑ Tested with incoming phone call
+
+## Resources
+
+**WWDC**: 2021-10247, 2023-10105
+
+**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturesessionwasinterruptednotification
+
+**Skills**: axiom-camera-capture, axiom-camera-capture-ref
diff --git a/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml b/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml
new file mode 100644
index 0000000..1229809
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Camera Capture Diagnostics"
+ short_description: "Camera freezes, preview rotated wrong, capture slow, session interrupted, black preview, front camera mirrored, camer..."
diff --git a/.claude/skills/axiom-camera-capture-ref/.openskills.json b/.claude/skills/axiom-camera-capture-ref/.openskills.json
new file mode 100644
index 0000000..3916955
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-camera-capture-ref",
+ "installedAt": "2026-04-12T08:06:00.200Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-camera-capture-ref/SKILL.md b/.claude/skills/axiom-camera-capture-ref/SKILL.md
new file mode 100644
index 0000000..11a3ed2
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-ref/SKILL.md
@@ -0,0 +1,779 @@
+---
+name: axiom-camera-capture-ref
+description: Reference — AVCaptureSession, AVCapturePhotoSettings, AVCapturePhotoOutput, RotationCoordinator, photoQualityPrioritization, deferred processing, AVCaptureMovieFileOutput, session presets, capture device APIs
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Camera Capture API Reference
+
+## Quick Reference
+
+```swift
+// SESSION SETUP
+import AVFoundation
+
+let session = AVCaptureSession()
+let sessionQueue = DispatchQueue(label: "camera.session")
+
+sessionQueue.async {
+ session.beginConfiguration()
+ session.sessionPreset = .photo
+
+ guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
+ let input = try? AVCaptureDeviceInput(device: camera),
+ session.canAddInput(input) else { return }
+ session.addInput(input)
+
+ let photoOutput = AVCapturePhotoOutput()
+ if session.canAddOutput(photoOutput) {
+ session.addOutput(photoOutput)
+ }
+
+ session.commitConfiguration()
+ session.startRunning()
+}
+
+// CAPTURE PHOTO
+var settings = AVCapturePhotoSettings()
+settings.photoQualityPrioritization = .balanced
+photoOutput.capturePhoto(with: settings, delegate: self)
+
+// ROTATION (iOS 17+)
+let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: previewLayer)
+previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
+```
+
+---
+
+## AVCaptureSession
+
+Central coordinator for capture data flow.
+
+### Session Presets
+
+| Preset | Resolution | Use Case |
+|--------|------------|----------|
+| `.photo` | Optimal for photos | Photo capture |
+| `.high` | Highest device quality | Video recording |
+| `.medium` | VGA quality | Preview, lower storage |
+| `.low` | CIF quality | Minimal storage |
+| `.hd1280x720` | 720p | HD video |
+| `.hd1920x1080` | 1080p | Full HD video |
+| `.hd4K3840x2160` | 4K | Ultra HD video |
+| `.inputPriority` | Use device format | Custom configuration |
+
+### Session Configuration
+
+```swift
+// Batch configuration (atomic)
+session.beginConfiguration()
+defer { session.commitConfiguration() }
+
+// Check preset support
+if session.canSetSessionPreset(.hd4K3840x2160) {
+ session.sessionPreset = .hd4K3840x2160
+}
+
+// Add input/output
+if session.canAddInput(input) {
+ session.addInput(input)
+}
+
+if session.canAddOutput(output) {
+ session.addOutput(output)
+}
+```
+
+### Session Lifecycle
+
+```swift
+// Start (ALWAYS on background queue)
+sessionQueue.async {
+ session.startRunning() // Blocking call
+}
+
+// Stop
+sessionQueue.async {
+ session.stopRunning()
+}
+
+// Check state
+session.isRunning // true/false
+session.isInterrupted // true during phone calls, etc.
+```
+
+### Session Notifications
+
+```swift
+// Session started
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionDidStartRunning,
+ object: session, queue: .main) { _ in }
+
+// Session stopped
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionDidStopRunning,
+ object: session, queue: .main) { _ in }
+
+// Session interrupted (phone call, etc.)
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionWasInterrupted,
+ object: session, queue: .main) { notification in
+ let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int
+ }
+
+// Interruption ended
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionInterruptionEnded,
+ object: session, queue: .main) { _ in }
+
+// Runtime error
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionRuntimeError,
+ object: session, queue: .main) { notification in
+ let error = notification.userInfo?[AVCaptureSessionErrorKey] as? Error
+ }
+```
+
+### Interruption Reasons
+
+| Reason | Value | Cause |
+|--------|-------|-------|
+| `.videoDeviceNotAvailableInBackground` | 1 | App went to background |
+| `.audioDeviceInUseByAnotherClient` | 2 | Another app using audio |
+| `.videoDeviceInUseByAnotherClient` | 3 | Another app using camera |
+| `.videoDeviceNotAvailableWithMultipleForegroundApps` | 4 | Split View (iPad) |
+| `.videoDeviceNotAvailableDueToSystemPressure` | 5 | Thermal throttling |
+
+---
+
+## AVCaptureDevice
+
+Represents a physical capture device (camera, microphone).
+
+### Getting Devices
+
+```swift
+// Default back camera
+AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
+
+// Default front camera
+AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
+
+// Default microphone
+AVCaptureDevice.default(for: .audio)
+
+// Discovery session for all cameras
+let discoverySession = AVCaptureDevice.DiscoverySession(
+ deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera],
+ mediaType: .video,
+ position: .unspecified
+)
+let cameras = discoverySession.devices
+```
+
+### Device Types
+
+| Type | Description |
+|------|-------------|
+| `.builtInWideAngleCamera` | Standard camera (1x) |
+| `.builtInUltraWideCamera` | Ultra-wide camera (0.5x) |
+| `.builtInTelephotoCamera` | Telephoto camera (2x, 3x) |
+| `.builtInDualCamera` | Wide + telephoto |
+| `.builtInDualWideCamera` | Wide + ultra-wide |
+| `.builtInTripleCamera` | Wide + ultra-wide + telephoto |
+| `.builtInTrueDepthCamera` | Front TrueDepth (Face ID) |
+| `.builtInLiDARDepthCamera` | LiDAR depth |
+
+### Device Configuration
+
+```swift
+do {
+ try device.lockForConfiguration()
+ defer { device.unlockForConfiguration() }
+
+ // Focus
+ if device.isFocusModeSupported(.continuousAutoFocus) {
+ device.focusMode = .continuousAutoFocus
+ }
+
+ // Exposure
+ if device.isExposureModeSupported(.continuousAutoExposure) {
+ device.exposureMode = .continuousAutoExposure
+ }
+
+ // Torch (flashlight)
+ if device.hasTorch && device.isTorchModeSupported(.on) {
+ device.torchMode = .on
+ }
+
+ // Zoom
+ device.videoZoomFactor = 2.0 // 2x zoom
+
+} catch {
+ print("Failed to configure device: \(error)")
+}
+```
+
+### Switching Cameras
+
+```swift
+// Switch between front and back during active session
+func switchCamera() {
+ sessionQueue.async { [self] in
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ // Remove current camera input
+ if let currentInput = session.inputs.first(where: { ($0 as? AVCaptureDeviceInput)?.device.hasMediaType(.video) == true }) as? AVCaptureDeviceInput {
+ session.removeInput(currentInput)
+
+ // Get opposite camera
+ let newPosition: AVCaptureDevice.Position = currentInput.device.position == .back ? .front : .back
+ guard let newDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: newPosition),
+ let newInput = try? AVCaptureDeviceInput(device: newDevice) else { return }
+
+ if session.canAddInput(newInput) {
+ session.addInput(newInput)
+ }
+ }
+ }
+}
+```
+
+**Important**: Always switch on the session queue, within beginConfiguration/commitConfiguration.
+
+### Authorization
+
+```swift
+// Check status
+let status = AVCaptureDevice.authorizationStatus(for: .video)
+
+switch status {
+case .authorized: break
+case .notDetermined:
+ await AVCaptureDevice.requestAccess(for: .video)
+case .denied, .restricted:
+ // Show settings prompt
+@unknown default: break
+}
+```
+
+---
+
+## AVCaptureDevice.RotationCoordinator (iOS 17+)
+
+Automatically tracks device orientation and provides rotation angles.
+
+### Setup
+
+```swift
+// Create with device and preview layer
+let coordinator = AVCaptureDevice.RotationCoordinator(
+ device: captureDevice,
+ previewLayer: previewLayer
+)
+```
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `videoRotationAngleForHorizonLevelPreview` | CGFloat | Rotation for preview layer |
+| `videoRotationAngleForHorizonLevelCapture` | CGFloat | Rotation for captured output |
+
+### Observation
+
+```swift
+// KVO observation for preview updates
+let observation = coordinator.observe(
+ \.videoRotationAngleForHorizonLevelPreview,
+ options: [.new]
+) { [weak previewLayer] coordinator, _ in
+ DispatchQueue.main.async {
+ previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
+ }
+}
+
+// Set initial value
+previewLayer.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
+```
+
+### Applying to Capture
+
+```swift
+func capturePhoto() {
+ if let connection = photoOutput.connection(with: .video) {
+ connection.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelCapture
+ }
+ photoOutput.capturePhoto(with: settings, delegate: self)
+}
+```
+
+---
+
+## AVCapturePhotoOutput
+
+Output for capturing still photos.
+
+### Configuration
+
+```swift
+let photoOutput = AVCapturePhotoOutput()
+
+// High resolution
+photoOutput.isHighResolutionCaptureEnabled = true
+
+// Max quality prioritization
+photoOutput.maxPhotoQualityPrioritization = .quality
+
+// Deferred processing (iOS 17+)
+photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
+
+// Live Photo
+photoOutput.isLivePhotoCaptureEnabled = true
+
+// Depth
+photoOutput.isDepthDataDeliveryEnabled = true
+
+// Portrait Effects Matte
+photoOutput.isPortraitEffectsMatteDeliveryEnabled = true
+```
+
+### Supported Features
+
+```swift
+// Check support before enabling
+photoOutput.isHighResolutionCaptureEnabled && photoOutput.isHighResolutionCaptureSupported
+photoOutput.isLivePhotoCaptureSupported
+photoOutput.isDepthDataDeliverySupported
+photoOutput.isPortraitEffectsMatteDeliverySupported
+photoOutput.maxPhotoQualityPrioritization // .speed, .balanced, .quality
+```
+
+### Responsive Capture APIs (iOS 17+)
+
+```swift
+// Zero Shutter Lag - uses ring buffer for instant capture
+photoOutput.isZeroShutterLagSupported
+photoOutput.isZeroShutterLagEnabled // true by default for iOS 17+ apps
+
+// Responsive Capture - overlapping captures
+photoOutput.isResponsiveCaptureSupported
+photoOutput.isResponsiveCaptureEnabled
+
+// Fast Capture Prioritization - adapts quality for burst-like capture
+photoOutput.isFastCapturePrioritizationSupported
+photoOutput.isFastCapturePrioritizationEnabled
+
+// Deferred Processing - proxy + background processing
+photoOutput.isAutoDeferredPhotoDeliverySupported
+photoOutput.isAutoDeferredPhotoDeliveryEnabled
+```
+
+---
+
+## AVCapturePhotoOutputReadinessCoordinator (iOS 17+)
+
+Provides synchronous shutter button state updates.
+
+### Setup
+
+```swift
+let coordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput)
+coordinator.delegate = self
+```
+
+### Tracking Captures
+
+```swift
+// Call BEFORE capturePhoto()
+coordinator.startTrackingCaptureRequest(using: settings)
+photoOutput.capturePhoto(with: settings, delegate: self)
+```
+
+### Delegate
+
+```swift
+func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator,
+ captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) {
+ switch captureReadiness {
+ case .ready: // Can capture immediately
+ case .notReadyMomentarily: // Brief delay, prevent double-tap
+ case .notReadyWaitingForCapture: // Flash firing, sensor reading
+ case .notReadyWaitingForProcessing: // Processing previous photo
+ case .sessionNotRunning: // Session stopped
+ @unknown default: break
+ }
+}
+```
+
+---
+
+## AVCapturePhotoSettings
+
+Configuration for a single photo capture.
+
+### Basic Settings
+
+```swift
+// Standard JPEG
+var settings = AVCapturePhotoSettings()
+
+// HEIF format
+settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc])
+
+// RAW
+settings = AVCapturePhotoSettings(rawPixelFormatType: kCVPixelFormatType_14Bayer_BGGR)
+
+// RAW + JPEG
+settings = AVCapturePhotoSettings(
+ rawPixelFormatType: kCVPixelFormatType_14Bayer_BGGR,
+ processedFormat: [AVVideoCodecKey: AVVideoCodecType.jpeg]
+)
+```
+
+### Quality Prioritization
+
+| Value | Speed | Quality | Use Case |
+|-------|-------|---------|----------|
+| `.speed` | Fastest | Lower | Social sharing, rapid capture |
+| `.balanced` | Medium | Good | General photography |
+| `.quality` | Slowest | Best | Professional, documents |
+
+```swift
+settings.photoQualityPrioritization = .speed
+```
+
+### Flash
+
+```swift
+settings.flashMode = .auto // .off, .on, .auto
+```
+
+### Apple ProRAW and HDR
+
+```swift
+// Check ProRAW support
+if photoOutput.isAppleProRAWSupported {
+ photoOutput.isAppleProRAWEnabled = true
+
+ // Capture ProRAW
+ let query = photoOutput.isAppleProRAWEnabled
+ ? AVCapturePhotoOutput.AppleProRAWQuery(photoOutput)
+ : nil
+ if let rawType = query?.availableRawPixelFormatTypes.first {
+ let settings = AVCapturePhotoSettings(
+ rawPixelFormatType: rawType,
+ processedFormat: [AVVideoCodecKey: AVVideoCodecType.hevc]
+ )
+ }
+}
+
+// HDR configuration
+settings.photoQualityPrioritization = .quality // Enables computational photography/HDR
+// HDR is automatic with .balanced or .quality — no separate toggle needed
+```
+
+**Note**: ProRAW requires iPhone 12 Pro or later. HDR is automatic with quality prioritization — Apple's Deep Fusion and Smart HDR are controlled by the system based on the quality setting.
+
+### Resolution
+
+```swift
+// High resolution still image
+settings.isHighResolutionPhotoEnabled = true
+
+// Max dimensions (limit resolution)
+settings.maxPhotoDimensions = CMVideoDimensions(width: 4032, height: 3024)
+```
+
+### Preview/Thumbnail
+
+```swift
+// Preview for immediate display
+settings.previewPhotoFormat = [
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
+]
+
+// Thumbnail
+settings.embeddedThumbnailPhotoFormat = [
+ AVVideoCodecKey: AVVideoCodecType.jpeg,
+ AVVideoWidthKey: 160,
+ AVVideoHeightKey: 120
+]
+```
+
+### Important Notes
+
+```swift
+// Settings cannot be reused
+// Each capture needs a NEW settings instance
+let settings1 = AVCapturePhotoSettings() // Use once
+let settings2 = AVCapturePhotoSettings() // Use for second capture
+
+// Copy settings for similar captures
+let settings2 = AVCapturePhotoSettings(from: settings1)
+```
+
+---
+
+## AVCapturePhotoCaptureDelegate
+
+Delegate for photo capture events.
+
+```swift
+extension CameraManager: AVCapturePhotoCaptureDelegate {
+
+ // Photo capture will begin
+ func photoOutput(_ output: AVCapturePhotoOutput,
+ willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
+ // Show shutter animation
+ }
+
+ // Photo capture finished
+ func photoOutput(_ output: AVCapturePhotoOutput,
+ didFinishProcessingPhoto photo: AVCapturePhoto,
+ error: Error?) {
+ guard error == nil else {
+ print("Capture error: \(error!)")
+ return
+ }
+
+ // Get JPEG data
+ if let data = photo.fileDataRepresentation() {
+ savePhoto(data)
+ }
+
+ // Or get raw pixel buffer
+ if let pixelBuffer = photo.pixelBuffer {
+ processBuffer(pixelBuffer)
+ }
+ }
+
+ // Deferred processing proxy (iOS 17+)
+ func photoOutput(_ output: AVCapturePhotoOutput,
+ didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy,
+ error: Error?) {
+ guard error == nil, let data = deferredPhotoProxy.fileDataRepresentation() else { return }
+ replaceThumbnailWithFinal(data)
+ }
+}
+```
+
+---
+
+## AVCaptureMovieFileOutput
+
+Output for recording video to file.
+
+### Setup
+
+```swift
+let movieOutput = AVCaptureMovieFileOutput()
+
+if session.canAddOutput(movieOutput) {
+ session.addOutput(movieOutput)
+}
+
+// Add audio input
+if let microphone = AVCaptureDevice.default(for: .audio),
+ let audioInput = try? AVCaptureDeviceInput(device: microphone),
+ session.canAddInput(audioInput) {
+ session.addInput(audioInput)
+}
+```
+
+### Recording
+
+```swift
+// Start recording
+let outputURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("mov")
+
+// Apply rotation
+if let connection = movieOutput.connection(with: .video) {
+ connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture
+}
+
+movieOutput.startRecording(to: outputURL, recordingDelegate: self)
+
+// Stop recording
+movieOutput.stopRecording()
+
+// Check state
+movieOutput.isRecording
+movieOutput.recordedDuration
+movieOutput.recordedFileSize
+```
+
+### Delegate
+
+```swift
+extension CameraManager: AVCaptureFileOutputRecordingDelegate {
+
+ func fileOutput(_ output: AVCaptureFileOutput,
+ didStartRecordingTo fileURL: URL,
+ from connections: [AVCaptureConnection]) {
+ // Recording started
+ }
+
+ func fileOutput(_ output: AVCaptureFileOutput,
+ didFinishRecordingTo outputFileURL: URL,
+ from connections: [AVCaptureConnection],
+ error: Error?) {
+ if let error = error {
+ print("Recording failed: \(error)")
+ return
+ }
+
+ // Video saved to outputFileURL
+ saveToPhotoLibrary(outputFileURL)
+ }
+}
+```
+
+---
+
+## AVCaptureVideoPreviewLayer
+
+Layer for displaying camera preview.
+
+### Setup
+
+```swift
+let previewLayer = AVCaptureVideoPreviewLayer(session: session)
+previewLayer.videoGravity = .resizeAspectFill
+previewLayer.frame = view.bounds
+view.layer.addSublayer(previewLayer)
+```
+
+### Video Gravity
+
+| Value | Behavior |
+|-------|----------|
+| `.resizeAspect` | Fit entire image, may letterbox |
+| `.resizeAspectFill` | Fill layer, may crop edges |
+| `.resize` | Stretch to fill (distorts) |
+
+### SwiftUI Integration
+
+```swift
+struct CameraPreview: UIViewRepresentable {
+ let session: AVCaptureSession
+
+ func makeUIView(context: Context) -> PreviewView {
+ let view = PreviewView()
+ view.previewLayer.session = session
+ view.previewLayer.videoGravity = .resizeAspectFill
+ return view
+ }
+
+ func updateUIView(_ uiView: PreviewView, context: Context) {}
+
+ class PreviewView: UIView {
+ override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
+ var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
+ }
+}
+```
+
+---
+
+## Common Code Patterns
+
+### Complete Camera Manager
+
+```swift
+import AVFoundation
+
+@MainActor
+class CameraManager: NSObject, ObservableObject {
+ let session = AVCaptureSession()
+ let photoOutput = AVCapturePhotoOutput()
+ private let sessionQueue = DispatchQueue(label: "camera.session")
+ private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
+ private var rotationObservation: NSKeyValueObservation?
+
+ @Published var isSessionRunning = false
+
+ func setup() async -> Bool {
+ guard await AVCaptureDevice.requestAccess(for: .video) else { return false }
+
+ return await withCheckedContinuation { continuation in
+ sessionQueue.async { [self] in
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ session.sessionPreset = .photo
+
+ guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
+ let input = try? AVCaptureDeviceInput(device: camera),
+ session.canAddInput(input) else {
+ continuation.resume(returning: false)
+ return
+ }
+ session.addInput(input)
+
+ guard session.canAddOutput(photoOutput) else {
+ continuation.resume(returning: false)
+ return
+ }
+ session.addOutput(photoOutput)
+ photoOutput.maxPhotoQualityPrioritization = .quality
+
+ continuation.resume(returning: true)
+ }
+ }
+ }
+
+ func start() {
+ sessionQueue.async { [self] in
+ session.startRunning()
+ DispatchQueue.main.async {
+ self.isSessionRunning = self.session.isRunning
+ }
+ }
+ }
+
+ func stop() {
+ sessionQueue.async { [self] in
+ session.stopRunning()
+ DispatchQueue.main.async {
+ self.isSessionRunning = false
+ }
+ }
+ }
+
+ func capturePhoto() {
+ var settings = AVCapturePhotoSettings()
+ settings.photoQualityPrioritization = .balanced
+
+ if let connection = photoOutput.connection(with: .video),
+ let angle = rotationCoordinator?.videoRotationAngleForHorizonLevelCapture {
+ connection.videoRotationAngle = angle
+ }
+
+ photoOutput.capturePhoto(with: settings, delegate: self)
+ }
+}
+
+extension CameraManager: AVCapturePhotoCaptureDelegate {
+ nonisolated func photoOutput(_ output: AVCapturePhotoOutput,
+ didFinishProcessingPhoto photo: AVCapturePhoto,
+ error: Error?) {
+ guard let data = photo.fileDataRepresentation() else { return }
+ // Handle photo data
+ }
+}
+```
+
+---
+
+## Resources
+
+**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturedevice, /avfoundation/avcapturephotosettings, /avfoundation/avcapturedevice/rotationcoordinator
+
+**Skills**: axiom-camera-capture, axiom-camera-capture-diag
diff --git a/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml b/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml
new file mode 100644
index 0000000..0314e6b
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Camera Capture Reference"
+ short_description: "Reference — AVCaptureSession, AVCapturePhotoSettings, AVCapturePhotoOutput, RotationCoordinator, photoQualityPrioriti..."
diff --git a/.claude/skills/axiom-camera-capture/.openskills.json b/.claude/skills/axiom-camera-capture/.openskills.json
new file mode 100644
index 0000000..2e464a8
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-camera-capture",
+ "installedAt": "2026-04-12T08:05:58.892Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-camera-capture/SKILL.md b/.claude/skills/axiom-camera-capture/SKILL.md
new file mode 100644
index 0000000..2aa08d9
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture/SKILL.md
@@ -0,0 +1,890 @@
+---
+name: axiom-camera-capture
+description: AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferred processing, capture responsiveness, zero-shutter-lag, photoQualityPrioritization, front camera mirroring
+license: MIT
+compatibility: iOS 17+, iPadOS 17+, macOS 14+, tvOS 17+, axiom-visionOS 1+
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-01-03"
+---
+
+# Camera Capture with AVFoundation
+
+Guides you through implementing camera capture: session setup, photo capture, video recording, responsive capture UX, rotation handling, and session lifecycle management.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Build a custom camera UI (not system picker)
+- ☑ Capture photos with quality/speed tradeoffs
+- ☑ Record video with audio
+- ☑ Handle device rotation correctly (RotationCoordinator)
+- ☑ Make capture feel responsive (zero-shutter-lag)
+- ☑ Handle session interruptions (phone calls, multitasking)
+- ☑ Switch between front/back cameras
+- ☑ Configure capture quality and resolution
+
+## Example Prompts
+
+"How do I set up a camera preview in SwiftUI?"
+"My camera freezes when I get a phone call"
+"The photo preview is rotated wrong on front camera"
+"How do I make photo capture feel instant?"
+"Should I use deferred processing?"
+"My camera takes too long to capture"
+"How do I switch between front and back cameras?"
+"How do I record video with audio?"
+
+## Red Flags
+
+Signs you're making this harder than it needs to be:
+
+- ❌ Calling `startRunning()` on main thread (blocks UI for seconds)
+- ❌ Using deprecated `videoOrientation` instead of RotationCoordinator (iOS 17+)
+- ❌ Not observing session interruptions (app freezes on phone call)
+- ❌ Creating new AVCaptureSession for each capture (expensive)
+- ❌ Using `.photo` preset for video (wrong format)
+- ❌ Ignoring `photoQualityPrioritization` (slow captures)
+- ❌ Not handling `.notAuthorized` permission state
+- ❌ Modifying session without `beginConfiguration()`/`commitConfiguration()`
+- ❌ Using UIImagePickerController for custom camera UI (limited control)
+
+## Mandatory First Steps
+
+Before implementing any camera feature:
+
+### 1. Choose Your Capture Mode
+
+```
+What do you need?
+
+┌─ Just let user pick a photo?
+│ └─ Don't use AVFoundation - use PHPicker or PhotosPicker
+│ See: /skill axiom-photo-library
+│
+├─ Simple photo/video capture with system UI?
+│ └─ UIImagePickerController (but limited customization)
+│
+├─ Custom camera UI with photo capture?
+│ └─ AVCaptureSession + AVCapturePhotoOutput
+│ → Continue with this skill
+│
+├─ Custom camera UI with video recording?
+│ └─ AVCaptureSession + AVCaptureMovieFileOutput
+│ → Continue with this skill
+│
+└─ Both photo and video in same session?
+ └─ AVCaptureSession + both outputs
+ → Continue with this skill
+```
+
+### 2. Request Camera Permission
+
+```swift
+import AVFoundation
+
+func requestCameraAccess() async -> Bool {
+ let status = AVCaptureDevice.authorizationStatus(for: .video)
+
+ switch status {
+ case .authorized:
+ return true
+ case .notDetermined:
+ return await AVCaptureDevice.requestAccess(for: .video)
+ case .denied, .restricted:
+ // Show settings prompt
+ return false
+ @unknown default:
+ return false
+ }
+}
+```
+
+**Info.plist required**:
+```xml
+NSCameraUsageDescription
+Take photos and videos
+```
+
+For audio (video recording):
+```xml
+NSMicrophoneUsageDescription
+Record audio with video
+```
+
+### 3. Understand Session Architecture
+
+```
+AVCaptureSession
+ ├─ Inputs
+ │ ├─ AVCaptureDeviceInput (camera)
+ │ └─ AVCaptureDeviceInput (microphone, for video)
+ │
+ ├─ Outputs
+ │ ├─ AVCapturePhotoOutput (photos)
+ │ ├─ AVCaptureMovieFileOutput (video files)
+ │ └─ AVCaptureVideoDataOutput (raw frames)
+ │
+ └─ Connections (automatic between compatible input/output)
+```
+
+**Key rule**: All session configuration happens on a **dedicated serial queue**, never main thread.
+
+## Core Patterns
+
+### Pattern 1: Basic Session Setup
+
+**Use case**: Set up camera preview with photo capture capability.
+
+```swift
+import AVFoundation
+
+class CameraManager: NSObject {
+ let session = AVCaptureSession()
+ let photoOutput = AVCapturePhotoOutput()
+
+ // CRITICAL: Dedicated serial queue for session work
+ private let sessionQueue = DispatchQueue(label: "camera.session")
+
+ func setupSession() {
+ sessionQueue.async { [self] in
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ // 1. Set session preset
+ session.sessionPreset = .photo
+
+ // 2. Add camera input
+ guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
+ for: .video,
+ position: .back),
+ let input = try? AVCaptureDeviceInput(device: camera),
+ session.canAddInput(input) else {
+ return
+ }
+ session.addInput(input)
+
+ // 3. Add photo output
+ guard session.canAddOutput(photoOutput) else { return }
+ session.addOutput(photoOutput)
+
+ // 4. Configure photo output
+ photoOutput.isHighResolutionCaptureEnabled = true
+ photoOutput.maxPhotoQualityPrioritization = .quality
+ }
+ }
+
+ func startSession() {
+ sessionQueue.async { [self] in
+ if !session.isRunning {
+ session.startRunning() // Blocking call - never on main thread!
+ }
+ }
+ }
+
+ func stopSession() {
+ sessionQueue.async { [self] in
+ if session.isRunning {
+ session.stopRunning()
+ }
+ }
+ }
+}
+```
+
+**Cost**: 30 min implementation
+
+### Pattern 2: SwiftUI Camera Preview
+
+**Use case**: Display camera preview in SwiftUI view.
+
+```swift
+import SwiftUI
+import AVFoundation
+
+struct CameraPreview: UIViewRepresentable {
+ let session: AVCaptureSession
+
+ func makeUIView(context: Context) -> PreviewView {
+ let view = PreviewView()
+ view.previewLayer.session = session
+ view.previewLayer.videoGravity = .resizeAspectFill
+ return view
+ }
+
+ func updateUIView(_ uiView: PreviewView, context: Context) {}
+
+ class PreviewView: UIView {
+ override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
+ var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
+ }
+}
+
+// Usage in SwiftUI
+struct CameraView: View {
+ @StateObject private var camera = CameraManager()
+
+ var body: some View {
+ CameraPreview(session: camera.session)
+ .ignoresSafeArea()
+ .onAppear { camera.startSession() }
+ .onDisappear { camera.stopSession() }
+ }
+}
+```
+
+**Cost**: 20 min implementation
+
+### Pattern 3: Rotation Handling with RotationCoordinator (iOS 17+)
+
+**Use case**: Keep preview and captured photos correctly oriented regardless of device rotation.
+
+**Why RotationCoordinator**: Deprecated `videoOrientation` requires manual observation of device orientation. RotationCoordinator automatically tracks gravity and provides angles.
+
+```swift
+import AVFoundation
+
+class CameraManager {
+ private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
+ private var rotationObservation: NSKeyValueObservation?
+
+ func setupRotationCoordinator(device: AVCaptureDevice, previewLayer: AVCaptureVideoPreviewLayer) {
+ // Create coordinator with device and preview layer
+ rotationCoordinator = AVCaptureDevice.RotationCoordinator(
+ device: device,
+ previewLayer: previewLayer
+ )
+
+ // Observe preview rotation changes
+ rotationObservation = rotationCoordinator?.observe(
+ \.videoRotationAngleForHorizonLevelPreview,
+ options: [.new]
+ ) { [weak previewLayer] coordinator, _ in
+ // Update preview layer rotation on main thread
+ DispatchQueue.main.async {
+ previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
+ }
+ }
+
+ // Set initial rotation
+ previewLayer.connection?.videoRotationAngle = rotationCoordinator!.videoRotationAngleForHorizonLevelPreview
+ }
+
+ func captureRotationAngle() -> CGFloat {
+ // Use this angle when capturing photos
+ rotationCoordinator?.videoRotationAngleForHorizonLevelCapture ?? 0
+ }
+}
+```
+
+**When capturing**:
+```swift
+func capturePhoto() {
+ let settings = AVCapturePhotoSettings()
+
+ // Apply rotation angle from coordinator
+ if let connection = photoOutput.connection(with: .video) {
+ connection.videoRotationAngle = captureRotationAngle()
+ }
+
+ photoOutput.capturePhoto(with: settings, delegate: self)
+}
+```
+
+**Cost**: 45 min implementation, prevents 2+ hours debugging rotation issues
+
+### Pattern 4: Responsive Capture Pipeline (iOS 17+)
+
+**Use case**: Make photo capture feel instant with zero-shutter-lag, overlapping captures, and responsive button states.
+
+**iOS 17+ introduces four complementary APIs** that work together for maximum responsiveness:
+
+#### 4a. Zero Shutter Lag
+
+Uses a ring buffer of recent frames to "time travel" back to the exact moment you tapped the shutter. Enabled automatically for iOS 17+ apps.
+
+```swift
+// Check if supported for current format
+if photoOutput.isZeroShutterLagSupported {
+ // Enabled by default for apps linking iOS 17+
+ // Opt out if causing issues:
+ // photoOutput.isZeroShutterLagEnabled = false
+}
+```
+
+**Why it matters**: Without ZSL, there's a delay between tap and frame capture. For action shots, the moment is already over.
+
+**Requirements**: iPhone XS and newer. Does NOT apply to flash captures, manual exposure, bracketed captures, or constituent photo delivery.
+
+#### 4b. Responsive Capture (Overlapping Captures)
+
+Allows a new capture to start while the previous one is still processing:
+
+```swift
+// Check support first
+if photoOutput.isZeroShutterLagSupported {
+ photoOutput.isZeroShutterLagEnabled = true // Required for responsive capture
+
+ if photoOutput.isResponsiveCaptureSupported {
+ photoOutput.isResponsiveCaptureEnabled = true
+ }
+}
+```
+
+**Tradeoff**: Increases peak memory usage. If your app is memory-constrained, consider leaving disabled.
+
+**Requirements**: A12 Bionic (iPhone XS) and newer.
+
+#### 4c. Fast Capture Prioritization
+
+Automatically adapts quality when taking multiple photos rapidly (like burst mode):
+
+```swift
+if photoOutput.isFastCapturePrioritizationSupported {
+ photoOutput.isFastCapturePrioritizationEnabled = true
+ // When enabled, rapid captures use "balanced" quality instead of "quality"
+ // to maintain consistent shot-to-shot time
+}
+```
+
+**When to enable**: User-facing toggle ("Prioritize Faster Shooting" in Camera.app). Off by default because it reduces quality.
+
+#### 4d. Readiness Coordinator (Button State Management)
+
+**Critical for UX**: Provides synchronous updates for shutter button state without async lag.
+
+```swift
+class CameraManager {
+ private var readinessCoordinator: AVCapturePhotoOutputReadinessCoordinator!
+
+ func setupReadinessCoordinator() {
+ readinessCoordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput)
+ readinessCoordinator.delegate = self
+ }
+
+ func capturePhoto() {
+ var settings = AVCapturePhotoSettings()
+ settings.photoQualityPrioritization = .balanced
+
+ // Tell coordinator to track this capture BEFORE calling capturePhoto
+ readinessCoordinator.startTrackingCaptureRequest(using: settings)
+
+ photoOutput.capturePhoto(with: settings, delegate: self)
+ }
+}
+
+extension CameraManager: AVCapturePhotoOutputReadinessCoordinatorDelegate {
+ func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator,
+ captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) {
+ DispatchQueue.main.async {
+ switch captureReadiness {
+ case .ready:
+ self.shutterButton.isEnabled = true
+ self.shutterButton.alpha = 1.0
+
+ case .notReadyMomentarily:
+ // Brief delay - disable to prevent double-tap
+ self.shutterButton.isEnabled = false
+
+ case .notReadyWaitingForCapture:
+ // Flash is firing - dim button
+ self.shutterButton.alpha = 0.5
+
+ case .notReadyWaitingForProcessing:
+ // Processing previous photo - show spinner
+ self.showProcessingIndicator()
+
+ case .sessionNotRunning:
+ self.shutterButton.isEnabled = false
+
+ @unknown default:
+ break
+ }
+ }
+ }
+}
+```
+
+**Why use Readiness Coordinator**: Without it, you'd need to track capture state manually and users might spam the shutter button during processing.
+
+#### Quality Prioritization (Baseline)
+
+Still useful even without the new APIs:
+
+```swift
+func capturePhoto() {
+ var settings = AVCapturePhotoSettings()
+
+ // Speed vs Quality tradeoff
+ // .speed - Fastest capture, lower quality
+ // .balanced - Good default
+ // .quality - Best quality, may have delay
+ settings.photoQualityPrioritization = .speed
+
+ // For specific use cases:
+ // - Social sharing: .speed (users expect instant)
+ // - Document scanning: .quality (accuracy matters)
+ // - General photography: .balanced
+
+ photoOutput.capturePhoto(with: settings, delegate: self)
+}
+```
+
+**Deferred Processing (iOS 17+)**:
+
+For maximum responsiveness, capture returns immediately with proxy image, full Deep Fusion processing happens in background:
+
+```swift
+// Check support and enable deferred processing
+if photoOutput.isAutoDeferredPhotoDeliverySupported {
+ photoOutput.isAutoDeferredPhotoDeliveryEnabled = true
+}
+```
+
+**Delegate callbacks with deferred processing**:
+
+```swift
+// Called for BOTH regular photos AND deferred proxies
+func photoOutput(_ output: AVCapturePhotoOutput,
+ didFinishProcessingPhoto photo: AVCapturePhoto,
+ error: Error?) {
+ guard error == nil else { return }
+
+ // Non-deferred photo - save directly
+ if !photo.isRawPhoto, let data = photo.fileDataRepresentation() {
+ savePhotoToLibrary(data)
+ }
+}
+
+// Called ONLY for deferred proxies - save to PhotoKit for later processing
+func photoOutput(_ output: AVCapturePhotoOutput,
+ didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy,
+ error: Error?) {
+ guard error == nil else { return }
+
+ // CRITICAL: Save proxy to library ASAP before app is backgrounded
+ // App may be force-quit if memory pressure is high during backgrounding
+ guard let proxyData = deferredPhotoProxy.fileDataRepresentation() else { return }
+
+ Task {
+ try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCreationRequest.forAsset()
+ // Use .photoProxy resource type - triggers deferred processing in Photos
+ request.addResource(with: .photoProxy, data: proxyData, options: nil)
+ }
+ }
+}
+```
+
+**When final processing happens**:
+- On-demand when image is requested from PhotoKit
+- Or automatically when device is idle (plugged in, not in use)
+
+**Fetching images with deferred processing awareness**:
+
+```swift
+// Request with secondary degraded image for smoother UX
+let options = PHImageRequestOptions()
+options.allowSecondaryDegradedImage = true // New in iOS 17
+
+PHImageManager.default().requestImage(
+ for: asset,
+ targetSize: targetSize,
+ contentMode: .aspectFill,
+ options: options
+) { image, info in
+ let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
+
+ if isDegraded {
+ // First: Low quality (immediate)
+ // Second: Medium quality (new - while processing)
+ // Third callback will be final quality
+ self.showTemporaryImage(image)
+ } else {
+ // Final quality - processing complete
+ self.showFinalImage(image)
+ }
+}
+```
+
+**Requirements**: iPhone 11 Pro and newer. Not used for flash captures or formats that don't benefit from extended processing.
+
+**Important considerations**:
+- Can't apply pixel buffer customizations (filters, metadata changes) to deferred photos
+- Use PhotoKit adjustments after processing for edits
+- Get proxy into library ASAP - limited time when backgrounded
+
+**Cost**: 1 hour implementation, prevents "camera feels slow" complaints
+
+### Pattern 5: Session Interruption Handling
+
+**Use case**: Handle phone calls, multitasking, system camera usage.
+
+```swift
+class CameraManager {
+ private var interruptionObservers: [NSObjectProtocol] = []
+
+ func setupInterruptionHandling() {
+ // Session was interrupted
+ let interruptedObserver = NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionWasInterrupted,
+ object: session,
+ queue: .main
+ ) { [weak self] notification in
+ guard let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
+ let interruptionReason = AVCaptureSession.InterruptionReason(rawValue: reason) else {
+ return
+ }
+
+ switch interruptionReason {
+ case .videoDeviceNotAvailableInBackground:
+ // App went to background - normal, will resume
+ self?.showPausedOverlay()
+
+ case .audioDeviceInUseByAnotherClient:
+ // Another app using audio
+ self?.showInterruptedBanner("Audio in use by another app")
+
+ case .videoDeviceInUseByAnotherClient:
+ // Another app using camera
+ self?.showInterruptedBanner("Camera in use by another app")
+
+ case .videoDeviceNotAvailableWithMultipleForegroundApps:
+ // Split View/Slide Over - camera not available
+ self?.showInterruptedBanner("Camera unavailable in Split View")
+
+ case .videoDeviceNotAvailableDueToSystemPressure:
+ // Thermal state - reduce quality or stop
+ self?.handleThermalPressure()
+
+ @unknown default:
+ self?.showInterruptedBanner("Camera interrupted")
+ }
+ }
+ interruptionObservers.append(interruptedObserver)
+
+ // Session interruption ended
+ let endedObserver = NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionInterruptionEnded,
+ object: session,
+ queue: .main
+ ) { [weak self] _ in
+ self?.hideInterruptedBanner()
+ self?.hidePausedOverlay()
+ // Session automatically resumes - no need to call startRunning()
+ }
+ interruptionObservers.append(endedObserver)
+ }
+
+ deinit {
+ interruptionObservers.forEach { NotificationCenter.default.removeObserver($0) }
+ }
+}
+```
+
+**Cost**: 30 min implementation, prevents "camera freezes" bug reports
+
+### Pattern 6: Camera Switching (Front/Back)
+
+**Use case**: Toggle between front and back cameras.
+
+```swift
+func switchCamera() {
+ sessionQueue.async { [self] in
+ guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else {
+ return
+ }
+
+ let currentPosition = currentInput.device.position
+ let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
+
+ guard let newDevice = AVCaptureDevice.default(
+ .builtInWideAngleCamera,
+ for: .video,
+ position: newPosition
+ ) else {
+ return
+ }
+
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ // Remove old input
+ session.removeInput(currentInput)
+
+ // Add new input
+ do {
+ let newInput = try AVCaptureDeviceInput(device: newDevice)
+ if session.canAddInput(newInput) {
+ session.addInput(newInput)
+
+ // Update rotation coordinator for new device
+ if let previewLayer = previewLayer {
+ setupRotationCoordinator(device: newDevice, previewLayer: previewLayer)
+ }
+ } else {
+ // Fallback: restore old input
+ session.addInput(currentInput)
+ }
+ } catch {
+ session.addInput(currentInput)
+ }
+ }
+}
+```
+
+**Front camera mirroring**: Front camera preview is mirrored by default (matches user expectation). Captured photos are NOT mirrored (correct for sharing). This is intentional.
+
+**Cost**: 20 min implementation
+
+### Pattern 7: Video Recording
+
+**Use case**: Record video with audio to file.
+
+```swift
+class CameraManager: NSObject {
+ let movieOutput = AVCaptureMovieFileOutput()
+ private var currentRecordingURL: URL?
+
+ func setupVideoRecording() {
+ sessionQueue.async { [self] in
+ session.beginConfiguration()
+ defer { session.commitConfiguration() }
+
+ // Set video preset
+ session.sessionPreset = .high // Or .hd1920x1080, .hd4K3840x2160
+
+ // Add microphone input
+ if let microphone = AVCaptureDevice.default(for: .audio),
+ let audioInput = try? AVCaptureDeviceInput(device: microphone),
+ session.canAddInput(audioInput) {
+ session.addInput(audioInput)
+ }
+
+ // Add movie output
+ if session.canAddOutput(movieOutput) {
+ session.addOutput(movieOutput)
+ }
+ }
+ }
+
+ func startRecording() {
+ guard !movieOutput.isRecording else { return }
+
+ let outputURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("mov")
+
+ currentRecordingURL = outputURL
+
+ // Apply rotation
+ if let connection = movieOutput.connection(with: .video) {
+ connection.videoRotationAngle = captureRotationAngle()
+ }
+
+ movieOutput.startRecording(to: outputURL, recordingDelegate: self)
+ }
+
+ func stopRecording() {
+ guard movieOutput.isRecording else { return }
+ movieOutput.stopRecording()
+ }
+}
+
+extension CameraManager: AVCaptureFileOutputRecordingDelegate {
+ func fileOutput(_ output: AVCaptureFileOutput,
+ didFinishRecordingTo outputFileURL: URL,
+ from connections: [AVCaptureConnection],
+ error: Error?) {
+ if let error = error {
+ print("Recording error: \(error)")
+ return
+ }
+
+ // Video saved to outputFileURL
+ saveVideoToPhotoLibrary(outputFileURL)
+ }
+}
+```
+
+**Cost**: 45 min implementation
+
+## Anti-Patterns
+
+### Anti-Pattern 1: Session Work on Main Thread
+
+**Wrong**:
+```swift
+func startCamera() {
+ session.startRunning() // Blocks UI for 1-3 seconds!
+}
+```
+
+**Right**:
+```swift
+func startCamera() {
+ sessionQueue.async { [self] in
+ session.startRunning()
+ }
+}
+```
+
+**Why it matters**: `startRunning()` is blocking. On main thread, UI freezes.
+
+### Anti-Pattern 2: Using Deprecated videoOrientation
+
+**Wrong** (pre-iOS 17):
+```swift
+// Manually tracking orientation
+NotificationCenter.default.addObserver(
+ forName: UIDevice.orientationDidChangeNotification,
+ object: nil,
+ queue: .main
+) { _ in
+ // Manual rotation logic...
+}
+```
+
+**Right** (iOS 17+):
+```swift
+let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview)
+// Automatically tracks gravity, provides angles
+```
+
+**Why it matters**: RotationCoordinator handles edge cases (face-up, face-down) that manual tracking misses.
+
+### Anti-Pattern 3: Ignoring Session Interruptions
+
+**Wrong**:
+```swift
+// No interruption handling - camera freezes on phone call
+```
+
+**Right**:
+```swift
+NotificationCenter.default.addObserver(
+ forName: .AVCaptureSessionWasInterrupted,
+ object: session,
+ queue: .main
+) { notification in
+ // Show UI feedback
+}
+```
+
+**Why it matters**: Without handling, camera appears frozen when interrupted.
+
+### Anti-Pattern 4: Modifying Session Without Configuration Block
+
+**Wrong**:
+```swift
+session.removeInput(oldInput)
+session.addInput(newInput) // May fail mid-stream
+```
+
+**Right**:
+```swift
+session.beginConfiguration()
+session.removeInput(oldInput)
+session.addInput(newInput)
+session.commitConfiguration() // Atomic change
+```
+
+**Why it matters**: Without configuration block, session may enter invalid state between calls.
+
+## Pressure Scenarios
+
+### Scenario 1: "Just Make the Camera Work by Friday"
+
+**Context**: Product wants camera feature shipped. You're considering skipping interruption handling.
+
+**Pressure**: "It works when I test it, let's ship."
+
+**Reality**: First user who gets a phone call while using camera will see frozen UI. App Store review may catch this.
+
+**Correct action**:
+1. Implement interruption handling (30 min)
+2. Test by calling your test device during camera use
+3. Verify UI shows appropriate feedback
+
+**Push-back template**: "Camera captures work, but the app freezes if a phone call comes in. I need 30 minutes to handle interruptions properly and avoid 1-star reviews."
+
+### Scenario 2: "The Camera is Too Slow"
+
+**Context**: QA reports photo capture feels sluggish. PM wants it "instant like the system camera."
+
+**Pressure**: "Just make it faster somehow."
+
+**Reality**: Default settings prioritize quality over speed. System camera uses deferred processing.
+
+**Correct action**:
+1. Set `photoQualityPrioritization = .speed` for social/sharing use cases
+2. Consider deferred processing for maximum responsiveness
+3. Show capture animation immediately (before processing completes)
+
+**Push-back template**: "We're currently optimizing for image quality. I can make capture feel instant by prioritizing speed and showing the preview immediately while processing continues in background. This is what the system Camera app does."
+
+### Scenario 3: "Why is the Front Camera Photo Mirrored?"
+
+**Context**: Designer reports front camera photos look "wrong" - they're not mirrored like the preview.
+
+**Pressure**: "The preview shows it one way, the photo should match."
+
+**Reality**: Preview is mirrored (user expectation - like a mirror). Photo is NOT mirrored (correct for sharing - text reads correctly). This is intentional behavior matching system camera.
+
+**Correct action**:
+1. Explain this is Apple's standard behavior
+2. If business requires mirrored photos (selfie apps), manually mirror in post-processing
+3. Never mirror the preview differently than expected
+
+**Push-back template**: "This is intentional Apple behavior. The preview is mirrored like a mirror so users can frame themselves, but the captured photo is unmirrored so text reads correctly when shared. We can add optional mirroring in post-processing if our use case requires it."
+
+## Checklist
+
+Before shipping camera features:
+
+**Session Setup**:
+- ☑ All session work on dedicated serial queue
+- ☑ `startRunning()` never called on main thread
+- ☑ Session preset matches use case (`.photo` for photos, `.high` for video)
+- ☑ Configuration changes wrapped in `beginConfiguration()`/`commitConfiguration()`
+
+**Permissions**:
+- ☑ Camera permission requested before session setup
+- ☑ `NSCameraUsageDescription` in Info.plist
+- ☑ `NSMicrophoneUsageDescription` if recording audio
+- ☑ Graceful handling of denied permission
+
+**Rotation**:
+- ☑ RotationCoordinator used (not deprecated videoOrientation)
+- ☑ Preview layer rotation updated via observation
+- ☑ Capture rotation angle applied when taking photos
+- ☑ Tested in all orientations (portrait, landscape, face-up)
+
+**Responsiveness**:
+- ☑ photoQualityPrioritization set appropriately for use case
+- ☑ Capture button shows immediate feedback
+- ☑ Deferred processing considered for maximum speed
+
+**Interruptions**:
+- ☑ Session interruption observer registered
+- ☑ UI feedback shown when interrupted
+- ☑ Tested with incoming phone call
+- ☑ Tested in Split View (iPad)
+
+**Camera Switching**:
+- ☑ Front/back switch updates rotation coordinator
+- ☑ Switch happens on session queue
+- ☑ Fallback if new camera unavailable
+
+**Video Recording** (if applicable):
+- ☑ Microphone input added
+- ☑ Recording delegate handles completion
+- ☑ File cleanup for temporary recordings
+
+## Resources
+
+**WWDC**: 2021-10247, 2023-10105
+
+**Docs**: /avfoundation/avcapturesession, /avfoundation/avcapturedevice/rotationcoordinator, /avfoundation/avcapturephotosettings, /avfoundation/avcapturephotooutputreadinesscoordinator
+
+**Skills**: axiom-camera-capture-ref, axiom-camera-capture-diag, axiom-photo-library
diff --git a/.claude/skills/axiom-camera-capture/agents/openai.yaml b/.claude/skills/axiom-camera-capture/agents/openai.yaml
new file mode 100644
index 0000000..da58d5e
--- /dev/null
+++ b/.claude/skills/axiom-camera-capture/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Camera Capture"
+ short_description: "AVCaptureSession, camera preview, photo capture, video recording, RotationCoordinator, session interruptions, deferre..."
diff --git a/.claude/skills/axiom-cloud-sync-diag/.openskills.json b/.claude/skills/axiom-cloud-sync-diag/.openskills.json
new file mode 100644
index 0000000..7f1a790
--- /dev/null
+++ b/.claude/skills/axiom-cloud-sync-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-cloud-sync-diag",
+ "installedAt": "2026-04-12T08:06:01.371Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-cloud-sync-diag/SKILL.md b/.claude/skills/axiom-cloud-sync-diag/SKILL.md
new file mode 100644
index 0000000..c864f85
--- /dev/null
+++ b/.claude/skills/axiom-cloud-sync-diag/SKILL.md
@@ -0,0 +1,562 @@
+---
+name: axiom-cloud-sync-diag
+description: Use when debugging 'file not syncing', 'CloudKit error', 'sync conflict', 'iCloud upload failed', 'ubiquitous item error', 'data not appearing on other devices', 'CKError', 'quota exceeded' - systematic iCloud sync diagnostics for both CloudKit and iCloud Drive
+license: MIT
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-12"
+---
+
+# iCloud Sync Diagnostics
+
+## Overview
+
+**Core principle** 90% of cloud sync problems stem from account/entitlement issues, network connectivity, or misunderstanding sync timing—not iCloud infrastructure bugs.
+
+iCloud (both CloudKit and iCloud Drive) handles billions of sync operations daily across all Apple devices. If your data isn't syncing, the issue is almost always configuration, connectivity, or timing expectations.
+
+## Red Flags — Suspect Cloud Sync Issue
+
+If you see ANY of these:
+- Files/data not appearing on other devices
+- "iCloud account not available" errors
+- Persistent sync conflicts
+- CloudKit quota exceeded
+- Upload/download stuck at 0%
+- Works on simulator but not device
+- Works on WiFi but not cellular
+
+❌ **FORBIDDEN** "iCloud is broken, we should build our own sync"
+- iCloud infrastructure handles trillions of operations
+- Building reliable sync is incredibly complex
+- 99% of issues are configuration or connectivity
+
+---
+
+## Mandatory First Steps
+
+**ALWAYS check these FIRST** (before changing code):
+
+```swift
+// 1. Check iCloud account status
+func checkICloudStatus() async {
+ let status = FileManager.default.ubiquityIdentityToken
+
+ if status == nil {
+ print("❌ Not signed into iCloud")
+ print("Settings → [Name] → iCloud → Sign in")
+ return
+ }
+
+ print("✅ Signed into iCloud")
+
+ // For CloudKit specifically
+ let container = CKContainer.default()
+ do {
+ let status = try await container.accountStatus()
+ switch status {
+ case .available:
+ print("✅ CloudKit available")
+ case .noAccount:
+ print("❌ No iCloud account")
+ case .restricted:
+ print("❌ iCloud restricted (parental controls?)")
+ case .couldNotDetermine:
+ print("⚠️ Could not determine status")
+ case .temporarilyUnavailable:
+ print("⚠️ Temporarily unavailable (retry)")
+ @unknown default:
+ print("⚠️ Unknown status")
+ }
+ } catch {
+ print("Error checking CloudKit: \(error)")
+ }
+}
+
+// 2. Check entitlements
+func checkEntitlements() {
+ // Verify iCloud container exists
+ if let containerURL = FileManager.default.url(
+ forUbiquityContainerIdentifier: nil
+ ) {
+ print("✅ iCloud container: \(containerURL)")
+ } else {
+ print("❌ No iCloud container")
+ print("Check Xcode → Signing & Capabilities → iCloud")
+ }
+}
+
+// 3. Check network connectivity
+func checkConnectivity() {
+ // Use NWPathMonitor or similar
+ print("Network: Check if device has internet")
+ print("Try on different networks (WiFi, cellular)")
+}
+
+// 4. Check device storage
+func checkStorage() {
+ let homeURL = FileManager.default.homeDirectoryForCurrentUser
+ if let values = try? homeURL.resourceValues(forKeys: [
+ .volumeAvailableCapacityKey
+ ]) {
+ let available = values.volumeAvailableCapacity ?? 0
+ print("Available space: \(available / 1_000_000) MB")
+
+ if available < 100_000_000 { // <100 MB
+ print("⚠️ Low storage may prevent sync")
+ }
+ }
+}
+```
+
+---
+
+## Decision Tree
+
+### CloudKit Sync Issues
+
+```
+CloudKit data not syncing?
+
+├─ Account unavailable?
+│ ├─ Check: await container.accountStatus()
+│ ├─ .noAccount → User not signed into iCloud
+│ ├─ .restricted → Parental controls or corporate restrictions
+│ └─ .temporarilyUnavailable → Network issue or iCloud outage
+│
+├─ CKError.quotaExceeded?
+│ └─ User exceeded iCloud storage quota
+│ → Prompt user to purchase more storage
+│ → Or delete old data
+│
+├─ CKError.networkUnavailable?
+│ └─ No internet connection
+│ → Check WiFi/cellular
+│ → Test on different network
+│
+├─ CKError.serverRecordChanged (conflict)?
+│ └─ Concurrent modifications
+│ → Implement conflict resolution
+│ → Use savePolicy correctly
+│
+└─ SwiftData not syncing?
+ ├─ Check ModelConfiguration CloudKit setup
+ ├─ Verify private database only (no public/shared)
+ └─ Check for @Attribute(.unique) (not supported with CloudKit)
+```
+
+### iCloud Drive Sync Issues
+
+```
+iCloud Drive files not syncing?
+
+├─ File not uploading?
+│ ├─ Check: url.resourceValues(.ubiquitousItemIsUploadingKey)
+│ ├─ Check: url.resourceValues(.ubiquitousItemUploadingErrorKey)
+│ └─ Error details will indicate issue
+│
+├─ File not downloading?
+│ ├─ Not requested? → startDownloadingUbiquitousItem(at:)
+│ ├─ Check: url.resourceValues(.ubiquitousItemDownloadingErrorKey)
+│ └─ May need manual download trigger
+│
+├─ File has conflicts?
+│ ├─ Check: url.resourceValues(.ubiquitousItemHasUnresolvedConflictsKey)
+│ └─ Resolve with NSFileVersion
+│
+└─ Files not appearing on other device?
+ ├─ Check iCloud account on both devices (same account?)
+ ├─ Check entitlements match on both
+ ├─ Wait (sync not instant, can take minutes)
+ └─ Check Settings → iCloud → iCloud Drive → [App] is enabled
+```
+
+---
+
+## Common CloudKit Errors
+
+### CKError.accountTemporarilyUnavailable
+
+**Cause**: iCloud servers temporarily unavailable or user signed out
+
+**Fix**:
+```swift
+if error.code == .accountTemporarilyUnavailable {
+ // Retry with exponential backoff
+ try await Task.sleep(for: .seconds(5))
+ try await retryOperation()
+}
+```
+
+### CKError.quotaExceeded
+
+**Cause**: User's iCloud storage full
+
+**Fix**:
+```swift
+if error.code == .quotaExceeded {
+ // Show alert to user
+ showAlert(
+ title: "iCloud Storage Full",
+ message: "Please free up space in Settings → [Name] → iCloud → Manage Storage"
+ )
+}
+```
+
+### CKError.serverRecordChanged
+
+**Cause**: Record modified on server since your last fetch. **Most common root cause**: saving a stale record without fetching the latest version first.
+
+**Diagnosis — check the simple fix FIRST**:
+```swift
+// ❌ WRONG: Saving without fetching latest version
+// This causes serverRecordChanged on EVERY concurrent edit
+let record = CKRecord(recordType: "Note", recordID: existingID)
+record["title"] = "Updated"
+try await database.save(record) // Overwrites server version → conflict
+
+// ✅ FIX: Fetch-then-modify-then-save (fixes 80% of cases)
+let record = try await database.record(for: existingID) // Get latest
+record["title"] = "Updated" // Modify the fetched record
+try await database.save(record) // Save with correct changeTag
+```
+
+**If fetch-then-save doesn't fix it** (true concurrent edits from multiple devices):
+```swift
+if error.code == .serverRecordChanged,
+ let serverRecord = error.serverRecord,
+ let clientRecord = error.clientRecord {
+ // Merge records — only needed for real multi-device conflicts
+ let merged = mergeRecords(server: serverRecord, client: clientRecord)
+ try await database.save(merged)
+}
+```
+
+### CKError.networkUnavailable
+
+**Cause**: No internet connection
+
+**Fix**:
+```swift
+if error.code == .networkUnavailable {
+ // Queue for retry when online
+ queueOperation(for: .whenOnline)
+
+ // Or show offline indicator
+ showOfflineIndicator()
+}
+```
+
+### Silent Data Loss in Batch Operations
+
+**Symptom**: Sync appears to work but records silently disappear or fail to save.
+
+**Common causes**:
+
+| Cause | Symptom | Fix |
+|-------|---------|-----|
+| Record size > 1 MB | Individual records silently dropped from batch | Split large data into CKAsset |
+| Batch partial failure | Some records save, others fail silently | Check `perRecordSaveBlock` for per-record errors |
+| Conflict auto-resolution | Last-writer-wins overwrites valid data | Implement merge-based conflict resolution |
+| Asset download not triggered | Record syncs but CKAsset content missing | Call `fetchRecordZoneChanges` with `desiredKeys` |
+
+**Diagnosis**:
+```swift
+// ❌ WRONG: Batch save with no per-record error handling
+let operation = CKModifyRecordsOperation(recordsToSave: records)
+operation.modifyRecordsResultBlock = { result in
+ // Only catches operation-level failures — misses per-record errors
+}
+
+// ✅ CORRECT: Check each record individually
+let operation = CKModifyRecordsOperation(recordsToSave: records)
+operation.perRecordSaveBlock = { recordID, result in
+ switch result {
+ case .success(let record):
+ print("✅ Saved: \(recordID)")
+ case .failure(let error):
+ print("❌ Failed: \(recordID) — \(error)")
+ // Log for retry — this record was silently lost otherwise
+ }
+}
+```
+
+---
+
+## Common iCloud Drive Errors
+
+### Upload Errors
+
+```swift
+// ✅ Check upload error
+func checkUploadError(url: URL) {
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemUploadingErrorKey
+ ])
+
+ if let error = values?.ubiquitousItemUploadingError {
+ print("Upload error: \(error.localizedDescription)")
+
+ if (error as NSError).code == NSFileWriteOutOfSpaceError {
+ print("iCloud storage full")
+ }
+ }
+}
+```
+
+### Download Errors
+
+```swift
+// ✅ Check download error
+func checkDownloadError(url: URL) {
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemDownloadingErrorKey
+ ])
+
+ if let error = values?.ubiquitousItemDownloadingError {
+ print("Download error: \(error.localizedDescription)")
+
+ // Common errors:
+ // - Network unavailable
+ // - Account unavailable
+ // - File deleted on server
+ }
+}
+```
+
+---
+
+## Debugging Patterns
+
+### Pattern 1: CloudKit Operation Not Completing
+
+**Symptom**: Save/fetch never completes, no error
+
+**Diagnosis**:
+```swift
+// Add timeout
+Task {
+ try await withTimeout(seconds: 30) {
+ try await database.save(record)
+ }
+}
+
+// Log operation lifecycle
+operation.database = database
+operation.completionBlock = {
+ print("Operation completed")
+}
+operation.qualityOfService = .userInitiated
+
+// Check if operation was cancelled
+if operation.isCancelled {
+ print("Operation was cancelled")
+}
+```
+
+**Common causes**:
+- No network connectivity
+- Account issues
+- Operation cancelled prematurely
+
+### Pattern 2: SwiftData CloudKit Not Syncing
+
+**Symptom**: SwiftData saves locally but doesn't sync
+
+**Diagnosis**:
+```swift
+// 1. Verify CloudKit configuration
+let config = ModelConfiguration(
+ cloudKitDatabase: .private("iCloud.com.example.app")
+)
+
+// 2. Check for incompatible attributes
+// ❌ @Attribute(.unique) not supported with CloudKit
+@Model
+class Task {
+ @Attribute(.unique) var id: UUID // ← Remove this
+ var title: String
+}
+
+// 3. Check all properties have defaults or are optional
+@Model
+class Task {
+ var title: String = "" // ✅ Has default
+ var dueDate: Date? // ✅ Optional
+}
+```
+
+### Pattern 3: File Coordinator Deadlock
+
+**Symptom**: File operations hang
+
+**Diagnosis**:
+```swift
+// ❌ WRONG: Nested coordination can deadlock
+coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in
+ // Don't create another coordinator here!
+ anotherCoordinator.coordinate(...) // ← Deadlock risk
+}
+
+// ✅ CORRECT: Single coordinator per operation
+coordinator.coordinate(writingItemAt: url, options: [], error: nil) { newURL in
+ // Direct file operations only
+ try data.write(to: newURL)
+}
+```
+
+### Pattern 4: Conflicts Not Resolving
+
+**Symptom**: Conflicts persist even after resolution
+
+**Diagnosis**:
+```swift
+// ❌ WRONG: Not marking as resolved
+let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url)
+for conflict in conflicts ?? [] {
+ // Missing: conflict.isResolved = true
+}
+
+// ✅ CORRECT: Mark resolved and remove
+for conflict in conflicts ?? [] {
+ conflict.isResolved = true
+}
+try NSFileVersion.removeOtherVersionsOfItem(at: url)
+```
+
+---
+
+## Production Crisis Scenario
+
+**SYMPTOM**: Users report data not syncing after app update
+
+**DIAGNOSIS STEPS** (run in order):
+
+1. **Check account status** (2 min):
+ ```swift
+ // On affected device
+ let status = FileManager.default.ubiquityIdentityToken
+ // nil? → Not signed in
+ ```
+
+2. **Verify entitlements unchanged** (5 min):
+ - Compare old vs new build entitlements
+ - Verify container IDs match
+
+3. **Check for breaking changes** (10 min):
+ - Did CloudKit schema change?
+ - Did ubiquitous container ID change?
+ - Are old and new versions compatible?
+
+4. **Test on clean device** (15 min):
+ - Factory reset device or use new test device
+ - Sign into iCloud
+ - Install app
+ - Does sync work on fresh install?
+
+**ROOT CAUSES** (90% of cases):
+- Entitlements changed/corrupted in build
+- CloudKit container ID mismatch
+- Breaking schema changes
+- Account restrictions (new parental controls, etc.)
+
+**FIX**:
+- Verify entitlements in build
+- Test migration path from old version
+- Add better error handling and user messaging
+
+---
+
+## Monitoring
+
+### CloudKit Console (recommended - WWDC 2024)
+
+**Access**: https://icloud.developer.apple.com/dashboard
+
+**Monitor**:
+- Error rates by type
+- Latency percentiles (p50, p95, p99)
+- Quota usage
+- Request volume
+
+**Set alerts for**:
+- High error rate (>5%)
+- Quota approaching limit (>80%)
+- Latency spikes
+
+### Client-Side Logging
+
+```swift
+// ✅ Log all CloudKit operations
+extension CKDatabase {
+ func saveWithLogging(_ record: CKRecord) async throws {
+ print("Saving record: \(record.recordID)")
+ let start = Date()
+
+ do {
+ try await self.save(record)
+ let duration = Date().timeIntervalSince(start)
+ print("✅ Saved in \(duration)s")
+ } catch let error as CKError {
+ print("❌ Save failed: \(error.code), \(error.localizedDescription)")
+ throw error
+ }
+ }
+}
+```
+
+---
+
+## Quick Diagnostic Checklist
+
+```swift
+func diagnoseCloudSyncIssue() async {
+ print("=== Cloud Sync Diagnosis ===")
+
+ // 1. Account
+ await checkICloudStatus()
+
+ // 2. Entitlements
+ checkEntitlements()
+
+ // 3. Network
+ checkConnectivity()
+
+ // 4. Storage
+ checkStorage()
+
+ // 5. For CloudKit
+ let container = CKContainer.default()
+ do {
+ let status = try await container.accountStatus()
+ print("CloudKit status: \(status)")
+ } catch {
+ print("CloudKit error: \(error)")
+ }
+
+ // 6. For iCloud Drive
+ if let url = getICloudContainerURL() {
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemDownloadingErrorKey,
+ .ubiquitousItemUploadingErrorKey
+ ])
+ print("Download error: \(values?.ubiquitousItemDownloadingError?.localizedDescription ?? "none")")
+ print("Upload error: \(values?.ubiquitousItemUploadingError?.localizedDescription ?? "none")")
+ }
+
+ print("=== End Diagnosis ===")
+}
+```
+
+---
+
+## Related Skills
+
+- `axiom-cloudkit-ref` — CloudKit implementation details
+- `axiom-icloud-drive-ref` — iCloud Drive implementation details
+- `axiom-storage` — Choose sync approach
+
+---
+
+**Last Updated**: 2025-12-12
+**Skill Type**: Diagnostic
diff --git a/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml b/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml
new file mode 100644
index 0000000..80df8bb
--- /dev/null
+++ b/.claude/skills/axiom-cloud-sync-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Cloud Sync Diagnostics"
+ short_description: "Debugging 'file not syncing', 'CloudKit error', 'sync conflict', 'iCloud upload failed', 'ubiquitous item error', 'da..."
diff --git a/.claude/skills/axiom-cloud-sync/.openskills.json b/.claude/skills/axiom-cloud-sync/.openskills.json
new file mode 100644
index 0000000..8c5f299
--- /dev/null
+++ b/.claude/skills/axiom-cloud-sync/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-cloud-sync",
+ "installedAt": "2026-04-12T08:06:00.825Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-cloud-sync/SKILL.md b/.claude/skills/axiom-cloud-sync/SKILL.md
new file mode 100644
index 0000000..affd1d3
--- /dev/null
+++ b/.claude/skills/axiom-cloud-sync/SKILL.md
@@ -0,0 +1,423 @@
+---
+name: axiom-cloud-sync
+description: Use when choosing between CloudKit vs iCloud Drive, implementing reliable sync, handling offline-first patterns, or designing sync architecture - prevents common sync mistakes that cause data loss
+license: MIT
+compatibility: iOS 10+, macOS 10.12+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-25"
+---
+
+# Cloud Sync
+
+## Overview
+
+**Core principle**: Choose the right sync technology for the data shape, then implement offline-first patterns that handle network failures gracefully.
+
+Two fundamentally different sync approaches:
+- **CloudKit** — Structured data (records with fields and relationships)
+- **iCloud Drive** — File-based data (documents, images, any file format)
+
+## Quick Decision Tree
+
+```
+What needs syncing?
+
+├─ Structured data (records, relationships)?
+│ ├─ Using SwiftData? → SwiftData + CloudKit (easiest, iOS 17+)
+│ ├─ Need shared/public database? → CKSyncEngine or raw CloudKit
+│ └─ Custom persistence (GRDB, SQLite)? → CKSyncEngine (iOS 17+)
+│
+├─ Documents/files users expect in Files app?
+│ └─ iCloud Drive (UIDocument or FileManager)
+│
+├─ Large binary blobs (images, videos)?
+│ ├─ Associated with structured data? → CKAsset in CloudKit
+│ └─ Standalone files? → iCloud Drive
+│
+└─ App settings/preferences?
+ └─ NSUbiquitousKeyValueStore (simple key-value, 1MB limit)
+```
+
+## CloudKit vs iCloud Drive
+
+| Aspect | CloudKit | iCloud Drive |
+|--------|----------|--------------|
+| **Data shape** | Structured records | Files/documents |
+| **Query support** | Full query language | Filename only |
+| **Relationships** | Native support | None (manual) |
+| **Conflict resolution** | Record-level | File-level |
+| **User visibility** | Hidden from user | Visible in Files app |
+| **Sharing** | Record/database sharing | File sharing |
+| **Offline** | Local cache required | Automatic download |
+
+## Red Flags
+
+If ANY of these appear, STOP and reconsider:
+
+- ❌ "Store JSON files in CloudKit" — Wrong tool. Use iCloud Drive for files
+- ❌ "Build relationships manually in iCloud Drive" — Wrong tool. Use CloudKit
+- ❌ "Assume sync is instant" — Network fails. Design offline-first
+- ❌ "Skip conflict handling" — Conflicts WILL happen on multiple devices
+- ❌ "Use CloudKit for user documents" — Users can't see them. Use iCloud Drive
+- ❌ "Sync on app launch only" — Users expect continuous sync
+
+## Offline-First Pattern
+
+**MANDATORY**: All sync code must work offline first.
+
+```swift
+// ✅ CORRECT: Offline-first architecture
+class OfflineFirstSync {
+ private let localStore: LocalDatabase // GRDB, SwiftData, Core Data
+ private let syncEngine: CKSyncEngine
+
+ // Write to LOCAL first, sync to cloud in background
+ func save(_ item: Item) async throws {
+ // 1. Save locally (instant)
+ try await localStore.save(item)
+
+ // 2. Queue for sync (non-blocking)
+ syncEngine.state.add(pendingRecordZoneChanges: [
+ .saveRecord(item.recordID)
+ ])
+ }
+
+ // Read from LOCAL (instant)
+ func fetch() async throws -> [Item] {
+ return try await localStore.fetchAll()
+ }
+}
+
+// ❌ WRONG: Cloud-first (blocks on network)
+func save(_ item: Item) async throws {
+ // Fails when offline, slow on bad network
+ try await cloudKit.save(item)
+ try await localStore.save(item)
+}
+```
+
+## Conflict Resolution Strategies
+
+Conflicts occur when two devices edit the same data before syncing.
+
+### Strategy 1: Last-Writer-Wins (Simplest)
+
+```swift
+// Server always has latest, client accepts it
+func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
+ return server // Accept server version
+}
+```
+
+**Use when**: Data is non-critical, user won't notice overwrites
+
+### Strategy 2: Merge (Most Common)
+
+```swift
+// Combine changes from both versions
+func resolveConflict(local: CKRecord, server: CKRecord) -> CKRecord {
+ let merged = server.copy() as! CKRecord
+
+ // For each field, apply custom merge logic
+ merged["notes"] = mergeText(
+ local["notes"] as? String,
+ server["notes"] as? String
+ )
+ merged["tags"] = mergeSets(
+ local["tags"] as? [String] ?? [],
+ server["tags"] as? [String] ?? []
+ )
+
+ return merged
+}
+```
+
+**Use when**: Both versions contain valuable changes
+
+### Strategy 3: User Choice
+
+```swift
+// Present conflict to user
+func resolveConflict(local: CKRecord, server: CKRecord) async -> CKRecord {
+ let choice = await presentConflictUI(local: local, server: server)
+ return choice == .keepLocal ? local : server
+}
+```
+
+**Use when**: Data is critical, user must decide
+
+## Common Patterns
+
+### Pattern 1: SwiftData + CloudKit (Recommended for New Apps)
+
+```swift
+import SwiftData
+
+// Automatic CloudKit sync with zero configuration
+@Model
+class Note {
+ var title: String
+ var content: String
+ var createdAt: Date
+
+ init(title: String, content: String) {
+ self.title = title
+ self.content = content
+ self.createdAt = Date()
+ }
+}
+
+// Container automatically syncs if CloudKit entitlement present
+let container = try ModelContainer(for: Note.self)
+```
+
+**Limitations**:
+- Private database only (no public/shared)
+- Automatic sync (less control over timing)
+- No custom conflict resolution
+- `@Attribute(.unique)` not supported with CloudKit sync — remove if using CloudKit
+
+### Pattern 2: CKSyncEngine (Custom Persistence)
+
+```swift
+// For GRDB, SQLite, or custom databases
+class MySyncManager: CKSyncEngineDelegate {
+ private let engine: CKSyncEngine
+ private let database: GRDBDatabase
+
+ func handleEvent(_ event: CKSyncEngine.Event) async {
+ switch event {
+ case .stateUpdate(let update):
+ // Persist sync state
+ await saveSyncState(update.stateSerialization)
+
+ case .fetchedDatabaseChanges(let changes):
+ // Apply changes to local DB
+ for zone in changes.modifications {
+ await handleZoneChanges(zone)
+ }
+
+ case .sentRecordZoneChanges(let sent):
+ // Mark records as synced
+ for saved in sent.savedRecords {
+ await markSynced(saved.recordID)
+ }
+ }
+ }
+}
+```
+
+See `axiom-cloudkit-ref` for complete CKSyncEngine setup.
+
+### Pattern 3: iCloud Drive Documents
+
+```swift
+import UIKit
+
+class MyDocument: UIDocument {
+ var content: Data?
+
+ override func contents(forType typeName: String) throws -> Any {
+ return content ?? Data()
+ }
+
+ override func load(fromContents contents: Any, ofType typeName: String?) throws {
+ content = contents as? Data
+ }
+}
+
+// Save to iCloud Drive (visible in Files app)
+let url = FileManager.default.url(forUbiquityContainerIdentifier: nil)?
+ .appendingPathComponent("Documents")
+ .appendingPathComponent("MyFile.txt")
+
+let doc = MyDocument(fileURL: url!)
+doc.content = "Hello".data(using: .utf8)
+doc.save(to: url!, for: .forCreating)
+```
+
+See `axiom-icloud-drive-ref` for NSFileCoordinator and conflict handling.
+
+## Anti-Patterns
+
+### 1. Ignoring Sync State
+
+```swift
+// ❌ WRONG: No awareness of pending changes
+var items: [Item] = [] // Are these synced? Pending? Conflicted?
+
+// ✅ CORRECT: Track sync state
+struct SyncableItem {
+ let item: Item
+ let syncState: SyncState // .synced, .pending, .conflict
+}
+```
+
+### 2. Blocking UI on Sync
+
+```swift
+// ❌ WRONG: UI blocks until sync completes
+func viewDidLoad() async {
+ items = try await cloudKit.fetchAll() // Spinner forever on airplane
+ tableView.reloadData()
+}
+
+// ✅ CORRECT: Show local data immediately
+func viewDidLoad() {
+ items = localStore.fetchAll() // Instant
+ tableView.reloadData()
+
+ Task {
+ await syncEngine.fetchChanges() // Background update
+ }
+}
+```
+
+### 3. CloudKit Schema Not Deployed to Production
+
+CloudKit has **separate schemas for Development and Production**. Your app in the App Store can only access the Production environment. If you add record types, fields, or indexes in Development but never deploy them, queries in Production return empty results with no error.
+
+```
+❌ Works in Xcode/TestFlight (Development) → empty results in App Store (Production)
+ Queries silently return zero results — no CKError, no crash, no clue.
+
+✅ Before every App Store submission:
+ 1. CloudKit Console → Select container
+ 2. "Deploy Schema Changes" → Review changes → Deploy
+ 3. Test with Production environment in Xcode scheme settings
+```
+
+**Time cost of skipping**: 3-7 days (rejection cycle + debugging "why does it work in TestFlight but not production?"). This is the #1 CloudKit gotcha for first-time submitters.
+
+### 4. No Retry Logic
+
+```swift
+// ❌ WRONG: Single attempt
+try await cloudKit.save(record)
+
+// ✅ CORRECT: Exponential backoff
+func saveWithRetry(_ record: CKRecord, attempts: Int = 3) async throws {
+ for attempt in 0.. CKSyncEngine.RecordZoneChangeBatch? {
+ // Return pending local changes
+ let pendingChanges = getPendingLocalChanges()
+ return CKSyncEngine.RecordZoneChangeBatch(
+ pendingSaves: pendingChanges,
+ recordIDsToDelete: []
+ )
+ }
+}
+```
+
+**Key concepts**:
+- **State serialization**: Persist sync state between app launches
+- **Events**: Delegate receives events for changes
+- **Batches**: You provide pending changes, engine uploads them
+- **Automatic conflict resolution**: Engine handles basic conflicts
+
+---
+
+## Approach 3: Raw CloudKit APIs (Legacy)
+
+**When to use**: Only if CKSyncEngine doesn't fit (rare)
+
+**Core types**:
+- `CKContainer` — Entry point
+- `CKDatabase` — Public/private/shared scope
+- `CKRecord` — Individual data record
+- `CKRecordZone` — Logical grouping
+- `CKAsset` — Binary file storage
+
+### Basic Operations
+
+```swift
+// ✅ Container and database
+let container = CKContainer.default()
+let privateDatabase = container.privateCloudDatabase
+let publicDatabase = container.publicCloudDatabase
+
+// ✅ Create record
+let record = CKRecord(recordType: "Task")
+record["title"] = "Buy groceries"
+record["isCompleted"] = false
+record["dueDate"] = Date()
+
+// ✅ Save record
+try await privateDatabase.save(record)
+
+// ✅ Fetch record
+let recordID = CKRecord.ID(recordName: "task-123")
+let fetchedRecord = try await privateDatabase.record(for: recordID)
+
+// ✅ Query records
+let predicate = NSPredicate(format: "isCompleted == NO")
+let query = CKQuery(recordType: "Task", predicate: predicate)
+let (matchResults, _) = try await privateDatabase.records(matching: query)
+
+for result in matchResults {
+ if case .success(let record) = result.1 {
+ print("Task: \(record["title"] as? String ?? "")")
+ }
+}
+
+// ✅ Delete record
+try await privateDatabase.deleteRecord(withID: recordID)
+```
+
+### Update Record
+
+```swift
+// ✅ Fetch-then-modify-then-save (prevents serverRecordChanged errors)
+let record = try await privateDatabase.record(for: recordID)
+record["title"] = "Updated title"
+record["isCompleted"] = true
+try await privateDatabase.save(record)
+
+// ✅ Batch modify (save + delete in one operation)
+let operation = CKModifyRecordsOperation(
+ recordsToSave: [updatedRecord1, updatedRecord2],
+ recordIDsToDelete: [deletedID]
+)
+operation.perRecordSaveBlock = { recordID, result in
+ switch result {
+ case .success: print("Saved: \(recordID)")
+ case .failure(let error): print("Failed: \(recordID) — \(error)")
+ }
+}
+try await privateDatabase.add(operation)
+```
+
+### Conflict Resolution
+
+```swift
+// ✅ Handle conflicts with savePolicy
+let operation = CKModifyRecordsOperation(
+ recordsToSave: [record],
+ recordIDsToDelete: nil
+)
+
+// Save only if server version unchanged
+operation.savePolicy = .ifServerRecordUnchanged
+
+// OR: Always overwrite server
+operation.savePolicy = .changedKeys // Only changed fields
+
+operation.modifyRecordsResultBlock = { result in
+ switch result {
+ case .success:
+ print("Saved")
+ case .failure(let error as CKError):
+ if error.code == .serverRecordChanged {
+ // Conflict - merge manually
+ let serverRecord = error.serverRecord
+ let clientRecord = error.clientRecord
+ let merged = mergeRecords(server: serverRecord, client: clientRecord)
+ // Retry with merged record
+ }
+ }
+}
+
+privateDatabase.add(operation)
+```
+
+---
+
+## Database Scopes
+
+| Scope | Accessibility | SwiftData Support | Use Case |
+|-------|---------------|-------------------|----------|
+| **Private** | User only | ✅ Yes | Personal user data |
+| **Public** | All users | ❌ No | Shared/public content |
+| **Shared** | Invited users | ❌ No | Collaboration |
+
+### Private Database
+
+```swift
+// ✅ Private database (most common)
+let privateDB = CKContainer.default().privateCloudDatabase
+
+// User must be signed into iCloud
+// Data syncs across user's devices
+// Not visible to other users
+```
+
+### Public Database
+
+```swift
+// ✅ Public database (for shared content)
+let publicDB = CKContainer.default().publicCloudDatabase
+
+// Accessible to all app users
+// Even unauthenticated users can read
+// Writes require authentication
+// Use for: Leaderboards, public content, discovery
+```
+
+### Shared Database
+
+```swift
+// ✅ Shared database (collaboration)
+let sharedDB = CKContainer.default().sharedCloudDatabase
+
+// For CKShare-based collaboration
+// Users invited to specific record zones
+// Use for: Shared documents, team data
+```
+
+---
+
+## CloudKit Assets (Files)
+
+```swift
+// ✅ Store files as CKAsset
+let imageURL = saveImageToTempFile(image) // Must be file URL
+let asset = CKAsset(fileURL: imageURL)
+
+let record = CKRecord(recordType: "Photo")
+record["image"] = asset
+record["caption"] = "Sunset"
+
+try await privateDatabase.save(record)
+
+// ✅ Retrieve asset
+let fetchedRecord = try await privateDatabase.record(for: recordID)
+if let asset = fetchedRecord["image"] as? CKAsset,
+ let fileURL = asset.fileURL {
+ let imageData = try Data(contentsOf: fileURL)
+ let image = UIImage(data: imageData)
+}
+```
+
+**Important**: CKAsset requires a **file URL**, not Data. Write data to temp file first.
+
+---
+
+## CloudKit Console (Monitoring - WWDC 2024)
+
+### Developer Notifications
+
+Set up alerts for:
+- Schema changes
+- Quota exceeded
+- High error rates
+- Custom thresholds
+
+### Telemetry
+
+Monitor:
+- Request count
+- Error rate
+- Latency (p50, p95, p99)
+- Bandwidth usage
+
+### Logs
+
+View:
+- Individual requests
+- Error details
+- Performance bottlenecks
+
+**Access**: https://icloud.developer.apple.com/dashboard
+
+---
+
+## Common Patterns
+
+### Pattern 1: Initial Sync
+
+```swift
+// ✅ Fetch all records on first launch
+func performInitialSync() async throws {
+ let predicate = NSPredicate(value: true) // All records
+ let query = CKQuery(recordType: "Task", predicate: predicate)
+
+ let (results, _) = try await privateDatabase.records(matching: query)
+
+ for result in results {
+ if case .success(let record) = result.1 {
+ saveToLocalDatabase(record)
+ }
+ }
+}
+```
+
+### Pattern 2: Incremental Sync
+
+```swift
+// ✅ Use CKServerChangeToken for incremental fetches
+func fetchChanges(since token: CKServerChangeToken?) async throws {
+ let zoneID = CKRecordZone.ID(zoneName: "Tasks")
+
+ let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration(
+ previousServerChangeToken: token
+ )
+
+ let operation = CKFetchRecordZoneChangesOperation(
+ recordZoneIDs: [zoneID],
+ configurationsByRecordZoneID: [zoneID: config]
+ )
+
+ operation.recordWasChangedBlock = { recordID, result in
+ if case .success(let record) = result {
+ updateLocalDatabase(with: record)
+ }
+ }
+
+ operation.recordWithIDWasDeletedBlock = { recordID, _ in
+ deleteFromLocalDatabase(recordID)
+ }
+
+ operation.recordZoneFetchResultBlock = { zoneID, result in
+ if case .success(let (token, _, _)) = result {
+ saveChangeToken(token) // For next fetch
+ }
+ }
+
+ try await privateDatabase.add(operation)
+}
+```
+
+---
+
+## Entitlements
+
+Required entitlements in Xcode:
+
+```xml
+
+com.apple.developer.icloud-services
+
+ CloudKit
+
+
+
+com.apple.developer.icloud-container-identifiers
+
+ iCloud.com.example.app
+
+```
+
+**Setup**:
+1. Xcode → Target → Signing & Capabilities
+2. "+ Capability" → iCloud
+3. Check "CloudKit"
+4. Select or create container
+
+---
+
+## Subscriptions (Push Notifications)
+
+### Database Subscription
+
+```swift
+// ✅ Get notified of ANY change in private database
+let subscription = CKDatabaseSubscription(subscriptionID: "all-changes")
+
+let notificationInfo = CKSubscription.NotificationInfo()
+notificationInfo.shouldSendContentAvailable = true // Silent push
+subscription.notificationInfo = notificationInfo
+
+try await privateDatabase.save(subscription)
+```
+
+### Query Subscription
+
+```swift
+// ✅ Get notified when records matching a query change
+let predicate = NSPredicate(format: "priority > 3")
+let subscription = CKQuerySubscription(
+ recordType: "Task",
+ predicate: predicate,
+ subscriptionID: "high-priority-tasks",
+ options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
+)
+
+let notificationInfo = CKSubscription.NotificationInfo()
+notificationInfo.alertBody = "High priority task changed"
+notificationInfo.shouldBadge = true
+subscription.notificationInfo = notificationInfo
+
+try await privateDatabase.save(subscription)
+```
+
+### Zone Subscription
+
+```swift
+// ✅ Get notified of any change in a specific zone
+let zoneID = CKRecordZone.ID(zoneName: "Tasks")
+let subscription = CKRecordZoneSubscription(
+ zoneID: zoneID,
+ subscriptionID: "tasks-zone"
+)
+
+let notificationInfo = CKSubscription.NotificationInfo()
+notificationInfo.shouldSendContentAvailable = true
+subscription.notificationInfo = notificationInfo
+
+try await privateDatabase.save(subscription)
+```
+
+### Handling Push Notifications
+
+```swift
+// In AppDelegate
+func application(_ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
+ let notification = CKNotification(fromRemoteNotificationDictionary: userInfo)
+
+ if notification.subscriptionID == "all-changes" {
+ try? await fetchChanges(since: savedChangeToken)
+ return .newData
+ }
+ return .noData
+}
+```
+
+---
+
+## Sharing Records
+
+### Create a Share
+
+```swift
+// ✅ Share a record with other users
+let record = try await privateDatabase.record(for: recordID)
+
+// Record must be in a custom zone (not default zone)
+let share = CKShare(rootRecord: record)
+share[CKShare.SystemFieldKey.title] = "Shared Task List"
+share.publicPermission = .none // Invite-only
+
+// Save both the record and share together
+let operation = CKModifyRecordsOperation(
+ recordsToSave: [record, share],
+ recordIDsToDelete: nil
+)
+try await privateDatabase.add(operation)
+```
+
+### Present Sharing UI
+
+```swift
+import CloudKit
+import UIKit
+
+// ✅ UIKit sharing controller
+let sharingController = UICloudSharingController(share: share, container: container)
+sharingController.delegate = self
+present(sharingController, animated: true)
+
+// Delegate methods
+extension ViewController: UICloudSharingControllerDelegate {
+ func cloudSharingController(_ csc: UICloudSharingController,
+ failedToSaveShareWithError error: Error) {
+ print("Share failed: \(error)")
+ }
+
+ func itemTitle(for csc: UICloudSharingController) -> String? {
+ return "My Shared List"
+ }
+}
+```
+
+### Manage Participants
+
+```swift
+// ✅ Check participants
+for participant in share.participants {
+ print("\(participant.userIdentity.nameComponents?.givenName ?? "Unknown")")
+ print(" Acceptance: \(participant.acceptanceStatus)")
+ print(" Permission: \(participant.permission)")
+ // .readOnly, .readWrite, .none
+}
+
+// ✅ Remove participant
+share.removeParticipant(participant)
+try await privateDatabase.save(share)
+```
+
+### Accept a Share
+
+```swift
+// In SceneDelegate or AppDelegate
+func userDidAcceptCloudKitShareWith(_ cloudKitShareMetadata: CKShare.Metadata) {
+ let operation = CKAcceptSharesOperation(shareMetadatas: [cloudKitShareMetadata])
+ operation.acceptSharesResultBlock = { result in
+ switch result {
+ case .success: print("Share accepted")
+ case .failure(let error): print("Accept failed: \(error)")
+ }
+ }
+ CKContainer(identifier: cloudKitShareMetadata.containerIdentifier)
+ .add(operation)
+}
+```
+
+---
+
+## Quick Reference
+
+| Task | Modern API (iOS 17+) | Legacy API |
+|------|----------------------|------------|
+| Structured data sync | SwiftData + CloudKit | CKSyncEngine or CKDatabase |
+| Custom persistence sync | CKSyncEngine | CKDatabase |
+| Conflict resolution | Automatic (SwiftData/CKSyncEngine) | Manual (savePolicy) |
+| Account changes | Handled automatically | Manual detection |
+| Monitoring | CloudKit Console telemetry | Manual logging |
+
+---
+
+## Related Skills
+
+- `axiom-swiftdata` — SwiftData implementation details
+- `axiom-storage` — Choose CloudKit vs iCloud Drive
+- `axiom-icloud-drive-ref` — File-based iCloud sync
+- `axiom-cloud-sync-diag` — Debug CloudKit sync issues
+
+---
+
+**Last Updated**: 2025-12-12
+**Skill Type**: Reference
+**Minimum iOS**: 10.0 (basic), 17.0 (CKSyncEngine, SwiftData integration)
+**WWDC Sessions**: 2023-10188 (CKSyncEngine), 2024-10122 (CloudKit Console)
diff --git a/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml b/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml
new file mode 100644
index 0000000..5ea7113
--- /dev/null
+++ b/.claude/skills/axiom-cloudkit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "CloudKit Reference"
+ short_description: "Implementing 'CloudKit sync', 'CKSyncEngine', 'CKRecord', 'CKDatabase', 'SwiftData CloudKit', 'shared database', 'pub..."
diff --git a/.claude/skills/axiom-codable/.openskills.json b/.claude/skills/axiom-codable/.openskills.json
new file mode 100644
index 0000000..fce86bc
--- /dev/null
+++ b/.claude/skills/axiom-codable/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-codable",
+ "installedAt": "2026-04-12T08:06:02.173Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-codable/SKILL.md b/.claude/skills/axiom-codable/SKILL.md
new file mode 100644
index 0000000..5cc8172
--- /dev/null
+++ b/.claude/skills/axiom-codable/SKILL.md
@@ -0,0 +1,832 @@
+---
+name: axiom-codable
+description: Use when working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies, custom containers, or encountering "Type does not conform to Decodable/Encodable" errors - comprehensive Codable patterns and anti-patterns for Swift 6.x
+license: MIT
+metadata:
+ version: "1.0"
+---
+
+# Swift Codable Patterns
+
+Comprehensive guide to Codable protocol conformance for JSON and PropertyList encoding/decoding in Swift 6.x.
+
+## Quick Reference
+
+### Decision Tree: When to Use Each Approach
+
+```
+Has your type...
+├─ All properties Codable? → Automatic synthesis (just add `: Codable`)
+├─ Property names differ from JSON keys? → CodingKeys customization
+├─ Needs to exclude properties? → CodingKeys customization
+├─ Enum with associated values? → Check enum synthesis patterns
+├─ Needs structural transformation? → Manual implementation + bridge types
+├─ Needs data not in JSON? → DecodableWithConfiguration (iOS 15+)
+└─ Complex nested JSON? → Manual implementation + nested containers
+```
+
+### Common Triggers
+
+| Error | Solution |
+|-------|----------|
+| "Type 'X' does not conform to protocol 'Decodable'" | Ensure all stored properties are Codable |
+| "No value associated with key X" | Check CodingKeys match JSON keys |
+| "Expected to decode X but found Y instead" | Type mismatch; check JSON structure or use bridge type |
+| "keyNotFound" | JSON missing expected key; make property optional or provide default |
+| "Date parsing failed" | Configure dateDecodingStrategy on decoder |
+
+---
+
+## Part 1: Automatic Synthesis
+
+Swift automatically synthesizes Codable conformance when all stored properties are Codable.
+
+### Struct Synthesis
+
+```swift
+// ✅ Automatic synthesis
+struct User: Codable {
+ let id: UUID // Codable
+ var name: String // Codable
+ var membershipPoints: Int // Codable
+}
+
+// JSON: {"id":"...", "name":"Alice", "membershipPoints":100}
+```
+
+**Requirements**:
+- All stored properties must conform to Codable
+- Properties use standard Swift types or other Codable types
+- No custom initialization logic needed
+
+### Enum Synthesis Patterns
+
+#### Pattern 1: Raw Value Enums
+
+```swift
+enum Direction: String, Codable {
+ case north, south, east, west
+}
+
+// Encodes as: "north"
+```
+
+The raw value itself becomes the JSON representation.
+
+#### Pattern 2: Enums Without Associated Values
+
+```swift
+enum Status: Codable {
+ case success
+ case failure
+ case pending
+}
+
+// Encodes as: {"success":{}}
+```
+
+Each case becomes an object with the case name as the key and empty dictionary as value.
+
+#### Pattern 3: Enums With Associated Values
+
+```swift
+enum APIResult: Codable {
+ case success(data: String, count: Int)
+ case error(code: Int, message: String)
+}
+
+// success case encodes as:
+// {"success":{"data":"example","count":5}}
+```
+
+**Gotcha**: Unlabeled associated values generate `_0`, `_1` keys:
+
+```swift
+enum Command: Codable {
+ case store(String, Int) // ❌ Unlabeled
+}
+
+// Encodes as: {"store":{"_0":"value","_1":42}}
+```
+
+**Fix**: Always label associated values for predictable JSON:
+
+```swift
+enum Command: Codable {
+ case store(key: String, value: Int) // ✅ Labeled
+}
+
+// Encodes as: {"store":{"key":"value","value":42}}
+```
+
+### When Synthesis Breaks
+
+Automatic synthesis fails when:
+1. **Computed properties** - Only stored properties are encoded
+2. **Non-Codable properties** - Custom types without Codable conformance
+3. **Property wrappers** - `@Published`, `@State` (except `@AppStorage` with Codable types)
+4. **Class inheritance** - Subclasses must implement `init(from:)` manually
+
+---
+
+## Part 2: CodingKeys Customization
+
+Use `CodingKeys` enum to customize encoding/decoding without full manual implementation.
+
+### Renaming Keys
+
+```swift
+struct Article: Codable {
+ let url: URL
+ let title: String
+ let body: String
+
+ enum CodingKeys: String, CodingKey {
+ case url = "source_link" // JSON uses "source_link"
+ case title = "content_name" // JSON uses "content_name"
+ case body // Matches JSON key
+ }
+}
+
+// JSON: {"source_link":"...", "content_name":"...", "body":"..."}
+```
+
+### Excluding Properties
+
+Omit properties from `CodingKeys` to exclude them from encoding/decoding:
+
+```swift
+struct NoteCollection: Codable {
+ let name: String
+ let notes: [Note]
+ var localDrafts: [Note] = [] // ✅ Must have default value
+
+ enum CodingKeys: CodingKey {
+ case name
+ case notes
+ // localDrafts omitted - not encoded/decoded
+ }
+}
+```
+
+**Rule**: Excluded properties require default values or you must implement `init(from:)` manually.
+
+### Snake Case Conversion
+
+For consistent snake_case → camelCase conversion:
+
+```swift
+let decoder = JSONDecoder()
+decoder.keyDecodingStrategy = .convertFromSnakeCase
+
+// JSON: {"first_name":"Alice", "last_name":"Smith"}
+// Decodes to: User(firstName: "Alice", lastName: "Smith")
+```
+
+### Enum Associated Value Keys
+
+Customize keys for enum associated values using `{CaseName}CodingKeys`:
+
+```swift
+enum Command: Codable {
+ case store(key: String, value: Int)
+ case delete(key: String)
+
+ enum StoreCodingKeys: String, CodingKey {
+ case key = "identifier" // Renames "key" to "identifier"
+ case value = "data" // Renames "value" to "data"
+ }
+
+ enum DeleteCodingKeys: String, CodingKey {
+ case key = "identifier"
+ }
+}
+
+// store case encodes as: {"store":{"identifier":"x","data":42}}
+```
+
+**Pattern**: `{CaseName}CodingKeys` with capitalized case name.
+
+---
+
+## Part 3: Manual Implementation
+
+For structural differences between JSON and Swift models, implement `init(from:)` and `encode(to:)`.
+
+### Container Types
+
+| Container | When to Use |
+|-----------|-------------|
+| **Keyed** | Dictionary-like data with string keys |
+| **Unkeyed** | Array-like sequential data |
+| **Single-value** | Wrapper types that encode as a single value |
+| **Nested** | Hierarchical JSON structures |
+
+### Nested Containers Example
+
+Flatten hierarchical JSON:
+
+```swift
+// JSON:
+// {
+// "latitude": 37.7749,
+// "longitude": -122.4194,
+// "additionalInfo": {
+// "elevation": 52
+// }
+// }
+
+struct Coordinate {
+ var latitude: Double
+ var longitude: Double
+ var elevation: Double // Nested in JSON, flat in Swift
+
+ enum CodingKeys: String, CodingKey {
+ case latitude, longitude, additionalInfo
+ }
+
+ enum AdditionalInfoKeys: String, CodingKey {
+ case elevation
+ }
+}
+
+extension Coordinate: Decodable {
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ latitude = try values.decode(Double.self, forKey: .latitude)
+ longitude = try values.decode(Double.self, forKey: .longitude)
+
+ let additionalInfo = try values.nestedContainer(
+ keyedBy: AdditionalInfoKeys.self,
+ forKey: .additionalInfo
+ )
+ elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
+ }
+}
+
+extension Coordinate: Encodable {
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(latitude, forKey: .latitude)
+ try container.encode(longitude, forKey: .longitude)
+
+ var additionalInfo = container.nestedContainer(
+ keyedBy: AdditionalInfoKeys.self,
+ forKey: .additionalInfo
+ )
+ try additionalInfo.encode(elevation, forKey: .elevation)
+ }
+}
+```
+
+### Bridge Types for Structural Mismatches
+
+When JSON structure fundamentally differs from Swift model:
+
+```swift
+// JSON: {"USD": 1.0, "EUR": 0.85, "GBP": 0.73}
+// Want: [ExchangeRate]
+
+struct ExchangeRate {
+ let currency: String
+ let rate: Double
+}
+
+// Bridge type for decoding
+private extension ExchangeRate {
+ struct List: Decodable {
+ let values: [ExchangeRate]
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let dictionary = try container.decode([String: Double].self)
+ values = dictionary.map { ExchangeRate(currency: $0, rate: $1) }
+ }
+ }
+}
+
+// Public interface
+extension ExchangeRate {
+ static func decode(from data: Data) throws -> [ExchangeRate] {
+ let list = try JSONDecoder().decode(List.self, from: data)
+ return list.values
+ }
+}
+```
+
+---
+
+## Part 4: Date Handling
+
+### Built-in Strategies
+
+```swift
+let decoder = JSONDecoder()
+
+// 1. ISO 8601 (recommended)
+decoder.dateDecodingStrategy = .iso8601
+// Expects: "2024-02-15T17:00:00+01:00"
+
+// 2. Unix timestamp (seconds)
+decoder.dateDecodingStrategy = .secondsSince1970
+// Expects: 1708012800
+
+// 3. Unix timestamp (milliseconds)
+decoder.dateDecodingStrategy = .millisecondsSince1970
+// Expects: 1708012800000
+
+// 4. Custom formatter
+let formatter = DateFormatter()
+formatter.dateFormat = "yyyy-MM-dd"
+formatter.locale = Locale(identifier: "en_US_POSIX") // ✅ Always set
+formatter.timeZone = TimeZone(secondsFromGMT: 0) // ✅ Always set
+decoder.dateDecodingStrategy = .formatted(formatter)
+
+// 5. Custom closure
+decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let dateString = try container.decode(String.self)
+
+ if let date = ISO8601DateFormatter().date(from: dateString) {
+ return date
+ }
+
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot decode date string \(dateString)"
+ )
+}
+```
+
+### ISO 8601 Nuances
+
+**Default**: `2024-02-15T17:00:00+01:00`
+**Timezone required**: Without timezone offset, decoding may fail across regions
+
+```swift
+// ❌ No timezone - parsing depends on device locale
+"2024-02-15T17:00:00"
+
+// ✅ With timezone - unambiguous
+"2024-02-15T17:00:00+01:00"
+```
+
+### Performance Consideration
+
+**Custom closures run for every date** - optimize expensive operations:
+
+```swift
+// ❌ Creates new formatter for every date
+decoder.dateDecodingStrategy = .custom { decoder in
+ let formatter = DateFormatter() // Expensive!
+ // ...
+}
+
+// ✅ Reuse formatter
+let sharedFormatter = DateFormatter()
+sharedFormatter.dateFormat = "yyyy-MM-dd"
+
+decoder.dateDecodingStrategy = .custom { decoder in
+ // Use sharedFormatter
+}
+```
+
+---
+
+## Part 5: Type Transformation
+
+### StringBacked Wrapper
+
+Handle APIs that encode numbers as strings:
+
+```swift
+protocol StringRepresentable: CustomStringConvertible {
+ init?(_ string: String)
+}
+
+extension Int: StringRepresentable {}
+extension Double: StringRepresentable {}
+
+struct StringBacked: Codable {
+ var value: Value
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let string = try container.decode(String.self)
+
+ guard let value = Value(string) else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot convert '\(string)' to \(Value.self)"
+ )
+ }
+
+ self.value = value
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(value.description)
+ }
+}
+
+// Usage
+struct Product: Codable {
+ let name: String
+ private let _price: StringBacked
+
+ var price: Double {
+ get { _price.value }
+ set { _price = StringBacked(value: newValue) }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case _price = "price"
+ }
+}
+
+// JSON: {"name":"Widget","price":"19.99"}
+// Decodes to: Product(name: "Widget", price: 19.99)
+```
+
+### Type Coercion
+
+For loosely typed APIs that may return different types:
+
+```swift
+struct FlexibleValue: Codable {
+ let stringValue: String
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+
+ if let string = try? container.decode(String.self) {
+ stringValue = string
+ } else if let int = try? container.decode(Int.self) {
+ stringValue = String(int)
+ } else if let double = try? container.decode(Double.self) {
+ stringValue = String(double)
+ } else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot decode value to String, Int, or Double"
+ )
+ }
+ }
+}
+```
+
+**Warning**: Avoid this pattern unless the API is truly unpredictable. Prefer strict types.
+
+---
+
+## Part 6: Advanced Patterns
+
+### DecodableWithConfiguration (iOS 15+)
+
+For types that need data unavailable in JSON:
+
+```swift
+struct User: Encodable, DecodableWithConfiguration {
+ let id: UUID
+ var name: String
+ var favorites: Favorites // Not in JSON, injected via configuration
+
+ enum CodingKeys: CodingKey {
+ case id, name
+ }
+
+ init(from decoder: Decoder, configuration: Favorites) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(UUID.self, forKey: .id)
+ name = try container.decode(String.self, forKey: .name)
+ favorites = configuration // Injected
+ }
+}
+
+// Usage (iOS 17+)
+let favorites = try await fetchFavorites()
+let user = try JSONDecoder().decode(
+ User.self,
+ from: data,
+ configuration: favorites
+)
+```
+
+### userInfo Workaround (iOS 15-16)
+
+```swift
+extension JSONDecoder {
+ private struct ConfigurationDecodingWrapper: Decodable {
+ var wrapped: T
+
+ init(from decoder: Decoder) throws {
+ let config = decoder.userInfo[configurationUserInfoKey] as! T.DecodingConfiguration
+ wrapped = try T(from: decoder, configuration: config)
+ }
+ }
+
+ func decode(
+ _ type: T.Type,
+ from data: Data,
+ configuration: T.DecodingConfiguration
+ ) throws -> T {
+ let decoder = JSONDecoder()
+ decoder.userInfo[Self.configurationUserInfoKey] = configuration
+ let wrapper = try decoder.decode(ConfigurationDecodingWrapper.self, from: data)
+ return wrapper.wrapped
+ }
+}
+
+private let configurationUserInfoKey = CodingUserInfoKey(rawValue: "configuration")!
+```
+
+### Partial Decoding
+
+Decode only the fields you need:
+
+```swift
+struct ArticlePreview: Decodable {
+ let id: UUID
+ let title: String
+ // Omit body, comments, etc.
+}
+
+// JSON has many more fields, but we only decode id and title
+```
+
+---
+
+## Part 7: Debugging
+
+### DecodingError Cases
+
+```swift
+do {
+ let user = try decoder.decode(User.self, from: data)
+} catch DecodingError.keyNotFound(let key, let context) {
+ print("Missing key '\(key)' at path: \(context.codingPath)")
+} catch DecodingError.typeMismatch(let type, let context) {
+ print("Type mismatch for \(type) at path: \(context.codingPath)")
+} catch DecodingError.valueNotFound(let type, let context) {
+ print("Value not found for \(type) at path: \(context.codingPath)")
+} catch DecodingError.dataCorrupted(let context) {
+ print("Data corrupted at path: \(context.codingPath)")
+} catch {
+ print("Other error: \(error)")
+}
+```
+
+### Debugging Techniques
+
+**1. Pretty-print JSON**
+
+```swift
+let encoder = JSONEncoder()
+encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+let jsonData = try encoder.encode(user)
+print(String(data: jsonData, encoding: .utf8)!)
+```
+
+**2. Inspect coding path**
+
+```swift
+// In custom init(from:)
+print("Decoding at path: \(decoder.codingPath)")
+```
+
+**3. Validate JSON structure**
+
+```swift
+// Quick check: Can it decode as Any?
+let json = try JSONSerialization.jsonObject(with: data)
+print(json) // See actual structure
+```
+
+---
+
+## Anti-Patterns
+
+| Anti-Pattern | Cost | Better Approach |
+|--------------|------|-----------------|
+| **Manual JSON string building** | Injection vulnerabilities, escaping bugs, no type safety | Use `JSONEncoder` |
+| **`try?` swallowing DecodingError** | Silent failures, debugging nightmares, data loss | Handle specific error cases |
+| **Optional properties to avoid decode errors** | Runtime crashes, nil checks everywhere, masks structural issues | Fix JSON/model mismatch or use `DecodableWithConfiguration` |
+| **Duplicating partial models** | 2-5 hours maintenance per change, sync issues, fragile | Use bridge types or configuration |
+| **Ignoring date timezone** | Intermittent bugs across regions, data corruption | Always use ISO8601 with timezone or explicit UTC |
+| **`JSONSerialization` for Codable types** | 3x more boilerplate, manual type casting, error-prone | Use `JSONDecoder`/`JSONEncoder` |
+| **No locale on DateFormatter** | Parsing fails in non-US locales | Set `locale = Locale(identifier: "en_US_POSIX")` |
+
+### Why try? is Dangerous
+
+```swift
+// ❌ Silent failure - production bug waiting to happen
+let user = try? JSONDecoder().decode(User.self, from: data)
+// If this fails, user is nil - why? No idea.
+
+// ✅ Explicit error handling
+do {
+ let user = try JSONDecoder().decode(User.self, from: data)
+} catch {
+ logger.error("Failed to decode user: \(error)")
+ // Now you know WHY it failed
+}
+```
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Just Use try? to Make It Compile"
+
+**Context**: API integration deadline tomorrow, decoder failing on some edge case.
+
+**Pressure**: "We can debug it later, just make it work now."
+
+**Why You'll Rationalize**:
+- "It's only failing on 1% of requests"
+- "We can add logging later"
+- "Customers won't notice"
+
+**What Actually Happens**:
+- Silent data loss for that 1%
+- No logs, so you can't debug in production
+- Customer complaints 3 months later
+- You've forgotten the context by then
+
+**Discipline Response**:
+
+> "Using `try?` here means we'll lose data silently. Let me spend 5 minutes handling the specific error case. If it's truly rare, I'll log it so we can fix the root cause."
+
+**5-Minute Fix**:
+
+```swift
+do {
+ return try decoder.decode(User.self, from: data)
+} catch DecodingError.keyNotFound(let key, let context) {
+ logger.error("Missing key '\(key)' in API response", metadata: [
+ "path": .string(context.codingPath.description),
+ "rawJSON": .string(String(data: data, encoding: .utf8) ?? "")
+ ])
+ throw APIError.invalidResponse(reason: "Missing key: \(key)")
+} catch {
+ logger.error("Failed to decode User", error: error)
+ throw APIError.decodingFailed(error)
+}
+```
+
+**Result**: You discover the API sometimes omits the `email` field for deleted users. Fix: make `email` optional only for that case, not all users.
+
+---
+
+### Scenario 2: "Dates Are Intermittent, Must Be Server Bug"
+
+**Context**: Date parsing works in your timezone but fails for European QA team.
+
+**Pressure**: "It works for me, QA must be doing something wrong."
+
+**Why You'll Rationalize**:
+- "My tests pass locally"
+- "The server is probably sending bad data"
+- "It's their device settings"
+
+**What Actually Happens**:
+- Server sends dates without timezone: `"2024-12-14T10:00:00"`
+- Your device (PST) interprets as 10:00 PST
+- QA device (CET) interprets as 10:00 CET
+- Different absolute times, intermittent bugs
+
+**Discipline Response**:
+
+> "Intermittent date failures are almost always timezone issues. Let me check if we're using ISO8601 with timezone offsets."
+
+**Check**:
+
+```swift
+// ❌ Current (fails across timezones)
+decoder.dateDecodingStrategy = .iso8601
+
+// Server sends: "2024-12-14T10:00:00" (no timezone)
+// PST device: Dec 14, 10:00 PST
+// CET device: Dec 14, 10:00 CET
+// Bug: Different times!
+
+// ✅ Fix: Require server to send timezone
+// "2024-12-14T10:00:00+00:00"
+// OR: Explicitly parse as UTC
+decoder.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let dateString = try container.decode(String.self)
+
+ let formatter = ISO8601DateFormatter()
+ formatter.timeZone = TimeZone(secondsFromGMT: 0) // Force UTC
+
+ guard let date = formatter.date(from: dateString) else {
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Invalid ISO8601 date: \(dateString)"
+ )
+ }
+
+ return date
+}
+```
+
+**Result**: Bug fixed, server adds timezone to API (or you parse explicitly as UTC). No more intermittent failures.
+
+---
+
+### Scenario 3: "Just Make It Optional"
+
+**Context**: New API field causes decoding to fail. Product manager wants a fix in 1 hour.
+
+**Pressure**: "Can't you just make that field optional? We need this shipped."
+
+**Why You'll Rationalize**:
+- "It's faster than fixing the API"
+- "We can make it non-optional later"
+- "Users won't notice"
+
+**What Actually Happens**:
+- Field is actually required for the feature
+- You add `user.email ?? ""` everywhere
+- 3 months later: production crash because `email` was nil
+- Now you can't remember why it was optional
+
+**Discipline Response**:
+
+> "Making it optional masks the real problem. Let me check if the API is wrong or our model is wrong. This will take 10 minutes."
+
+**Investigation**:
+
+```swift
+// Step 1: Print raw JSON
+do {
+ let json = try JSONSerialization.jsonObject(with: data)
+ print(json)
+} catch {
+ print("Invalid JSON: \(error)")
+}
+
+// Step 2: Check if key exists but value is null
+// {"email": null} vs key missing entirely
+
+// Step 3: Check API docs - is email actually required?
+```
+
+**Common Outcomes**:
+1. **API is wrong**: Field should be there → File bug, get hotfix
+2. **Model is wrong**: Field is optional in some flows → Use proper optionality with clear documentation
+3. **Structural mismatch**: Field is nested → Use nested container
+
+**Result**: You discover `email` is nested in `user.contact.email` in the new API version. Fix with nested container, not optionality.
+
+```swift
+// ✅ Correct fix
+struct User: Decodable {
+ let id: UUID
+ let email: String // Still required
+
+ enum CodingKeys: CodingKey {
+ case id, contact
+ }
+
+ enum ContactKeys: CodingKey {
+ case email
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(UUID.self, forKey: .id)
+
+ let contact = try container.nestedContainer(
+ keyedBy: ContactKeys.self,
+ forKey: .contact
+ )
+ email = try contact.decode(String.self, forKey: .email)
+ }
+}
+```
+
+---
+
+## Related Skills
+
+- **swift-concurrency** — Codable types crossing actor boundaries must be `Sendable`
+- **swiftdata** — `@Model` types use Codable for CloudKit sync
+- **networking** — `Coder` protocol wraps Codable for Network.framework
+- **app-intents-ref** — `AppEnum` parameters use Codable serialization
+
+---
+
+## Key Takeaways
+
+1. **Prefer automatic synthesis** — Add `: Codable` when structure matches JSON
+2. **Use CodingKeys for simple mismatches** — Rename or exclude without manual code
+3. **Manual implementation for structural differences** — Nested containers, bridge types
+4. **Always set locale and timezone** — `DateFormatter` requires `en_US_POSIX` and explicit timezone
+5. **Never swallow errors with try?** — Handle `DecodingError` cases explicitly
+6. **Codable + Sendable** — Value types (structs/enums) are ideal for async networking
+
+**Core Principle**: Codable is Swift's universal serialization protocol. Master it once, use it everywhere.
diff --git a/.claude/skills/axiom-codable/agents/openai.yaml b/.claude/skills/axiom-codable/agents/openai.yaml
new file mode 100644
index 0000000..cb9c2ac
--- /dev/null
+++ b/.claude/skills/axiom-codable/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Codable"
+ short_description: "Working with Codable protocol, JSON encoding/decoding, CodingKeys customization, enum serialization, date strategies,..."
diff --git a/.claude/skills/axiom-code-signing-diag/.openskills.json b/.claude/skills/axiom-code-signing-diag/.openskills.json
new file mode 100644
index 0000000..91cfbac
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-code-signing-diag",
+ "installedAt": "2026-04-12T08:06:03.332Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-code-signing-diag/SKILL.md b/.claude/skills/axiom-code-signing-diag/SKILL.md
new file mode 100644
index 0000000..4d3b708
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-diag/SKILL.md
@@ -0,0 +1,350 @@
+---
+name: axiom-code-signing-diag
+description: Use when code signing fails during build, archive, or upload — certificate not found, provisioning profile mismatch, errSecInternalComponent in CI, ITMS-90035 invalid signature, ambiguous identity, entitlement mismatch. Covers certificate, profile, keychain, entitlement, and archive signing diagnostics.
+license: MIT
+---
+
+# Code Signing Diagnostics
+
+Systematic troubleshooting for code signing failures: missing certificates, provisioning profile mismatches, Keychain issues in CI, entitlement conflicts, and App Store upload rejections.
+
+## Overview
+
+**Core Principle**: When code signing fails, the problem is usually:
+1. **Certificate issues** (expired, missing, wrong type, revoked) — 30%
+2. **Provisioning profile issues** (expired, missing cert, wrong App ID, missing capability) — 25%
+3. **Entitlement mismatches** (capability in Xcode but not in profile, or vice versa) — 15%
+4. **Keychain issues** (locked in CI, errSecInternalComponent, partition list) — 15%
+5. **Archive/export issues** (wrong export method, wrong cert type for distribution) — 10%
+6. **Ambiguous identity** (multiple matching certificates, Xcode picks wrong one) — 5%
+
+**Always verify certificate + profile + entitlements BEFORE rewriting build settings or regenerating everything.**
+
+## Red Flags
+
+Symptoms that indicate code signing–specific issues:
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| "No signing certificate found" | Certificate expired, revoked, or not in keychain |
+| "Provisioning profile doesn't include signing certificate" | Profile generated with different cert than the one in keychain |
+| ITMS-90035 Invalid Signature | Signed with Development cert instead of Distribution |
+| ITMS-90161 Invalid Provisioning Profile | Profile expired or doesn't match binary |
+| errSecInternalComponent in CI | Keychain locked or `set-key-partition-list` not called |
+| "Ambiguous — matches multiple" | Multiple valid certs with same name (dev + expired) |
+| "Entitlement not allowed by profile" | Capability added in Xcode but profile not regenerated |
+| "codesign wants to access key" dialog | Keychain access not granted to codesign |
+| Build works locally, fails in CI | Missing keychain setup steps (create, unlock, partition list) |
+| "Profile doesn't match bundle ID" | Bundle identifier mismatch between Xcode target and profile |
+| Export fails after successful archive | ExportOptions.plist specifies wrong method or profile |
+| App extension signing fails | Extension needs its own profile with matching team and prefix |
+
+## Anti-Rationalization
+
+| Rationalization | Why It Fails | Time Cost |
+|----------------|--------------|-----------|
+| "Certificate was fine yesterday" | Certificates expire and get revoked. Profiles auto-regenerate in portal changes. Always re-verify. | 30-60 min debugging build settings when cert expired overnight |
+| "Let me regenerate everything" | Regenerating certificates revokes the old ones, breaking other team members and CI. Diagnose first. | 2-4 hours + broken teammates + CI pipeline down |
+| "I'll reset my keychain" | Destroys ALL stored credentials (SSH keys, saved passwords, other certs). Diagnose the specific cert. | 1-2 hours restoring all credentials |
+| "Just disable code signing for now" | Code signing can't be disabled for device builds or distribution. You'll hit the same issue later with less time. | Wasted time plus the original problem remains |
+| "It's an Xcode bug, let me reinstall" | Code signing is configuration, not an Xcode bug. Reinstalling doesn't change your certificates or profiles. | 2-4 hours reinstalling Xcode while the config stays broken |
+| "I'll use the team provisioning profile" | Xcode's auto-managed wildcard profile lacks specific entitlements (push, App Groups). It won't work for apps needing capabilities. | 30+ min discovering missing capabilities |
+| "CI worked before, nothing changed on our side" | Apple revokes certificates for security reasons. CI runner macOS updates change keychain behavior. Provisioning profiles expire after 1 year. | Hours of "but we didn't change anything" while the cert is expired |
+| "Let me check the code first" | Code signing errors are NEVER code bugs. They are 100% configuration — certificates, profiles, entitlements, and keychains. | Hours debugging working code while the profile is expired |
+| "Set build.keychain as default" | `security default-keychain -s build.keychain` replaces the login keychain as default, breaking access to SSH keys, saved passwords, and other credentials. Use `list-keychains -s` instead. | 30+ min restoring default keychain + mysterious SSH/credential failures |
+
+## Mandatory First Steps
+
+Before changing build settings or regenerating certificates, run these diagnostics:
+
+### Step 1: Check Signing Identities
+
+```bash
+security find-identity -v -p codesigning
+```
+
+**Expected output**:
+- At least one valid identity with "Apple Development" or "Apple Distribution"
+- Each shows SHA-1 hash + name + Team ID
+
+**Problems**:
+- 0 valid identities → No certificates installed or all expired
+- Only "Apple Development" but trying to archive → Need Distribution certificate
+- Multiple entries with same name → Ambiguous identity (see Tree 5)
+
+### Step 2: Decode Provisioning Profile
+
+```bash
+# Find the profile being used
+find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3
+
+# Decode it
+security cms -D -i path/to/embedded.mobileprovision
+```
+
+**Check these fields**:
+- `ExpirationDate` → Not expired?
+- `DeveloperCertificates` → Contains your current certificate?
+- `Entitlements` → Contains all capabilities your app uses?
+- `ProvisionedDevices` → Contains your test device UDID? (Development/Ad Hoc only)
+- `Name` → Matches what Xcode is configured to use?
+
+### Step 3: Extract and Compare Entitlements
+
+```bash
+# What entitlements does the built app have?
+codesign -d --entitlements - /path/to/MyApp.app
+
+# What entitlements does the profile grant?
+security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - -
+
+# What entitlements does Xcode's .entitlements file declare?
+cat MyApp/MyApp.entitlements
+```
+
+**All three must agree.** Any mismatch → signing failure.
+
+### Step 4: Verify Certificate in Profile
+
+```bash
+# Get certificate SHA-1 from keychain
+security find-identity -v -p codesigning | grep "Apple Distribution"
+# Output: 1) ABCDEF123... "Apple Distribution: Company (TEAMID)"
+
+# Check if that certificate is embedded in the profile
+security cms -D -i embedded.mobileprovision | plutil -extract DeveloperCertificates xml1 -o - -
+# Decode one of the base64 certificates:
+echo "" | base64 -d | openssl x509 -inform DER -noout -fingerprint -sha1
+```
+
+The SHA-1 from the profile must match the SHA-1 from `find-identity`.
+
+## Decision Trees
+
+### Tree 1: "No signing certificate found"
+
+```dot
+digraph tree1 {
+ "No signing certificate?" [shape=diamond];
+ "Run find-identity" [shape=box, label="security find-identity -v -p codesigning"];
+ "0 identities?" [shape=diamond];
+ "Has identities but wrong type?" [shape=diamond];
+ "Certificate expired?" [shape=diamond];
+
+ "Import certificate" [shape=box, label="Import .p12 into keychain\nsecurity import cert.p12 -k login.keychain-db -P pass -T /usr/bin/codesign"];
+ "Download from portal" [shape=box, label="Download certificate from\nApple Developer Portal\nor use Xcode > Preferences > Accounts"];
+ "Use correct cert type" [shape=box, label="Archive needs Apple Distribution\nDebug needs Apple Development\nCheck CODE_SIGN_IDENTITY"];
+ "Renew certificate" [shape=box, label="Revoke expired cert in portal\nCreate new cert\nUpdate profiles to use new cert"];
+ "CI keychain issue" [shape=box, label="In CI: create keychain, import cert,\nunlock, set-key-partition-list\n(see code-signing-ref CI section)"];
+
+ "No signing certificate?" -> "Run find-identity";
+ "Run find-identity" -> "0 identities?" [label="check output"];
+ "0 identities?" -> "Import certificate" [label="yes, no certs at all"];
+ "0 identities?" -> "Has identities but wrong type?" [label="no, has some"];
+ "Has identities but wrong type?" -> "Use correct cert type" [label="yes, dev only but need dist"];
+ "Has identities but wrong type?" -> "Certificate expired?" [label="no, correct type exists"];
+ "Certificate expired?" -> "Renew certificate" [label="yes"];
+ "Certificate expired?" -> "CI keychain issue" [label="no, cert valid but CI fails"];
+ "Import certificate" -> "Download from portal" [label="don't have .p12"];
+}
+```
+
+### Tree 2: "Provisioning profile doesn't include signing certificate"
+
+```dot
+digraph tree2 {
+ "Profile cert mismatch?" [shape=diamond];
+ "Automatic signing?" [shape=diamond];
+
+ "Clean and retry" [shape=box, label="Xcode > Preferences > Accounts\n> Download Manual Profiles\nClean build folder (Cmd+Shift+K)"];
+ "Regenerate profile" [shape=box, label="Apple Developer Portal:\n1. Edit profile\n2. Select current certificate\n3. Generate\n4. Download and install"];
+ "Check cert match" [shape=box, label="Step 4: Verify certificate SHA-1\nin keychain matches SHA-1\nembedded in profile"];
+ "Revoked cert" [shape=box, label="If someone regenerated the cert,\nall existing profiles are invalid.\nRegenerate profiles with new cert."];
+
+ "Profile cert mismatch?" -> "Automatic signing?" [label="check Xcode"];
+ "Automatic signing?" -> "Clean and retry" [label="yes"];
+ "Automatic signing?" -> "Check cert match" [label="no, manual signing"];
+ "Check cert match" -> "Regenerate profile" [label="SHA-1 mismatch"];
+ "Check cert match" -> "Revoked cert" [label="cert not in profile at all"];
+}
+```
+
+### Tree 3: ITMS-90035/90161 Invalid Signature/Profile
+
+```dot
+digraph tree3 {
+ "Upload rejected?" [shape=diamond];
+ "ITMS-90035?" [shape=diamond];
+ "ITMS-90161?" [shape=diamond];
+ "ITMS-90046?" [shape=diamond];
+
+ "Wrong cert" [shape=box, label="Signed with Development cert.\nRe-archive with Apple Distribution.\nCODE_SIGN_IDENTITY = Apple Distribution"];
+ "Cert expired" [shape=box, label="Certificate expired between\narchive and upload.\nRenew cert, re-archive."];
+ "Profile expired" [shape=box, label="Provisioning profile expired.\nRegenerate in portal,\nre-archive."];
+ "Profile mismatch" [shape=box, label="Profile doesn't match binary.\nCheck bundle ID alignment.\nVerify ExportOptions.plist."];
+ "Check entitlements" [shape=box, label="Entitlement not in profile.\nAdd capability in portal,\nregenerate profile, re-archive."];
+
+ "Upload rejected?" -> "ITMS-90035?" [label="check error code"];
+ "Upload rejected?" -> "ITMS-90046?" [label="ITMS-90046"];
+ "ITMS-90035?" -> "Wrong cert" [label="'Invalid Signature'"];
+ "ITMS-90035?" -> "ITMS-90161?" [label="different error"];
+ "ITMS-90046?" -> "Check entitlements" [label="'Invalid Entitlements'"];
+ "ITMS-90161?" -> "Profile expired" [label="'Invalid Provisioning Profile'"];
+ "ITMS-90161?" -> "Profile mismatch" [label="'profile doesn't match'"];
+ "Wrong cert" -> "Cert expired" [label="cert IS Distribution but still fails"];
+}
+```
+
+### Tree 4: errSecInternalComponent / Keychain Locked in CI
+
+```dot
+digraph tree4 {
+ "errSecInternalComponent?" [shape=diamond];
+ "Keychain created?" [shape=diamond];
+ "Keychain unlocked?" [shape=diamond];
+ "Partition list set?" [shape=diamond];
+ "Search list correct?" [shape=diamond];
+
+ "Create keychain" [shape=box, label="security create-keychain -p pass build.keychain"];
+ "Unlock keychain" [shape=box, label="security unlock-keychain -p pass build.keychain"];
+ "Set partition list" [shape=box, label="security set-key-partition-list\n-S apple-tool:,apple: -s\n-k pass build.keychain\n(MOST COMMON FIX)"];
+ "Add to search list" [shape=box, label="security list-keychains -d user\n-s build.keychain login.keychain-db"];
+ "Set timeout" [shape=box, label="security set-keychain-settings\n-t 3600 -l build.keychain\n(prevent lock during long builds)"];
+ "Check runner image" [shape=box, label="Runner image may have changed.\nCheck GitHub Actions runner changelog.\nmacOS updates change keychain defaults."];
+
+ "errSecInternalComponent?" -> "Keychain created?" [label="CI environment"];
+ "Keychain created?" -> "Create keychain" [label="no"];
+ "Keychain created?" -> "Keychain unlocked?" [label="yes"];
+ "Keychain unlocked?" -> "Unlock keychain" [label="no"];
+ "Keychain unlocked?" -> "Partition list set?" [label="yes"];
+ "Partition list set?" -> "Set partition list" [label="no — this is the #1 fix"];
+ "Partition list set?" -> "Search list correct?" [label="yes"];
+ "Search list correct?" -> "Add to search list" [label="no — keychain not in search path"];
+ "Search list correct?" -> "Set timeout" [label="yes — try extending timeout"];
+ "Set timeout" -> "Check runner image" [label="still failing"];
+}
+```
+
+### Tree 5: Ambiguous Identity / Multiple Certificates
+
+```dot
+digraph tree5 {
+ "Ambiguous identity?" [shape=diamond];
+ "Same name, different dates?" [shape=diamond];
+ "Dev and Dist both present?" [shape=diamond];
+
+ "List all" [shape=box, label="security find-identity -v -p codesigning\nNote SHA-1 hashes and expiry dates"];
+ "Delete expired" [shape=box, label="Open Keychain Access\nDelete expired certificate\n(check expiry with openssl x509 -enddate)"];
+ "Use SHA-1" [shape=box, label="Specify exact identity by SHA-1:\nCODE_SIGN_IDENTITY = 'SHA1HASH'\nor codesign -s 'SHA1HASH'"];
+ "Specify full name" [shape=box, label="Use full identity name:\nCODE_SIGN_IDENTITY =\n'Apple Distribution: Company (TEAMID)'"];
+
+ "Ambiguous identity?" -> "List all" [label="first"];
+ "List all" -> "Same name, different dates?" [label="inspect"];
+ "Same name, different dates?" -> "Delete expired" [label="yes, old + new cert"];
+ "Same name, different dates?" -> "Dev and Dist both present?" [label="no"];
+ "Dev and Dist both present?" -> "Specify full name" [label="yes, Xcode picks wrong one"];
+ "Dev and Dist both present?" -> "Use SHA-1" [label="still ambiguous"];
+}
+```
+
+### Tree 6: Entitlement Mismatch / Missing Capability
+
+```dot
+digraph tree6 {
+ "Entitlement error?" [shape=diamond];
+ "In Xcode but not profile?" [shape=diamond];
+ "In profile but not Xcode?" [shape=diamond];
+
+ "Run Step 3" [shape=box, label="Compare entitlements:\n1. codesign -d --entitlements - App\n2. Profile entitlements\n3. .entitlements file"];
+ "Regenerate profile" [shape=box, label="Apple Developer Portal:\n1. App ID > Capabilities\n2. Enable missing capability\n3. Edit profile\n4. Generate and download"];
+ "Add capability" [shape=box, label="Xcode > Target >\nSigning & Capabilities >\n+ Capability"];
+ "Remove stale entitlement" [shape=box, label="Remove capability from\n.entitlements file that\nisn't supported by profile type"];
+
+ "Entitlement error?" -> "Run Step 3" [label="diagnose"];
+ "Run Step 3" -> "In Xcode but not profile?" [label="compare"];
+ "In Xcode but not profile?" -> "Regenerate profile" [label="yes, capability missing from profile"];
+ "In Xcode but not profile?" -> "In profile but not Xcode?" [label="no"];
+ "In profile but not Xcode?" -> "Add capability" [label="yes"];
+ "In profile but not Xcode?" -> "Remove stale entitlement" [label="entitlement not valid for profile type"];
+}
+```
+
+## Quick Reference Table
+
+| Symptom | Check | Fix |
+|---------|-------|-----|
+| No signing certificate found | `security find-identity -v -p codesigning` | Import cert or download from portal |
+| Provisioning profile doesn't include cert | Step 4: SHA-1 comparison | Regenerate profile with current cert |
+| ITMS-90035 Invalid Signature | `codesign -dv` on archived app | Re-archive with Apple Distribution cert |
+| ITMS-90161 Invalid Provisioning Profile | `security cms -D -i` on profile | Regenerate non-expired profile |
+| ITMS-90046 Invalid Entitlements | Step 3: three-way comparison | Add capability in portal, regenerate profile |
+| errSecInternalComponent | CI keychain setup | `set-key-partition-list` (most common fix) |
+| Ambiguous identity | `security find-identity -v` count | Delete expired cert or use SHA-1 hash |
+| Entitlement mismatch | Three-way entitlement comparison | Align Xcode, profile, and .entitlements |
+| Profile expired | `security cms -D` check ExpirationDate | Download fresh profile from portal |
+| Profile missing push | `grep aps-environment` in profile | Enable Push in portal, regenerate profile |
+| Extension signing fails | Extension target signing config | Each extension needs own profile with matching team |
+| Works locally, fails CI | CI keychain script completeness | Full setup: create, unlock, import, partition list, search list |
+| "codesign wants to access key" | Keychain access settings | `security set-key-partition-list` or Keychain Access > Get Info > Access Control |
+| App Groups entitlement error | Three-way comparison | Add App Group in portal App ID, regenerate profile |
+| Build works, export fails | ExportOptions.plist | Verify method, profile name, team ID in plist |
+
+## Pressure Scenarios
+
+### Scenario 1: "Just regenerate everything"
+
+**Context**: Code signing fails. Team member suggests revoking all certificates and generating new ones to start fresh.
+
+**Pressure**: "It'll only take 5 minutes to regenerate everything."
+
+**Reality**: Revoking a distribution certificate invalidates ALL provisioning profiles that use it — across ALL team members and CI systems. Every developer needs new certificates. Every CI pipeline breaks. Every profile needs regeneration. The "5 minute fix" becomes a 2-4 hour team-wide outage.
+
+**Correct action**: Diagnose the specific issue with Steps 1-4. Most signing failures are a single expired or mismatched component, not a systemic problem.
+
+**Push-back template**: "Revoking certificates breaks signing for everyone on the team and all CI pipelines. Let me run the diagnostic steps first — 90% of signing issues are a single expired cert or mismatched profile, fixable in 5 minutes without affecting anyone else."
+
+### Scenario 2: "Xcode updated, signing broke — rollback"
+
+**Context**: After an Xcode update, code signing stopped working. Someone suggests rolling back Xcode.
+
+**Pressure**: "The build worked before the update, so the update broke it."
+
+**Reality**: Xcode updates sometimes invalidate managed signing caches or change how automatic signing selects profiles. But the certificates and profiles themselves don't change. Rolling back Xcode loses access to new SDK features and doesn't fix the underlying configuration.
+
+**Correct action**: Run Steps 1-2 to verify certificates and profiles are still valid. Check if Xcode's automatic signing is selecting a different profile. Try: Xcode → Preferences → Accounts → Download Manual Profiles. Clean build folder.
+
+**Push-back template**: "Xcode updates don't change our certificates or profiles. Let me check what Xcode's automatic signing is selecting now — it's likely picking a different profile than before. A 5-minute check will tell us exactly what changed."
+
+### Scenario 3: "The archive failed, let me re-archive — it's probably corruption"
+
+**Context**: Archive succeeded but export or upload failed. Developer wants to re-archive assuming the archive was corrupted.
+
+**Pressure**: "Just do it again, it'll probably work this time."
+
+**Reality**: Archive "corruption" is extremely rare. Export failures are almost always: wrong ExportOptions.plist method, wrong certificate type in the archive, or profile mismatch. Re-archiving with the same settings produces the same result.
+
+**Correct action**: Inspect the archive before re-building:
+1. `codesign -dv` on the .app inside the .xcarchive to see what signed it
+2. Check ExportOptions.plist method matches intent (app-store, ad-hoc, etc.)
+3. Verify the profile specified in ExportOptions exists and isn't expired
+
+**Push-back template**: "Re-archiving with the same settings will produce the same result. Let me check what's actually in the archive — it takes 30 seconds with codesign -dv and will tell us exactly why export failed."
+
+## Checklist
+
+Before declaring a signing issue fixed:
+
+- [ ] `security find-identity -v -p codesigning` shows the expected identity
+- [ ] Profile decoded with `security cms -D` — not expired, contains correct cert
+- [ ] Three-way entitlement comparison agrees (binary, profile, .entitlements file)
+- [ ] Build/archive succeeds with correct `CODE_SIGN_IDENTITY`
+- [ ] If CI: keychain created, unlocked, partition list set, cert imported
+- [ ] If CI: cleanup step runs on success AND failure
+
+## Resources
+
+**WWDC**: 2021-10204, 2022-110353
+
+**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app
+
+**Skills**: axiom-code-signing, axiom-code-signing-ref
diff --git a/.claude/skills/axiom-code-signing-diag/agents/openai.yaml b/.claude/skills/axiom-code-signing-diag/agents/openai.yaml
new file mode 100644
index 0000000..7d45bc4
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Code Signing Diagnostics"
+ short_description: "Code signing fails during build, archive, or upload"
diff --git a/.claude/skills/axiom-code-signing-ref/.openskills.json b/.claude/skills/axiom-code-signing-ref/.openskills.json
new file mode 100644
index 0000000..da0f03e
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-code-signing-ref",
+ "installedAt": "2026-04-12T08:06:03.961Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-code-signing-ref/SKILL.md b/.claude/skills/axiom-code-signing-ref/SKILL.md
new file mode 100644
index 0000000..bd0d3a9
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-ref/SKILL.md
@@ -0,0 +1,700 @@
+---
+name: axiom-code-signing-ref
+description: Use when needing certificate CLI commands, provisioning profile inspection, entitlement extraction, Keychain management scripts, codesign verification, fastlane match setup, Xcode build settings for signing, or APNs .p8 vs .p12 decision. Covers complete code signing API and CLI surface.
+license: MIT
+---
+
+# Code Signing API Reference
+
+Comprehensive CLI and API reference for iOS/macOS code signing: certificate management, provisioning profile inspection, entitlement extraction, Keychain operations, codesign verification, fastlane match, and Xcode build settings.
+
+## Quick Reference
+
+```bash
+# Diagnostic flow — run these 3 commands first for any signing issue
+security find-identity -v -p codesigning # List valid signing identities
+security cms -D -i path/to/embedded.mobileprovision # Decode provisioning profile
+codesign -d --entitlements - MyApp.app # Extract entitlements from binary
+```
+
+---
+
+## Certificate Reference
+
+### Certificate Types
+
+| Type | Purpose | Validity | Max Per Account |
+|------|---------|----------|-----------------|
+| Apple Development | Debug builds on registered devices | 1 year | Unlimited (per developer) |
+| Apple Distribution | App Store + TestFlight submission | 1 year | 3 per account |
+| iOS Distribution (legacy) | App Store submission (pre-Xcode 11) | 1 year | 3 per account |
+| iOS Development (legacy) | Debug builds (pre-Xcode 11) | 1 year | Unlimited |
+| Developer ID Application | macOS distribution outside App Store | 5 years | 5 per account |
+| Developer ID Installer | macOS package signing | 5 years | 5 per account |
+| Apple Push Services | APNs .p12 certificate auth (legacy) | 1 year | 1 per App ID |
+
+### CSR Generation
+
+```bash
+# Generate Certificate Signing Request
+openssl req -new -newkey rsa:2048 -nodes \
+ -keyout CertificateSigningRequest.key \
+ -out CertificateSigningRequest.certSigningRequest \
+ -subj "/emailAddress=dev@example.com/CN=Developer Name/C=US"
+```
+
+Or use Keychain Access: Certificate Assistant → Request a Certificate From a Certificate Authority.
+
+### Certificate Inspection
+
+```bash
+# View certificate details (from .cer file)
+openssl x509 -in certificate.cer -inform DER -text -noout
+
+# View certificate from .p12
+openssl pkcs12 -in certificate.p12 -nokeys -clcerts | openssl x509 -text -noout
+
+# List certificates in Keychain with SHA-1 hashes
+security find-identity -v -p codesigning
+
+# Example output:
+# 1) ABC123... "Apple Development: dev@example.com (TEAMID)"
+# 2) DEF456... "Apple Distribution: Company Name (TEAMID)"
+# 2 valid identities found
+
+# Find specific certificate by name
+security find-certificate -c "Apple Distribution" login.keychain-db -p
+
+# Check certificate expiration (pipe PEM output to openssl)
+security find-certificate -c "Apple Distribution" login.keychain-db -p | openssl x509 -noout -enddate
+```
+
+### Certificate Installation
+
+```bash
+# Import .p12 into Keychain (interactive — prompts for password)
+security import certificate.p12 -k ~/Library/Keychains/login.keychain-db -P "$P12_PASSWORD" -T /usr/bin/codesign
+
+# Import .cer into Keychain
+security import certificate.cer -k ~/Library/Keychains/login.keychain-db
+
+# For CI: import into temporary keychain (see CI section below)
+```
+
+---
+
+## Provisioning Profile Reference
+
+### Profile Types
+
+| Type | Contains | Use Case |
+|------|----------|----------|
+| Development | Dev cert + device UDIDs + App ID + entitlements | Debug builds on registered devices |
+| Ad Hoc | Distribution cert + device UDIDs + App ID + entitlements | Testing on specific devices without TestFlight |
+| App Store | Distribution cert + App ID + entitlements (no device list) | App Store + TestFlight submission |
+| Enterprise | Enterprise cert + App ID + entitlements (no device list) | In-house distribution (Enterprise program only) |
+
+### Profile Contents
+
+A provisioning profile (.mobileprovision) is a signed plist containing:
+
+```
+├── AppIDName — App ID name
+├── ApplicationIdentifierPrefix — Team ID
+├── CreationDate — When profile was created
+├── DeveloperCertificates — Embedded signing certificates (DER-encoded)
+├── Entitlements — Granted entitlements
+│ ├── application-identifier
+│ ├── aps-environment (development|production)
+│ ├── com.apple.developer.associated-domains
+│ ├── keychain-access-groups
+│ └── ...
+├── ExpirationDate — When profile expires (1 year)
+├── Name — Profile name in Apple Developer Portal
+├── ProvisionedDevices — UDIDs (Development/Ad Hoc only)
+├── TeamIdentifier — Team ID array
+├── TeamName — Team display name
+├── TimeToLive — Days until expiration
+├── UUID — Unique profile identifier
+└── Version — Profile version (1)
+```
+
+### Decode Provisioning Profile
+
+```bash
+# Decode and display full contents
+security cms -D -i path/to/embedded.mobileprovision
+
+# Extract specific fields
+security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - -
+
+# Check aps-environment (push notifications)
+security cms -D -i embedded.mobileprovision | grep -A1 "aps-environment"
+
+# Check expiration
+security cms -D -i embedded.mobileprovision | grep -A1 "ExpirationDate"
+
+# List provisioned devices (Development/Ad Hoc only)
+security cms -D -i embedded.mobileprovision | grep -A100 "ProvisionedDevices"
+
+# Check team ID
+security cms -D -i embedded.mobileprovision | grep -A1 "TeamIdentifier"
+```
+
+### Profile Installation Paths
+
+```bash
+# Installed profiles (Xcode manages these)
+~/Library/MobileDevice/Provisioning Profiles/
+
+# List installed profiles
+ls ~/Library/MobileDevice/Provisioning\ Profiles/
+
+# Decode a specific installed profile
+security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/.mobileprovision
+
+# Find profile embedded in app bundle
+find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -5
+
+# Install a profile manually (copy to managed directory)
+cp MyProfile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
+```
+
+---
+
+## Entitlements Reference
+
+### Common Entitlements
+
+| Entitlement | Key | Values |
+|-------------|-----|--------|
+| App ID | `application-identifier` | `TEAMID.com.example.app` |
+| Push Notifications | `aps-environment` | `development` / `production` |
+| App Groups | `com.apple.security.application-groups` | `["group.com.example.shared"]` |
+| Keychain Sharing | `keychain-access-groups` | `["TEAMID.com.example.keychain"]` |
+| Associated Domains | `com.apple.developer.associated-domains` | `["applinks:example.com"]` |
+| iCloud | `com.apple.developer.icloud-container-identifiers` | Container IDs |
+| HealthKit | `com.apple.developer.healthkit` | `true` |
+| Apple Pay | `com.apple.developer.in-app-payments` | Merchant IDs |
+| Network Extensions | `com.apple.developer.networking.networkextension` | Array of types |
+| Siri | `com.apple.developer.siri` | `true` |
+| Sign in with Apple | `com.apple.developer.applesignin` | `["Default"]` |
+
+### Entitlement .plist Format
+
+```xml
+
+
+
+
+ aps-environment
+ development
+ com.apple.security.application-groups
+
+ group.com.example.shared
+
+ com.apple.developer.associated-domains
+
+ applinks:example.com
+
+
+
+```
+
+### Extraction and Comparison
+
+```bash
+# Extract entitlements from signed app binary
+codesign -d --entitlements - /path/to/MyApp.app
+
+# Extract to file for comparison
+codesign -d --entitlements entitlements.plist /path/to/MyApp.app
+
+# Compare entitlements between app and provisioning profile
+diff <(codesign -d --entitlements - MyApp.app 2>/dev/null) \
+ <(security cms -D -i embedded.mobileprovision | plutil -extract Entitlements xml1 -o - -)
+
+# Extract entitlements from .ipa
+unzip -o MyApp.ipa -d /tmp/ipa_contents
+codesign -d --entitlements - /tmp/ipa_contents/Payload/*.app
+
+# Verify entitlements match between build and profile
+codesign -d --entitlements - --xml MyApp.app # XML format
+```
+
+---
+
+## CLI Command Reference
+
+### `security` Commands
+
+```bash
+# --- Identity & Certificate ---
+
+# List valid code signing identities
+security find-identity -v -p codesigning
+# -v: valid only, -p codesigning: code signing policy
+
+# Find certificate by common name
+security find-certificate -c "Apple Distribution" login.keychain-db -p
+# -c: common name substring, -p: output PEM, keychain is positional arg
+
+# Find certificate by SHA-1 hash
+security find-certificate -Z -a login.keychain-db | grep -B5 "ABC123"
+
+# --- Import/Export ---
+
+# Import .p12 (with password, allow codesign access)
+security import certificate.p12 -k login.keychain-db -P "$PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
+
+# Import .cer
+security import certificate.cer -k login.keychain-db
+
+# Export certificate to .p12
+security export -t identities -f pkcs12 -k login.keychain-db -P "$PASSWORD" -o exported.p12
+
+# --- Provisioning Profile Decode ---
+
+# Decode provisioning profile (CMS/PKCS7 signed plist)
+security cms -D -i embedded.mobileprovision
+
+# --- Keychain Management ---
+
+# Create temporary keychain (for CI)
+security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+
+# Set as default keychain
+security default-keychain -s build.keychain
+
+# Add to search list (required for codesign to find certs)
+security list-keychains -d user -s build.keychain login.keychain-db
+
+# Unlock keychain (required in CI before signing)
+security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+
+# Set keychain lock timeout (0 = never lock during session)
+security set-keychain-settings -t 3600 -l build.keychain
+# -t: timeout in seconds, -l: lock on sleep
+
+# Allow codesign to access keys without UI prompt (critical for CI)
+security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
+
+# Delete keychain (CI cleanup)
+security delete-keychain build.keychain
+```
+
+### `codesign` Commands
+
+```bash
+# --- Signing ---
+
+# Sign with specific identity
+codesign -s "Apple Distribution: Company Name (TEAMID)" MyApp.app
+# -s: signing identity (name or SHA-1 hash)
+
+# Sign with entitlements file
+codesign -s "Apple Distribution" --entitlements entitlements.plist MyApp.app
+
+# Force re-sign (overwrite existing signature)
+codesign -f -s "Apple Distribution" MyApp.app
+
+# Sign with timestamp (required for notarization)
+codesign -s "Developer ID Application" --timestamp MyApp.app
+
+# Deep sign (sign all nested code — frameworks, extensions)
+codesign --deep -s "Apple Distribution" MyApp.app
+# Warning: --deep is unreliable for complex apps. Sign each component individually.
+
+# --- Verification ---
+
+# Verify signature is valid
+codesign --verify --verbose=4 MyApp.app
+
+# Verify deep (check nested code)
+codesign --verify --deep --strict MyApp.app
+
+# Display signing information
+codesign -dv MyApp.app
+# Shows: Identifier, Format, TeamIdentifier, Signing Authority chain
+
+# Display verbose signing info
+codesign -dvvv MyApp.app
+
+# Extract entitlements from signed binary
+codesign -d --entitlements - MyApp.app
+codesign -d --entitlements - --xml MyApp.app # XML format
+```
+
+### `openssl` Commands
+
+> **Note**: macOS ships with LibreSSL, not OpenSSL. Some `openssl pkcs12` commands may fail with "MAC verification failed" on stock macOS. Install OpenSSL via `brew install openssl` if needed, then use the full path (`/opt/homebrew/opt/openssl/bin/openssl`).
+
+```bash
+# --- Certificate Inspection ---
+
+# View .cer details
+openssl x509 -in certificate.cer -inform DER -text -noout
+
+# View .pem details
+openssl x509 -in certificate.pem -text -noout
+
+# Check certificate expiration
+openssl x509 -in certificate.cer -inform DER -noout -enddate
+
+# Extract public key
+openssl x509 -in certificate.cer -inform DER -pubkey -noout
+
+# --- PKCS12 (.p12) ---
+
+# Extract certificate from .p12
+openssl pkcs12 -in certificate.p12 -nokeys -clcerts -out cert.pem
+
+# Extract private key from .p12
+openssl pkcs12 -in certificate.p12 -nocerts -nodes -out key.pem
+
+# Create .p12 from cert + key
+openssl pkcs12 -export -in cert.pem -inkey key.pem -out certificate.p12
+
+# Verify .p12 contents
+openssl pkcs12 -info -in certificate.p12 -nokeys
+```
+
+---
+
+## Xcode Build Settings Reference
+
+| Setting | Key | Values |
+|---------|-----|--------|
+| Code Signing Style | `CODE_SIGN_STYLE` | `Automatic` / `Manual` |
+| Signing Identity | `CODE_SIGN_IDENTITY` | `Apple Development` / `Apple Distribution` / `iPhone Distribution` |
+| Development Team | `DEVELOPMENT_TEAM` | Team ID (10-char alphanumeric) |
+| Provisioning Profile | `PROVISIONING_PROFILE_SPECIFIER` | Profile name or UUID |
+| Provisioning Profile (legacy) | `PROVISIONING_PROFILE` | Profile UUID (deprecated, use SPECIFIER) |
+| Other Code Signing Flags | `OTHER_CODE_SIGN_FLAGS` | `--timestamp` / `--options runtime` |
+| Code Sign Entitlements | `CODE_SIGN_ENTITLEMENTS` | Path to .entitlements file |
+| Enable Hardened Runtime | `ENABLE_HARDENED_RUNTIME` | `YES` / `NO` (macOS) |
+
+### xcodebuild Signing Overrides
+
+```bash
+# Automatic signing
+xcodebuild -scheme MyApp -configuration Release \
+ CODE_SIGN_STYLE=Automatic \
+ DEVELOPMENT_TEAM=YOURTEAMID
+
+# Manual signing
+xcodebuild -scheme MyApp -configuration Release \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="Apple Distribution: Company Name (TEAMID)" \
+ PROVISIONING_PROFILE_SPECIFIER="MyApp App Store Profile"
+
+# Archive for distribution
+xcodebuild archive -scheme MyApp \
+ -archivePath build/MyApp.xcarchive \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="Apple Distribution" \
+ PROVISIONING_PROFILE_SPECIFIER="MyApp App Store"
+
+# Export .ipa from archive
+xcodebuild -exportArchive \
+ -archivePath build/MyApp.xcarchive \
+ -exportOptionsPlist ExportOptions.plist \
+ -exportPath build/ipa
+```
+
+### ExportOptions.plist
+
+```xml
+
+
+
+
+ method
+ app-store
+ teamID
+ YOURTEAMID
+ signingStyle
+ manual
+ signingCertificate
+ Apple Distribution
+ provisioningProfiles
+
+ com.example.myapp
+ MyApp App Store Profile
+
+ uploadSymbols
+
+
+
+```
+
+Export method values: `app-store`, `ad-hoc`, `enterprise`, `development`, `developer-id`.
+
+---
+
+## fastlane match Reference
+
+### Setup
+
+```bash
+# Initialize match (interactive — choose storage type)
+fastlane match init
+# Options: git, google_cloud, s3, azure_blob
+
+# Generate certificates + profiles for all types
+fastlane match development
+fastlane match appstore
+fastlane match adhoc
+```
+
+### Matchfile
+
+```ruby
+# fastlane/Matchfile
+git_url("https://github.com/your-org/certificates.git")
+storage_mode("git")
+
+type("appstore") # Default type
+
+app_identifier(["com.example.app", "com.example.app.widget"])
+username("dev@example.com")
+team_id("YOURTEAMID")
+
+# For multiple targets with different profiles
+# for_lane(:beta) do
+# type("adhoc")
+# end
+```
+
+### Usage
+
+```bash
+# Generate or fetch development certs + profiles
+fastlane match development
+
+# Generate or fetch App Store certs + profiles
+fastlane match appstore
+
+# CI: read-only mode (never create, only fetch)
+fastlane match appstore --readonly
+
+# Force regenerate (revokes existing)
+fastlane match nuke distribution # Revoke all distribution certs
+fastlane match appstore # Generate fresh
+# Also: nuke development, nuke enterprise
+```
+
+### Environment Variables for CI
+
+```bash
+MATCH_GIT_URL="https://github.com/your-org/certificates.git"
+MATCH_PASSWORD="encryption_password" # Encrypts the repo
+MATCH_KEYCHAIN_NAME="fastlane_tmp"
+MATCH_KEYCHAIN_PASSWORD="keychain_password"
+MATCH_READONLY="true" # CI should never create certs
+FASTLANE_USER="dev@example.com"
+FASTLANE_TEAM_ID="YOURTEAMID"
+```
+
+### CI Fastfile Example
+
+```ruby
+# fastlane/Fastfile
+lane :release do
+ setup_ci # Creates temporary keychain
+
+ match(
+ type: "appstore",
+ readonly: true, # Critical: never create certs in CI
+ keychain_name: "fastlane_tmp",
+ keychain_password: ""
+ )
+
+ build_app(
+ scheme: "MyApp",
+ export_method: "app-store"
+ )
+
+ upload_to_app_store(skip_metadata: true, skip_screenshots: true)
+end
+```
+
+---
+
+## Keychain Management for CI
+
+### Complete CI Keychain Setup Script
+
+```bash
+#!/bin/bash
+set -euo pipefail
+
+KEYCHAIN_NAME="ci-build.keychain-db"
+KEYCHAIN_PASSWORD="ci-temporary-password"
+
+# 1. Create temporary keychain
+security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
+
+# 2. Add to search list (MUST include login.keychain-db or it disappears)
+security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain-db
+
+# 3. Unlock keychain
+security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
+
+# 4. Prevent keychain from locking during build
+security set-keychain-settings -t 3600 -l "$KEYCHAIN_NAME"
+
+# 5. Import signing certificate
+security import "$P12_PATH" -k "$KEYCHAIN_NAME" -P "$P12_PASSWORD" \
+ -T /usr/bin/codesign -T /usr/bin/security
+
+# 6. Allow codesign access without UI prompt (CRITICAL)
+# Without this, CI gets errSecInternalComponent
+security set-key-partition-list -S apple-tool:,apple: -s \
+ -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
+
+echo "Keychain ready for code signing"
+```
+
+### CI Keychain Cleanup Script
+
+```bash
+#!/bin/bash
+# Run in CI post-build (always, even on failure)
+
+KEYCHAIN_NAME="ci-build.keychain-db"
+
+# Delete temporary keychain
+security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
+
+# Restore default keychain search list
+security list-keychains -d user -s login.keychain-db
+```
+
+### GitHub Actions Example
+
+```yaml
+- name: Install signing certificate
+ env:
+ P12_BASE64: ${{ secrets.P12_BASE64 }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ PROVISION_PROFILE_BASE64: ${{ secrets.PROVISION_PROFILE_BASE64 }}
+ run: |
+ # Decode certificate
+ echo "$P12_BASE64" | base64 --decode > certificate.p12
+
+ # Decode provisioning profile
+ echo "$PROVISION_PROFILE_BASE64" | base64 --decode > profile.mobileprovision
+
+ # Create and configure keychain
+ security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+ security list-keychains -d user -s build.keychain login.keychain-db
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
+ security set-keychain-settings -t 3600 -l build.keychain
+ security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" \
+ -T /usr/bin/codesign -T /usr/bin/security
+ security set-key-partition-list -S apple-tool:,apple: -s \
+ -k "$KEYCHAIN_PASSWORD" build.keychain
+
+ # Install provisioning profile
+ mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
+ cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
+
+- name: Cleanup keychain
+ if: always()
+ run: security delete-keychain build.keychain 2>/dev/null || true
+```
+
+### Xcode Cloud
+
+Xcode Cloud manages signing automatically:
+- Certificates are managed by Apple — no manual cert management needed
+- Provisioning profiles are fetched from Developer Portal
+- Configure signing in Xcode → cloud workflow settings
+- Use `ci_post_clone.sh` for custom keychain operations if needed
+
+---
+
+## APNs Authentication: .p8 vs .p12
+
+| Aspect | .p8 (Token-Based) | .p12 (Certificate-Based) |
+|--------|-------|-------------|
+| Validity | Never expires (revoke to invalidate) | 1 year (must renew annually) |
+| Scope | All apps in team | Single App ID |
+| Max per team | 2 keys | 1 cert per App ID |
+| Setup complexity | Lower (one key for all apps) | Higher (per-app certificate) |
+| Server implementation | JWT token generation required | TLS client certificate |
+| Recommended | Yes (Apple's current recommendation) | Legacy (still supported) |
+
+### .p8 Key Usage
+
+```bash
+# Generate JWT for APNs (simplified — use a library in production)
+# Header: {"alg": "ES256", "kid": "KEY_ID"}
+# Payload: {"iss": "TEAM_ID", "iat": TIMESTAMP}
+# Sign with .p8 private key
+
+# JWT is valid for 1 hour — cache and refresh before expiry
+```
+
+### .p12 Certificate Usage
+
+```bash
+# Send push with certificate authentication
+curl -v \
+ --cert-type P12 --cert apns-cert.p12:password \
+ --header "apns-topic: com.example.app" \
+ --header "apns-push-type: alert" \
+ --data '{"aps":{"alert":"Hello"}}' \
+ --http2 https://api.sandbox.push.apple.com/3/device/$TOKEN
+# Production: https://api.push.apple.com/3/device/$TOKEN
+```
+
+---
+
+## Error Codes Reference
+
+### security Command Errors
+
+| Error | Code | Cause |
+|-------|------|-------|
+| errSecInternalComponent | -2070 | Keychain locked or `set-key-partition-list` not called |
+| errSecItemNotFound | -25300 | Certificate/key not in searched keychains |
+| errSecDuplicateItem | -25299 | Certificate already exists in keychain |
+| errSecAuthFailed | -25293 | Wrong keychain password |
+| errSecInteractionNotAllowed | -25308 | Keychain locked, no UI available (CI without unlock) |
+| errSecMissingEntitlement | -34018 | App missing required entitlement for keychain access |
+
+### codesign Errors
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| `No signing certificate found` | No valid identity in keychain | Import cert or check expiration |
+| `ambiguous (matches ...)` | Multiple matching identities | Specify full identity name or SHA-1 hash |
+| `not valid for use in ...` | Cert type mismatch (dev vs dist) | Use correct certificate type |
+| `a sealed resource is missing or invalid` | Modified resources after signing | Re-sign after all modifications |
+| `invalid signature (code or signature have been modified)` | Binary tampered post-signing | Re-sign or rebuild |
+
+### ITMS (App Store Upload) Errors
+
+| Code | Error | Cause | Fix |
+|------|-------|-------|-----|
+| ITMS-90035 | Invalid Signature | Wrong certificate type or expired cert | Sign with valid Apple Distribution cert |
+| ITMS-90161 | Invalid Provisioning Profile | Profile doesn't match app | Regenerate profile in Developer Portal |
+| ITMS-90046 | Invalid Code Signing Entitlements | Entitlements not in profile | Add capability in portal, regenerate profile |
+| ITMS-90056 | Missing Push Notification Entitlement | aps-environment not in profile | Enable Push Notifications capability |
+| ITMS-90174 | Missing Provisioning Profile | No profile embedded | Archive with correct signing settings |
+| ITMS-90283 | Invalid Provisioning Profile | Profile expired | Download fresh profile |
+| ITMS-90426 | Invalid Swift Support | Swift libraries not signed correctly | Use Xcode's organizer to export (not manual) |
+| ITMS-90474 | Missing Bundle Identifier | Bundle ID doesn't match profile | Align bundle ID across Xcode, portal, and profile |
+| ITMS-90478 | Invalid Team ID | Team ID mismatch | Verify DEVELOPMENT_TEAM build setting |
+| ITMS-90717 | Invalid App Store Distribution Certificate | Using Development cert for App Store | Switch to Apple Distribution certificate |
+
+## Resources
+
+**WWDC**: 2021-10204, 2022-110353
+
+**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app
+
+**Skills**: axiom-code-signing, axiom-code-signing-diag
diff --git a/.claude/skills/axiom-code-signing-ref/agents/openai.yaml b/.claude/skills/axiom-code-signing-ref/agents/openai.yaml
new file mode 100644
index 0000000..9147a73
--- /dev/null
+++ b/.claude/skills/axiom-code-signing-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Code Signing Reference"
+ short_description: "Needing certificate CLI commands, provisioning profile inspection, entitlement extraction, Keychain management script..."
diff --git a/.claude/skills/axiom-code-signing/.openskills.json b/.claude/skills/axiom-code-signing/.openskills.json
new file mode 100644
index 0000000..32c5d4b
--- /dev/null
+++ b/.claude/skills/axiom-code-signing/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-code-signing",
+ "installedAt": "2026-04-12T08:06:02.543Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-code-signing/SKILL.md b/.claude/skills/axiom-code-signing/SKILL.md
new file mode 100644
index 0000000..c75dfe5
--- /dev/null
+++ b/.claude/skills/axiom-code-signing/SKILL.md
@@ -0,0 +1,455 @@
+---
+name: axiom-code-signing
+description: Use when setting up code signing, managing certificates, configuring provisioning profiles, debugging signing errors, setting up CI/CD signing, or preparing distribution builds. Covers certificate lifecycle, automatic vs manual signing, entitlements, fastlane match, Keychain management, and App Store distribution signing.
+license: MIT
+---
+
+# Code Signing
+
+Certificate management, provisioning profiles, entitlements configuration, CI/CD signing setup, and distribution build preparation for iOS/macOS apps.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Set up code signing for a new project or CI/CD pipeline
+- ☑ Debug any code signing error (certificate, profile, entitlement, Keychain)
+- ☑ Configure signing for App Store, TestFlight, or Ad Hoc distribution
+- ☑ Manage certificates and profiles across a team
+- ☑ Set up fastlane match for team-wide certificate management
+- ☑ Understand automatic vs manual signing tradeoffs
+- ☑ Add a new capability that requires entitlement changes
+- ☑ Fix CI/CD signing failures (errSecInternalComponent, locked keychain)
+
+## Example Prompts
+
+"How do I set up code signing for my app?"
+"My build fails with 'No signing certificate found'"
+"How do I set up fastlane match for my team?"
+"errSecInternalComponent in GitHub Actions"
+"ITMS-90035 when uploading to App Store"
+"How do I add push notification entitlements?"
+"Multiple certificates match — ambiguous identity"
+"Code signing works locally but fails in CI"
+"How do I sign for App Store distribution?"
+"My provisioning profile expired, what do I do?"
+
+## Red Flags
+
+Signs you're making this harder than it needs to be:
+
+- ❌ Sharing certificates via Slack/email — .p12 files in chat create security risks and version confusion. Use fastlane match or Xcode's automatic signing.
+- ❌ Disabling code signing to "fix" a build — Code signing can't be disabled for device or distribution builds. The error will return.
+- ❌ Committing .p12 or .mobileprovision to git — These are secrets. Use CI secrets management (GitHub Secrets, environment variables).
+- ❌ Regenerating all certificates "to be safe" — Revokes existing certs, breaking every team member and CI pipeline.
+- ❌ Using a personal certificate for team/CI builds — When that person leaves or their cert expires, everything breaks.
+- ❌ Ignoring "profile doesn't include signing certificate" warnings — This always becomes a build failure. Fix it now.
+- ❌ Setting `CODE_SIGN_IDENTITY = ""` to suppress errors — Defers the problem to archive/export where it's harder to debug.
+- ❌ Manually managing profiles for automatic-signing projects — Pick one approach. Mixing causes conflicts.
+
+## Mandatory First Steps
+
+Before configuring or debugging any code signing issue:
+
+### 1. List Available Signing Identities
+
+```bash
+security find-identity -v -p codesigning
+```
+
+This tells you what certificates are installed, valid, and available for signing.
+
+### 2. Decode the Provisioning Profile
+
+```bash
+# Find profile embedded in most recent build
+find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3
+
+# Decode it
+security cms -D -i path/to/embedded.mobileprovision
+```
+
+Check: expiration, embedded certificates, entitlements, device list.
+
+### 3. Extract Entitlements from the Binary
+
+```bash
+codesign -d --entitlements - /path/to/MyApp.app
+```
+
+Compare these against the profile's entitlements and your .entitlements file. All three must agree.
+
+### 4. Verify Certificate in Profile
+
+```bash
+# Get SHA-1 from keychain
+security find-identity -v -p codesigning | grep "Apple Distribution"
+# Output: 1) ABCDEF123... "Apple Distribution: Company (TEAMID)"
+
+# Get SHA-1 from profile
+security cms -D -i embedded.mobileprovision | plutil -extract DeveloperCertificates xml1 -o - -
+echo "" | base64 -d | openssl x509 -inform DER -noout -fingerprint -sha1
+```
+
+The SHA-1 hashes must match. If they don't, the profile was generated with a different certificate than the one in your keychain.
+
+## Automatic vs Manual Signing
+
+```dot
+digraph signing_decision {
+ "New project or\nsmall team?" [shape=diamond];
+ "CI/CD pipeline?" [shape=diamond];
+ "Multiple targets\nwith different profiles?" [shape=diamond];
+ "Team > 3 developers?" [shape=diamond];
+
+ "Automatic Signing" [shape=box, label="Use Automatic Signing\nXcode manages everything"];
+ "Manual + match" [shape=box, label="Use Manual Signing\n+ fastlane match"];
+ "Manual Signing" [shape=box, label="Use Manual Signing\nspecify profiles explicitly"];
+ "Automatic + overrides" [shape=box, label="Use Automatic Signing\nwith xcodebuild overrides\nfor CI"];
+
+ "New project or\nsmall team?" -> "CI/CD pipeline?" [label="no, larger team"];
+ "New project or\nsmall team?" -> "Automatic Signing" [label="yes, solo/2-3 devs"];
+ "CI/CD pipeline?" -> "Team > 3 developers?" [label="yes, have CI"];
+ "CI/CD pipeline?" -> "Multiple targets\nwith different profiles?" [label="no CI"];
+ "Team > 3 developers?" -> "Manual + match" [label="yes"];
+ "Team > 3 developers?" -> "Automatic + overrides" [label="no, small team + CI"];
+ "Multiple targets\nwith different profiles?" -> "Manual Signing" [label="yes"];
+ "Multiple targets\nwith different profiles?" -> "Automatic Signing" [label="no"];
+}
+```
+
+### Automatic Signing
+
+**Best for**: Solo developers, small teams, projects without CI.
+
+Xcode manages certificates and provisioning profiles automatically. You just select a team.
+
+```
+Xcode → Target → Signing & Capabilities → ✓ Automatically manage signing → Select Team
+```
+
+**How it works**:
+- Xcode creates/downloads certificates as needed
+- Generates provisioning profiles that match your capabilities
+- Regenerates profiles when you add/remove capabilities
+- Registers devices when you connect them
+
+**Limitations**:
+- Only one developer's cert per machine (can conflict in teams)
+- CI requires `xcodebuild` overrides or Xcode Cloud
+- Can't share profiles across team members easily
+- May select unexpected profile if multiple are available
+
+### Manual Signing
+
+**Best for**: Teams, CI/CD pipelines, apps with multiple targets/extensions.
+
+You explicitly specify which certificate and profile to use.
+
+```
+Xcode → Target → Signing & Capabilities → ✗ Automatically manage signing
+→ Select Provisioning Profile for each configuration (Debug/Release)
+```
+
+**How it works**:
+- You create certificates and profiles in Apple Developer Portal
+- Download and install profiles manually (or use match)
+- Specify exact profile in Xcode or xcodebuild
+- Full control over which cert/profile combination is used
+
+**Required build settings**:
+```
+CODE_SIGN_STYLE = Manual
+CODE_SIGN_IDENTITY = Apple Distribution: Company Name (TEAMID)
+PROVISIONING_PROFILE_SPECIFIER = MyApp App Store Profile
+DEVELOPMENT_TEAM = YOURTEAMID
+```
+
+## Certificate Types and Lifecycle
+
+### Certificate Type Selection
+
+| Scenario | Certificate Type | Notes |
+|----------|-----------------|-------|
+| Debug build on device | Apple Development | Auto-created by Xcode |
+| TestFlight / App Store | Apple Distribution | 3 max per account |
+| Ad Hoc distribution | Apple Distribution | Same cert, different profile type |
+| macOS outside App Store | Developer ID Application | 5-year validity |
+
+### Certificate Renewal Workflow
+
+Certificates expire after 1 year (5 years for Developer ID). When a cert expires:
+
+1. **Don't revoke the expired cert** — it's already expired; revoking removes it from the portal and invalidates any profiles still referencing it
+2. Create a new certificate in Apple Developer Portal
+3. Download and install the new certificate
+4. Edit ALL provisioning profiles that used the old cert → select the new cert → regenerate
+5. Download and install updated profiles
+6. Update CI with the new .p12 and profiles
+7. If using fastlane match: `fastlane match nuke [type]` then `fastlane match [type]`
+
+**Team coordination**: Notify the team before regenerating. Distribution certs are shared — regenerating one affects everyone.
+
+## Provisioning Profile Patterns
+
+### Development Profile
+
+For debug builds on registered devices:
+
+- Contains: Development certificate + registered device UDIDs + App ID + entitlements
+- Created: Automatically by Xcode (automatic signing) or manually in portal
+- Devices: Must be explicitly registered in portal (100 device limit per type per year)
+
+### Ad Hoc Profile
+
+For testing on specific devices without TestFlight:
+
+- Contains: Distribution certificate + registered device UDIDs + App ID + entitlements
+- Use case: QA testing, client demos, beta testing without TestFlight
+- Limitation: Same 100-device annual limit as Development
+
+### App Store Profile
+
+For TestFlight and App Store submission:
+
+- Contains: Distribution certificate + App ID + entitlements (NO device list)
+- No device limit — TestFlight supports up to 10,000 testers
+- Required for Xcode Organizer upload or `xcodebuild -exportArchive`
+
+### Enterprise Profile
+
+For in-house distribution (Apple Developer Enterprise Program only):
+
+- Contains: Enterprise certificate + App ID + entitlements (NO device list)
+- Distribute internally without device registration
+- Cannot submit to App Store
+- Apple audits for compliance — misuse (distributing to public) results in program termination
+
+## Entitlements Configuration
+
+### Adding a New Capability
+
+1. **Apple Developer Portal**: App IDs → Select your App ID → Capabilities → Enable the capability
+2. **Xcode**: Target → Signing & Capabilities → + Capability → Select capability
+3. **Regenerate profile**: If using manual signing, edit the provisioning profile to include the new capability, then generate and download
+
+If using automatic signing, Xcode handles steps 1 and 3 automatically.
+
+### Common Capability → Entitlement Mapping
+
+| Capability | Entitlement Key | Profile Requirement |
+|-----------|-----------------|---------------------|
+| Push Notifications | `aps-environment` | Must be in profile |
+| App Groups | `com.apple.security.application-groups` | Must be in profile |
+| Associated Domains | `com.apple.developer.associated-domains` | Must be in profile |
+| Sign in with Apple | `com.apple.developer.applesignin` | Must be in profile |
+| HealthKit | `com.apple.developer.healthkit` | Must be in profile |
+| iCloud | `com.apple.developer.icloud-*` | Must be in profile |
+| In-App Purchase | Automatic | No profile change needed |
+| Background Modes | `UIBackgroundModes` (Info.plist) | No profile change needed |
+| Keychain Sharing | `keychain-access-groups` | Must be in profile |
+
+### Multi-Target Entitlements
+
+Each target (app, widget, extension, etc.) needs its own:
+- App ID in the Developer Portal
+- Provisioning profile
+- .entitlements file
+
+All targets must share the same Team ID. App Groups enable data sharing between targets.
+
+```
+com.example.app → MyApp.entitlements
+com.example.app.widget → Widget/Widget.entitlements
+com.example.app.NotificationService → NotificationService/NotificationService.entitlements
+```
+
+## CI/CD Signing Setup
+
+### Without fastlane (raw scripts)
+
+See `axiom-code-signing-ref` for complete CI keychain scripts. The critical steps:
+
+1. **Create** temporary keychain
+2. **Add to search list** (include login.keychain-db) — do NOT use `default-keychain -s` as it breaks access to login keychain credentials
+3. **Unlock** keychain
+4. **Import** .p12 certificate
+5. **Set partition list** (prevents errSecInternalComponent)
+6. **Install** provisioning profile to `~/Library/MobileDevice/Provisioning Profiles/`
+7. **Build/archive** with explicit signing settings
+8. **Cleanup** — delete temporary keychain (always, even on failure)
+
+### With fastlane match
+
+fastlane match manages certificates and profiles in a shared git repo (or cloud storage), encrypted with a passphrase.
+
+**Initial setup** (run once by a team admin):
+```bash
+fastlane match init # Choose storage (git, google_cloud, s3)
+fastlane match development # Generate dev certs + profiles
+fastlane match appstore # Generate distribution certs + profiles
+```
+
+**CI usage** (readonly — never generate in CI):
+```ruby
+# Fastfile
+lane :release do
+ setup_ci # Creates temporary keychain
+ match(type: "appstore", readonly: true)
+ build_app(scheme: "MyApp", export_method: "app-store")
+end
+```
+
+**Key rules**:
+- CI must use `readonly: true` — never generate certificates from CI
+- Set `MATCH_PASSWORD` as a CI secret (encrypts/decrypts the cert repo)
+- Use `setup_ci` to create a temporary keychain (handles all keychain setup)
+- Each app target needs its own `app_identifier` in Matchfile
+
+### Xcode Cloud
+
+Xcode Cloud handles signing automatically:
+- No certificate management needed — Apple manages signing infrastructure
+- Configure in Xcode → Product → Xcode Cloud → Manage Workflows
+- Use `ci_post_clone.sh` for custom setup (SPM auth, certificates from custom sources)
+- Distribution signing is handled during the "Archive" action
+
+## Anti-Patterns
+
+### Anti-Pattern 1: Sharing .p12 via Chat
+
+**Wrong**:
+```
+Slack: "Hey, here's the distribution cert 📎 cert.p12, password is Company123"
+```
+
+**Right**: Use fastlane match (encrypted git repo) or Xcode's automatic signing with a shared team account.
+
+**Why it matters**: .p12 files shared in chat create security risks (credentials in chat history), version confusion (which cert is current?), and single-point-of-failure (if the sender's cert expires or is revoked). Time cost: 1-2 hours per team member when the shared cert breaks.
+
+### Anti-Pattern 2: Committing Secrets to Git
+
+**Wrong**:
+```
+git add certificates/distribution.p12
+git add profiles/AppStore.mobileprovision
+git commit -m "Add signing files"
+```
+
+**Right**: Use CI secrets management:
+```bash
+# GitHub Actions
+echo "$P12_BASE64" | base64 --decode > certificate.p12 # From secrets
+echo "$PROFILE_BASE64" | base64 --decode > profile.mobileprovision
+```
+
+**Why it matters**: Certificates and profiles are secrets — they allow anyone to sign apps as your team. Even in private repos, git history is permanent. Once committed, the credential is in every clone forever.
+
+**If already committed**: Scrub history with `git filter-repo --path path/to/cert.p12 --invert-paths`, then rotate the compromised certificate (revoke in portal, create new, update CI secrets). Every existing clone still has the old cert — treat it as compromised.
+
+### Anti-Pattern 3: "Fix" Signing by Disabling It
+
+**Wrong**:
+```
+CODE_SIGN_IDENTITY = ""
+CODE_SIGNING_REQUIRED = NO
+CODE_SIGNING_ALLOWED = NO
+```
+
+**Right**: Diagnose the actual signing error with the mandatory diagnostic steps.
+
+**Why it matters**: Disabling code signing "works" for Simulator builds but fails for device builds, archives, and distribution. The problem is deferred to a point where it's harder to debug and closer to a deadline. Time cost: the original issue plus 30+ minutes of export/upload debugging.
+
+### Anti-Pattern 4: Using Personal Cert for Team/CI
+
+**Wrong**:
+```
+# CI pipeline uses one developer's personal certificate
+security import ~/charles-personal.p12 ...
+CODE_SIGN_IDENTITY = "Apple Distribution: Charles Personal (ABC123)"
+```
+
+**Right**: Use a dedicated team certificate managed via fastlane match or a shared Apple Developer account:
+```ruby
+match(type: "appstore", readonly: true)
+```
+
+**Why it matters**: When that developer leaves, changes machines, or their cert expires, CI and all team distribution breaks. One person's personal cert should never be a shared infrastructure dependency. Time cost: 2-4 hours of emergency cert rotation when the person is unavailable.
+
+## Pressure Scenarios
+
+### Scenario 1: "Just disable code signing so we can ship today"
+
+**Context**: Build deadline approaching, code signing error blocking the archive.
+
+**Pressure**: "We don't need signing for testing, just disable it and we'll fix it after release."
+
+**Reality**: You can't submit to TestFlight or the App Store without valid code signing. Disabling it now defers a blocking issue to the most time-pressured moment — the actual submission. The diagnostic steps take 5-10 minutes and will identify the root cause.
+
+**Correct action**: Run Steps 1-4 from Mandatory First Steps. Most signing issues are a single expired or mismatched component.
+
+**Push-back template**: "We can't submit to TestFlight or the App Store without valid signing. The diagnostic takes 5 minutes and will tell us exactly what's wrong. Disabling it now means we'll hit the same blocker during submission with even less time."
+
+### Scenario 2: "Use my personal certificate for the team build"
+
+**Context**: CI is broken because the distribution certificate expired. A team member offers their personal cert as a quick fix.
+
+**Pressure**: "I have a working cert, just use mine for now."
+
+**Reality**: Using a personal certificate creates a single point of failure. When that person is unavailable, on vacation, or leaves the company, signing breaks with no path to recovery without their cooperation. The proper fix (renewing the team cert) takes the same amount of time.
+
+**Correct action**: Renew the team's distribution certificate in Apple Developer Portal. Update CI secrets. Regenerate provisioning profiles with the new cert.
+
+**Push-back template**: "Your cert would work short-term, but it makes you a single point of failure for all builds. Renewing the team cert takes the same 10 minutes and doesn't create a dependency on one person."
+
+### Scenario 3: "Commit the .p12 to the repo so CI has it"
+
+**Context**: Setting up CI/CD for the first time. Developer wants to commit the certificate to git for convenience.
+
+**Pressure**: "It's a private repo, nobody else can see it."
+
+**Reality**: Git history is permanent. Even in private repos, the .p12 (which contains the private key) lives in every clone, every fork, and every backup forever. If the repo is ever made public, open-sourced, or accessed by a contractor, the signing key is exposed. CI secrets management exists specifically for this.
+
+**Correct action**: Base64-encode the .p12 and store as a CI secret (GitHub Secrets, GitLab CI Variables, etc.). Decode at build time.
+
+**Push-back template**: "Git history is permanent — once committed, the certificate is in every clone forever. CI secrets management (GitHub Secrets) takes the same effort to set up and is designed for exactly this. Let me set it up properly."
+
+## Checklist
+
+Before archiving for distribution:
+
+**Certificates**:
+- [ ] Distribution certificate valid (not expired, not revoked)
+- [ ] `security find-identity -v -p codesigning` shows the expected identity
+- [ ] Certificate matches what provisioning profile expects
+
+**Provisioning Profiles**:
+- [ ] Profile not expired
+- [ ] Profile type matches distribution method (App Store, Ad Hoc, Enterprise)
+- [ ] Profile contains the certificate being used for signing
+- [ ] Profile bundle ID matches target's bundle identifier
+
+**Entitlements**:
+- [ ] All capabilities in Xcode match capabilities in profile
+- [ ] .entitlements file doesn't contain capabilities not in profile
+- [ ] App extensions have their own profiles with correct entitlements
+- [ ] App Groups consistent across main app and extensions
+
+**Build Settings**:
+- [ ] `CODE_SIGN_STYLE` matches intent (Automatic or Manual)
+- [ ] `CODE_SIGN_IDENTITY` correct for build type (Development vs Distribution)
+- [ ] `PROVISIONING_PROFILE_SPECIFIER` set for manual signing
+- [ ] `DEVELOPMENT_TEAM` set to correct Team ID
+
+**CI/CD** (if applicable):
+- [ ] Keychain created, unlocked, and partition list set
+- [ ] Certificate imported into CI keychain
+- [ ] Profile installed to `~/Library/MobileDevice/Provisioning Profiles/`
+- [ ] Cleanup step runs on success and failure (`if: always()`)
+
+## Resources
+
+**WWDC**: 2021-10204, 2022-110353
+
+**Docs**: /security, /bundleresources/entitlements, /xcode/distributing-your-app
+
+**Skills**: axiom-code-signing-ref, axiom-code-signing-diag
diff --git a/.claude/skills/axiom-code-signing/agents/openai.yaml b/.claude/skills/axiom-code-signing/agents/openai.yaml
new file mode 100644
index 0000000..b91168c
--- /dev/null
+++ b/.claude/skills/axiom-code-signing/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Code Signing"
+ short_description: "Setting up code signing, managing certificates, configuring provisioning profiles, debugging signing errors, setting ..."
diff --git a/.claude/skills/axiom-combine-patterns/.openskills.json b/.claude/skills/axiom-combine-patterns/.openskills.json
new file mode 100644
index 0000000..2d0c38e
--- /dev/null
+++ b/.claude/skills/axiom-combine-patterns/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-combine-patterns",
+ "installedAt": "2026-04-12T08:06:04.520Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-combine-patterns/SKILL.md b/.claude/skills/axiom-combine-patterns/SKILL.md
new file mode 100644
index 0000000..cd9387a
--- /dev/null
+++ b/.claude/skills/axiom-combine-patterns/SKILL.md
@@ -0,0 +1,642 @@
+---
+name: axiom-combine-patterns
+description: Use when working with Combine publishers, AnyCancellable lifecycle, @Published properties, or bridging Combine with async/await. Covers reactive patterns, operator selection, memory management, and migration strategy.
+license: MIT
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-03-12"
+---
+
+# Combine Patterns
+
+## Overview
+
+Combine remains embedded in massive production codebases — UIKit delegates, NotificationCenter bridging, KVO observation, and @Published properties are everywhere. New code prefers async/await, but interop and maintenance of existing Combine pipelines is daily work. This skill covers the decisions and pitfalls that matter: when to use Combine vs async/await, how to avoid memory leaks, and how to bridge between the two paradigms.
+
+**Core principle**: Combine is not dead — it's mature. The question isn't "should I use Combine?" but "is Combine the right tool for THIS specific data flow?"
+
+## When to Use This Skill
+
+- Working with existing Combine pipelines
+- Deciding between Combine and async/await for a new data flow
+- Debugging AnyCancellable memory leaks or silent pipeline failures
+- Using @Published or ObservableObject
+- Bridging Combine publishers with async/await code
+- Working with Subjects (PassthroughSubject, CurrentValueSubject)
+
+## When NOT to Use This Skill
+
+- Timer.publish patterns → route via `axiom-ios-concurrency` to timer-patterns skill (dedicated timer lifecycle coverage)
+- @Observable migration from ObservableObject → use `axiom-swift-concurrency` (modern observation)
+- UIKit ↔ SwiftUI bridging → route via `axiom-ios-ui` (view wrapping, not data flow)
+- General async/await patterns → use `axiom-swift-concurrency`
+
+## Example Prompts
+
+- "Should I use Combine or async/await for this?"
+- "My Combine pipeline silently stops producing values"
+- "How do I convert a publisher to an async sequence?"
+- "AnyCancellable is leaking — where do I store it?"
+- "What's the difference between combineLatest and zip?"
+- "How do I debounce a text field with Combine?"
+- "My @Published property update isn't reaching the view"
+- "How do I bridge a Combine publisher into async/await code?"
+
+---
+
+## Part 1: Combine vs async/await Decision Tree
+
+| Use Case | Combine | async/await | Why |
+|----------|---------|-------------|-----|
+| One-shot network call | No | Yes | async/await is simpler, no cancellable management |
+| Stream of values over time | Yes | AsyncStream | Combine's operators (debounce, combineLatest) are richer |
+| Debounce/throttle user input | Yes | Awkward | Combine has built-in debounce/throttle; AsyncStream requires manual implementation |
+| Merge multiple sources | Yes | TaskGroup | Combine's merge/combineLatest handle heterogeneous streams naturally |
+| Existing UIKit KVO/Notification | Yes | Bridge | publisher(for:) and NotificationCenter.default.publisher are idiomatic Combine |
+| New project iOS 17+ | No | Yes | @Observable + async/await is the modern pattern |
+| Existing codebase with Combine | Maintain | Migrate incrementally | Don't rewrite working pipelines — bridge at boundaries |
+
+### Quick Decision
+
+```
+Is it a one-shot operation (network call, file read)?
+├─ Yes → async/await (simpler, no cancellable management)
+│
+Does it need time-based operators (debounce, throttle, delay)?
+├─ Yes → Combine (built-in operators, no manual implementation)
+│
+Are you combining multiple ongoing streams?
+├─ Yes → Combine (combineLatest, merge, zip are purpose-built)
+│
+Is this new code on iOS 17+?
+├─ Yes → async/await + @Observable (modern pattern)
+│
+Is it existing Combine code that works?
+└─ Yes → Keep it. Bridge at boundaries when async/await code needs the data.
+```
+
+---
+
+## Part 2: Publisher/Subscriber Lifecycle
+
+### AnyCancellable Storage Rules
+
+AnyCancellable cancels its subscription when deallocated. If you don't store it, the pipeline is cancelled immediately after setup.
+
+#### ❌ Pipeline dies instantly
+
+```swift
+func setupPipeline() {
+ publisher
+ .sink { value in
+ self.handle(value) // Never called
+ }
+ // AnyCancellable returned by sink is discarded → subscription cancelled
+}
+```
+
+#### ✅ Store in Set
+
+```swift
+private var cancellables = Set()
+
+func setupPipeline() {
+ publisher
+ .sink { [weak self] value in
+ self?.handle(value)
+ }
+ .store(in: &cancellables)
+}
+```
+
+### Why Set, Not Array
+
+`Set` is the idiomatic choice because:
+- `store(in:)` works with both `Set` and `RangeReplaceableCollection` (including `Array`), but `Set` is conventional
+- Order doesn't matter for subscriptions
+- Prevents accidental duplicates if setup runs twice
+
+### 4 Memory Leak Patterns
+
+#### Leak 1: Strong self in sink
+
+```swift
+// ❌ LEAK: sink closure captures self strongly
+publisher
+ .sink { value in
+ self.handle(value) // Strong capture → retain cycle
+ }
+ .store(in: &cancellables)
+
+// ✅ FIX: weak self
+publisher
+ .sink { [weak self] value in
+ self?.handle(value)
+ }
+ .store(in: &cancellables)
+```
+
+#### Leak 2: Missing store(in:)
+
+```swift
+// ❌ LEAK: cancellable assigned to local var, not stored
+let cancellable = publisher.sink { handle($0) }
+// cancellable deallocated at end of scope → pipeline cancelled
+
+// ✅ FIX: store in instance property
+publisher.sink { [weak self] in self?.handle($0) }
+ .store(in: &cancellables)
+```
+
+#### Leak 3: Over-retained cancellables
+
+```swift
+// ❌ LEAK: cancellables set never cleared, old pipelines accumulate
+func refreshData() {
+ // Each call adds another subscription without removing the previous one
+ dataPublisher
+ .sink { [weak self] in self?.update($0) }
+ .store(in: &cancellables)
+}
+
+// ✅ FIX: clear before re-subscribing
+func refreshData() {
+ cancellables.removeAll() // Cancel previous subscriptions
+ dataPublisher
+ .sink { [weak self] in self?.update($0) }
+ .store(in: &cancellables)
+}
+```
+
+#### Leak 4: assign(to:on:) strong capture
+
+`assign(to:on:)` captures the `on:` parameter **strongly**. When the target is `self`, you get a retain cycle: `self → cancellables → subscription → self`.
+
+```swift
+// ❌ LEAK: assign(to:on:) retains self strongly — deinit never called
+userPublisher
+ .map { $0.name }
+ .assign(to: \.displayName, on: self)
+ .store(in: &cancellables)
+
+// ✅ FIX: use assign(to:) with @Published projected value (iOS 14+)
+userPublisher
+ .map { $0.name }
+ .assign(to: &$displayName)
+// No store(in:) needed — subscription tied to @Published property lifetime
+```
+
+Key difference: `assign(to: &$prop)` does NOT return an `AnyCancellable` — the subscription is managed internally and cancelled when the `@Published` property's owner deallocates. No retain cycle, no cancellable storage needed.
+
+If you must support iOS 13, use `sink` with `[weak self]` instead.
+
+---
+
+## Part 3: Essential Operators
+
+One canonical example per group. These cover 90% of real-world usage.
+
+### Transform
+
+```swift
+// map: transform each value
+publisher.map { $0.name }
+
+// compactMap: transform + filter nil
+publisher.compactMap { Int($0) }
+
+// flatMap: one-to-many (each value produces a new publisher)
+searchText
+ .flatMap { query in
+ api.search(query) // Returns a publisher
+ }
+```
+
+**flatMap gotcha**: Without `.switchToLatest()` or `maxPublishers: .max(1)`, flatMap creates a new inner publisher for every upstream value. For search-as-you-type, use `map` + `switchToLatest` instead:
+
+```swift
+searchText
+ .map { query in api.search(query) }
+ .switchToLatest() // Cancels previous search when new query arrives
+```
+
+### Combine Multiple Sources
+
+```swift
+// combineLatest: latest value from each, fires when ANY changes
+Publishers.CombineLatest(namePublisher, agePublisher)
+ .map { name, age in "\(name), \(age)" }
+
+// merge: interleave values from same-type publishers
+Publishers.Merge(localUpdates, remoteUpdates)
+ .sink { update in handle(update) }
+
+// zip: pairs values 1:1 (waits for both to produce)
+Publishers.Zip(requestA, requestB)
+ .sink { responseA, responseB in /* both complete */ }
+```
+
+| Operator | Fires When | Use Case |
+|----------|-----------|----------|
+| combineLatest | Any input changes | Form validation (all fields) |
+| merge | Any input produces | Combining event streams |
+| zip | All inputs produce one value | Parallel requests that must complete together |
+
+### Time-Based
+
+```swift
+// debounce: wait until values stop arriving (search-as-you-type)
+searchTextPublisher
+ .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
+ .sink { [weak self] query in self?.search(query) }
+ .store(in: &cancellables)
+
+// throttle: emit at most once per interval (scroll position)
+scrollOffsetPublisher
+ .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
+ .sink { [weak self] offset in self?.updateHeader(offset) }
+ .store(in: &cancellables)
+```
+
+| Operator | Behavior | Use Case |
+|----------|----------|----------|
+| debounce | Waits for silence, then emits last value | Search fields, auto-save |
+| throttle(latest: true) | Emits latest value at fixed intervals | Scroll tracking, sensor data |
+| throttle(latest: false) | Emits first value at fixed intervals | Rate-limiting button taps |
+
+### Error Handling
+
+```swift
+// tryMap: transform that can throw
+publisher.tryMap { data in
+ try JSONDecoder().decode(Model.self, from: data)
+}
+
+// mapError: convert error types
+publisher.mapError { error in
+ AppError.network(error)
+}
+
+// replaceError: provide fallback value (terminates error path)
+publisher.replaceError(with: defaultValue)
+
+// retry: re-subscribe on failure
+publisher.retry(3) // Retry up to 3 times before propagating error
+```
+
+**Error handling order matters**: `retry` should come before `replaceError`. Retry re-subscribes to the upstream publisher; replaceError terminates the error and makes the pipeline infallible.
+
+```swift
+api.fetchData()
+ .retry(3) // Try 3 more times on failure
+ .replaceError(with: cached) // If all retries fail, use cache
+ .sink { data in update(data) }
+ .store(in: &cancellables)
+```
+
+**replaceError after flatMap kills the outer pipeline**: If `replaceError` is downstream of `flatMap`, a single inner publisher error terminates the entire pipeline — not just that one request. Move error handling inside `flatMap` so each inner publisher handles its own errors:
+
+```swift
+// ❌ One API error kills the entire pipeline
+$searchText
+ .flatMap { query in api.search(query) }
+ .replaceError(with: []) // Pipeline completes on first error
+ .sink { ... }
+
+// ✅ Each search handles its own errors independently
+$searchText
+ .flatMap { query in
+ api.search(query)
+ .replaceError(with: []) // Only this search affected
+ }
+ .sink { ... }
+```
+
+---
+
+## Part 4: @Published + ObservableObject
+
+### willSet Timing
+
+`@Published` fires its publisher in `willSet`, not `didSet`. This means subscribers see the new value before the property has actually been set on the object.
+
+```swift
+class ViewModel: ObservableObject {
+ @Published var count = 0
+
+ init() {
+ $count.sink { newValue in
+ // 'newValue' is the incoming value
+ // BUT self.count is still the OLD value here
+ print("New: \(newValue), Current: \(self.count)")
+ // Prints "New: 1, Current: 0" when count is set to 1
+ }
+ .store(in: &cancellables)
+ }
+}
+```
+
+If you need to read the property's value after it's been set, don't subscribe to `$count` — use a `didSet` observer instead, or read `self.count` after a brief deferral. The `$` publisher is designed for reacting to the incoming value, not for reading post-mutation state.
+
+### Nested ObservableObject Trap
+
+SwiftUI does NOT observe nested ObservableObject changes. Only the top-level object's `objectWillChange` triggers view updates.
+
+```swift
+// ❌ View won't update when settings.theme changes
+class AppState: ObservableObject {
+ @Published var settings = Settings() // Settings is also ObservableObject
+}
+
+class Settings: ObservableObject {
+ @Published var theme = "light" // Changes here don't propagate
+}
+
+// ✅ FIX: Forward objectWillChange manually
+class AppState: ObservableObject {
+ @Published var settings = Settings()
+ private var cancellables = Set()
+
+ init() {
+ settings.objectWillChange
+ .sink { [weak self] _ in
+ self?.objectWillChange.send()
+ }
+ .store(in: &cancellables)
+ }
+}
+```
+
+**Better fix for iOS 17+**: Migrate to `@Observable`, which handles nested observation automatically. See `axiom-swift-concurrency` for migration patterns.
+
+### Thread Safety Warning
+
+`@Published` is NOT thread-safe. Setting a `@Published` property from a background thread triggers `objectWillChange` off the main thread, which can crash SwiftUI views.
+
+```swift
+// ❌ CRASH: @Published set from background thread
+class ViewModel: ObservableObject {
+ @Published var data: [Item] = []
+
+ func fetch() {
+ Task {
+ let items = await api.fetchItems()
+ data = items // Background thread → crash
+ }
+ }
+}
+
+// ✅ FIX: Ensure main thread
+@MainActor
+class ViewModel: ObservableObject {
+ @Published var data: [Item] = []
+
+ func fetch() {
+ Task {
+ let items = await api.fetchItems()
+ data = items // Safe — @MainActor ensures main thread
+ }
+ }
+}
+```
+
+---
+
+## Part 5: Bridging Combine and async/await
+
+### Publisher → AsyncSequence
+
+Use `.values` to consume any publisher as an async sequence:
+
+```swift
+let cancellable = notificationPublisher
+ .sink { notification in handle(notification) }
+
+// ✅ Modern equivalent using .values
+for await notification in notificationPublisher.values {
+ handle(notification)
+}
+```
+
+**Caveats with `.values`**:
+- The `for await` loop runs indefinitely until the publisher completes or the Task is cancelled
+- Errors thrown by the publisher terminate the loop
+- Only one consumer — if two `for await` loops consume the same `.values`, behavior is undefined
+
+### async/await → Publisher
+
+Wrap an async function in `Future` for Combine consumption:
+
+```swift
+func fetchUser(id: String) async throws -> User { ... }
+
+// Wrap as a Combine publisher
+let userPublisher = Future { promise in
+ Task {
+ do {
+ let user = try await fetchUser(id: "123")
+ promise(.success(user))
+ } catch {
+ promise(.failure(error))
+ }
+ }
+}
+```
+
+**Future executes immediately** — it runs its closure when created, not when subscribed. Wrap in `Deferred` if you need lazy execution:
+
+```swift
+let lazyPublisher = Deferred {
+ Future { promise in
+ Task {
+ do {
+ let user = try await fetchUser(id: "123")
+ promise(.success(user))
+ } catch {
+ promise(.failure(error))
+ }
+ }
+ }
+}
+```
+
+### Gradual Migration Strategy
+
+Don't rewrite working Combine code. Bridge at the boundary:
+
+```
+Combine pipeline → .values → async/await code
+ (bridge)
+
+async function → Future → Combine pipeline
+ (bridge)
+```
+
+**Migration priority**:
+1. New code: write in async/await
+2. Boundary: bridge with `.values` or `Future`
+3. Existing Combine: leave working pipelines alone
+4. Rewrite: only when the pipeline needs significant changes anyway
+
+---
+
+## Part 6: Subjects
+
+### PassthroughSubject vs CurrentValueSubject
+
+| Feature | PassthroughSubject | CurrentValueSubject |
+|---------|-------------------|-------------------|
+| Initial value | None | Required |
+| Late subscribers | Miss previous values | Get current value immediately |
+| `.value` property | No | Yes (read current value) |
+| Use case | Events (button taps, notifications) | State (current selection, loading status) |
+
+```swift
+// Event-driven: no initial value, late subscribers miss past events
+let taps = PassthroughSubject()
+taps.send()
+
+// State-driven: always has a current value
+let isLoading = CurrentValueSubject(false)
+isLoading.value = true // Direct access
+isLoading.send(false) // Also works
+```
+
+### Send-After-Completion Pitfall
+
+Once a Subject receives a completion event, all subsequent `send()` calls are silently ignored. No crash, no error — just silence.
+
+```swift
+let subject = PassthroughSubject()
+
+subject.send(1) // Delivered
+subject.send(completion: .finished)
+subject.send(2) // Silently ignored — no crash, no warning
+
+// This is the most common cause of "my pipeline stopped working"
+```
+
+**Diagnosis**: If a pipeline silently stops producing values, check whether anything upstream sent a `.finished` or `.failure` completion. Once complete, the pipeline is dead.
+
+---
+
+## Part 7: Cold vs Hot Publishers (share/multicast)
+
+Most Combine publishers are **cold** — they start work when subscribed and each subscriber gets its own independent execution. `URLSession.dataTaskPublisher` fires a new HTTP request per subscriber.
+
+```swift
+// ❌ Two subscribers = two network requests
+let publisher = URLSession.shared
+ .dataTaskPublisher(for: url)
+ .map(\.data)
+ .eraseToAnyPublisher()
+
+publisher.sink { cache.store($0) }.store(in: &cancellables) // Request 1
+publisher.sink { display($0) }.store(in: &cancellables) // Request 2
+```
+
+### share()
+
+`.share()` makes a cold publisher hot — the first subscriber triggers the work, subsequent subscribers share the output:
+
+```swift
+// ✅ One request, shared result
+let publisher = URLSession.shared
+ .dataTaskPublisher(for: url)
+ .map(\.data)
+ .share()
+ .eraseToAnyPublisher()
+
+publisher.sink { cache.store($0) }.store(in: &cancellables) // Triggers request
+publisher.sink { display($0) }.store(in: &cancellables) // Shares result
+```
+
+### share() Gotchas
+
+| Gotcha | Effect | Fix |
+|--------|--------|-----|
+| Late subscribers miss values | `share()` uses PassthroughSubject — no replay | Attach all subscribers before the first value arrives, or use `multicast` with `CurrentValueSubject` |
+| Upstream completed before subscriber attaches | Late subscriber immediately gets `.finished` with no values | Ensure subscription order, or cache the result outside Combine |
+| All subscribers cancel → upstream cancels | New subscriber after that triggers a NEW upstream execution | Expected behavior, but surprising if you assumed the result was cached |
+
+### When to use share()
+
+```
+Multiple subscribers to the same expensive publisher?
+├─ No → Don't use share() (unnecessary complexity)
+│
+├─ Yes, all subscribe at the same time?
+│ └─ Yes → share() works
+│
+└─ Yes, subscribers attach at different times?
+ └─ Use multicast(subject:) with CurrentValueSubject, or cache the result in a property
+```
+
+---
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Combine is dead, just use async/await" | Combine has no deprecation notice. Thousands of production apps use it. Rewriting working pipelines wastes time and introduces bugs. Bridge incrementally instead. |
+| "I'll just use .sink everywhere" | Without `[weak self]` and proper `store(in:)`, every sink is a potential memory leak. The lifecycle rules in Part 2 prevent the top 4 leak patterns. |
+| "assign(to:on:) is fine, it's the standard API" | It captures `on:` strongly — retain cycle if target is `self`. Use `assign(to: &$prop)` instead (Part 2, Leak 4). |
+| "debounce and throttle are the same thing" | debounce waits for silence; throttle emits at intervals. Using the wrong one causes either delayed responses or missed events. Part 3 has the decision table. |
+| "I know how @Published works" | @Published fires on willSet, not didSet. Nested ObservableObject doesn't propagate. Background thread access crashes. Part 4 covers all three traps. |
+| "I'll migrate everything to async/await at once" | Full rewrites of working Combine code introduce bugs and waste time. Bridge at boundaries (Part 5). Rewrite only when the pipeline needs significant changes anyway. |
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Let's migrate all Combine code to async/await"
+
+**Setup**: Tech lead wants to modernize the codebase. "Combine is legacy, let's rip it out."
+
+**Pressure**: Authority + scope creep. The entire data layer uses Combine publishers, @Published properties, and operator chains.
+
+**Expected with skill**: Push back with the gradual migration strategy (Part 5). New code uses async/await. Boundaries use `.values` and `Future`. Existing working pipelines stay until they need changes. Full rewrite is the most expensive option with the least benefit.
+
+**Pushback template**: "Combine isn't deprecated — Apple still ships it in every SDK. A full rewrite of working pipelines introduces bugs we don't have today. Let's bridge at boundaries: new code in async/await, `.values` to consume existing publishers, and we only rewrite a pipeline when we're already changing it significantly."
+
+---
+
+### Scenario 2: "Pipeline silently stopped — just recreate it"
+
+**Setup**: A Combine pipeline stopped producing values after a refactor. No crash, no error.
+
+**Pressure**: Time pressure. "Just tear it down and rebuild."
+
+**Expected with skill**: Diagnose before rebuilding. Check: (1) Was a completion sent upstream? (send-after-completion, Part 6). (2) Is the AnyCancellable still alive? (storage rules, Part 2). (3) Did the publisher error without handling? (replaceError / catch, Part 3). These three causes cover 90% of silent pipeline failures.
+
+**Diagnostic checklist**:
+1. Is the `AnyCancellable` still stored? (Set not cleared, not deallocated)
+2. Did anything upstream send `.finished` or `.failure`?
+3. Is there a `tryMap` or other throwing operator without error handling?
+4. Was `switchToLatest` used where the outer publisher completed?
+
+**Pushback template**: "Before rebuilding, let me check four things: cancellable lifecycle, upstream completions, unhandled errors, and switchToLatest completion. One of these is almost always the cause. It takes 5 minutes to diagnose vs 30 minutes to rebuild and test."
+
+---
+
+### Scenario 3: "Settings changes aren't updating the UI"
+
+**Setup**: A settings screen uses a nested ObservableObject. The parent `AppState` holds a `Settings` object. When the user changes `settings.theme`, the UI doesn't update.
+
+**Pressure**: "The binding works in isolation, it must be a SwiftUI bug. Let me just force a refresh with objectWillChange.send()."
+
+**Expected with skill**: Recognize the nested ObservableObject trap (Part 4). SwiftUI does NOT observe nested ObservableObject changes — only the top-level object's `objectWillChange` triggers view updates. The fix is either forwarding `objectWillChange` from the nested object, or migrating to `@Observable` (iOS 17+) which handles nesting automatically.
+
+**Anti-pattern without skill**: Sprinkling `objectWillChange.send()` calls throughout the code, adding `@Published` to every nested property (which doesn't help), or restructuring the model to flatten everything into one object (losing separation of concerns).
+
+**Pushback template**: "SwiftUI only observes the top-level ObservableObject. Nested objects need their objectWillChange forwarded to the parent. Part 4 has the exact pattern — it's a 5-line fix in the parent's init, not a SwiftUI bug."
+
+---
+
+## Resources
+
+**WWDC**: 2019-722, 2019-721, 2020-10034
+
+**Docs**: /combine, /combine/anycancellable, /combine/published
+
+**Skills**: swift-concurrency, memory-debugging
diff --git a/.claude/skills/axiom-combine-patterns/agents/openai.yaml b/.claude/skills/axiom-combine-patterns/agents/openai.yaml
new file mode 100644
index 0000000..17ff2f1
--- /dev/null
+++ b/.claude/skills/axiom-combine-patterns/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Combine Patterns"
+ short_description: "Working with Combine publishers, AnyCancellable lifecycle, @Published properties, or bridging Combine with async/await"
diff --git a/.claude/skills/axiom-concurrency-profiling/.openskills.json b/.claude/skills/axiom-concurrency-profiling/.openskills.json
new file mode 100644
index 0000000..1673d2e
--- /dev/null
+++ b/.claude/skills/axiom-concurrency-profiling/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-concurrency-profiling",
+ "installedAt": "2026-04-12T08:06:05.046Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-concurrency-profiling/SKILL.md b/.claude/skills/axiom-concurrency-profiling/SKILL.md
new file mode 100644
index 0000000..7895cd5
--- /dev/null
+++ b/.claude/skills/axiom-concurrency-profiling/SKILL.md
@@ -0,0 +1,249 @@
+---
+name: axiom-concurrency-profiling
+description: Use when profiling async/await performance, diagnosing actor contention, or investigating thread pool exhaustion. Covers Swift Concurrency Instruments template, task visualization, actor contention analysis, thread pool debugging.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Concurrency Profiling — Instruments Workflows
+
+Profile and optimize Swift async/await code using Instruments.
+
+## When to Use
+
+✅ **Use when:**
+- UI stutters during async operations
+- Suspecting actor contention
+- Tasks queued but not executing
+- Main thread blocked during async work
+- Need to visualize task execution flow
+
+❌ **Don't use when:**
+- Issue is pure CPU performance (use Time Profiler)
+- Memory issues unrelated to concurrency (use Allocations)
+- Haven't confirmed concurrency is the bottleneck
+
+## Swift Concurrency Template
+
+### What It Shows
+
+| Track | Information |
+|-------|-------------|
+| **Swift Tasks** | Task lifetimes, parent-child relationships |
+| **Swift Actors** | Actor access, contention visualization |
+| **Thread States** | Blocked vs running vs suspended |
+
+### Statistics
+
+- **Running Tasks**: Tasks currently executing
+- **Alive Tasks**: Tasks present at a point in time
+- **Total Tasks**: Cumulative count created
+
+### Color Coding
+
+- **Blue**: Task executing
+- **Red**: Task waiting (contention)
+- **Gray**: Task suspended (awaiting)
+
+## Workflow 1: Diagnose Main Thread Blocking
+
+**Symptom**: UI freezes, main thread timeline full
+
+1. Profile with Swift Concurrency template
+2. Look at main thread → "Swift Tasks" lane
+3. Find long blue bars (task executing on main)
+4. Check if work could be offloaded
+
+**Solution patterns**:
+
+```swift
+// ❌ Heavy work on MainActor
+@MainActor
+class ViewModel: ObservableObject {
+ func process() {
+ let result = heavyComputation() // Blocks UI
+ self.data = result
+ }
+}
+
+// ✅ Offload heavy work
+@MainActor
+class ViewModel: ObservableObject {
+ func process() async {
+ let result = await Task.detached {
+ heavyComputation()
+ }.value
+ self.data = result
+ }
+}
+```
+
+## Workflow 2: Find Actor Contention
+
+**Symptom**: Tasks serializing unexpectedly, parallel work running sequentially
+
+1. Enable "Swift Actors" instrument
+2. Look for serialized access patterns
+3. Red = waiting, Blue = executing
+4. High red:blue ratio = contention problem
+
+**Solution patterns**:
+
+```swift
+// ❌ All work serialized through actor
+actor DataProcessor {
+ func process(_ data: Data) -> Result {
+ heavyProcessing(data) // All callers wait
+ }
+}
+
+// ✅ Mark heavy work as nonisolated
+actor DataProcessor {
+ nonisolated func process(_ data: Data) -> Result {
+ heavyProcessing(data) // Runs in parallel
+ }
+
+ func storeResult(_ result: Result) {
+ // Only actor state access serialized
+ }
+}
+```
+
+**More fixes**:
+- Split actor into multiple (domain separation)
+- Use Mutex for hot paths (faster than actor hop)
+- Reduce actor scope (fewer isolated properties)
+
+## Workflow 3: Thread Pool Exhaustion
+
+**Symptom**: Tasks queued but not executing, gaps in task execution
+
+**Cause**: Blocking calls exhaust cooperative pool
+
+1. Look for gaps in task execution across all threads
+2. Check for blocking primitives
+3. Replace with async equivalents
+
+**Common culprits**:
+
+```swift
+// ❌ Blocks cooperative thread
+Task {
+ semaphore.wait() // NEVER do this
+ // ...
+ semaphore.signal()
+}
+
+// ❌ Synchronous file I/O in async context
+Task {
+ let data = Data(contentsOf: fileURL) // Blocks
+}
+
+// ✅ Use async APIs
+Task {
+ let (data, _) = try await URLSession.shared.data(from: fileURL)
+}
+```
+
+**Debug flag**:
+```
+SWIFT_CONCURRENCY_COOPERATIVE_THREAD_BOUNDS=1
+```
+Detects unsafe blocking in async context.
+
+## Workflow 4: Priority Inversion
+
+**Symptom**: High-priority task waits for low-priority
+
+1. Inspect task priorities in Instruments
+2. Follow wait chains
+3. Ensure critical paths use appropriate priority
+
+```swift
+// ✅ Explicit priority for critical work
+Task(priority: .userInitiated) {
+ await criticalUIUpdate()
+}
+```
+
+## Thread Pool Model
+
+Swift uses a **cooperative thread pool** matching CPU core count:
+
+| Aspect | GCD | Swift Concurrency |
+|--------|-----|-------------------|
+| Threads | Grows unbounded | Fixed to core count |
+| Blocking | Creates new threads | Suspends, frees thread |
+| Dependencies | Hidden | Runtime-tracked |
+| Context switch | Full kernel switch | Lightweight continuation |
+
+**Why blocking is catastrophic**:
+- Each blocked thread holds memory + kernel structures
+- Limited threads means blocked = no progress
+- Pool exhaustion deadlocks the app
+
+## Quick Checks (Before Profiling)
+
+Run these checks first:
+
+1. **Is work actually async?**
+ - Look for suspension points (`await`)
+ - Sync code in async function still blocks
+
+2. **Holding locks across await?**
+ ```swift
+ // ❌ Deadlock risk
+ mutex.withLock {
+ await something() // Never!
+ }
+ ```
+
+3. **Tasks in tight loops?**
+ ```swift
+ // ❌ Overhead may exceed benefit
+ for item in items {
+ Task { process(item) }
+ }
+
+ // ✅ Structured concurrency
+ await withTaskGroup(of: Void.self) { group in
+ for item in items {
+ group.addTask { process(item) }
+ }
+ }
+ ```
+
+4. **DispatchSemaphore in async context?**
+ - Always unsafe — use `withCheckedContinuation` instead
+
+## Common Issues Summary
+
+| Issue | Symptom in Instruments | Fix |
+|-------|------------------------|-----|
+| MainActor overload | Long blue bars on main | `Task.detached`, `nonisolated` |
+| Actor contention | High red:blue ratio | Split actors, use `nonisolated` |
+| Thread exhaustion | Gaps in all threads | Remove blocking calls |
+| Priority inversion | High-pri waits for low-pri | Check task priorities |
+| Too many tasks | Task creation overhead | Use task groups |
+
+## Safe vs Unsafe Primitives
+
+**Safe with cooperative pool**:
+- `await`, actors, task groups
+- `os_unfair_lock`, `NSLock` (short critical sections)
+- `Mutex` (iOS 18+)
+
+**Unsafe (violate forward progress)**:
+- `DispatchSemaphore.wait()`
+- `pthread_cond_wait`
+- Sync file/network I/O
+- `Thread.sleep()` in Task
+
+## Resources
+
+**WWDC**: 2022-110350, 2021-10254
+
+**Docs**: /xcode/improving-app-responsiveness
+
+**Skills**: axiom-swift-concurrency, axiom-performance-profiling, axiom-synchronization, axiom-lldb (interactive thread state inspection)
diff --git a/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml b/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml
new file mode 100644
index 0000000..16240a4
--- /dev/null
+++ b/.claude/skills/axiom-concurrency-profiling/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Concurrency Profiling"
+ short_description: "Profiling async/await performance, diagnosing actor contention, or investigating thread pool exhaustion"
diff --git a/.claude/skills/axiom-contacts-ref/.openskills.json b/.claude/skills/axiom-contacts-ref/.openskills.json
new file mode 100644
index 0000000..343aaf0
--- /dev/null
+++ b/.claude/skills/axiom-contacts-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-contacts-ref",
+ "installedAt": "2026-04-12T08:06:05.993Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-contacts-ref/SKILL.md b/.claude/skills/axiom-contacts-ref/SKILL.md
new file mode 100644
index 0000000..ad4f794
--- /dev/null
+++ b/.claude/skills/axiom-contacts-ref/SKILL.md
@@ -0,0 +1,609 @@
+---
+name: axiom-contacts-ref
+description: Use when needing Contacts API details — CNContactStore, CNMutableContact, CNSaveRequest, CNContactFormatter, CNContactVCardSerialization, CNContactPickerViewController, ContactAccessButton, contactAccessPicker, ContactProvider extension, CNChangeHistoryFetchRequest, contact key descriptors, and CNError codes
+license: MIT
+---
+
+# Contacts API Reference
+
+## Overview
+
+The Contacts framework provides programmatic access to the system contact database. ContactsUI provides system view controllers for contact selection and display. ContactProvider enables apps to expose their own contacts to the system.
+
+**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+
+
+---
+
+# Part 1: CNContactStore
+
+The primary gateway for contact data. "Fetch methods perform I/O — avoid using the main thread."
+
+## Authorization
+
+```swift
+// Check status (static method)
+let status = CNContactStore.authorizationStatus(for: .contacts)
+// Returns: .notDetermined, .restricted, .denied, .authorized, .limited (iOS 18+)
+
+// Request access
+let store = CNContactStore()
+try await store.requestAccess(for: .contacts) // Returns Bool
+```
+
+**Info.plist required**: `NSContactsUsageDescription` (crash without it).
+
+**Special entitlement**: `com.apple.developer.contacts.notes` — required to read/write `note` field. Requires Apple approval.
+
+## Fetching Contacts
+
+```swift
+// Single contact by identifier
+let contact = try store.unifiedContact(
+ withIdentifier: identifier,
+ keysToFetch: keys
+)
+
+// Search by predicate
+let contacts = try store.unifiedContacts(
+ matching: predicate,
+ keysToFetch: keys
+)
+
+// Current user's card
+let me = try store.unifiedMeContact(withKeysToFetch: keys)
+
+// Memory-efficient enumeration
+let request = CNContactFetchRequest(keysToFetch: keys)
+request.predicate = predicate // Optional filter
+request.sortOrder = .userDefault // .none, .givenName, .familyName, .userDefault
+try store.enumerateContacts(with: request) { contact, stop in
+ // stop.pointee = true to break early
+}
+```
+
+## Built-in Predicates
+
+```swift
+CNContact.predicateForContacts(matchingName: "John")
+CNContact.predicateForContacts(matchingEmailAddress: "john@example.com")
+CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: "+1555..."))
+CNContact.predicateForContacts(withIdentifiers: [id1, id2])
+CNContact.predicateForContactsInGroup(withIdentifier: groupId)
+CNContact.predicateForContactsInContainer(withIdentifier: containerId)
+```
+
+## Containers and Groups
+
+```swift
+store.containers(matching: predicate) // [CNContainer]
+store.groups(matching: predicate) // [CNGroup]
+store.defaultContainerIdentifier // String (property, not method)
+```
+
+**CNContainer types**: `.local`, `.exchange`, `.cardDAV`, `.unassigned`
+
+## Change Tracking
+
+```swift
+store.currentHistoryToken // Data? — save for incremental sync
+```
+
+## Change Notification
+
+```swift
+NotificationCenter.default.addObserver(
+ forName: .CNContactStoreDidChange, object: nil, queue: .main
+) { _ in
+ // Refetch visible contacts
+}
+```
+
+## Save Operations
+
+```swift
+let saveRequest = CNSaveRequest()
+saveRequest.add(contact, toContainerWithIdentifier: nil) // nil = default
+saveRequest.update(contact)
+saveRequest.delete(contact.mutableCopy() as! CNMutableContact)
+try store.execute(saveRequest)
+```
+
+---
+
+# Part 2: CNContact Key Descriptors
+
+You MUST specify which properties to fetch. Accessing an unfetched property throws `CNContactPropertyNotFetchedException`.
+
+## Common Key Constants
+
+| Key | Property |
+|-----|----------|
+| `CNContactIdentifierKey` | `identifier` |
+| `CNContactGivenNameKey` | `givenName` |
+| `CNContactFamilyNameKey` | `familyName` |
+| `CNContactMiddleNameKey` | `middleName` |
+| `CNContactNamePrefixKey` | `namePrefix` |
+| `CNContactNameSuffixKey` | `nameSuffix` |
+| `CNContactNicknameKey` | `nickname` |
+| `CNContactOrganizationNameKey` | `organizationName` |
+| `CNContactJobTitleKey` | `jobTitle` |
+| `CNContactDepartmentNameKey` | `departmentName` |
+| `CNContactPhoneNumbersKey` | `phoneNumbers` |
+| `CNContactEmailAddressesKey` | `emailAddresses` |
+| `CNContactPostalAddressesKey` | `postalAddresses` |
+| `CNContactUrlAddressesKey` | `urlAddresses` |
+| `CNContactSocialProfilesKey` | `socialProfiles` |
+| `CNContactInstantMessageAddressesKey` | `instantMessageAddresses` |
+| `CNContactBirthdayKey` | `birthday` |
+| `CNContactNonGregorianBirthdayKey` | `nonGregorianBirthday` |
+| `CNContactDatesKey` | `dates` |
+| `CNContactNoteKey` | `note` (requires entitlement) |
+| `CNContactImageDataKey` | `imageData` |
+| `CNContactThumbnailImageDataKey` | `thumbnailImageData` |
+| `CNContactImageDataAvailableKey` | `imageDataAvailable` |
+| `CNContactRelationsKey` | `contactRelations` |
+| `CNContactTypeKey` | `contactType` (.person, .organization) |
+
+## Convenience Descriptors
+
+```swift
+// All keys needed for name display (locale-aware)
+CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
+CNContactFormatter.descriptorForRequiredKeys(for: .phoneticFullName)
+
+// All keys needed for vCard export
+CNContactVCardSerialization.descriptorForRequiredKeys()
+```
+
+**Always prefer formatter descriptors** over manual key lists for name display.
+
+---
+
+# Part 3: CNMutableContact
+
+Mutable subclass of `CNContact`. **Not thread-safe** — use immutable `CNContact` for cross-thread access.
+
+## Creating a Contact
+
+```swift
+let contact = CNMutableContact()
+contact.givenName = "Jane"
+contact.familyName = "Appleseed"
+contact.organizationName = "Apple Inc."
+contact.jobTitle = "Engineer"
+
+// Phone numbers
+contact.phoneNumbers = [
+ CNLabeledValue(label: CNLabelPhoneNumberMobile,
+ value: CNPhoneNumber(stringValue: "+15551234567")),
+ CNLabeledValue(label: CNLabelWork,
+ value: CNPhoneNumber(stringValue: "+15559876543"))
+]
+
+// Email addresses
+contact.emailAddresses = [
+ CNLabeledValue(label: CNLabelHome, value: "jane@example.com" as NSString),
+ CNLabeledValue(label: CNLabelWork, value: "jane@apple.com" as NSString)
+]
+
+// Postal addresses
+let address = CNMutablePostalAddress()
+address.street = "1 Apple Park Way"
+address.city = "Cupertino"
+address.state = "CA"
+address.postalCode = "95014"
+address.country = "United States"
+contact.postalAddresses = [CNLabeledValue(label: CNLabelWork, value: address)]
+
+// Birthday
+contact.birthday = DateComponents(year: 1990, month: 6, day: 15)
+
+// Photo
+contact.imageData = imageData
+```
+
+## Removing Values
+
+Set strings/arrays to empty, other properties to `nil`.
+
+## Constraint
+
+"You may modify only those properties whose values you fetched from the contacts database."
+
+---
+
+# Part 4: CNSaveRequest
+
+Batch operations for contacts, groups, and subgroups.
+
+**Platform**: iOS 9.0+, iPadOS 9.0+, macOS 10.11+, Mac Catalyst 13.1+ (no watchOS)
+
+## Contact Operations
+
+```swift
+let request = CNSaveRequest()
+request.add(contact, toContainerWithIdentifier: containerId) // nil = default
+request.update(contact)
+request.delete(contact)
+```
+
+## Group Operations
+
+```swift
+request.add(group, toContainerWithIdentifier: containerId)
+request.update(group)
+request.delete(group)
+request.addMember(contact, to: group)
+request.removeMember(contact, from: group)
+request.addSubgroup(subgroup, to: parentGroup)
+request.removeSubgroup(subgroup, from: parentGroup)
+```
+
+## Properties
+
+| Property | Type | Notes |
+|----------|------|-------|
+| `shouldRefetchContacts` | `Bool` | Refetch added/updated contacts post-execution |
+| `transactionAuthor` | `String?` | Identifies who made the change (for change history filtering) |
+
+## Execution
+
+```swift
+try store.execute(request)
+```
+
+**Concurrency**: "Last change wins" for overlapping concurrent changes.
+
+---
+
+# Part 5: CNContactFormatter
+
+Locale-aware name formatting.
+
+```swift
+let formatter = CNContactFormatter()
+
+// Format name
+let name = formatter.string(from: contact) // String?
+let name = CNContactFormatter.string(from: contact, style: .fullName)
+
+// Attributed string variants
+let attributed = formatter.attributedString(from: contact)
+
+// Locale information
+let order = CNContactFormatter.nameOrder(for: contact) // .givenNameFirst, .familyNameFirst
+let delimiter = CNContactFormatter.delimiter(for: contact) // Locale-appropriate separator
+```
+
+## Styles
+
+| Style | Example |
+|-------|---------|
+| `.fullName` | "Jane Appleseed" or "Appleseed Jane" (per locale) |
+| `.phoneticFullName` | Phonetic representation |
+
+---
+
+# Part 6: CNContactVCardSerialization
+
+```swift
+// Export contacts to vCard data
+let data = try CNContactVCardSerialization.data(with: contacts)
+
+// Import contacts from vCard data
+let contacts = try CNContactVCardSerialization.contacts(with: data)
+
+// Required keys for export
+let keys = CNContactVCardSerialization.descriptorForRequiredKeys()
+```
+
+---
+
+# Part 7: ContactsUI
+
+## CNContactPickerViewController (iOS 9+)
+
+Lets users pick contacts **without requiring app-level authorization**. App receives one-time snapshot.
+
+```swift
+let picker = CNContactPickerViewController()
+picker.delegate = self
+picker.displayedPropertyKeys = [CNContactPhoneNumbersKey, CNContactEmailAddressesKey]
+present(picker, animated: true)
+```
+
+### Predicates (set BEFORE presentation)
+
+```swift
+// Which contacts are selectable
+picker.predicateForEnablingContact = NSPredicate(
+ format: "phoneNumbers.@count > 0"
+)
+
+// Auto-select whole contact (skip property selection)
+picker.predicateForSelectionOfContact = NSPredicate(
+ format: "emailAddresses.@count > 0"
+)
+
+// Which properties can be selected individually
+picker.predicateForSelectionOfProperty = NSPredicate(
+ format: "key == 'phoneNumbers'"
+)
+```
+
+**Gotcha**: Changing predicates only takes effect before the view is presented.
+
+### Delegate (CNContactPickerDelegate)
+
+```swift
+func contactPicker(_ picker: CNContactPickerViewController,
+ didSelect contact: CNContact) { }
+func contactPicker(_ picker: CNContactPickerViewController,
+ didSelect contacts: [CNContact]) { } // Multi-selection
+func contactPicker(_ picker: CNContactPickerViewController,
+ didSelect contactProperty: CNContactProperty) { }
+func contactPickerDidCancel(_ picker: CNContactPickerViewController) { }
+```
+
+## CNContactViewController (iOS 9+)
+
+Display a single contact with three initialization modes:
+
+```swift
+// Existing contact
+let vc = CNContactViewController(for: contact)
+
+// Unknown contact (partial data)
+let vc = CNContactViewController(forUnknownContact: partialContact)
+
+// New contact
+let vc = CNContactViewController(forNewContact: nil)
+
+// Display mode
+vc.allowsEditing = true
+vc.allowsActions = true // Call, message, email buttons
+vc.displayedPropertyKeys = [CNContactPhoneNumbersKey]
+vc.highlightProperty(withKey: CNContactPhoneNumbersKey, identifier: nil)
+```
+
+---
+
+# Part 8: Contact Access Button (iOS 18+)
+
+SwiftUI component for privacy-conscious contact access.
+
+```swift
+ContactAccessButton(queryString: searchText) { identifiers in
+ let contacts = await fetchContacts(withIdentifiers: identifiers)
+}
+```
+
+### Caption Options
+
+| Value | Shows |
+|-------|-------|
+| `.defaultText` | Default text |
+| `.email` | Email address |
+| `.phone` | Phone number |
+
+### Modifiers
+
+```swift
+.font(.system(weight: .bold))
+.foregroundStyle(.gray)
+.tint(.green)
+.contactAccessButtonCaption(.phone)
+.contactAccessButtonStyle(ContactAccessButton.Style(imageWidth: 30))
+```
+
+### Security
+
+Button only grants access when:
+- **Legible**: Sufficient contrast between text and background
+- **Unobstructed**: Entire button visible, not clipped
+- **Validated tap**: Real user interaction, not simulated
+
+---
+
+# Part 9: contactAccessPicker (iOS 18+)
+
+Modal sheet for managing limited access contact set. For bulk or non-immediate use cases.
+
+```swift
+@State private var isPresented = false
+
+Button("Share More Contacts") {
+ isPresented.toggle()
+}
+.contactAccessPicker(isPresented: $isPresented) { identifiers in
+ // identifiers: [String] — newly permitted contacts only
+ let contacts = await fetchContacts(withIdentifiers: identifiers)
+}
+```
+
+**Difference from CNContactPickerViewController**: `contactAccessPicker` changes persistent access. `CNContactPickerViewController` provides one-time snapshots.
+
+---
+
+# Part 10: ContactProvider Framework (iOS 18+)
+
+Enables apps to expose contacts to the system Contacts ecosystem from third-party sources.
+
+## Architecture
+
+1. **Main app** controls the extension via `ContactProviderManager`
+2. **Extension** enumerates contacts to the system
+3. Communication via **App Group** shared container
+
+## ContactProviderManager (Main App Only)
+
+```swift
+let manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts")
+
+try await manager.enable() // Async — may prompt user
+try await manager.disable() // Deactivate
+try await manager.reset() // Clear all provider contacts
+try await manager.invalidate() // Terminate extension
+try await manager.signalEnumerator(for: .default) // Trigger enumeration
+
+manager.isEnabled // Bool — activation state
+```
+
+**Cannot be used in app extensions** — main app only.
+
+## ContactProviderExtension Protocol
+
+```swift
+@main
+class Provider: ContactProviderExtension {
+ func configure(for domain: ContactProviderDomain) {
+ // Setup data access
+ }
+
+ func enumerator(for collection: ContactItem.Identifier)
+ -> ContactItemEnumerator {
+ return MyEnumerator()
+ }
+
+ func invalidate() async throws {
+ // Cleanup
+ }
+}
+```
+
+**Info.plist**: Extension point `com.apple.contact.provider.extension`
+
+## Enumeration
+
+Two patterns:
+1. **Content enumeration** — full initial sync via `ContactItemContentObserver`
+2. **Change enumeration** — incremental updates via `ContactItemChangeObserver` and `ContactItemSyncAnchor`
+
+```swift
+class MyEnumerator: ContactItemEnumerator {
+ func enumerateContent(
+ in page: ContactItemPage,
+ for observer: ContactItemContentObserver
+ ) {
+ let contact = CNMutableContact()
+ contact.givenName = "Jane"
+ contact.familyName = "Appleseed"
+ let item = ContactItem.contact(contact, ContactItem.Identifier("jane-001"))
+ observer.didEnumerate([item])
+ observer.didFinishEnumeratingContent(upTo: generationMarker)
+ }
+
+ func enumerateChanges(
+ startingAt anchor: ContactItemSyncAnchor,
+ for observer: ContactItemChangeObserver
+ ) {
+ // Incremental updates since anchor
+ observer.didFinishEnumeratingChanges(upTo: newAnchor)
+ }
+}
+```
+
+## ContactProvider Errors
+
+| Code | Meaning |
+|------|---------|
+| `featureNotAvailable` | Framework not available |
+| `deniedByUser` | User rejected |
+| `extensionNotFound` | Extension not registered |
+| `enumerationTimeout` | Extension too slow |
+| `cannotEnumerate` | Enumeration failed |
+| `pageExpired` | Content page expired |
+| `changeAnchorExpired` | Sync anchor expired |
+| `itemsLimitReached` | Too many contacts |
+
+---
+
+# Part 11: Change History (TN3149)
+
+## CNChangeHistoryFetchRequest
+
+```swift
+let request = CNChangeHistoryFetchRequest()
+request.startingToken = savedToken // nil = full fetch
+request.includeGroupChanges = false // Default NO
+request.mutableObjects = false // Default NO
+request.shouldUnifyResults = true // Default YES
+request.additionalContactKeyDescriptors = [
+ CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
+]
+request.excludedTransactionAuthors = [Bundle.main.bundleIdentifier!]
+```
+
+## CNChangeHistoryEventVisitor Protocol
+
+```swift
+// Required
+func visit(_ event: CNChangeHistoryDropEverythingEvent) // Full re-sync
+func visit(_ event: CNChangeHistoryAddContactEvent) // New contact
+func visit(_ event: CNChangeHistoryUpdateContactEvent) // Modified
+func visit(_ event: CNChangeHistoryDeleteContactEvent) // Deleted
+
+// Optional (when includeGroupChanges = true)
+func visit(_ event: CNChangeHistoryAddGroupEvent)
+func visit(_ event: CNChangeHistoryUpdateGroupEvent)
+func visit(_ event: CNChangeHistoryDeleteGroupEvent)
+func visit(_ event: CNChangeHistoryAddMemberToGroupEvent)
+func visit(_ event: CNChangeHistoryRemoveMemberFromGroupEvent)
+func visit(_ event: CNChangeHistoryAddSubgroupToGroupEvent)
+func visit(_ event: CNChangeHistoryRemoveSubgroupFromGroupEvent)
+```
+
+**Must use visitor pattern** — do NOT use `isKindOfClass:` to determine event type.
+
+**Gotcha**: `enumeratorForChangeHistoryFetchRequest:error:` is **Objective-C only** — unavailable in Swift.
+
+**Token expiration**: Returns `DropEverything` + `Add` events for all contacts — same code handles full and incremental sync.
+
+**Transaction authors**: Use reverse-domain notation (bundle identifier). Filters results but doesn't provide attribution.
+
+---
+
+# Part 12: Error Reference
+
+## CNError Codes
+
+| Category | Code | Meaning |
+|----------|------|---------|
+| Authorization | `authorizationDenied` | No permission |
+| Authorization | `featureDisabledByUser` | Feature turned off |
+| Data | `recordDoesNotExist` | Contact/group deleted |
+| Data | `recordNotWritable` | Read-only contact |
+| Data | `insertedRecordAlreadyExists` | Duplicate insert |
+| Validation | `validationTypeMismatch` | Wrong value type |
+| Validation | `validationMultipleErrors` | Multiple validation failures |
+| History | `changeHistoryExpired` | Sync token expired |
+| History | `changeHistoryInvalidAnchor` | Bad sync anchor |
+| History | `changeHistoryInvalidFetchRequest` | Invalid request |
+
+Error `userInfo` provides: `affectedRecords`, `affectedRecordIdentifiers`, `keyPaths`.
+
+---
+
+# Part 13: Platform Availability
+
+| API | iOS | macOS | watchOS | visionOS |
+|-----|-----|-------|---------|----------|
+| CNContactStore | 9.0+ | 10.11+ | 2.0+ | 1.0+ |
+| Limited access | 18.0+ | — | — | — |
+| CNContactPickerViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
+| CNContactViewController | 9.0+ | (Catalyst 13.1+) | — | 1.0+ |
+| ContactAccessButton | 18.0+ | — | — | — |
+| contactAccessPicker | 18.0+ | — | — | — |
+| ContactProvider | 18.0+ | — | — | — |
+| CNChangeHistoryFetchRequest | 13.0+ | 10.15+ | — | 1.0+ |
+| CNSaveRequest | 9.0+ | 10.11+ | — | 1.0+ |
+
+---
+
+## Resources
+
+**WWDC**: 2024-10121
+
+**Docs**: /contacts, /contacts/cncontactstore, /contacts/cnmutablecontact, /contactsui, /contactsui/cncontactpickerviewcontroller, /contactprovider, /technotes/tn3149
+
+**Skills**: contacts, eventkit-ref, privacy-ux
diff --git a/.claude/skills/axiom-contacts-ref/agents/openai.yaml b/.claude/skills/axiom-contacts-ref/agents/openai.yaml
new file mode 100644
index 0000000..2dfe8da
--- /dev/null
+++ b/.claude/skills/axiom-contacts-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Contacts Reference"
+ short_description: "Needing Contacts API details"
diff --git a/.claude/skills/axiom-contacts/.openskills.json b/.claude/skills/axiom-contacts/.openskills.json
new file mode 100644
index 0000000..ca84de6
--- /dev/null
+++ b/.claude/skills/axiom-contacts/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-contacts",
+ "installedAt": "2026-04-12T08:06:05.528Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-contacts/SKILL.md b/.claude/skills/axiom-contacts/SKILL.md
new file mode 100644
index 0000000..ff77d89
--- /dev/null
+++ b/.claude/skills/axiom-contacts/SKILL.md
@@ -0,0 +1,294 @@
+---
+name: axiom-contacts
+description: Use when accessing ANY contact data, requesting Contacts permissions, choosing between picker and store access, implementing Contact Access Button, or migrating to iOS 18 limited access. Covers authorization levels, CNContactStore, ContactProvider, key fetching, incremental sync.
+license: MIT
+---
+
+# Contacts — Discipline
+
+## Core Philosophy
+
+> "The contact access button is a powerful new way to manage access to contacts, right in your app. Instead of a full-screen picker, this button fits into your existing UI."
+
+**Mental model**: Contacts has four authorization levels. Most apps should use the Contact Access Button or CNContactPickerViewController, which require no authorization at all. Only request store access when you need persistent contact data.
+
+## When to Use This Skill
+
+Use this skill when:
+- Reading or writing contacts
+- Choosing between picker, Contact Access Button, or full store access
+- Requesting Contacts permissions
+- Implementing contact search or selection UIs
+- Migrating to iOS 18 limited access
+- Building a Contact Provider extension
+- Implementing incremental contact sync
+
+Do NOT use this skill for:
+- Calendar events or reminders (use **eventkit**)
+- General privacy patterns (use **privacy-ux**)
+- SwiftUI architecture (use **swiftui-architecture**)
+
+## Related Skills
+
+- **contacts-ref** — Complete Contacts/ContactsUI API reference
+- **eventkit** — EventKit discipline skill
+- **privacy-ux** — General iOS privacy and permission UX
+- **keychain** — If storing contact-related credentials
+
+---
+
+## Access Level Decision Tree
+
+```dot
+digraph contacts_access {
+ rankdir=TB;
+ "What does your app need?" [shape=diamond];
+ "One-time contact selection?" [shape=diamond];
+ "Persistent access to specific contacts?" [shape=diamond];
+ "Search + incremental discovery?" [shape=diamond];
+ "Read entire contact database?" [shape=diamond];
+
+ "CNContactPickerViewController" [shape=box, label="No Auth Required\nCNContactPickerViewController\nOne-time snapshot"];
+ "Contact Access Button" [shape=box, label="Limited or Not Determined\nContactAccessButton\nGrants access per-contact"];
+ "contactAccessPicker" [shape=box, label="Limited Access\ncontactAccessPicker\nBulk contact selection"];
+ "Full access" [shape=box, label="Full Access\nCNContactStore\nRead/write all contacts"];
+
+ "What does your app need?" -> "One-time contact selection?" [label="pick a contact"];
+ "One-time contact selection?" -> "CNContactPickerViewController" [label="yes"];
+ "One-time contact selection?" -> "Persistent access to specific contacts?" [label="no, need persistent"];
+ "Persistent access to specific contacts?" -> "Search + incremental discovery?" [label="yes"];
+ "Search + incremental discovery?" -> "Contact Access Button" [label="search flow\n(best UX)"];
+ "Search + incremental discovery?" -> "contactAccessPicker" [label="bulk selection\n(friend matching)"];
+ "Persistent access to specific contacts?" -> "Read entire contact database?" [label="no"];
+ "Read entire contact database?" -> "Full access" [label="yes, core feature"];
+}
+```
+
+---
+
+## The Four Authorization Levels
+
+### Not Determined
+
+App hasn't requested access yet. `CNContactStore` auto-prompts on first access attempt. `ContactAccessButton` works in this state — tapping it triggers a simplified limited-access prompt.
+
+### Limited Access (iOS 18+)
+
+User selected specific contacts to share. Your app sees only those contacts via `CNContactStore`. The API surface is identical to full access — only the visible contacts differ.
+
+**Your app always has access to contacts it creates**, regardless of authorization level.
+
+### Full Access
+
+Read/write access to all contacts. Users must explicitly choose "Full Access" in the two-stage prompt. Reserve this for apps where contacts are the core feature.
+
+### Denied
+
+No access to contact data. App cannot read, write, or enumerate contacts.
+
+---
+
+## Contact Access Button (iOS 18+)
+
+The preferred way to give users control over which contacts your app can access. Shows search results for contacts your app doesn't yet have access to. One tap grants access.
+
+```swift
+@State private var searchText = ""
+
+var body: some View {
+ VStack {
+ // Your app's own search results first
+ ForEach(myAppResults) { result in
+ ContactRow(result)
+ }
+
+ // Contact Access Button for contacts not yet shared
+ if authStatus == .limited || authStatus == .notDetermined {
+ ContactAccessButton(queryString: searchText) { identifiers in
+ let contacts = await fetchContacts(withIdentifiers: identifiers)
+ // Use the newly accessible contacts
+ }
+ }
+ }
+}
+```
+
+### Customization
+
+```swift
+ContactAccessButton(queryString: searchText)
+ .font(.system(weight: .bold)) // Upper text + action label
+ .foregroundStyle(.gray) // Primary text color
+ .tint(.green) // Action label color
+ .contactAccessButtonCaption(.phone) // .defaultText, .email, .phone
+ .contactAccessButtonStyle(
+ ContactAccessButton.Style(imageWidth: 30)
+ )
+```
+
+### Security Requirements
+
+The button only grants access when:
+- Contents are clearly **legible** (sufficient contrast)
+- Button is fully **unobstructed** (not clipped)
+- Taps are **validated events** (not simulated)
+
+If any requirement fails, tapping the button does nothing. Always ensure adequate contrast and avoid clipping.
+
+---
+
+## Anti-Patterns
+
+| Pattern | Time Cost | Why It's Wrong | Fix |
+|---------|-----------|----------------|-----|
+| Requesting full access for contact picking | 1-2 sprint days recovering denied users | Full access prompts denied 40%+ of the time | Use CNContactPickerViewController or ContactAccessButton |
+| Accessing unfetched key on CNContact | 15-30 min debugging crash | `CNContactPropertyNotFetchedException` — no clear error message | Always specify `keysToFetch` |
+| Using manual name key lists instead of formatter descriptor | 10-20 min debugging per locale | Different cultures use different name field combinations | Use `CNContactFormatter.descriptorForRequiredKeys(for:)` |
+| Creating multiple CNContactStore instances | 30+ min debugging stale data | Objects from one store can't be used with another | Create one, reuse it |
+| Fetching all keys "just in case" | App Store review risk | Overfetching triggers stricter privacy scrutiny | Fetch only the keys you need |
+| Using `CNContactStore` on main thread | 1-2 hours debugging UI freezes | "Fetch methods perform I/O" — Apple docs | Run fetches on background thread |
+| Missing `NSContactsUsageDescription` in Info.plist | 15 min debugging crash | App crashes on any contact store access attempt | Add the usage description |
+| Mutating CNMutableContact across threads | 2-4 hours debugging corruption | "CNMutableContact objects are not thread-safe" | Use immutable CNContact for cross-thread access |
+| Ignoring `.limited` status | 1-2 hours debugging "missing contacts" | App assumes full access but only sees subset | Check status and show ContactAccessButton |
+| Not handling `note` field entitlement | 30 min debugging empty notes | `com.apple.developer.contacts.notes` required | Apply for entitlement from Apple |
+
+---
+
+## Key Patterns
+
+### Minimal Key Fetching
+
+Always specify exactly which properties you need:
+
+```swift
+let keys: [CNKeyDescriptor] = [
+ CNContactGivenNameKey as CNKeyDescriptor,
+ CNContactFamilyNameKey as CNKeyDescriptor,
+ CNContactPhoneNumbersKey as CNKeyDescriptor,
+ CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
+]
+
+let request = CNContactFetchRequest(keysToFetch: keys)
+```
+
+**Rule**: You may only modify properties whose values you fetched. Accessing an unfetched property throws `CNContactPropertyNotFetchedException`.
+
+See **contacts-ref** for search predicates, save operations, name formatting, and vCard serialization.
+
+---
+
+## Incremental Sync (Change History)
+
+For apps that cache contacts and need to detect changes:
+
+```swift
+// Save the token after initial fetch
+var changeToken = store.currentHistoryToken
+
+// Later, fetch changes since last token
+let request = CNChangeHistoryFetchRequest()
+request.startingToken = changeToken
+request.shouldUnifyResults = true
+request.additionalContactKeyDescriptors = [
+ CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
+]
+
+// Process via visitor pattern (required)
+class MyVisitor: NSObject, CNChangeHistoryEventVisitor {
+ func visit(_ event: CNChangeHistoryDropEverythingEvent) {
+ // Full re-sync needed — token expired or first fetch
+ }
+ func visit(_ event: CNChangeHistoryAddContactEvent) {
+ // New contact added
+ }
+ func visit(_ event: CNChangeHistoryUpdateContactEvent) {
+ // Contact modified
+ }
+ func visit(_ event: CNChangeHistoryDeleteContactEvent) {
+ // Contact deleted
+ }
+}
+```
+
+**Gotcha**: `enumeratorForChangeHistoryFetchRequest:error:` is Objective-C only — unavailable in Swift. Use a bridging wrapper.
+
+**Token expiration**: When token expires, the system returns a `DropEverything` event followed by `Add` events for all contacts. Same code path handles full and incremental sync.
+
+---
+
+## Contact Provider Extension (iOS 18+)
+
+Expose your app's contact graph to the system Contacts ecosystem.
+
+```swift
+// In main app: enable and signal
+let manager = try ContactProviderManager(domainIdentifier: "com.myapp.contacts")
+try await manager.enable() // May prompt user authorization
+try await manager.signalEnumerator() // Trigger sync when data changes
+
+// In extension
+@main
+class Provider: ContactProviderExtension {
+ func configure(for domain: ContactProviderDomain) { /* setup */ }
+
+ func enumerator(for collection: ContactItem.Identifier) -> ContactItemEnumerator {
+ return MyEnumerator()
+ }
+
+ func invalidate() async throws { /* cleanup */ }
+}
+```
+
+**Requires**: App Group for data sharing between app and extension.
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "We need full access to show contact suggestions"
+
+**Pressure**: PM wants full Contacts access for autocomplete.
+
+**Why resist**: ContactAccessButton provides exactly this — search results for contacts the app doesn't have, one-tap to grant access. No scary full-access prompt.
+
+**Response**: "ContactAccessButton gives us search-driven contact discovery without asking for full access. Users grant access to exactly the contacts they want to share, one at a time. Denial rate drops from 40%+ to near zero."
+
+### Scenario 2: "Just fetch all keys, we might display any field"
+
+**Pressure**: Developer fetches all contact keys "to be safe."
+
+**Why resist**: Overfetching contacts data is both a privacy concern (triggers stricter Apple review) and a performance problem (slower fetches, more memory).
+
+**Response**: "Fetching only the keys we display means faster queries and less privacy exposure. Use `CNContactFormatter.descriptorForRequiredKeys(for:)` for name display — it handles all locale variations."
+
+### Scenario 3: "CNContactPickerViewController is enough, we don't need the button"
+
+**Pressure**: Team sticks with picker because it's familiar.
+
+**Why resist**: Picker gives one-time snapshots — the contacts are not persistently accessible. If you need to store or sync the contact, you need persistent access through ContactAccessButton or full authorization.
+
+**Response**: "Picker works for one-time actions (share a phone number). But if we need to remember the contact (friend list, favorites), we need ContactAccessButton for persistent limited access."
+
+---
+
+## Migration Checklist
+
+When updating an app for iOS 18 limited access:
+
+1. Check `authorizationStatus(for: .contacts)` for `.limited` case
+2. Add `ContactAccessButton` to contact search flows
+3. Test that app handles seeing only a subset of contacts
+4. Verify app-created contacts are always visible
+5. Add `contactAccessPicker` for bulk access management if needed
+6. Test the two-stage authorization prompt flow
+7. Ensure `keysToFetch` is minimal — limited access doesn't change key behavior
+
+---
+
+## Resources
+
+**WWDC**: 2024-10121
+
+**Docs**: /contacts, /contactsui, /contactprovider, /technotes/tn3149
+
+**Skills**: contacts-ref, eventkit, privacy-ux
diff --git a/.claude/skills/axiom-contacts/agents/openai.yaml b/.claude/skills/axiom-contacts/agents/openai.yaml
new file mode 100644
index 0000000..27e87e8
--- /dev/null
+++ b/.claude/skills/axiom-contacts/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Contacts"
+ short_description: "Accessing ANY contact data, requesting Contacts permissions, choosing between picker and store access, implementing C..."
diff --git a/.claude/skills/axiom-core-data-diag/.openskills.json b/.claude/skills/axiom-core-data-diag/.openskills.json
new file mode 100644
index 0000000..b2a6675
--- /dev/null
+++ b/.claude/skills/axiom-core-data-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-data-diag",
+ "installedAt": "2026-04-12T08:06:07.075Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-data-diag/SKILL.md b/.claude/skills/axiom-core-data-diag/SKILL.md
new file mode 100644
index 0000000..7fdad72
--- /dev/null
+++ b/.claude/skills/axiom-core-data-diag/SKILL.md
@@ -0,0 +1,920 @@
+---
+name: axiom-core-data-diag
+description: Use when debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core Data bridging, or testing migrations without data loss - systematic Core Data diagnostics with safety-first migration patterns
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Core Data Diagnostics & Migration
+
+## Overview
+
+Core Data issues manifest as production crashes from schema mismatches, mysterious concurrency errors, performance degradation under load, and data corruption from unsafe migrations. **Core principle** 85% of Core Data problems stem from misunderstanding thread-confinement, schema migration requirements, and relationship query patterns—not Core Data defects.
+
+## Red Flags — Suspect Core Data Issue
+
+If you see ANY of these, suspect a Core Data misunderstanding, not framework breakage:
+- Crash on production launch: "Unresolvable fault" after schema change
+- Thread-confinement error: "Accessing NSManagedObject on a different thread"
+- App suddenly slow after adding a User→Posts relationship
+- SwiftData app needs complex features; considering mixing Core Data alongside
+- Schema migration works in simulator but crashes on production
+- ❌ **FORBIDDEN** "Core Data is broken, we need a different database"
+ - Core Data handles trillions of records in production apps
+ - Schema mismatches and thread errors are always developer code, not framework
+ - Do not rationalize away the issue—diagnose it
+
+**Critical distinction** Simulator deletes the database on each rebuild, hiding schema mismatch issues. Real devices keep persistent databases and crash immediately on schema mismatch. **MANDATORY: Test migrations on real device with real data before shipping.**
+
+## Mandatory First Steps
+
+**ALWAYS run these FIRST** (before changing code):
+
+```swift
+// 1. Identify the crash/issue type
+// Screenshot the crash message and note:
+// - "Unresolvable fault" = schema mismatch
+// - "different thread" = thread-confinement
+// - Slow performance = N+1 queries or fetch size issues
+// - Data corruption = unsafe migration
+// Record: "Crash type: [exact message]"
+
+// 2. Check if it's schema mismatch
+// Compare these:
+let coordinator = persistentStoreCoordinator
+let model = coordinator.managedObjectModel
+let store = coordinator.persistentStores.first
+
+// Get actual store schema version:
+do {
+ let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(
+ ofType: NSSQLiteStoreType,
+ at: storeURL,
+ options: nil
+ )
+ print("Store version identifier: \(metadata[NSStoreModelVersionIdentifiersKey] ?? "unknown")")
+
+ // Get app's current model version:
+ print("App model version: \(model.versionIdentifiers)")
+
+ // If different = schema mismatch
+} catch {
+ print("Schema check error: \(error)")
+}
+// Record: "Store version vs. app model: match or mismatch?"
+
+// 3. Check thread-confinement for concurrency errors
+// For any NSManagedObject access:
+print("Main thread? \(Thread.isMainThread)")
+print("Context concurrency type: \(context.concurrencyType.rawValue)")
+print("Accessing from: \(Thread.current)")
+// Record: "Thread mismatch? Yes/no"
+
+// 4. Profile relationship access for N+1 problems
+// In Xcode, run with arguments:
+// -com.apple.CoreData.SQLDebug 1
+// Check Console for SQL queries:
+// SELECT * FROM USERS; (1 query)
+// SELECT * FROM POSTS WHERE user_id = 1; (1 query per user = N+1!)
+// Record: "N+1 found? Yes/no, how many extra queries"
+
+// 5. Check SwiftData vs. Core Data confusion
+if #available(iOS 17.0, *) {
+ // If using SwiftData @Model + Core Data simultaneously:
+ // Error: "Store is locked" or "EXC_BAD_ACCESS"
+ // = trying to access same database from both layers
+ print("Using both SwiftData and Core Data on same store?")
+}
+// Record: "Mixing SwiftData + Core Data? Yes/no"
+```
+
+#### What this tells you
+- **Schema mismatch** → Proceed to Pattern 1 (lightweight migration decision)
+- **Thread-confinement error** → Proceed to Pattern 2 (async/await concurrency)
+- **N+1 queries** → Proceed to Pattern 3 (relationship prefetching)
+- **SwiftData + Core Data conflict** → Proceed to Pattern 4 (bridging)
+- **Slow after migration** → Proceed to Pattern 5 (testing safety)
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, identify ONE of these:
+
+1. If crash is "Unresolvable fault" AND store/model versions differ → Schema mismatch (not user error)
+2. If crash mentions "different thread" AND you're using DispatchQueue → Thread-confinement (not thread-safe design)
+3. If performance degrades with relationship access → N+1 queries (check SQL log)
+4. If SwiftData and Core Data code exist together → Conflicting data layers (architectural issue)
+5. If migration test passes but production fails → Edge case in real data (testing gap)
+
+#### If diagnostics are contradictory or unclear
+- STOP. Do NOT proceed to patterns yet
+- Add print statements to every NSManagedObject access (thread check)
+- Add `-com.apple.CoreData.SQLDebug 1` and count SQL queries
+- Establish baseline: what's actually happening vs. what you assumed
+
+## Decision Tree
+
+```
+Core Data problem suspected?
+├─ Crash: "Unresolvable fault"?
+│ └─ YES → Schema mismatch (store ≠ app model)
+│ ├─ Add new required field? → Pattern 1a (lightweight migration)
+│ ├─ Remove field, rename, or change type? → Pattern 1b (heavy migration)
+│ └─ Don't know how to fix? → Pattern 1c (testing safety)
+│
+├─ Crash: "different thread"?
+│ └─ YES → Thread-confinement violated
+│ ├─ Using DispatchQueue for background work? → Pattern 2a (async context)
+│ ├─ Mixing Core Data with async/await? → Pattern 2b (structured concurrency)
+│ └─ SwiftUI @FetchRequest causing issues? → Pattern 2c (@FetchRequest safety)
+│
+├─ Performance: App became slow?
+│ └─ YES → Likely N+1 queries
+│ ├─ Accessing user.posts in loop? → Pattern 3a (prefetching)
+│ ├─ Large result set? → Pattern 3b (batch sizing)
+│ └─ Just added relationships? → Pattern 3c (relationship tuning)
+│
+├─ Using both SwiftData and Core Data?
+│ └─ YES → Data layer conflict
+│ ├─ Need Core Data features SwiftData lacks? → Pattern 4a (drop to Core Data)
+│ ├─ Already committed to SwiftData? → Pattern 4b (stay in SwiftData)
+│ └─ Unsure which to use? → Pattern 4c (decision framework)
+│
+└─ Migration works locally but crashes in production?
+ └─ YES → Testing gap
+ ├─ Didn't test with real data? → Pattern 5a (production testing)
+ ├─ Schema change affects large dataset? → Pattern 5b (migration safety)
+ └─ Need verification before shipping? → Pattern 5c (pre-deployment checklist)
+```
+
+## Common Patterns
+
+### Pattern Selection Rules (MANDATORY)
+
+#### Apply ONE pattern at a time, starting with diagnostics
+
+1. **Always start with Mandatory First Steps** — Identify the actual problem
+2. **Run decision tree** — Narrow to specific pattern
+3. **Apply ONE pattern** — Don't combine patterns
+4. **Test on real device** — Simulator hides issues
+5. **Verify with migration test** — Before deploying
+
+#### FORBIDDEN
+- ❌ Changing code without diagnostics
+- ❌ Skipping real device testing
+- ❌ Using simulator success as proof of migration safety
+- ❌ Mixing multiple migration patterns
+- ❌ Deploying migrations without pre-deployment verification
+
+---
+
+### Pattern 1a: Lightweight Migration (Simple Schema Changes)
+
+**PRINCIPLE** Core Data can automatically migrate simple schemas (additive changes) without data loss if done correctly.
+
+#### ✅ SAFE Lightweight Migrations
+- Adding new optional field: `@NSManaged var nickname: String?`
+- Adding new required field WITH default: Create attribute with default value
+- Renaming entity or attribute: Use mapping model with automatic mapping
+- Removing unused field: Just delete from model (data stays on disk, ignored)
+
+#### ❌ WRONG (Crashes production)
+```swift
+// BAD: Adding required field without migration
+@NSManaged var userID: String // Required, no default
+
+// BAD: Assuming simulator = production
+// Works in simulator (deletes DB), crashes on real device
+
+// BAD: Modifying field type
+@NSManaged var createdAt: Date // Was String, now Date
+// Core Data can't automatically convert
+```
+
+#### ✅ CORRECT (Safe lightweight migration)
+```swift
+// 1. In Xcode: Editor → Add Model Version
+// Creates new .xcdatamodel version file
+
+// 2. In new version, add required field WITH default:
+@NSManaged var userID: String = UUID().uuidString
+
+// 3. Mark as current model version:
+// File Inspector → Versioned Core Data Model
+// Check "Current Model Version"
+
+// 4. Test:
+// Simulate old version: delete app, copy old database, run with new code
+// Real app loads → migration succeeded
+
+// 5. Deploy when confident
+```
+
+#### When this works
+- Adding optional fields (always safe)
+- Adding required fields WITH default values
+- Removing fields
+- Renaming entities/attributes with mapping model
+
+#### When this FAILS (don't try lightweight)
+- Changing field type (String → Int)
+- Making optional field required (data has nulls, can't convert)
+- Complex relationship changes
+- Custom data transformations needed
+
+**Time cost** 5-10 minutes for lightweight migration setup
+
+---
+
+### Pattern 1b: Heavy Migration (Complex Schema Changes)
+
+**PRINCIPLE** When lightweight migration won't work, use NSEntityMigrationPolicy for custom transformation logic.
+
+#### Use when
+- Changing field types (String → Date)
+- Making optional required (need to populate existing nulls)
+- Complex relationship restructuring
+- Custom data transformations (e.g., split "firstName lastName" into separate fields)
+
+#### Example: Convert String dates to Date objects
+
+```swift
+// 1. Create mapping model
+// File → New → Mapping Model
+// Source: old version, Destination: new version
+
+// 2. Create custom migration policy
+class DateMigrationPolicy: NSEntityMigrationPolicy {
+ override func createDestinationInstances(
+ forSource sInstance: NSManagedObject,
+ in mapping: NSEntityMapping,
+ manager: NSMigrationManager
+ ) throws {
+ let destination = NSEntityDescription.insertNewObject(
+ forEntityName: mapping.destinationEntityName ?? "",
+ into: manager.destinationContext
+ )
+
+ for key in sInstance.entity.attributesByName.keys {
+ destination.setValue(sInstance.value(forKey: key), forKey: key)
+ }
+
+ // Custom transformation: String → Date
+ if let dateString = sInstance.value(forKey: "createdAt") as? String,
+ let date = ISO8601DateFormatter().date(from: dateString) {
+ destination.setValue(date, forKey: "createdAt")
+ } else {
+ destination.setValue(Date(), forKey: "createdAt")
+ }
+
+ manager.associate(source: sInstance, withDestinationInstance: destination, for: mapping)
+ }
+}
+
+// 3. In mapping model Inspector:
+// Set Custom Policy Class: DateMigrationPolicy
+
+// 4. Test extensively with real data before shipping
+```
+
+#### Critical safety rules
+- ALWAYS backup database before testing migration
+- Test migration on COPY of production data
+- Verify data integrity after migration (spot checks)
+- Create rollback plan if migration fails
+
+**Time cost** 30-60 minutes per migration + testing
+
+---
+
+### Pattern 2a: Async Context for Background Fetching
+
+**PRINCIPLE** Core Data objects are thread-confined. Fetch on background thread, convert to lightweight representations for main thread.
+
+#### ❌ WRONG (Thread-confinement crash)
+```swift
+DispatchQueue.global().async {
+ let context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
+ let results = try! context.fetch(request)
+
+ DispatchQueue.main.async {
+ self.objects = results // ❌ CRASH: objects faulted on background thread
+ }
+}
+```
+
+#### ✅ CORRECT (Use private queue context for background work)
+```swift
+// Create background context
+let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+backgroundContext.parent = viewContext
+
+// Fetch on background thread
+backgroundContext.perform {
+ do {
+ let results = try backgroundContext.fetch(userRequest)
+
+ // Convert to lightweight representation BEFORE main thread
+ let userIDs = results.map { $0.id } // Just the IDs, not full objects
+
+ DispatchQueue.main.async {
+ // On main thread, fetch full objects from main context
+ let mainResults = try self.viewContext.fetch(request)
+ self.objects = mainResults
+ }
+ } catch {
+ print("Fetch error: \(error)")
+ }
+}
+```
+
+#### Why this works
+- Background context fetches on background thread (safe)
+- Converts heavy objects to lightweight values (safe to pass to main)
+- Main context fetches on main thread (safe)
+- No thread-confined objects crossing thread boundaries
+
+**Time cost** 10 minutes to restructure
+
+---
+
+### Pattern 2b: Structured Concurrency (async/await with Core Data)
+
+**PRINCIPLE** Use NSPersistentContainer or NSManagedObjectContext async methods for Swift Concurrency compatibility.
+
+#### ✅ CORRECT (iOS 13+ async APIs)
+```swift
+// iOS 13+: Use async perform
+let users = try await viewContext.perform {
+ try viewContext.fetch(userRequest)
+}
+// Executes fetch on correct thread, returns to caller
+
+// iOS 17+: Use Swift Concurrency async/await directly
+let users = try await container.mainContext.fetch(userRequest)
+
+// For background work:
+let backgroundUsers = try await backgroundContext.perform {
+ try backgroundContext.fetch(userRequest)
+}
+// Fetch happens on background queue, thread-safe
+```
+
+#### ❌ WRONG (Mixing Swift Concurrency with DispatchQueue)
+```swift
+async {
+ DispatchQueue.global().async {
+ try context.fetch(request) // ❌ Wrong thread!
+ }
+}
+```
+
+**Time cost** 5 minutes to convert from DispatchQueue to async/await
+
+---
+
+### Pattern 3a: Relationship Prefetching (Prevent N+1)
+
+**PRINCIPLE** Tell Core Data to fetch relationships eagerly instead of lazy-loading on access.
+
+#### ❌ WRONG (N+1 query pattern)
+```swift
+let users = try context.fetch(userRequest)
+
+for user in users {
+ let posts = user.posts // ❌ Triggers fetch for EACH user!
+ // 1 fetch for users + N fetches for relationships = N+1 total
+}
+```
+
+#### ✅ CORRECT (Prefetch relationships)
+```swift
+var request = NSFetchRequest(entityName: "User")
+
+// Tell Core Data to fetch relationships eagerly
+request.relationshipKeyPathsForPrefetching = ["posts", "comments"]
+// Now relationships are fetched in a single query per relationship
+
+let users = try context.fetch(request)
+
+for user in users {
+ let posts = user.posts // ✅ INSTANT: Already fetched
+ // Total: 1 fetch for users + 1 fetch for all posts = 2 queries
+}
+```
+
+#### Other optimization patterns
+```swift
+// Batch size: fetch in chunks for large result sets
+request.fetchBatchSize = 100
+
+// Faulting behavior: convert faults to lightweight snapshots
+request.returnsObjectsAsFaults = false // Keep objects in memory
+// Use carefully—can cause memory pressure with large results
+
+// Distinct: remove duplicates from relationship fetches
+request.returnsDistinctResults = true
+```
+
+**Time cost** 2-5 minutes to add prefetching
+
+---
+
+### Pattern 3b: Fetch Batch Sizing
+
+**PRINCIPLE** For large result sets, fetch in batches to manage memory.
+
+#### Example: Scrolling through 100,000 users
+
+```swift
+var request = NSFetchRequest(entityName: "User")
+request.fetchBatchSize = 100 // Fetch 100 at a time
+
+// Set sort descriptor for stable pagination
+request.sortDescriptors = [NSSortDescriptor(key: "id", ascending: true)]
+
+let results = try context.fetch(request)
+// Memory footprint: ~100 users at a time, not all 100,000
+
+for user in results {
+ // Accessing user 0-99: in memory
+ // Accessing user 100: batch refetch (user 100-199)
+ // Auto-pagination, minimal memory usage
+}
+```
+
+**Time cost** 3 minutes to tune batch size
+
+---
+
+### Pattern 4a: Core Data Features SwiftData Doesn't Have
+
+**Scenario** You chose SwiftData, but need features it lacks.
+
+#### SwiftData lacks
+- Complex migrations (auto-migration only)
+- Custom validation (before save)
+- Relationship delete rules (cascade, deny, nullify)
+- Direct SQL queries
+- Advanced prefetching
+- Faulting control
+
+#### When to drop to Core Data from SwiftData
+- Need custom migrations
+- Need validation logic
+- Need complex relationship rules
+- Need raw SQL for performance
+- Need fault tolerance patterns
+
+#### ✅ CORRECT (Hybrid approach when necessary)
+```swift
+// Keep SwiftData for simple entities
+@Model final class Note {
+ var id: String
+ var title: String
+}
+
+// Drop to Core Data for complex operations
+let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+backgroundContext.parent = container.viewContext
+
+// Fetch with Core Data, convert to SwiftData models
+let results = try backgroundContext.perform {
+ try backgroundContext.fetch(coreDataRequest)
+}
+```
+
+**CRITICAL** Do NOT access the same entity from both SwiftData and Core Data simultaneously. One or the other, not both.
+
+**Time cost** 30-60 minutes to create bridging layer
+
+---
+
+### Pattern 4b: Stay in SwiftData (Recommended for New Projects)
+
+**Scenario** You're in SwiftData and wondering if you need Core Data.
+
+#### SwiftData provides 80% of Core Data functionality for modern apps
+- Type-safe models (@Model)
+- Reactive queries (@Query)
+- CloudKit sync (built-in)
+- Automatic migrations (for simple changes)
+- Proper async/await integration
+
+#### When SwiftData is sufficient
+- Simple schemas (users, notes, todos)
+- Minimal relationship complexity
+- CloudKit sync needed
+- iOS 17+ requirement acceptable
+- No legacy Core Data code to maintain
+
+#### Decision: Stay in SwiftData if you can answer YES to 3+ of these
+- ✅ iOS 17+ only (no iOS 16 support needed)
+- ✅ Simple relationships (1-to-many, not many-to-many)
+- ✅ Standard migrations (add fields, remove fields)
+- ✅ CloudKit sync beneficial
+- ✅ Type safety important
+
+#### Decision: Drop to Core Data if
+- ❌ Need iOS 16 support (SwiftData iOS 17+ only)
+- ❌ Complex relationship rules (cascade rules, constraints)
+- ❌ Custom migrations required
+- ❌ Raw SQL needed for performance
+- ❌ Already have Core Data codebase
+
+**Time cost** 0 minutes (decision only)
+
+---
+
+### Pattern 5a: Safe Production Testing Before Migration
+
+**PRINCIPLE** Never deploy a migration without testing against real data.
+
+#### MANDATORY Pre-Deployment Checklist
+
+```swift
+// Step 1: Export production database
+// From running app in simulator or real device:
+// ~/Library/Developer/CoreData/[AppName]/
+// Copy entire [AppName].sqlite database
+
+// Step 2: Create migration test
+@Test func testProductionDataMigration() throws {
+ // Copy production database to test location
+ let testDB = tempDirectory.appendingPathComponent("test.sqlite")
+ try FileManager.default.copyItem(from: prodDatabase, to: testDB)
+
+ // Attempt migration
+ var config = ModelConfiguration(url: testDB, isStoredInMemory: false)
+ let container = try ModelContainer(for: User.self, configurations: [config])
+
+ // Verify data integrity
+ let context = container.mainContext
+ let allUsers = try context.fetch(FetchDescriptor())
+
+ // Spot checks: verify specific records migrated correctly
+ guard let user1 = allUsers.first(where: { $0.id == "test-id-1" }) else {
+ throw MigrationError.missingUser
+ }
+
+ // Check derived data is correct
+ XCTAssertEqual(user1.name, "Expected Name")
+ XCTAssertNotNil(user1.createdAt)
+
+ // Check relationships
+ XCTAssertEqual(user1.posts.count, expectedPostCount)
+}
+
+// Step 3: Run test against real production data
+// Pass ✓ before shipping
+```
+
+#### Safety rules
+- ❌ NEVER test migrations with simulator (simulator deletes DB)
+- ✅ ALWAYS test with copy of real production data
+- ✅ ALWAYS verify spot checks (specific records)
+- ✅ ALWAYS check relationships loaded correctly
+- ✅ ALWAYS have rollback plan documented
+
+**Time cost** 15-30 minutes to create migration test
+
+---
+
+### Pattern 5c: Pre-Deployment Verification Checklist
+
+#### MANDATORY before shipping ANY Core Data change
+
+- [ ] Did you create a new .xcdatamodel version? (Not just editing the existing one)
+- [ ] Does the new version have a mapping model if needed?
+- [ ] Did you test migration with real production data? (Not simulator)
+- [ ] Did you verify 5+ specific records migrated correctly?
+- [ ] Did you check relationships loaded?
+- [ ] Did you test on real device (oldest supported)?
+- [ ] Does app launch without crashing? (Fresh install)
+- [ ] Does app launch with old data? (Migration path)
+- [ ] Is rollback plan documented? (In case production fails)
+
+#### If you answer NO to any item
+- ❌ DO NOT SHIP
+- Go back, fix the issue, re-test
+- One "NO" = data loss risk
+
+**Time cost** 5 minutes checklist
+
+---
+
+## Quick Reference Table
+
+| Issue | Check | Fix |
+|-------|-------|-----|
+| "Unresolvable fault" crash | Do store/model versions match? | Create .xcdatamodel version + mapping model |
+| "Different thread" crash | Is fetch happening on main thread? | Use private queue context for background work |
+| App became slow | Are relationships being prefetched? | Add relationshipKeyPathsForPrefetching |
+| N+1 query performance | Check `-com.apple.CoreData.SQLDebug 1` logs | Add prefetching or convert to lightweight representation |
+| SwiftData needs Core Data features | Do you need custom migrations? | Use Core Data NSEntityMigrationPolicy |
+| Not sure about SwiftData vs. Core Data | Do you need iOS 16 support? | Use Core Data for iOS 16, SwiftData for iOS 17+ |
+| Migration test works, production fails | Did you test with real data? | Create migration test with production database copy |
+
+---
+
+## When You're Stuck After 30 Minutes
+
+If you've spent >30 minutes and the Core Data issue persists:
+
+#### STOP. You either
+1. Skipped mandatory diagnostics (most common)
+2. Misidentified the actual problem
+3. Applied wrong pattern for your symptom
+4. Haven't tested on real device/real data
+5. Have edge case requiring custom NSEntityMigrationPolicy
+
+#### MANDATORY checklist before claiming "skill didn't work"
+
+- [ ] I ran all Mandatory First Steps diagnostics
+- [ ] I identified the problem type (schema, concurrency, performance, bridging, testing)
+- [ ] I checked Core Data SQL debug logs (`-com.apple.CoreData.SQLDebug 1`)
+- [ ] I tested on real device with real data (not simulator)
+- [ ] I applied the FIRST matching pattern from Decision Tree
+- [ ] I created a migration test if schema changed
+- [ ] I verified at least 3 specific records migrated correctly
+- [ ] I have a rollback plan documented
+
+#### If ALL boxes are checked and still broken
+- You need custom NSEntityMigrationPolicy (not covered by basic patterns)
+- Time cost: 60-90 minutes for complex migration
+- Ask: "What data transformation is actually needed?" and implement custom policy
+
+#### Time cost transparency
+- Pattern 1 (lightweight migration): 5-10 minutes
+- Pattern 1 (heavy migration with custom policy): 60-90 minutes
+- Pattern 2 (concurrency): 5-10 minutes
+- Pattern 3 (prefetching): 2-5 minutes
+- Pattern 4 (bridging): 30-60 minutes
+- Pattern 5 (testing): 15-30 minutes
+
+---
+
+## Common Mistakes
+
+❌ **Testing migration in simulator only**
+- Simulator deletes database on rebuild, hiding schema mismatches
+- Fix: ALWAYS test on real device or with production database copy
+
+❌ **Assuming default values protect against data loss**
+- Default values only work for new records, not existing data
+- Fix: Use NSEntityMigrationPolicy for existing data
+
+❌ **Accessing Core Data objects across threads without conversion**
+- Objects are thread-confined, can't cross thread boundaries
+- Fix: Convert to lightweight representations before passing to other threads
+
+❌ **Not realizing relationship access = database query**
+- `user.posts` triggers a fetch for EACH user (N+1)
+- Fix: Use relationshipKeyPathsForPrefetching or extract IDs first
+
+❌ **Mixing SwiftData and Core Data on same store**
+- Both layers can't access the same database simultaneously
+- Fix: Choose one layer, or use hybrid approach with separate entities
+
+❌ **Deploying migrations without pre-deployment testing**
+- Edge cases in production data cause crashes
+- Fix: MANDATORY migration test with real production data
+
+❌ **Rationalizing: "I'll just delete the data"**
+- ❌ FORBIDDEN: Users won't appreciate losing their data
+- Users uninstall and leave bad reviews
+- Fix: Invest in safe migration testing
+
+---
+
+## Production Crisis Pressure: Defending Safe Migration Patterns
+
+### The Problem
+
+Under production crisis pressure, you'll face requests to:
+- "Users are crashing - just delete the database and start fresh"
+- "Migration is taking too long - skip the testing and ship it"
+- "We can't wait 2 days for proper migration - hack it together"
+- "Schema mismatch? Just force-create a new store"
+
+These sound like pragmatic crisis responses. **But they cause data loss and permanent user trust damage.** Your job: defend using data safety principles and customer impact, not fear of pressure.
+
+### Red Flags — PM/Manager Requests That Cause Data Loss
+
+If you hear ANY of these during a production crisis, **STOP and reference this skill**:
+
+- ❌ **"Delete the persistent store and start fresh"** – Users lose ALL their data permanently
+- ❌ **"Force lightweight migration without testing"** – High risk of data corruption in production
+- ❌ **"Skip migration and create new store"** – Abandons existing user data
+- ❌ **"We'll fix data issues after launch"** – Impossible to recover lost/corrupted data
+- ❌ **"Just ship it, we can handle support tickets"** – Data loss creates permanent user churn
+- ❌ **"Test on simulator is enough"** – Simulator deletes database on rebuild, hides schema mismatches
+
+### How to Push Back Professionally
+
+#### Step 1: Quantify the Customer Impact
+
+```
+"I want to resolve this crash ASAP, but let me show you what deleting the store means:
+
+Current situation:
+- 10,000 active users with data
+- Average 50 items per user (500,000 total records)
+- Users have 1 week to 2 years of accumulated data
+
+If we delete the store:
+- 10,000 users lose ALL their data on next app launch
+- Uninstall rate: 60-80% (industry standard after data loss)
+- App Store reviews: Expect 1-star reviews citing data loss
+- Recovery: Impossible - data is gone permanently
+
+Safe alternative:
+- Test migration on real device with production data copy (2-4 hours)
+- Deploy migration that preserves user data
+- Uninstall rate: <5% (standard update churn)"
+```
+
+#### Step 2: Demonstrate the Risk
+
+Show the PM/manager what happens:
+1. Copy production database from device backup
+2. Run proposed "quick fix" (delete store)
+3. Show: All user data gone permanently
+4. Show alternative: Safe migration preserving data
+5. Time comparison: 30 min hack vs. 2-4 hour safe migration
+
+#### Reference
+- "Users don't forgive data loss" (App Store review patterns)
+- Migration testing on real device prevents 95% of production crashes
+- Schema mismatch crashes affect 100% of existing users
+
+#### Step 3: Offer Compromise
+
+```
+"I can get us through this crisis while protecting user data:
+
+#### Fast track (4 hours total)
+1. Copy production database from TestFlight user (30 min)
+2. Write and test migration on real device copy (2 hours)
+3. Submit build with tested migration (30 min)
+4. Monitor first 100 updates for crashes (1 hour)
+
+#### Fallback if migration fails
+- Have "delete store" build ready as Plan B
+- Only deploy if migration shows 100% failure rate
+- Communicate data loss to users proactively
+
+This approach:
+- Tries safe path first (protects user data)
+- Has emergency fallback (if migration impossible)
+- Honest timeline (4 hours vs. "just delete it" 30 min)"
+```
+
+#### Step 4: Document the Decision
+
+If overruled (PM insists on deleting store):
+
+```
+Slack message to PM + team:
+
+"Production crisis: Schema mismatch causing crashes for existing users.
+
+PM decision: Delete persistent store to resolve immediately.
+
+Impact assessment:
+- 10,000 users lose ALL data permanently on next app launch
+- Expected uninstall rate: 60-80% based on data loss patterns
+- App Store review damage: High risk of 1-star reviews
+- Customer support: Expect high volume of data loss complaints
+- Recovery: Impossible - deleted data cannot be recovered
+
+Alternative proposed (4-hour safe migration) was declined due to urgency.
+
+I'm flagging this decision proactively so we can:
+1. Prepare support team for data loss complaints
+2. Draft App Store response to expected negative reviews
+3. Consider user communication about data loss before launch"
+```
+
+#### Why this works
+- You're not questioning their judgment under pressure
+- You're quantifying user impact (business consequences)
+- You're offering a solution with honest timeline
+- You're providing fallback option (not blocking progress)
+- You're documenting the decision (protects you post-launch)
+
+### Real-World Example: Production Crash (500K Active Users)
+
+#### Scenario
+- Production app crashing for 100% of users after update
+- Error: "The model used to open the store is incompatible with the one used to create the store"
+- CTO says: "Delete the database and ship hotfix in 2 hours"
+- 500,000 active users with average 6 months of data each
+
+#### What to do
+
+```swift
+// ❌ WRONG - Deletes all user data (CTO's request)
+let coordinator = NSPersistentStoreCoordinator(managedObjectModel: model)
+let storeURL = /* persistent store URL */
+try? FileManager.default.removeItem(at: storeURL) // 500K users lose data
+try! coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
+ configurationName: nil,
+ at: storeURL,
+ options: nil)
+
+// ✅ CORRECT - Safe lightweight migration (4-hour timeline)
+let options = [
+ NSMigratePersistentStoresAutomaticallyOption: true,
+ NSInferMappingModelAutomaticallyOption: true
+]
+
+do {
+ try coordinator.addPersistentStore(ofType: NSSQLiteStoreType,
+ configurationName: nil,
+ at: storeURL,
+ options: options)
+ // Migration succeeded - user data preserved
+} catch {
+ // Migration failed — NOW consider deleting with user communication
+ print("Migration error: \(error)")
+}
+```
+
+#### In the meeting, show
+1. Schema version mismatch causing crash
+2. Lightweight migration can fix automatically
+3. Testing on production database copy (2 hours)
+4. Time comparison: 2 hours (safe) vs. immediate (data loss)
+
+**Time estimate** 4 hours total (2 hours migration testing, 2 hours build/deploy)
+
+#### Result
+- Honest timeline manages expectations
+- Safe migration preserves 500K users' data
+- Uninstall rate: 3% (standard update churn)
+- App Store reviews: No data loss complaints
+
+#### Alternative if migration truly impossible
+- Document why migration failed
+- Communicate data loss to users proactively
+- Provide export feature in next version
+
+### When to Accept Data Loss (Even If You Disagree)
+
+Sometimes data loss is the only option. Accept if:
+
+- [ ] Migration is genuinely impossible (tried on production data copy)
+- [ ] PM/CTO understand 60-80% expected uninstall rate
+- [ ] Team commits to user communication about data loss
+- [ ] You've documented technical reasons migration failed
+
+#### Document in Slack
+
+```
+"Production crisis: Migration failed on production data copy after 4-hour testing.
+
+Technical details:
+- Attempted lightweight migration: Failed with [error]
+- Attempted heavy migration with mapping model: Failed with [error]
+- Root cause: [specific schema incompatibility]
+
+Data loss decision:
+- No safe migration path exists
+- PM approved delete persistent store approach
+- Expected impact: 60-80% uninstall rate (500K → 100-200K users)
+
+Mitigation plan:
+- Add data export feature before next schema change
+- Communicate data loss to users via in-app message
+- Prepare support team for complaints
+- Monitor uninstall rates post-launch"
+```
+
+This protects you and shows you exhausted safe options first.
+
+---
+
+## Real-World Impact
+
+**Before** Core Data debugging 3-8 hours per issue
+- Crashes in production (schema mismatch)
+- Performance gradually degrades (N+1 queries)
+- Thread errors in background operations
+- Data corruption from unsafe migrations
+- Customer trust damaged
+
+**After** 30 minutes to 2 hours with systematic diagnosis
+- Identify problem type with diagnostics (5 min)
+- Apply correct pattern (5-10 min)
+- Test on real device (varies)
+- Deploy with confidence
+
+**Key insight** Core Data has well-established patterns for every common issue. The problem is developers don't know which pattern applies to their symptom.
+
+---
+
+**Last Updated**: 2025-11-30
+**Status**: TDD-tested with pressure scenarios
+**Framework**: Core Data (Foundation framework)
+**Complements**: SwiftData skill (understanding relationship to Core Data)
diff --git a/.claude/skills/axiom-core-data-diag/agents/openai.yaml b/.claude/skills/axiom-core-data-diag/agents/openai.yaml
new file mode 100644
index 0000000..1029d25
--- /dev/null
+++ b/.claude/skills/axiom-core-data-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Data Diagnostics"
+ short_description: "Debugging schema migration crashes, concurrency thread-confinement errors, N+1 query performance, SwiftData to Core D..."
diff --git a/.claude/skills/axiom-core-data/.openskills.json b/.claude/skills/axiom-core-data/.openskills.json
new file mode 100644
index 0000000..cbd62bb
--- /dev/null
+++ b/.claude/skills/axiom-core-data/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-data",
+ "installedAt": "2026-04-12T08:06:06.545Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-data/SKILL.md b/.claude/skills/axiom-core-data/SKILL.md
new file mode 100644
index 0000000..a45ed0a
--- /dev/null
+++ b/.claude/skills/axiom-core-data/SKILL.md
@@ -0,0 +1,417 @@
+---
+name: axiom-core-data
+description: Use when choosing Core Data vs SwiftData, setting up the Core Data stack, modeling relationships, or implementing concurrency patterns - prevents thread-confinement errors and migration crashes
+license: MIT
+compatibility: iOS 3+, macOS 10.4+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-25"
+---
+
+# Core Data
+
+## Overview
+
+**Core principle**: Core Data is a mature object graph and persistence framework. Use it when needing features SwiftData doesn't support, or when targeting older iOS versions.
+
+**When to use Core Data vs SwiftData**:
+- **SwiftData** (iOS 17+) — New apps, simpler API, Swift-native
+- **Core Data** — iOS 16 and earlier, advanced features, existing codebases
+
+## Quick Decision Tree
+
+```
+Which persistence framework?
+
+├─ Targeting iOS 17+ only?
+│ ├─ Simple data model? → SwiftData (recommended)
+│ ├─ Need public CloudKit database? → Core Data (SwiftData is private-only)
+│ ├─ Need custom migration logic? → Core Data (more control)
+│ └─ Existing Core Data app? → Keep Core Data or migrate gradually
+│
+├─ Targeting iOS 16 or earlier?
+│ └─ Core Data (SwiftData unavailable)
+│
+└─ Need both? → Use Core Data with SwiftData wrapper (advanced)
+```
+
+## Red Flags
+
+If ANY of these appear, STOP:
+
+- ❌ "Access managed objects on any thread" — Thread-confinement violation
+- ❌ "Skip migration testing on real device" — Simulator hides schema issues
+- ❌ "Use a singleton context everywhere" — Leads to concurrency crashes
+- ❌ "Force lightweight migration always" — Complex changes need mapping models
+- ❌ "Fetch in view body" — Use @FetchRequest or observe in view model
+
+## Core Data Stack Setup
+
+### Modern Stack (iOS 10+)
+
+```swift
+import CoreData
+
+class CoreDataStack {
+ static let shared = CoreDataStack()
+
+ lazy var persistentContainer: NSPersistentContainer = {
+ let container = NSPersistentContainer(name: "Model")
+
+ // Configure for CloudKit if needed
+ // container.persistentStoreDescriptions.first?.cloudKitContainerOptions =
+ // NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.app")
+
+ container.loadPersistentStores { description, error in
+ if let error = error {
+ // Handle appropriately for production
+ fatalError("Failed to load store: \(error)")
+ }
+ }
+
+ // Enable automatic merging
+ container.viewContext.automaticallyMergesChangesFromParent = true
+ container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
+
+ return container
+ }()
+
+ var viewContext: NSManagedObjectContext {
+ persistentContainer.viewContext
+ }
+
+ func newBackgroundContext() -> NSManagedObjectContext {
+ persistentContainer.newBackgroundContext()
+ }
+}
+```
+
+### CloudKit Integration
+
+```swift
+import CoreData
+
+class CloudKitStack {
+ lazy var container: NSPersistentCloudKitContainer = {
+ let container = NSPersistentCloudKitContainer(name: "Model")
+
+ guard let description = container.persistentStoreDescriptions.first else {
+ fatalError("No store description")
+ }
+
+ // Enable CloudKit sync
+ description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
+ containerIdentifier: "iCloud.com.yourapp"
+ )
+
+ // Enable history tracking for sync
+ description.setOption(true as NSNumber,
+ forKey: NSPersistentHistoryTrackingKey)
+ description.setOption(true as NSNumber,
+ forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
+
+ container.loadPersistentStores { _, error in
+ if let error = error {
+ fatalError("CloudKit store failed: \(error)")
+ }
+ }
+
+ container.viewContext.automaticallyMergesChangesFromParent = true
+
+ return container
+ }()
+}
+```
+
+## Concurrency Patterns
+
+### The Golden Rule
+
+**NEVER pass NSManagedObject across threads.** Pass objectID instead.
+
+```swift
+// ❌ WRONG: Passing object across threads
+let user = viewContext.fetch(...) // Main thread
+Task.detached {
+ print(user.name) // CRASH: Wrong thread
+}
+
+// ✅ CORRECT: Pass objectID, fetch on target context
+let userID = user.objectID
+Task.detached {
+ let bgContext = CoreDataStack.shared.newBackgroundContext()
+ let user = bgContext.object(with: userID) as! User
+ print(user.name) // Safe
+}
+```
+
+### Background Processing
+
+```swift
+// ✅ CORRECT: Background context for heavy work
+func importData(_ items: [ImportItem]) async throws {
+ let context = CoreDataStack.shared.newBackgroundContext()
+
+ try await context.perform {
+ for item in items {
+ let entity = Entity(context: context)
+ entity.configure(from: item)
+ }
+
+ try context.save()
+ }
+}
+
+// Changes automatically merge to viewContext if configured
+```
+
+### Async/Await (iOS 15+)
+
+```swift
+// Modern async context operations
+func fetchUsers() async throws -> [User] {
+ let context = CoreDataStack.shared.viewContext
+
+ return try await context.perform {
+ let request = User.fetchRequest()
+ request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
+ return try context.fetch(request)
+ }
+}
+```
+
+## Relationship Modeling
+
+### One-to-Many
+
+```swift
+// In User entity
+@NSManaged var posts: NSSet?
+
+// Convenience accessors
+extension User {
+ var postsArray: [Post] {
+ (posts?.allObjects as? [Post]) ?? []
+ }
+
+ func addPost(_ post: Post) {
+ mutableSetValue(forKey: "posts").add(post)
+ }
+}
+```
+
+### Many-to-Many
+
+```swift
+// Both sides have NSSet
+// User.tags <-> Tag.users
+
+extension User {
+ func addTag(_ tag: Tag) {
+ mutableSetValue(forKey: "tags").add(tag)
+ // Core Data automatically adds to tag.users
+ }
+}
+```
+
+### Delete Rules
+
+| Rule | Behavior | Use Case |
+|------|----------|----------|
+| **Nullify** | Set relationship to nil | Optional relationships |
+| **Cascade** | Delete related objects | Owned children (User → Posts) |
+| **Deny** | Prevent deletion if related objects exist | Protect referenced data |
+| **No Action** | Do nothing (manual cleanup required) | Rarely appropriate |
+
+## Fetching Patterns
+
+### SwiftUI Integration
+
+```swift
+struct UserList: View {
+ @FetchRequest(
+ sortDescriptors: [NSSortDescriptor(keyPath: \User.name, ascending: true)],
+ predicate: NSPredicate(format: "isActive == YES"),
+ animation: .default
+ )
+ private var users: FetchedResults
+
+ var body: some View {
+ List(users) { user in
+ Text(user.name ?? "Unknown")
+ }
+ }
+}
+
+// Dynamic predicates
+struct FilteredList: View {
+ @FetchRequest var items: FetchedResults-
+
+ init(category: String) {
+ _items = FetchRequest(
+ sortDescriptors: [NSSortDescriptor(keyPath: \Item.date, ascending: false)],
+ predicate: NSPredicate(format: "category == %@", category)
+ )
+ }
+}
+```
+
+### Batch Fetching (Avoid N+1)
+
+```swift
+// ❌ WRONG: N+1 queries
+let users = try context.fetch(User.fetchRequest())
+for user in users {
+ print(user.posts?.count ?? 0) // Fault fired for each user
+}
+
+// ✅ CORRECT: Prefetch relationships
+let request = User.fetchRequest()
+request.relationshipKeyPathsForPrefetching = ["posts"]
+let users = try context.fetch(request)
+for user in users {
+ print(user.posts?.count ?? 0) // Already loaded
+}
+```
+
+### Batch Size for Large Datasets
+
+```swift
+let request = User.fetchRequest()
+request.fetchBatchSize = 20 // Load 20 at a time as needed
+request.returnsObjectsAsFaults = true // Default, memory efficient
+```
+
+## Schema Migration
+
+### Lightweight Migration (Automatic)
+
+Handled automatically for:
+- Adding optional attributes
+- Removing attributes
+- Renaming (with renaming identifier)
+- Adding relationships with optional or default value
+
+```swift
+let description = NSPersistentStoreDescription()
+description.shouldMigrateStoreAutomatically = true
+description.shouldInferMappingModelAutomatically = true
+```
+
+### When Mapping Model Is Needed
+
+- Changing attribute types
+- Splitting/merging entities
+- Complex relationship changes
+- Data transformation during migration
+
+```swift
+// Create mapping model in Xcode:
+// File → New → Mapping Model
+// Select source and destination models
+```
+
+### Migration Testing Checklist
+
+**MANDATORY before shipping**:
+
+1. ✓ Test on REAL DEVICE (simulator deletes DB on rebuild)
+2. ✓ Install old version, create data
+3. ✓ Install new version over it
+4. ✓ Verify all data accessible
+5. ✓ Check migration performance (large datasets)
+
+## Anti-Patterns
+
+### 1. Singleton Context for Everything
+
+```swift
+// ❌ WRONG: One context for all operations
+class DataManager {
+ let context = CoreDataStack.shared.viewContext
+
+ func importInBackground() {
+ // Using main context on background = crash
+ for item in largeDataset {
+ let entity = Entity(context: context)
+ }
+ }
+}
+
+// ✅ CORRECT: Context per operation type
+func importInBackground() {
+ let bgContext = CoreDataStack.shared.newBackgroundContext()
+ bgContext.perform {
+ // Safe background work
+ }
+}
+```
+
+### 2. Fetching in View Body
+
+```swift
+// ❌ WRONG: Fetch on every render
+var body: some View {
+ let users = try? context.fetch(User.fetchRequest()) // Called repeatedly!
+ List(users ?? []) { ... }
+}
+
+// ✅ CORRECT: Use @FetchRequest
+@FetchRequest(sortDescriptors: [])
+var users: FetchedResults
+
+var body: some View {
+ List(users) { ... } // Automatic updates
+}
+```
+
+### 3. Ignoring Merge Policy
+
+```swift
+// ❌ WRONG: No merge policy (conflicts crash)
+let context = container.viewContext
+
+// ✅ CORRECT: Define merge behavior
+context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
+context.automaticallyMergesChangesFromParent = true
+```
+
+## Performance Tips
+
+1. **Use fetchBatchSize** for large result sets
+2. **Prefetch relationships** that will be accessed
+3. **Use background contexts** for imports/exports
+4. **Batch save** — don't save after each insert
+5. **Use fetchLimit** when only first N results are needed
+6. **Profile with SQL debug**: `-com.apple.CoreData.SQLDebug 1`
+
+## Pressure Scenarios
+
+### Scenario 1: "SwiftData is simpler, let's migrate now"
+
+**Situation**: New iOS 17 features available, temptation to migrate mid-project.
+
+**Risk**: Migration is complex. Mixed Core Data + SwiftData has sharp edges.
+
+**Response**: "Complete current milestone first. Migration needs dedicated time and testing."
+
+### Scenario 2: "Skip migration testing, simulator works"
+
+**Situation**: Schema change tested only in simulator.
+
+**Risk**: Simulator deletes database on rebuild. Real devices keep persistent data and crash.
+
+**Response**: "MANDATORY: Test on real device with real data. 15 minutes now prevents production crash."
+
+## tvOS
+
+**CoreData + CloudKit is dangerous on tvOS.** CloudKit metadata causes significant space inflation in the local store, and tvOS has no persistent local storage — the system deletes Caches (including Application Support) at any time. The inflated store plus random deletion is a worst-case combination.
+
+**Recommendation**: Use SQLiteData with CloudKit SyncEngine instead for tvOS data persistence. See `axiom-tvos` for full tvOS storage constraints.
+
+## Related Skills
+
+- `axiom-core-data-diag` — Debugging migrations, thread errors, N+1 queries
+- `axiom-swiftdata` — Modern alternative for iOS 17+
+- `axiom-database-migration` — Safe schema evolution patterns
+- `axiom-swift-concurrency` — Async/await patterns for Core Data
diff --git a/.claude/skills/axiom-core-data/agents/openai.yaml b/.claude/skills/axiom-core-data/agents/openai.yaml
new file mode 100644
index 0000000..38c3441
--- /dev/null
+++ b/.claude/skills/axiom-core-data/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Data"
+ short_description: "Choosing Core Data vs SwiftData, setting up the Core Data stack, modeling relationships, or implementing concurrency ..."
diff --git a/.claude/skills/axiom-core-location-diag/.openskills.json b/.claude/skills/axiom-core-location-diag/.openskills.json
new file mode 100644
index 0000000..bb2cc87
--- /dev/null
+++ b/.claude/skills/axiom-core-location-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-location-diag",
+ "installedAt": "2026-04-12T08:06:08.079Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-location-diag/SKILL.md b/.claude/skills/axiom-core-location-diag/SKILL.md
new file mode 100644
index 0000000..a313283
--- /dev/null
+++ b/.claude/skills/axiom-core-location-diag/SKILL.md
@@ -0,0 +1,540 @@
+---
+name: axiom-core-location-diag
+description: Use for Core Location troubleshooting - no location updates, background location broken, authorization denied, geofence not triggering
+license: MIT
+compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-01-03"
+---
+
+# Core Location Diagnostics
+
+Symptom-based troubleshooting for Core Location issues.
+
+## When to Use
+
+- Location updates never arrive
+- Background location stops working
+- Authorization always denied
+- Location accuracy unexpectedly poor
+- Geofence events not triggering
+- Location icon won't go away
+
+## Related Skills
+
+- `axiom-core-location` — Implementation patterns, decision trees
+- `axiom-core-location-ref` — API reference, code examples
+- `axiom-energy-diag` — Battery drain from location
+- `axiom-mapkit-diag` — For map-specific location display issues (Symptom 7)
+
+---
+
+## Symptom 1: Location Updates Never Arrive
+
+### Quick Checks
+
+```swift
+// 1. Check authorization
+let status = CLLocationManager().authorizationStatus
+print("Authorization: \(status.rawValue)")
+// 0=notDetermined, 1=restricted, 2=denied, 3=authorizedAlways, 4=authorizedWhenInUse
+
+// 2. Check if location services enabled system-wide
+print("Services enabled: \(CLLocationManager.locationServicesEnabled())")
+
+// 3. Check accuracy authorization
+let accuracy = CLLocationManager().accuracyAuthorization
+print("Accuracy: \(accuracy == .fullAccuracy ? "full" : "reduced")")
+```
+
+### Decision Tree
+
+```
+Q1: What does authorizationStatus return?
+├─ .notDetermined → Authorization never requested
+│ Fix: Add CLServiceSession(authorization: .whenInUse) or requestWhenInUseAuthorization()
+│
+├─ .denied → User denied access
+│ Fix: Show UI explaining why location needed, link to Settings
+│
+├─ .restricted → Parental controls block access
+│ Fix: Inform user, offer manual location input
+│
+└─ .authorizedWhenInUse / .authorizedAlways → Check next
+
+Q2: Is locationServicesEnabled() returning true?
+├─ NO → Location services disabled system-wide
+│ Fix: Show UI prompting user to enable in Settings → Privacy → Location Services
+│
+└─ YES → Check next
+
+Q3: Are you iterating the AsyncSequence?
+├─ NO → Updates only arrive when you await
+│ Fix: Task { for try await update in CLLocationUpdate.liveUpdates() { ... } }
+│
+└─ YES → Check next
+
+Q4: Is the Task cancelled or broken?
+├─ YES → Task cancelled before updates arrived
+│ Fix: Ensure Task lives long enough (store in property, not local)
+│
+└─ NO → Check next
+
+Q5: Is location available? (iOS 17+)
+├─ Check update.locationUnavailable
+│ If true: Device cannot determine location (indoors, airplane mode, no GPS)
+│ Fix: Wait or inform user to move to better location
+│
+└─ Check update.authorizationDenied / update.authorizationDeniedGlobally
+ If true: Handle denial gracefully
+```
+
+### Info.plist Checklist
+
+```xml
+
+NSLocationWhenInUseUsageDescription
+Your clear explanation here
+
+
+NSLocationAlwaysAndWhenInUseUsageDescription
+Your clear explanation here
+```
+
+Missing these keys = silent failure with no prompt.
+
+---
+
+## Symptom 2: Background Location Not Working
+
+### Quick Checks
+
+1. **Background mode capability**: Xcode → Signing & Capabilities → Background Modes → Location updates
+2. **Info.plist**: Should have `UIBackgroundModes` with `location` value
+3. **CLBackgroundActivitySession**: Must be created AND held
+
+### Decision Tree
+
+```
+Q1: Is "Location updates" checked in Background Modes?
+├─ NO → Background location silently disabled
+│ Fix: Xcode → Signing & Capabilities → Background Modes → Location updates
+│
+└─ YES → Check next
+
+Q2: Are you holding CLBackgroundActivitySession?
+├─ NO / Using local variable → Session deallocates, background stops
+│ Fix: Store in property: var backgroundSession: CLBackgroundActivitySession?
+│
+└─ YES → Check next
+
+Q3: Was session started from foreground?
+├─ NO → Cannot start new session from background
+│ Fix: Create CLBackgroundActivitySession while app in foreground
+│
+└─ YES → Check next
+
+Q4: Is app being terminated and not recovering?
+├─ YES → Not recreating session on relaunch
+│ Fix: In didFinishLaunchingWithOptions:
+│ if wasTrackingLocation {
+│ backgroundSession = CLBackgroundActivitySession()
+│ startLocationUpdates()
+│ }
+│
+└─ NO → Check authorization level
+
+Q5: What is authorization level?
+├─ .authorizedWhenInUse → This is fine with CLBackgroundActivitySession
+│ The blue indicator allows background access
+│
+├─ .authorizedAlways → Should work, check session lifecycle
+│
+└─ .denied → No background access possible
+```
+
+### Common Mistakes
+
+```swift
+// ❌ WRONG: Local variable deallocates immediately
+func startTracking() {
+ let session = CLBackgroundActivitySession() // Dies at end of function!
+ startLocationUpdates()
+}
+
+// ✅ RIGHT: Property keeps session alive
+var backgroundSession: CLBackgroundActivitySession?
+
+func startTracking() {
+ backgroundSession = CLBackgroundActivitySession()
+ startLocationUpdates()
+}
+```
+
+---
+
+## Symptom 3: Authorization Always Denied
+
+### Decision Tree
+
+```
+Q1: Is this a fresh install or returning user?
+├─ FRESH INSTALL with immediate denial → Check Info.plist strings
+│ Missing/empty NSLocationWhenInUseUsageDescription = automatic denial
+│
+└─ RETURNING USER → Check previous denial
+
+Q2: Did user previously deny?
+├─ YES → User must manually re-enable in Settings
+│ Fix: Show UI explaining value, with button to open Settings:
+│ UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+│
+└─ NO → Check next
+
+Q3: Are you requesting authorization at wrong time?
+├─ Requesting when app not "in use" → insufficientlyInUse
+│ Check: update.insufficientlyInUse or diagnostic.insufficientlyInUse
+│ Fix: Only request authorization from foreground, during user interaction
+│
+└─ NO → Check next
+
+Q4: Is device in restricted mode?
+├─ YES → .restricted status (parental controls, MDM)
+│ Fix: Cannot override. Offer manual location input.
+│
+└─ NO → Check Info.plist again
+
+Q5: Are Info.plist strings compelling?
+├─ Generic string → Users more likely to deny
+│ Bad: "This app needs your location"
+│ Good: "Your location helps us show restaurants within walking distance"
+│
+└─ Review: Look at string from user's perspective
+```
+
+### Info.plist String Best Practices
+
+```xml
+
+NSLocationWhenInUseUsageDescription
+We need your location.
+
+
+NSLocationWhenInUseUsageDescription
+Your location helps show restaurants, coffee shops, and attractions within walking distance.
+
+
+NSLocationAlwaysAndWhenInUseUsageDescription
+We need your location always.
+
+
+NSLocationAlwaysAndWhenInUseUsageDescription
+Enable background location to receive reminders when you arrive at saved places, even when the app is closed.
+```
+
+---
+
+## Symptom 4: Location Accuracy Unexpectedly Poor
+
+### Quick Checks
+
+```swift
+// 1. Check accuracy authorization
+let accuracy = CLLocationManager().accuracyAuthorization
+print("Accuracy auth: \(accuracy == .fullAccuracy ? "full" : "reduced")")
+
+// 2. Check update's accuracy flag (iOS 17+)
+for try await update in CLLocationUpdate.liveUpdates() {
+ if update.accuracyLimited {
+ print("Accuracy limited - updates every 15-20 min")
+ }
+ if let location = update.location {
+ print("Horizontal accuracy: \(location.horizontalAccuracy)m")
+ }
+}
+```
+
+### Decision Tree
+
+```
+Q1: What is accuracyAuthorization?
+├─ .reducedAccuracy → User chose approximate location
+│ Options:
+│ 1. Accept reduced accuracy (weather, city-level features)
+│ 2. Request temporary full accuracy:
+│ CLServiceSession(authorization: .whenInUse, fullAccuracyPurposeKey: "Navigation")
+│ 3. Explain value and link to Settings
+│
+└─ .fullAccuracy → Check environment and configuration
+
+Q2: What is horizontalAccuracy on locations?
+├─ < 0 (typically -1) → INVALID location, do not use
+│ Meaning: System could not determine accuracy (no valid fix)
+│ Fix: Filter out: guard location.horizontalAccuracy >= 0 else { continue }
+│ Common when: Indoors with no WiFi, airplane mode, immediately after cold start
+│
+├─ > 100m → Likely using WiFi/cell only (no GPS)
+│ Causes: Indoors, airplane mode, dense urban canyon
+│ Fix: User needs to move to better location, or wait for GPS lock
+│
+├─ 10-100m → Normal for most use cases
+│ If need better: Use .automotiveNavigation or .otherNavigation config
+│
+└─ < 10m → Good GPS accuracy
+ Note: .automotiveNavigation can achieve ~5m
+
+Q3: What LiveConfiguration are you using?
+├─ .default or none → System manages, may prioritize battery
+│ If need more accuracy: Use .fitness, .otherNavigation, or .automotiveNavigation
+│
+├─ .fitness → Good for pedestrian activities
+│
+└─ .automotiveNavigation → Highest accuracy, axiom-highest battery
+ Only use for actual navigation
+
+Q4: Is the location stale?
+├─ Check location.timestamp
+│ If old: Device hasn't moved, or updates paused (isStationary)
+│
+└─ If timestamp recent but accuracy poor: Environmental issue
+```
+
+### Requesting Temporary Full Accuracy (iOS 18+)
+
+```swift
+// Requires Info.plist entry:
+// NSLocationTemporaryUsageDescriptionDictionary
+// NavigationPurpose: "Precise location enables turn-by-turn directions"
+
+let session = CLServiceSession(
+ authorization: .whenInUse,
+ fullAccuracyPurposeKey: "NavigationPurpose"
+)
+```
+
+---
+
+## Symptom 5: Geofence Events Not Triggering
+
+### Quick Checks
+
+```swift
+let monitor = await CLMonitor("MyMonitor")
+
+// 1. Check condition count (max 20)
+let count = await monitor.identifiers.count
+print("Conditions: \(count)/20")
+
+// 2. Check specific condition
+if let record = await monitor.record(for: "MyGeofence") {
+ let lastEvent = record.lastEvent
+ print("State: \(lastEvent.state)")
+ print("Date: \(lastEvent.date)")
+
+ if let geo = record.condition as? CLMonitor.CircularGeographicCondition {
+ print("Center: \(geo.center)")
+ print("Radius: \(geo.radius)m")
+ }
+}
+```
+
+### Decision Tree
+
+```
+Q1: How many conditions are monitored?
+├─ 20 → At the limit, new conditions ignored
+│ Fix: Prioritize important conditions, swap dynamically based on user location
+│ Check: lastEvent.conditionLimitExceeded
+│
+└─ < 20 → Check next
+
+Q2: What is the radius?
+├─ < 100m → Unreliable, may not trigger
+│ Fix: Use minimum 100m radius for reliable detection
+│
+└─ >= 100m → Check next
+
+Q3: Is the app awaiting monitor.events?
+├─ NO → Events not processed, lastEvent not updated
+│ Fix: Always have a Task awaiting:
+│ for try await event in monitor.events { ... }
+│
+└─ YES → Check next
+
+Q4: Was monitor reinitialized on app launch?
+├─ NO → Monitor conditions lost after termination
+│ Fix: Recreate monitor with same name in didFinishLaunchingWithOptions
+│
+└─ YES → Check next
+
+Q5: What does lastEvent show?
+├─ state: .unknown → System hasn't determined state yet
+│ Wait for determination, or check if monitoring is working
+│
+├─ state: .satisfied → Inside region, waiting for exit
+│
+├─ state: .unsatisfied → Outside region, waiting for entry
+│
+└─ Check lastEvent.date → When was last update?
+ If very old: May not be monitoring correctly
+
+Q6: Is accuracyLimited preventing monitoring?
+├─ Check: lastEvent.accuracyLimited
+│ If true: Reduced accuracy prevents geofencing
+│ Fix: Request full accuracy or accept limitation
+│
+└─ NO → Check environment (device must have location access)
+```
+
+### Common Mistakes
+
+```swift
+// ❌ WRONG: Not awaiting events
+let monitor = await CLMonitor("Test")
+await monitor.add(condition, identifier: "Place")
+// Nothing happens - no Task awaiting events!
+
+// ✅ RIGHT: Always await events
+let monitor = await CLMonitor("Test")
+await monitor.add(condition, identifier: "Place")
+
+Task {
+ for try await event in monitor.events {
+ switch event.state {
+ case .satisfied: handleEntry(event.identifier)
+ case .unsatisfied: handleExit(event.identifier)
+ case .unknown: break
+ @unknown default: break
+ }
+ }
+}
+
+// ❌ WRONG: Creating multiple monitors with same name
+let monitor1 = await CLMonitor("App") // OK
+let monitor2 = await CLMonitor("App") // UNDEFINED BEHAVIOR
+
+// ✅ RIGHT: One monitor instance per name
+class LocationService {
+ private var monitor: CLMonitor?
+
+ func setup() async {
+ monitor = await CLMonitor("App")
+ }
+}
+```
+
+---
+
+## Symptom 6: Location Icon Won't Go Away
+
+### Quick Checks
+
+The location arrow appears when:
+- App actively receiving location updates
+- CLMonitor is monitoring conditions
+- Background activity session active
+
+### Decision Tree
+
+```
+Q1: Is your app still iterating liveUpdates?
+├─ YES → Updates continue until you break/cancel
+│ Fix: Cancel the Task or break from loop:
+│ locationTask?.cancel()
+│
+└─ NO → Check next
+
+Q2: Is CLBackgroundActivitySession still held?
+├─ YES → Session keeps location access active
+│ Fix: Invalidate when done:
+│ backgroundSession?.invalidate()
+│ backgroundSession = nil
+│
+└─ NO → Check next
+
+Q3: Is CLMonitor still monitoring conditions?
+├─ YES → CLMonitor uses location for geofencing
+│ Note: This is expected behavior - icon shows monitoring active
+│ Fix: If truly done, remove all conditions:
+│ for id in await monitor.identifiers {
+│ await monitor.remove(id)
+│ }
+│
+└─ NO → Check next
+
+Q4: Is legacy CLLocationManager still running?
+├─ Check: manager.stopUpdatingLocation() called?
+│ Check: manager.stopMonitoring(for: region) for all regions?
+│ Fix: Ensure all legacy APIs stopped
+│
+└─ NO → Check other location-using frameworks
+
+Q5: Other frameworks using location?
+├─ MapKit with showsUserLocation = true → Shows location
+│ Fix: mapView.showsUserLocation = false when not needed
+│
+├─ Core Motion with location → Shows location
+│
+└─ Check all location-using code
+```
+
+### Force Stop All Location
+
+```swift
+// Stop modern APIs
+locationTask?.cancel()
+backgroundSession?.invalidate()
+backgroundSession = nil
+
+// Remove all CLMonitor conditions
+for id in await monitor.identifiers {
+ await monitor.remove(id)
+}
+
+// Stop legacy APIs
+manager.stopUpdatingLocation()
+manager.stopMonitoringSignificantLocationChanges()
+manager.stopMonitoringVisits()
+
+for region in manager.monitoredRegions {
+ manager.stopMonitoring(for: region)
+}
+```
+
+---
+
+## Console Debugging
+
+### Filter Location Logs
+
+```bash
+# View locationd logs
+log stream --predicate 'subsystem == "com.apple.locationd"' --level debug
+
+# View your app's location-related logs
+log stream --predicate 'subsystem == "com.apple.CoreLocation"' --level debug
+
+# Filter for specific process
+log stream --predicate 'process == "YourAppName" AND subsystem == "com.apple.CoreLocation"'
+```
+
+### Common Log Messages
+
+| Log Message | Meaning |
+|-------------|---------|
+| `Client is not authorized` | Authorization denied or not requested |
+| `Location services disabled` | System-wide toggle off |
+| `Accuracy authorization is reduced` | User chose approximate location |
+| `Condition limit exceeded` | At 20-condition maximum |
+| `Background location access denied` | Missing background capability or session |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10180, 2023-10147, 2024-10212
+
+**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate
+
+**Skills**: axiom-core-location, axiom-core-location-ref, axiom-energy-diag
diff --git a/.claude/skills/axiom-core-location-diag/agents/openai.yaml b/.claude/skills/axiom-core-location-diag/agents/openai.yaml
new file mode 100644
index 0000000..cf131bd
--- /dev/null
+++ b/.claude/skills/axiom-core-location-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Location Diagnostics"
+ short_description: "Core Location troubleshooting"
diff --git a/.claude/skills/axiom-core-location-ref/.openskills.json b/.claude/skills/axiom-core-location-ref/.openskills.json
new file mode 100644
index 0000000..7f4f2e1
--- /dev/null
+++ b/.claude/skills/axiom-core-location-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-location-ref",
+ "installedAt": "2026-04-12T08:06:08.634Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-location-ref/SKILL.md b/.claude/skills/axiom-core-location-ref/SKILL.md
new file mode 100644
index 0000000..804732f
--- /dev/null
+++ b/.claude/skills/axiom-core-location-ref/SKILL.md
@@ -0,0 +1,746 @@
+---
+name: axiom-core-location-ref
+description: Use for Core Location API reference - CLLocationUpdate, CLMonitor, CLServiceSession, authorization, background location, geofencing
+license: MIT
+compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-01-03"
+---
+
+# Core Location Reference
+
+Comprehensive API reference for modern Core Location (iOS 17+).
+
+## When to Use
+
+- Need API signatures for CLLocationUpdate, CLMonitor, CLServiceSession
+- Implementing geofencing or region monitoring
+- Configuring background location updates
+- Understanding authorization patterns
+- Debugging location service issues
+
+## Related Skills
+
+- `axiom-core-location` — Anti-patterns, decision trees, pressure scenarios
+- `axiom-core-location-diag` — Symptom-based troubleshooting
+- `axiom-energy-ref` — Location as battery subsystem (accuracy vs power)
+
+---
+
+## Part 1: Modern API Overview (iOS 17+)
+
+Four key classes replace legacy CLLocationManager patterns:
+
+| Class | Purpose | iOS |
+|-------|---------|-----|
+| `CLLocationUpdate` | AsyncSequence for location updates | 17+ |
+| `CLMonitor` | Condition-based geofencing/beacons | 17+ |
+| `CLServiceSession` | Declarative authorization goals | 18+ |
+| `CLBackgroundActivitySession` | Background location support | 17+ |
+
+**Migration path**: Legacy CLLocationManager still works, but new APIs provide:
+- Swift concurrency (async/await)
+- Automatic pause/resume
+- Simplified authorization
+- Better battery efficiency
+
+---
+
+## Part 2: CLLocationUpdate API
+
+### Basic Usage
+
+```swift
+import CoreLocation
+
+Task {
+ do {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ if let location = update.location {
+ // Process location
+ }
+ if update.isStationary {
+ break // Stop when user stops moving
+ }
+ }
+ } catch {
+ // Handle location errors
+ }
+}
+```
+
+### LiveConfiguration Options
+
+```swift
+CLLocationUpdate.liveUpdates(.default)
+CLLocationUpdate.liveUpdates(.automotiveNavigation)
+CLLocationUpdate.liveUpdates(.otherNavigation)
+CLLocationUpdate.liveUpdates(.fitness)
+CLLocationUpdate.liveUpdates(.airborne)
+```
+
+Choose based on use case. If unsure, use `.default` or omit parameter.
+
+### Key Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `location` | `CLLocation?` | Current location (nil if unavailable) |
+| `isStationary` | `Bool` | True when device stopped moving |
+| `authorizationDenied` | `Bool` | User denied location access |
+| `authorizationDeniedGlobally` | `Bool` | Location services disabled system-wide |
+| `authorizationRequestInProgress` | `Bool` | Awaiting user authorization decision |
+| `accuracyLimited` | `Bool` | Reduced accuracy (updates every 15-20 min) |
+| `locationUnavailable` | `Bool` | Cannot determine location |
+| `insufficientlyInUse` | `Bool` | Can't request auth (not in foreground) |
+
+### Automatic Pause/Resume
+
+When device becomes stationary:
+1. Final update delivered with `isStationary = true` and valid `location`
+2. Updates pause (saves battery)
+3. When device moves, updates resume with `isStationary = false`
+
+No action required—happens automatically.
+
+### AsyncSequence Operations
+
+```swift
+// Get first location with speed > 10 m/s
+let fastUpdate = try await CLLocationUpdate.liveUpdates()
+ .first { $0.location?.speed ?? 0 > 10 }
+
+// WARNING: Avoid filters that may never match (e.g., horizontalAccuracy < 1)
+```
+
+---
+
+## Part 3: CLMonitor API
+
+Swift actor for monitoring geographic conditions and beacons.
+
+### Basic Geofencing
+
+```swift
+let monitor = await CLMonitor("MyMonitor")
+
+// Add circular region
+let condition = CLMonitor.CircularGeographicCondition(
+ center: CLLocationCoordinate2D(latitude: 37.33, longitude: -122.01),
+ radius: 100
+)
+await monitor.add(condition, identifier: "ApplePark")
+
+// Await events
+for try await event in monitor.events {
+ switch event.state {
+ case .satisfied: // User entered region
+ handleEntry(event.identifier)
+ case .unsatisfied: // User exited region
+ handleExit(event.identifier)
+ case .unknown:
+ break
+ @unknown default:
+ break
+ }
+}
+```
+
+### CircularGeographicCondition
+
+```swift
+CLMonitor.CircularGeographicCondition(
+ center: CLLocationCoordinate2D,
+ radius: CLLocationDistance // meters, minimum ~100m effective
+)
+```
+
+### BeaconIdentityCondition
+
+Three granularity levels:
+
+```swift
+// All beacons with UUID (any site)
+CLMonitor.BeaconIdentityCondition(uuid: myUUID)
+
+// Specific site (UUID + major)
+CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100)
+
+// Specific beacon (UUID + major + minor)
+CLMonitor.BeaconIdentityCondition(uuid: myUUID, major: 100, minor: 5)
+```
+
+### Condition Limit
+
+**Maximum 20 conditions per app.** Prioritize what to monitor. Swap regions dynamically based on user location if needed.
+
+### Adding with Assumed State
+
+```swift
+// If you know initial state
+await monitor.add(condition, identifier: "Work", assuming: .unsatisfied)
+```
+
+Core Location will correct if assumption wrong.
+
+### Accessing Records
+
+```swift
+// Get single record
+if let record = await monitor.record(for: "ApplePark") {
+ let condition = record.condition
+ let lastEvent = record.lastEvent
+ let state = lastEvent.state
+ let date = lastEvent.date
+}
+
+// Get all identifiers
+let allIds = await monitor.identifiers
+```
+
+### Event Properties
+
+| Property | Description |
+|----------|-------------|
+| `identifier` | String identifier of condition |
+| `state` | `.satisfied`, `.unsatisfied`, `.unknown` |
+| `date` | When state changed |
+| `refinement` | For wildcard beacons, actual UUID/major/minor detected |
+| `conditionLimitExceeded` | Too many conditions (max 20) |
+| `conditionUnsupported` | Condition type not available |
+| `accuracyLimited` | Reduced accuracy prevents monitoring |
+
+### Critical Requirements
+
+1. **One monitor per name** — Only one instance with given name at a time
+2. **Always await events** — Events only become `lastEvent` after handling
+3. **Reinitialize on launch** — Recreate monitor in `didFinishLaunchingWithOptions`
+
+---
+
+## Part 4: CLServiceSession API (iOS 18+)
+
+Declarative authorization—tell Core Location what you need, not what to do.
+
+### Basic Usage
+
+```swift
+// Hold session for duration of feature
+let session = CLServiceSession(authorization: .whenInUse)
+
+for try await update in CLLocationUpdate.liveUpdates() {
+ // Process updates
+}
+```
+
+### Authorization Requirements
+
+```swift
+CLServiceSession(authorization: .none) // No auth request
+CLServiceSession(authorization: .whenInUse) // Request When In Use
+CLServiceSession(authorization: .always) // Request Always (must start in foreground)
+```
+
+### Full Accuracy Request
+
+```swift
+// For features requiring precise location (e.g., navigation)
+CLServiceSession(
+ authorization: .whenInUse,
+ fullAccuracyPurposeKey: "NavigationPurpose" // Key in Info.plist
+)
+```
+
+Requires `NSLocationTemporaryUsageDescriptionDictionary` in Info.plist.
+
+### Implicit Sessions
+
+Iterating `CLLocationUpdate.liveUpdates()` or `CLMonitor.events` creates implicit session with `.whenInUse` goal.
+
+To disable implicit sessions:
+```xml
+
+NSLocationRequireExplicitServiceSession
+
+```
+
+### Session Layering
+
+Don't replace sessions—layer them:
+
+```swift
+// Base session for app
+let baseSession = CLServiceSession(authorization: .whenInUse)
+
+// Additional session when navigation feature active
+let navSession = CLServiceSession(
+ authorization: .whenInUse,
+ fullAccuracyPurposeKey: "Nav"
+)
+// Both sessions active simultaneously
+```
+
+### Diagnostic Properties
+
+```swift
+for try await diagnostic in session.diagnostics {
+ if diagnostic.authorizationDenied {
+ // User denied—offer alternative
+ }
+ if diagnostic.authorizationDeniedGlobally {
+ // Location services off system-wide
+ }
+ if diagnostic.insufficientlyInUse {
+ // Can't request auth (not foreground)
+ }
+ if diagnostic.alwaysAuthorizationDenied {
+ // Always auth specifically denied
+ }
+ if !diagnostic.authorizationRequestInProgress {
+ // Decision made (granted or denied)
+ break
+ }
+}
+```
+
+### Session Lifecycle
+
+Sessions persist through:
+- App backgrounding
+- App suspension
+- App termination (Core Location tracks)
+
+On relaunch, recreate sessions immediately in `didFinishLaunchingWithOptions`.
+
+---
+
+## Part 5: Authorization State Machine
+
+### Authorization Levels
+
+| Status | Description |
+|--------|-------------|
+| `.notDetermined` | User hasn't decided |
+| `.restricted` | Parental controls prevent access |
+| `.denied` | User explicitly refused |
+| `.authorizedWhenInUse` | Access while app active |
+| `.authorizedAlways` | Background access |
+
+### Accuracy Authorization
+
+| Value | Description |
+|-------|-------------|
+| `.fullAccuracy` | Precise location |
+| `.reducedAccuracy` | Approximate (~5km), updates every 15-20 min |
+
+### Required Info.plist Keys
+
+```xml
+
+NSLocationWhenInUseUsageDescription
+We need your location to show nearby places
+
+
+NSLocationAlwaysAndWhenInUseUsageDescription
+We track your location to send arrival reminders
+
+
+NSLocationDefaultAccuracyReduced
+
+```
+
+### Legacy Authorization Pattern
+
+```swift
+@MainActor
+class LocationManager: NSObject, CLLocationManagerDelegate {
+ private let manager = CLLocationManager()
+
+ func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
+ switch manager.authorizationStatus {
+ case .notDetermined:
+ manager.requestWhenInUseAuthorization()
+ case .authorizedWhenInUse, .authorizedAlways:
+ enableLocationFeatures()
+ case .denied, .restricted:
+ disableLocationFeatures()
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+---
+
+## Part 6: Background Location
+
+### Requirements
+
+1. **Background mode capability**: Signing & Capabilities → Background Modes → Location updates
+2. **Info.plist**: Adds `UIBackgroundModes` with `location` value
+3. **CLBackgroundActivitySession** or **LiveActivity**
+
+### CLBackgroundActivitySession
+
+```swift
+// Create and HOLD reference (deallocation invalidates session)
+var backgroundSession: CLBackgroundActivitySession?
+
+func startBackgroundTracking() {
+ // Must start from foreground
+ backgroundSession = CLBackgroundActivitySession()
+
+ Task {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ processUpdate(update)
+ }
+ }
+}
+
+func stopBackgroundTracking() {
+ backgroundSession?.invalidate()
+ backgroundSession = nil
+}
+```
+
+### Background Indicator
+
+Blue status bar/pill appears when:
+- App authorized as "When In Use"
+- App receiving location in background
+- CLBackgroundActivitySession active
+
+### App Lifecycle
+
+1. **Foreground → Background**: Session continues
+2. **Background → Suspended**: Session preserved, updates pause
+3. **Suspended → Terminated**: Core Location tracks session
+4. **Terminated → Background launch**: Recreate session immediately
+
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Recreate background session if was tracking
+ if wasTrackingLocation {
+ backgroundSession = CLBackgroundActivitySession()
+ startLocationUpdates()
+ }
+ return true
+}
+```
+
+---
+
+## Part 7: Legacy APIs (iOS 12-16)
+
+### CLLocationManager Delegate Pattern
+
+```swift
+class LocationManager: NSObject, CLLocationManagerDelegate {
+ private let manager = CLLocationManager()
+
+ override init() {
+ super.init()
+ manager.delegate = self
+ manager.desiredAccuracy = kCLLocationAccuracyBest
+ manager.distanceFilter = 10 // meters
+ }
+
+ func startUpdates() {
+ manager.startUpdatingLocation()
+ }
+
+ func stopUpdates() {
+ manager.stopUpdatingLocation()
+ }
+
+ func locationManager(_ manager: CLLocationManager,
+ didUpdateLocations locations: [CLLocation]) {
+ guard let location = locations.last else { return }
+ // Process location
+ }
+}
+```
+
+### Accuracy Constants
+
+| Constant | Accuracy | Battery Impact |
+|----------|----------|----------------|
+| `kCLLocationAccuracyBestForNavigation` | ~5m | Highest |
+| `kCLLocationAccuracyBest` | ~10m | Very High |
+| `kCLLocationAccuracyNearestTenMeters` | ~10m | High |
+| `kCLLocationAccuracyHundredMeters` | ~100m | Medium |
+| `kCLLocationAccuracyKilometer` | ~1km | Low |
+| `kCLLocationAccuracyThreeKilometers` | ~3km | Very Low |
+| `kCLLocationAccuracyReduced` | ~5km | Lowest |
+
+### Legacy Region Monitoring
+
+```swift
+// Deprecated in iOS 17, use CLMonitor instead
+let region = CLCircularRegion(
+ center: coordinate,
+ radius: 100,
+ identifier: "MyRegion"
+)
+region.notifyOnEntry = true
+region.notifyOnExit = true
+manager.startMonitoring(for: region)
+```
+
+### Significant Location Changes
+
+Low-power alternative for coarse tracking:
+
+```swift
+manager.startMonitoringSignificantLocationChanges()
+// Updates ~500m movements, works in background
+```
+
+### Visit Monitoring
+
+Detect arrivals/departures:
+
+```swift
+manager.startMonitoringVisits()
+
+func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
+ let arrival = visit.arrivalDate
+ let departure = visit.departureDate
+ let coordinate = visit.coordinate
+}
+```
+
+---
+
+## Part 8: Geofencing Best Practices
+
+### Region Size
+
+- **Minimum effective radius**: ~100 meters
+- **Smaller regions**: May not trigger reliably
+- **Larger regions**: More reliable but less precise
+
+### 20-Region Limit Strategy
+
+```swift
+// Dynamic region management
+func updateMonitoredRegions(userLocation: CLLocation) async {
+ let nearbyPOIs = fetchNearbyPOIs(around: userLocation, limit: 20)
+
+ // Remove old regions
+ for id in await monitor.identifiers {
+ if !nearbyPOIs.contains(where: { $0.id == id }) {
+ await monitor.remove(id)
+ }
+ }
+
+ // Add new regions
+ for poi in nearbyPOIs {
+ let condition = CLMonitor.CircularGeographicCondition(
+ center: poi.coordinate,
+ radius: 100
+ )
+ await monitor.add(condition, identifier: poi.id)
+ }
+}
+```
+
+### Entry/Exit Timing
+
+- **Entry**: Usually within seconds to minutes
+- **Exit**: May take 3-5 minutes after leaving
+- **Accuracy depends on**: Cell towers, WiFi, GPS availability
+
+### Persistence
+
+- Conditions persist across app launches
+- Must reinitialize monitor with same name on launch
+- Core Location wakes app for events
+
+---
+
+## Part 9: Testing and Simulation
+
+### Xcode Location Simulation
+
+1. Run on simulator
+2. Debug → Simulate Location → Choose location
+3. Or use custom GPX file
+
+### Custom GPX Route
+
+```xml
+
+
+
+
+
+
+
+
+
+```
+
+### Testing Authorization States
+
+Settings → Privacy & Security → Location Services:
+- Toggle app authorization
+- Toggle system-wide location services
+- Test reduced accuracy
+
+### Console Filtering
+
+```bash
+# Filter location logs
+log stream --predicate 'subsystem == "com.apple.locationd"'
+```
+
+---
+
+## Part 10: Swift Concurrency Integration
+
+### Task Cancellation
+
+```swift
+let locationTask = Task {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ if Task.isCancelled { break }
+ processUpdate(update)
+ }
+}
+
+// Later
+locationTask.cancel()
+```
+
+### MainActor Considerations
+
+```swift
+@MainActor
+class LocationViewModel: ObservableObject {
+ @Published var currentLocation: CLLocation?
+
+ func startTracking() {
+ Task {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ // Already on MainActor, safe to update @Published
+ self.currentLocation = update.location
+ }
+ }
+ }
+}
+```
+
+### Error Handling
+
+```swift
+Task {
+ do {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ if update.authorizationDenied {
+ throw LocationError.authorizationDenied
+ }
+ processUpdate(update)
+ }
+ } catch {
+ handleError(error)
+ }
+}
+```
+
+---
+
+## Part 11: Geocoding
+
+### CLGeocoder — Forward Geocoding (Address → Coordinate)
+
+```swift
+let geocoder = CLGeocoder()
+
+func geocodeAddress(_ address: String) async throws -> CLLocation? {
+ let placemarks = try await geocoder.geocodeAddressString(address)
+ return placemarks.first?.location
+}
+
+// With locale for localized results
+let placemarks = try await geocoder.geocodeAddressString(
+ "1 Apple Park Way",
+ in: nil, // CLRegion hint (optional)
+ preferredLocale: Locale(identifier: "en_US")
+)
+```
+
+### CLGeocoder — Reverse Geocoding (Coordinate → Address)
+
+```swift
+func reverseGeocode(_ location: CLLocation) async throws -> CLPlacemark? {
+ let placemarks = try await geocoder.reverseGeocodeLocation(location)
+ return placemarks.first
+}
+
+// Usage
+if let placemark = try await reverseGeocode(location) {
+ let street = placemark.thoroughfare // "Apple Park Way"
+ let city = placemark.locality // "Cupertino"
+ let state = placemark.administrativeArea // "CA"
+ let zip = placemark.postalCode // "95014"
+ let country = placemark.country // "United States"
+ let isoCountry = placemark.isoCountryCode // "US"
+}
+```
+
+### CLPlacemark Key Properties
+
+| Property | Example | Notes |
+|----------|---------|-------|
+| `name` | "Apple Park" | Location name |
+| `thoroughfare` | "Apple Park Way" | Street name |
+| `subThoroughfare` | "1" | Street number |
+| `locality` | "Cupertino" | City |
+| `subLocality` | "Silicon Valley" | Neighborhood |
+| `administrativeArea` | "CA" | State/province |
+| `postalCode` | "95014" | ZIP/postal code |
+| `country` | "United States" | Country name |
+| `isoCountryCode` | "US" | ISO country code |
+| `timeZone` | America/Los_Angeles | Time zone |
+| `location` | CLLocation | Coordinate |
+
+### Geocoding Rate Limits
+
+- **One request at a time** — CLGeocoder throws if a request is in progress
+- **Apple rate-limits** — Throttle to avoid `kCLErrorGeocodeCanceled`
+- **Cache results** — Don't re-geocode the same address/coordinate
+- **Batch carefully** — Add delays between sequential geocode requests
+
+```swift
+// Check if geocoder is busy
+if geocoder.isGeocoding {
+ geocoder.cancelGeocode() // Cancel previous before starting new
+}
+```
+
+---
+
+## Troubleshooting Quick Reference
+
+| Symptom | Check |
+|---------|-------|
+| No location updates | Authorization status, Info.plist keys |
+| Background not working | Background mode capability, CLBackgroundActivitySession |
+| Always auth not effective | CLServiceSession with `.always`, started in foreground |
+| Geofence not triggering | Region count (max 20), radius (min ~100m) |
+| Reduced accuracy only | Check `accuracyAuthorization`, request temporary full accuracy |
+| Location icon stays on | Ensure `stopUpdatingLocation()` or break from async loop |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10180, 2023-10147, 2024-10212
+
+**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession
+
+**Skills**: axiom-core-location, axiom-core-location-diag, axiom-energy-ref
diff --git a/.claude/skills/axiom-core-location-ref/agents/openai.yaml b/.claude/skills/axiom-core-location-ref/agents/openai.yaml
new file mode 100644
index 0000000..813982b
--- /dev/null
+++ b/.claude/skills/axiom-core-location-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Location Reference"
+ short_description: "Core Location API reference"
diff --git a/.claude/skills/axiom-core-location/.openskills.json b/.claude/skills/axiom-core-location/.openskills.json
new file mode 100644
index 0000000..53509db
--- /dev/null
+++ b/.claude/skills/axiom-core-location/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-location",
+ "installedAt": "2026-04-12T08:06:07.581Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-location/SKILL.md b/.claude/skills/axiom-core-location/SKILL.md
new file mode 100644
index 0000000..b42ec50
--- /dev/null
+++ b/.claude/skills/axiom-core-location/SKILL.md
@@ -0,0 +1,484 @@
+---
+name: axiom-core-location
+description: Use for Core Location implementation patterns - authorization strategy, monitoring strategy, accuracy selection, background location
+license: MIT
+compatibility: iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-01-03"
+---
+
+# Core Location Patterns
+
+Discipline skill for Core Location implementation decisions. Prevents common authorization mistakes, battery drain, and background location failures.
+
+## When to Use
+
+- Choosing authorization strategy (When In Use vs Always)
+- Deciding monitoring approach (continuous vs significant-change vs CLMonitor)
+- Implementing geofencing or background location
+- Debugging "location not working" issues
+- Reviewing location code for anti-patterns
+
+## Related Skills
+
+- `axiom-core-location-ref` — API reference, code examples
+- `axiom-core-location-diag` — Symptom-based troubleshooting
+- `axiom-energy` — Location as battery subsystem
+
+---
+
+## Part 1: Anti-Patterns (with Time Costs)
+
+### Anti-Pattern 1: Premature Always Authorization
+
+**Wrong** (30-60% denial rate):
+```swift
+// First launch: "Can we have Always access?"
+manager.requestAlwaysAuthorization()
+```
+
+**Right** (5-10% denial rate):
+```swift
+// Start with When In Use
+CLServiceSession(authorization: .whenInUse)
+
+// Later, when user triggers background feature:
+CLServiceSession(authorization: .always)
+```
+
+**Time cost**: 15 min to fix code, but 30-60% of users permanently denied = feature adoption destroyed.
+
+**Why**: Users deny aggressive requests. Start minimal, upgrade when user understands value.
+
+---
+
+### Anti-Pattern 2: Continuous Updates for Geofencing
+
+**Wrong** (10x battery drain):
+```swift
+for try await update in CLLocationUpdate.liveUpdates() {
+ if isNearTarget(update.location) {
+ triggerGeofence()
+ }
+}
+```
+
+**Right** (system-managed, low power):
+```swift
+let monitor = await CLMonitor("Geofences")
+let condition = CLMonitor.CircularGeographicCondition(
+ center: target, radius: 100
+)
+await monitor.add(condition, identifier: "Target")
+
+for try await event in monitor.events {
+ if event.state == .satisfied { triggerGeofence() }
+}
+```
+
+**Time cost**: 5 min to refactor, saves 10x battery.
+
+---
+
+### Anti-Pattern 3: Ignoring Stationary Detection
+
+**Wrong** (wasted battery):
+```swift
+for try await update in CLLocationUpdate.liveUpdates() {
+ processLocation(update.location)
+ // Never stops, even when device stationary
+}
+```
+
+**Right** (automatic pause/resume):
+```swift
+for try await update in CLLocationUpdate.liveUpdates() {
+ if let location = update.location {
+ processLocation(location)
+ }
+ if update.isStationary, let location = update.location {
+ // Device stopped moving - updates pause automatically
+ // Will resume when device moves again
+ saveLastKnownLocation(location)
+ }
+}
+```
+
+**Time cost**: 2 min to add check, saves significant battery.
+
+---
+
+### Anti-Pattern 4: No Graceful Denial Handling
+
+**Wrong** (broken UX):
+```swift
+for try await update in CLLocationUpdate.liveUpdates() {
+ guard let location = update.location else { continue }
+ // User denied - silent failure, no feedback
+}
+```
+
+**Right** (graceful degradation):
+```swift
+for try await update in CLLocationUpdate.liveUpdates() {
+ if update.authorizationDenied {
+ showManualLocationPicker()
+ break
+ }
+ if update.authorizationDeniedGlobally {
+ showSystemLocationDisabledMessage()
+ break
+ }
+ if let location = update.location {
+ processLocation(location)
+ }
+}
+```
+
+**Time cost**: 10 min to add handling, prevents confused users.
+
+---
+
+### Anti-Pattern 5: Wrong Accuracy for Use Case
+
+**Wrong** (battery drain for weather app):
+```swift
+// Weather app using navigation accuracy
+CLLocationUpdate.liveUpdates(.automotiveNavigation)
+```
+
+**Right** (match accuracy to need):
+```swift
+// Weather: city-level is fine
+CLLocationUpdate.liveUpdates(.default) // or .fitness for runners
+
+// Navigation: needs high accuracy
+CLLocationUpdate.liveUpdates(.automotiveNavigation)
+```
+
+| Use Case | Configuration | Accuracy | Battery |
+|----------|---------------|----------|---------|
+| Navigation | `.automotiveNavigation` | ~5m | Highest |
+| Fitness tracking | `.fitness` | ~10m | High |
+| Store finder | `.default` | ~10-100m | Medium |
+| Weather | `.default` | ~100m+ | Low |
+
+**Time cost**: 1 min to change, significant battery savings.
+
+---
+
+### Anti-Pattern 6: Not Stopping Updates
+
+**Wrong** (battery drain, location icon persists):
+```swift
+func viewDidLoad() {
+ Task {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ updateMap(update.location)
+ }
+ }
+}
+// User navigates away, updates continue forever
+```
+
+**Right** (cancel when done):
+```swift
+private var locationTask: Task?
+
+func startTracking() {
+ locationTask = Task {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ if Task.isCancelled { break }
+ updateMap(update.location)
+ }
+ }
+}
+
+func stopTracking() {
+ locationTask?.cancel()
+ locationTask = nil
+}
+```
+
+**Time cost**: 5 min to add cancellation, stops battery drain.
+
+---
+
+### Anti-Pattern 7: Ignoring CLServiceSession (iOS 18+)
+
+**Wrong** (procedural authorization juggling):
+```swift
+func requestAuth() {
+ switch manager.authorizationStatus {
+ case .notDetermined:
+ manager.requestWhenInUseAuthorization()
+ case .authorizedWhenInUse:
+ if needsFullAccuracy {
+ manager.requestTemporaryFullAccuracyAuthorization(...)
+ }
+ // Complex state machine...
+ }
+}
+```
+
+**Right** (declarative goals):
+```swift
+// Just declare what you need - Core Location handles the rest
+let session = CLServiceSession(authorization: .whenInUse)
+
+// For feature needing full accuracy
+let navSession = CLServiceSession(
+ authorization: .whenInUse,
+ fullAccuracyPurposeKey: "Navigation"
+)
+
+// Monitor diagnostics if needed
+for try await diag in session.diagnostics {
+ if diag.authorizationDenied { handleDenial() }
+}
+```
+
+**Time cost**: 30 min to migrate, simpler code, fewer bugs.
+
+---
+
+## Part 2: Decision Trees
+
+### Authorization Strategy
+
+```
+Q1: Does your feature REQUIRE background location?
+├─ NO → Use .whenInUse
+│ └─ Q2: Does any feature need precise location?
+│ ├─ ALWAYS → Add fullAccuracyPurposeKey to session
+│ └─ SOMETIMES → Layer full-accuracy session when feature active
+│
+└─ YES → Start with .whenInUse, upgrade to .always when user triggers feature
+ └─ Q3: When does user first need background location?
+ ├─ IMMEDIATELY (e.g., fitness tracker) → Request .always on first relevant action
+ └─ LATER (e.g., geofence reminders) → Add .always session when user creates first geofence
+```
+
+### Monitoring Strategy
+
+```
+Q1: What are you monitoring for?
+├─ USER POSITION (continuous tracking)
+│ └─ Use CLLocationUpdate.liveUpdates()
+│ └─ Q2: What activity?
+│ ├─ Driving navigation → .automotiveNavigation
+│ ├─ Walking/cycling nav → .otherNavigation
+│ ├─ Fitness tracking → .fitness
+│ ├─ Airplane apps → .airborne
+│ └─ General → .default or omit
+│
+├─ ENTRY/EXIT REGIONS (geofencing)
+│ └─ Use CLMonitor with CircularGeographicCondition
+│ └─ Note: Maximum 20 conditions per app
+│
+├─ BEACON PROXIMITY
+│ └─ Use CLMonitor with BeaconIdentityCondition
+│ └─ Choose granularity: UUID only, UUID+major, UUID+major+minor
+│
+└─ SIGNIFICANT CHANGES ONLY (lowest power)
+ └─ Use startMonitoringSignificantLocationChanges() (legacy)
+ └─ Updates ~500m movements, works in background
+```
+
+### Accuracy Selection
+
+```
+Q1: What's the minimum accuracy that makes your feature work?
+├─ TURN-BY-TURN NAV needs 5-10m → .automotiveNavigation / .otherNavigation
+├─ FITNESS TRACKING needs 10-20m → .fitness
+├─ STORE FINDER needs 100m → .default
+├─ WEATHER/CITY needs 1km+ → .default (reduced accuracy acceptable)
+└─ GEOFENCING uses system determination → CLMonitor handles it
+
+Q2: Will user be moving fast?
+├─ DRIVING (high speed) → .automotiveNavigation (extra processing for speed)
+├─ CYCLING/WALKING → .otherNavigation
+└─ STATIONARY/SLOW → .default
+
+Always start with lowest acceptable accuracy. Higher accuracy = higher battery drain.
+```
+
+---
+
+## Part 3: Pressure Scenarios
+
+### Scenario 1: "Just Use Always Authorization"
+
+**Context**: PM says "Users want location reminders. Just request Always access on first launch so it works."
+
+**Pressure**: Ship fast, seems simpler.
+
+**Reality**:
+- 30-60% of users will deny Always authorization when asked upfront
+- Users who deny can only re-enable in Settings (most won't)
+- Feature adoption destroyed before users understand value
+
+**Response**:
+> "Always authorization has 30-60% denial rates when requested upfront. We should start with When In Use, then request Always upgrade when the user creates their first location reminder. This gives us a 5-10% denial rate because users understand why they need it."
+
+**Evidence**: Apple's own guidance in WWDC 2024-10212: "CLServiceSessions should be taken proactively... hold one requiring full-accuracy when people engage a feature that would warrant a special ask for it."
+
+---
+
+### Scenario 2: "Location Isn't Working in Background"
+
+**Context**: QA reports "App stops getting location when backgrounded."
+
+**Pressure**: Quick fix before release.
+
+**Wrong fixes**:
+- Add all background modes
+- Use `allowsBackgroundLocationUpdates = true` without understanding
+- Request Always authorization
+
+**Right diagnosis**:
+1. Check background mode capability exists
+2. Check CLBackgroundActivitySession is held (not deallocated)
+3. Check session started from foreground
+4. Check authorization level (.whenInUse works with CLBackgroundActivitySession)
+
+**Response**:
+> "Background location requires specific setup. Let me check: (1) Background mode capability, (2) CLBackgroundActivitySession held during tracking, (3) session started from foreground. Missing any of these causes silent failure."
+
+**Checklist**:
+```swift
+// 1. Signing & Capabilities → Background Modes → Location updates
+// 2. Hold session reference (property, not local variable)
+var backgroundSession: CLBackgroundActivitySession?
+
+func startBackgroundTracking() {
+ // 3. Must start from foreground
+ backgroundSession = CLBackgroundActivitySession()
+ startLocationUpdates()
+}
+```
+
+---
+
+### Scenario 3: "Geofence Events Aren't Firing"
+
+**Context**: Geofences work in testing but not in production for some users.
+
+**Pressure**: "It works on my device" dismissal.
+
+**Common causes**:
+1. **Too many conditions**: Maximum 20 per app
+2. **Radius too small**: Minimum ~100m for reliable triggering
+3. **Overlapping regions**: Can cause confusion
+4. **Not awaiting events**: Events only become lastEvent after handled
+5. **Not reinitializing on launch**: Monitor must be recreated
+
+**Response**:
+> "Geofencing has several system constraints. Check: (1) Are we within the 20-condition limit? (2) Are all radii at least 100m? (3) Is the app reinitializing CLMonitor on launch? (4) Is the app always awaiting on monitor.events?"
+
+**Diagnostic code**:
+```swift
+// Check condition count
+let count = await monitor.identifiers.count
+if count >= 20 {
+ print("At 20-condition limit!")
+}
+
+// Check all conditions
+for id in await monitor.identifiers {
+ if let record = await monitor.record(for: id) {
+ let condition = record.condition
+ if let geo = condition as? CLMonitor.CircularGeographicCondition {
+ if geo.radius < 100 {
+ print("Radius too small: \(id)")
+ }
+ }
+ }
+}
+```
+
+---
+
+## Part 4: Checklists
+
+### Pre-Release Location Checklist
+
+**Info.plist**:
+- [ ] `NSLocationWhenInUseUsageDescription` with clear explanation
+- [ ] `NSLocationAlwaysAndWhenInUseUsageDescription` if using Always (clear why background needed)
+- [ ] `NSLocationDefaultAccuracyReduced` if reduced accuracy acceptable
+- [ ] `NSLocationTemporaryUsageDescriptionDictionary` if requesting temporary full accuracy
+- [ ] `UIBackgroundModes` includes `location` if background tracking
+
+**Authorization**:
+- [ ] Start with minimal authorization (.whenInUse)
+- [ ] Upgrade to .always only when user triggers background feature
+- [ ] Handle authorization denial gracefully (offer alternatives)
+- [ ] Handle global location services disabled
+- [ ] Test with reduced accuracy authorization
+
+**Updates**:
+- [ ] Using appropriate LiveConfiguration for use case
+- [ ] Handling isStationary for pause/resume
+- [ ] Cancelling location tasks when feature inactive
+- [ ] Not using continuous updates for geofencing
+
+**Testing**:
+- [ ] Tested authorization denial flow
+- [ ] Tested reduced accuracy mode
+- [ ] Tested background-to-foreground transitions
+- [ ] Tested app termination and relaunch recovery
+
+### Background Location Checklist
+
+**Setup**:
+- [ ] Background mode capability added (Location updates)
+- [ ] CLBackgroundActivitySession created and HELD (not local variable)
+- [ ] Session started from foreground
+- [ ] Updates restarted on background launch in didFinishLaunchingWithOptions
+
+**Authorization**:
+- [ ] Using .whenInUse with CLBackgroundActivitySession, OR
+- [ ] Using .always (but only if needed beyond background indicator)
+
+**Lifecycle**:
+- [ ] Persisting "was tracking" state for relaunch recovery
+- [ ] Recreating CLBackgroundActivitySession on background launch
+- [ ] Restarting CLLocationUpdate iteration on launch
+- [ ] CLMonitor reinitialized with same name on launch
+
+**Testing**:
+- [ ] Blue background location indicator appears when backgrounded
+- [ ] Updates continue when app backgrounded
+- [ ] Updates resume after app suspended and resumed
+- [ ] Updates resume after app terminated and relaunched
+
+---
+
+## Part 5: iOS Version Considerations
+
+| Feature | iOS Version | Notes |
+|---------|-------------|-------|
+| CLLocationUpdate | iOS 17+ | AsyncSequence API |
+| CLMonitor | iOS 17+ | Replaces CLCircularRegion |
+| CLBackgroundActivitySession | iOS 17+ | Background with blue indicator |
+| CLServiceSession | iOS 18+ | Declarative authorization |
+| Implicit service sessions | iOS 18+ | From iterating liveUpdates |
+| CLLocationManager | iOS 2+ | Legacy but still works |
+
+**For iOS 14-16 support**: Use CLLocationManager delegate pattern (see core-location-ref Part 7).
+
+**For iOS 17+**: Prefer CLLocationUpdate and CLMonitor.
+
+**For iOS 18+**: Add CLServiceSession for declarative authorization.
+
+---
+
+## Resources
+
+**WWDC**: 2023-10180, 2023-10147, 2024-10212
+
+**Docs**: /corelocation, /corelocation/clmonitor, /corelocation/cllocationupdate, /corelocation/clservicesession
+
+**Skills**: axiom-core-location-ref, axiom-core-location-diag, axiom-energy
diff --git a/.claude/skills/axiom-core-location/agents/openai.yaml b/.claude/skills/axiom-core-location/agents/openai.yaml
new file mode 100644
index 0000000..d468964
--- /dev/null
+++ b/.claude/skills/axiom-core-location/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Location"
+ short_description: "Core Location implementation patterns"
diff --git a/.claude/skills/axiom-core-spotlight-ref/.openskills.json b/.claude/skills/axiom-core-spotlight-ref/.openskills.json
new file mode 100644
index 0000000..af929f0
--- /dev/null
+++ b/.claude/skills/axiom-core-spotlight-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-core-spotlight-ref",
+ "installedAt": "2026-04-12T08:06:09.175Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-core-spotlight-ref/SKILL.md b/.claude/skills/axiom-core-spotlight-ref/SKILL.md
new file mode 100644
index 0000000..29e2adc
--- /dev/null
+++ b/.claude/skills/axiom-core-spotlight-ref/SKILL.md
@@ -0,0 +1,907 @@
+---
+name: axiom-core-spotlight-ref
+description: Use when indexing app content for Spotlight search, using NSUserActivity for prediction/handoff, or choosing between CSSearchableItem and IndexedEntity - covers Core Spotlight framework and NSUserActivity integration for iOS 9+
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Core Spotlight & NSUserActivity Reference
+
+## Overview
+
+Comprehensive guide to Core Spotlight framework and NSUserActivity for making app content discoverable in Spotlight search, enabling Siri predictions, and supporting Handoff. Core Spotlight directly indexes app content while NSUserActivity captures user engagement for prediction.
+
+**Key distinction** Core Spotlight = indexing all app content; NSUserActivity = marking current user activity for prediction/handoff.
+
+---
+
+## When to Use This Skill
+
+Use this skill when:
+- Indexing app content (documents, notes, orders, messages) for Spotlight
+- Using NSUserActivity for Handoff or Siri predictions
+- Choosing between CSSearchableItem, IndexedEntity, and NSUserActivity
+- Implementing activity continuation from Spotlight results
+- Batch indexing for performance
+- Deleting indexed content
+- Debugging Spotlight search not finding app content
+- Integrating NSUserActivity with App Intents (appEntityIdentifier)
+
+Do NOT use this skill for:
+- App Shortcuts implementation (use app-shortcuts-ref)
+- App Intents basics (use app-intents-ref)
+- Overall discoverability strategy (use app-discoverability)
+
+---
+
+## Related Skills
+
+- **app-intents-ref** — App Intents framework including IndexedEntity
+- **app-discoverability** — Strategic guide for making apps discoverable
+- **app-shortcuts-ref** — App Shortcuts for instant availability
+
+---
+
+## When to Use Each API
+
+| Use Case | Approach | Example |
+|----------|----------|---------|
+| User viewing specific screen | `NSUserActivity` | User opened order details |
+| Index all app content | `CSSearchableItem` | All 500 orders searchable |
+| App Intents entity search | `IndexedEntity` | "Find orders where..." |
+| Handoff between devices | `NSUserActivity` | Continue editing note on Mac |
+| Background content indexing | `CSSearchableItem` batch | Index documents on launch |
+
+**Apple guidance** Use NSUserActivity for user-initiated activities (screens currently visible), not as a general indexing mechanism. For comprehensive content indexing, use Core Spotlight's CSSearchableItem.
+
+---
+
+## Core Spotlight (CSSearchableItem)
+
+### Creating Searchable Items
+
+```swift
+import CoreSpotlight
+import UniformTypeIdentifiers
+
+func indexOrder(_ order: Order) {
+ // 1. Create attribute set with metadata
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Ordered on \(order.date.formatted())"
+ attributes.keywords = ["coffee", "order", order.coffeeName.lowercased()]
+ attributes.thumbnailData = order.imageData
+
+ // Optional: Add location
+ attributes.latitude = order.location.coordinate.latitude
+ attributes.longitude = order.location.coordinate.longitude
+
+ // Optional: Add rating
+ attributes.rating = NSNumber(value: order.rating)
+
+ // 2. Create searchable item
+ let item = CSSearchableItem(
+ uniqueIdentifier: order.id.uuidString, // Stable ID
+ domainIdentifier: "orders", // Grouping
+ attributeSet: attributes
+ )
+
+ // Optional: Set expiration
+ item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365) // 1 year
+
+ // 3. Index the item
+ CSSearchableIndex.default().indexSearchableItems([item]) { error in
+ if let error = error {
+ print("Indexing error: \(error.localizedDescription)")
+ }
+ }
+}
+```
+
+---
+
+### Key Properties
+
+#### uniqueIdentifier
+**Purpose** Stable, persistent ID unique to this item within your app.
+
+```swift
+uniqueIdentifier: order.id.uuidString
+```
+
+**Requirements:**
+- Must be stable (same item = same identifier)
+- Used for updates and deletion
+- Scoped to your app
+
+---
+
+#### domainIdentifier
+**Purpose** Groups related items for bulk operations.
+
+```swift
+domainIdentifier: "orders"
+```
+
+**Use cases:**
+- Delete all items in a domain
+- Organize by type (orders, documents, messages)
+- Batch operations
+
+**Pattern:**
+```swift
+// Index with domains
+item1.domainIdentifier = "orders"
+item2.domainIdentifier = "documents"
+
+// Delete entire domain
+CSSearchableIndex.default().deleteSearchableItems(
+ withDomainIdentifiers: ["orders"]
+) { error in }
+```
+
+---
+
+### CSSearchableItemAttributeSet
+
+Metadata describing the searchable content.
+
+```swift
+let attributes = CSSearchableItemAttributeSet(contentType: .item)
+
+// Required
+attributes.title = "Order #1234"
+attributes.displayName = "Coffee Order"
+
+// Highly recommended
+attributes.contentDescription = "Medium latte with oat milk"
+attributes.keywords = ["coffee", "latte", "order"]
+attributes.thumbnailData = imageData
+
+// Optional but valuable
+attributes.contentCreationDate = Date()
+attributes.contentModificationDate = Date()
+attributes.rating = NSNumber(value: 5)
+attributes.comment = "My favorite order"
+```
+
+#### Common Attributes
+
+| Attribute | Purpose | Example |
+|-----------|---------|---------|
+| `title` | Primary title | "Coffee Order #1234" |
+| `displayName` | User-visible name | "Morning Latte" |
+| `contentDescription` | Description text | "Medium latte with oat milk" |
+| `keywords` | Search terms | ["coffee", "latte"] |
+| `thumbnailData` | Preview image | JPEG/PNG data |
+| `contentCreationDate` | When created | Date() |
+| `contentModificationDate` | Last modified | Date() |
+| `rating` | Star rating | NSNumber(value: 5) |
+| `latitude` / `longitude` | Location | 37.7749, -122.4194 |
+
+#### Document-Specific Attributes
+
+```swift
+// For document types
+attributes.contentType = UTType.pdf
+attributes.author = "John Doe"
+attributes.pageCount = 10
+attributes.fileSize = 1024000
+attributes.path = "/path/to/document.pdf"
+```
+
+#### Message-Specific Attributes
+
+```swift
+// For messages
+attributes.recipients = ["jane@example.com"]
+attributes.recipientNames = ["Jane Doe"]
+attributes.authorNames = ["John Doe"]
+attributes.subject = "Meeting notes"
+```
+
+---
+
+### Batch Indexing for Performance
+
+#### ❌ DON'T: Index items one at a time
+```swift
+// Bad: 100 index operations
+for order in orders {
+ CSSearchableIndex.default().indexSearchableItems([order.asSearchableItem()]) { _ in }
+}
+```
+
+#### ✅ DO: Batch index operations
+```swift
+// Good: 1 index operation
+let items = orders.map { $0.asSearchableItem() }
+
+CSSearchableIndex.default().indexSearchableItems(items) { error in
+ if let error = error {
+ print("Batch indexing error: \(error)")
+ } else {
+ print("Indexed \(items.count) items")
+ }
+}
+```
+
+**Recommended batch size** 100-500 items per call. For larger sets, split into multiple batches.
+
+---
+
+### Deletion Patterns
+
+#### Delete by Identifier
+```swift
+let identifiers = ["order-1", "order-2", "order-3"]
+
+CSSearchableIndex.default().deleteSearchableItems(
+ withIdentifiers: identifiers
+) { error in
+ if let error = error {
+ print("Deletion error: \(error)")
+ }
+}
+```
+
+#### Delete by Domain
+```swift
+// Delete all items in "orders" domain
+CSSearchableIndex.default().deleteSearchableItems(
+ withDomainIdentifiers: ["orders"]
+) { error in }
+```
+
+#### Delete All
+```swift
+// Nuclear option: delete everything
+CSSearchableIndex.default().deleteAllSearchableItems { error in
+ if let error = error {
+ print("Failed to delete all: \(error)")
+ }
+}
+```
+
+**When to delete:**
+- User deletes content
+- Content expires
+- User logs out
+- App reset/reinstall
+
+---
+
+### App Entity Integration (App Intents)
+
+#### Create from App Entity
+```swift
+import AppIntents
+
+struct OrderEntity: AppEntity, IndexedEntity {
+ var id: UUID
+
+ @Property(title: "Coffee", indexingKey: \.title)
+ var coffeeName: String
+
+ @Property(title: "Date", indexingKey: \.contentCreationDate)
+ var orderDate: Date
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation = "Order"
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(coffeeName)", subtitle: "Order from \(orderDate.formatted())")
+ }
+}
+
+// Create searchable item from entity
+let order = OrderEntity(id: UUID(), coffeeName: "Latte", orderDate: Date())
+let item = CSSearchableItem(appEntity: order)
+CSSearchableIndex.default().indexSearchableItems([item])
+```
+
+#### Associate Entity with Existing Item
+```swift
+let attributes = CSSearchableItemAttributeSet(contentType: .item)
+attributes.title = "Order #1234"
+
+let item = CSSearchableItem(
+ uniqueIdentifier: "order-1234",
+ domainIdentifier: "orders",
+ attributeSet: attributes
+)
+
+// Associate with App Intent entity
+item.associateAppEntity(orderEntity, priority: .default)
+```
+
+**Benefits:**
+- Automatic "Find" actions in Shortcuts
+- Spotlight search returns entities directly
+- App Intents integration
+
+---
+
+## NSUserActivity
+
+### Overview
+
+NSUserActivity captures user engagement for:
+- **Handoff** — Continue activity on another device
+- **Spotlight search** — Index currently viewed content
+- **Siri predictions** — Suggest returning to this screen
+- **Quick Note** — Link notes to app content
+
+**Platform support** iOS 8.0+, iPadOS 8.0+, macOS 10.10+, tvOS 9.0+, watchOS 2.0+, axiom-visionOS 1.0+
+
+---
+
+### Eligibility Properties
+
+```swift
+let activity = NSUserActivity(activityType: "com.app.viewOrder")
+
+// Enable Spotlight search
+activity.isEligibleForSearch = true
+
+// Enable Siri predictions
+activity.isEligibleForPrediction = true
+
+// Enable Handoff to other devices
+activity.isEligibleForHandoff = true
+
+// Contribute URL to global search (public content only)
+activity.isEligibleForPublicIndexing = false
+```
+
+**Privacy note** Only set `isEligibleForPublicIndexing = true` for publicly accessible content (e.g., blog posts with public URLs).
+
+---
+
+### Basic Pattern
+
+```swift
+func viewOrder(_ order: Order) {
+ // 1. Create activity
+ let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
+ activity.title = order.coffeeName
+
+ // 2. Set eligibility
+ activity.isEligibleForSearch = true
+ activity.isEligibleForPrediction = true
+
+ // 3. Provide identifier for updates/deletion
+ activity.persistentIdentifier = order.id.uuidString
+
+ // 4. Provide rich metadata
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Your \(order.coffeeName) order"
+ attributes.thumbnailData = order.imageData
+ activity.contentAttributeSet = attributes
+
+ // 5. Mark as current
+ activity.becomeCurrent()
+
+ // 6. Store reference (important!)
+ self.userActivity = activity
+}
+```
+
+**Critical** Maintain strong reference to activity. It won't appear in search without one.
+
+---
+
+### becomeCurrent() and resignCurrent()
+
+```swift
+// UIKit pattern
+class OrderDetailViewController: UIViewController {
+ var currentActivity: NSUserActivity?
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ let activity = NSUserActivity(activityType: "com.app.viewOrder")
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.becomeCurrent() // Mark as active
+
+ self.currentActivity = activity
+ self.userActivity = activity // UIKit integration
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ currentActivity?.resignCurrent() // Mark as inactive
+ }
+}
+```
+
+```swift
+// SwiftUI pattern
+struct OrderDetailView: View {
+ let order: Order
+
+ var body: some View {
+ VStack {
+ Text(order.coffeeName)
+ }
+ .onAppear {
+ let activity = NSUserActivity(activityType: "com.app.viewOrder")
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.becomeCurrent()
+
+ // SwiftUI automatically manages userActivity
+ self.userActivity = activity
+ }
+ }
+}
+```
+
+---
+
+### App Intents Integration (appEntityIdentifier)
+
+Connect NSUserActivity to App Intent entities.
+
+```swift
+func viewOrder(_ order: Order) {
+ let activity = NSUserActivity(activityType: "com.app.viewOrder")
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.isEligibleForPrediction = true
+
+ // Connect to App Intent entity
+ activity.appEntityIdentifier = order.id.uuidString
+
+ // Now Spotlight can surface this as an entity suggestion
+ activity.becomeCurrent()
+ self.userActivity = activity
+}
+```
+
+**Benefits:**
+- Siri suggests this order in relevant contexts
+- App Intents can reference this activity
+- Shortcuts integration
+
+---
+
+### On-Screen Content Tagging
+
+**Pattern from WWDC** Tag currently visible content for Spotlight parameter suggestions.
+
+```swift
+func showEvent(_ event: Event) {
+ let activity = NSUserActivity(activityType: "com.app.viewEvent")
+ activity.persistentIdentifier = event.id.uuidString
+
+ // Spotlight suggests this event for intent parameters
+ activity.appEntityIdentifier = event.id.uuidString
+
+ activity.becomeCurrent()
+ userActivity = activity
+}
+```
+
+**Result** When users invoke intents requiring an event parameter, Spotlight suggests the currently visible event.
+
+---
+
+### Quick Note Integration (macOS/iPadOS)
+
+For Quick Note linking, activities must:
+1. Be the app's **current activity** (via `becomeCurrent()`)
+2. Have a clear, concise `title` (nouns, not verbs)
+3. Provide stable, consistent identifiers
+4. Support navigation to linked content indefinitely
+5. Gracefully handle missing content
+
+```swift
+let activity = NSUserActivity(activityType: "com.app.viewNote")
+activity.title = note.title // ✅ "Project Ideas" not ❌ "View Note"
+activity.persistentIdentifier = note.id.uuidString
+activity.targetContentIdentifier = note.id.uuidString
+activity.becomeCurrent()
+```
+
+---
+
+### Activity Continuation (Handling Spotlight Taps)
+
+When users tap Spotlight results, handle continuation:
+
+#### UIKit
+```swift
+// AppDelegate or SceneDelegate
+func application(
+ _ application: UIApplication,
+ continue userActivity: NSUserActivity,
+ restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
+) -> Bool {
+ guard userActivity.activityType == "com.app.viewOrder" else {
+ return false
+ }
+
+ // Extract identifier
+ if let identifier = userActivity.persistentIdentifier,
+ let orderID = UUID(uuidString: identifier) {
+ // Navigate to order
+ navigateToOrder(orderID)
+ return true
+ }
+
+ return false
+}
+```
+
+#### SwiftUI
+```swift
+@main
+struct CoffeeApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .onContinueUserActivity("com.app.viewOrder") { userActivity in
+ if let identifier = userActivity.persistentIdentifier,
+ let orderID = UUID(uuidString: identifier) {
+ // Navigate to order
+ navigateToOrder(orderID)
+ }
+ }
+ }
+ }
+}
+```
+
+#### Searchable Item Continuation
+```swift
+// When continuing from CSSearchableItem
+func application(
+ _ application: UIApplication,
+ continue userActivity: NSUserActivity,
+ restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
+) -> Bool {
+ if userActivity.activityType == CSSearchableItemActionType {
+ // Get identifier from Core Spotlight item
+ if let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String {
+ // Navigate based on identifier
+ navigateToItem(identifier)
+ return true
+ }
+ }
+
+ return false
+}
+```
+
+---
+
+### Deletion APIs
+
+#### Delete All Saved Activities
+```swift
+NSUserActivity.deleteAllSavedUserActivities { }
+```
+
+#### Delete Specific Activities
+```swift
+let identifiers = ["order-1", "order-2"]
+
+NSUserActivity.deleteSavedUserActivities(
+ withPersistentIdentifiers: identifiers
+) { }
+```
+
+**When to delete:**
+- User deletes content
+- User logs out
+- Content no longer accessible
+
+---
+
+## NSUserActivity vs CSSearchableItem
+
+| Aspect | NSUserActivity | CSSearchableItem |
+|--------|---------------|------------------|
+| **Purpose** | Current user activity | Indexing all content |
+| **When to use** | User viewing a screen | Background content indexing |
+| **Scope** | One item at a time | Batch operations |
+| **Handoff** | Supported | Not supported |
+| **Prediction** | Supported | Not supported |
+| **Search** | Limited | Full Spotlight integration |
+| **Example** | User viewing order detail | Index all 500 orders |
+
+**Recommended** Use both:
+- NSUserActivity for screens currently visible
+- CSSearchableItem for comprehensive content indexing
+
+---
+
+## Testing & Debugging
+
+### Verify Indexed Items
+
+#### Using Spotlight
+1. Open Spotlight (swipe down on Home Screen)
+2. Search for indexed content keywords
+3. Verify your app's results appear
+4. Tap result → Verify navigation works
+
+#### Using Console Logs
+```swift
+CSSearchableIndex.default().fetchLastClientState { clientState, error in
+ if let error = error {
+ print("Error fetching client state: \(error)")
+ } else {
+ print("Client state: \(clientState?.base64EncodedString() ?? "none")")
+ }
+}
+```
+
+---
+
+### Common Issues
+
+#### Items not appearing in Spotlight
+- Wait 1-2 minutes for indexing
+- Verify `isEligibleForSearch = true`
+- Check System Settings → Siri & Search → [App] → Show App in Search
+- Restart device
+- Check console for indexing errors
+
+#### Activity not triggering Handoff
+- Verify `isEligibleForHandoff = true`
+- Ensure both devices signed into same iCloud account
+- Check Bluetooth and Wi-Fi enabled on both devices
+- Verify activityType is reverse DNS (com.company.app.action)
+
+#### Continuation not working
+- Verify `application(_:continue:restorationHandler:)` implemented
+- Check activityType matches exactly
+- Ensure persistentIdentifier is set
+- Test with debugger to verify method is called
+
+---
+
+## Best Practices
+
+### 1. Selective Indexing
+
+#### ❌ DON'T: Index everything
+```swift
+// Bad: Index all 10,000 items
+let allItems = try await ItemService.shared.all()
+```
+
+#### ✅ DO: Index selectively
+```swift
+// Good: Index recent/important items
+let recentItems = try await ItemService.shared.recent(limit: 100)
+let favoriteItems = try await ItemService.shared.favorites()
+```
+
+**Why** Performance, quota limits, user experience.
+
+---
+
+### 2. Use Domain Identifiers
+
+#### ❌ DON'T: Rely only on unique identifiers
+```swift
+// Hard to delete all orders
+CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: allOrderIDs)
+```
+
+#### ✅ DO: Group with domains
+```swift
+// Easy to delete all orders
+CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: ["orders"])
+```
+
+---
+
+### 3. Set Expiration Dates
+
+#### ❌ DON'T: Index items forever
+```swift
+// Bad: Items never expire
+let item = CSSearchableItem(/* ... */)
+```
+
+#### ✅ DO: Set reasonable expiration
+```swift
+// Good: Expire after 1 year
+item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
+```
+
+---
+
+### 4. Provide Rich Metadata
+
+#### ❌ DON'T: Minimal metadata
+```swift
+attributes.title = "Item"
+```
+
+#### ✅ DO: Rich, searchable metadata
+```swift
+attributes.title = "Medium Latte Order"
+attributes.contentDescription = "Ordered on December 12, 2025"
+attributes.keywords = ["coffee", "latte", "order", "medium"]
+attributes.thumbnailData = imageData
+```
+
+---
+
+### 5. Handle Missing Content Gracefully
+
+```swift
+func application(
+ _ application: UIApplication,
+ continue userActivity: NSUserActivity,
+ restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
+) -> Bool {
+ guard let identifier = userActivity.persistentIdentifier else {
+ return false
+ }
+
+ // Attempt to load content
+ if let item = try? await ItemService.shared.fetch(id: identifier) {
+ navigate(to: item)
+ return true
+ } else {
+ // Content deleted or unavailable
+ showAlert("This content is no longer available")
+
+ // Delete activity from search
+ NSUserActivity.deleteSavedUserActivities(
+ withPersistentIdentifiers: [identifier]
+ )
+
+ return true // Still handled
+ }
+}
+```
+
+---
+
+## Complete Example
+
+### Comprehensive Integration
+
+```swift
+import CoreSpotlight
+import UniformTypeIdentifiers
+
+class OrderManager {
+
+ // MARK: - Core Spotlight Indexing
+
+ func indexOrder(_ order: Order) {
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Order from \(order.date.formatted())"
+ attributes.keywords = ["coffee", "order", order.coffeeName.lowercased()]
+ attributes.thumbnailData = order.thumbnailImageData
+ attributes.contentCreationDate = order.date
+ attributes.rating = NSNumber(value: order.rating)
+
+ let item = CSSearchableItem(
+ uniqueIdentifier: order.id.uuidString,
+ domainIdentifier: "orders",
+ attributeSet: attributes
+ )
+
+ item.expirationDate = Date().addingTimeInterval(60 * 60 * 24 * 365)
+
+ CSSearchableIndex.default().indexSearchableItems([item]) { error in
+ if let error = error {
+ print("Indexing error: \(error)")
+ }
+ }
+ }
+
+ func deleteOrder(_ orderID: UUID) {
+ // Delete from Core Spotlight
+ CSSearchableIndex.default().deleteSearchableItems(
+ withIdentifiers: [orderID.uuidString]
+ )
+
+ // Delete NSUserActivity
+ NSUserActivity.deleteSavedUserActivities(
+ withPersistentIdentifiers: [orderID.uuidString]
+ )
+ }
+
+ func deleteAllOrders() {
+ CSSearchableIndex.default().deleteSearchableItems(
+ withDomainIdentifiers: ["orders"]
+ )
+ }
+
+ // MARK: - NSUserActivity for Current Screen
+
+ func createActivityForOrder(_ order: Order) -> NSUserActivity {
+ let activity = NSUserActivity(activityType: "com.coffeeapp.viewOrder")
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.isEligibleForPrediction = true
+ activity.persistentIdentifier = order.id.uuidString
+
+ // Connect to App Intents
+ activity.appEntityIdentifier = order.id.uuidString
+
+ // Rich metadata
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Your \(order.coffeeName) order"
+ attributes.thumbnailData = order.thumbnailImageData
+ activity.contentAttributeSet = attributes
+
+ return activity
+ }
+}
+
+// UIKit view controller
+class OrderDetailViewController: UIViewController {
+ var order: Order!
+ var currentActivity: NSUserActivity?
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+
+ currentActivity = OrderManager.shared.createActivityForOrder(order)
+ currentActivity?.becomeCurrent()
+ self.userActivity = currentActivity
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ currentActivity?.resignCurrent()
+ }
+}
+
+// SwiftUI view
+struct OrderDetailView: View {
+ let order: Order
+
+ var body: some View {
+ VStack {
+ Text(order.coffeeName)
+ .font(.largeTitle)
+
+ Text("Ordered on \(order.date.formatted())")
+ .foregroundColor(.secondary)
+ }
+ .userActivity("com.coffeeapp.viewOrder") { activity in
+ activity.title = order.coffeeName
+ activity.isEligibleForSearch = true
+ activity.isEligibleForPrediction = true
+ activity.persistentIdentifier = order.id.uuidString
+ activity.appEntityIdentifier = order.id.uuidString
+
+ let attributes = CSSearchableItemAttributeSet(contentType: .item)
+ attributes.title = order.coffeeName
+ attributes.contentDescription = "Your \(order.coffeeName) order"
+ activity.contentAttributeSet = attributes
+ }
+ }
+}
+```
+
+---
+
+## Resources
+
+**WWDC**: 260, 2015-709
+
+**Docs**: /corespotlight, /corespotlight/cssearchableitem, /foundation/nsuseractivity
+
+**Skills**: axiom-app-intents-ref, axiom-app-discoverability, axiom-app-shortcuts-ref
+
+---
+
+**Remember** Core Spotlight indexes all your app's content; NSUserActivity marks what the user is currently doing. Use CSSearchableItem for batch indexing, NSUserActivity for active screens, and connect them to App Intents with appEntityIdentifier for comprehensive discoverability.
diff --git a/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml b/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml
new file mode 100644
index 0000000..e18a208
--- /dev/null
+++ b/.claude/skills/axiom-core-spotlight-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Core Spotlight Reference"
+ short_description: "Indexing app content for Spotlight search, using NSUserActivity for prediction/handoff, or choosing between CSSearcha..."
diff --git a/.claude/skills/axiom-cryptokit-ref/.openskills.json b/.claude/skills/axiom-cryptokit-ref/.openskills.json
new file mode 100644
index 0000000..ecb0bce
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-cryptokit-ref",
+ "installedAt": "2026-04-12T08:06:10.560Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-cryptokit-ref/SKILL.md b/.claude/skills/axiom-cryptokit-ref/SKILL.md
new file mode 100644
index 0000000..bb3475f
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit-ref/SKILL.md
@@ -0,0 +1,705 @@
+---
+name: axiom-cryptokit-ref
+description: Use when needing CryptoKit API details — hash functions (SHA2/SHA3), HMAC, AES-GCM/ChaChaPoly encryption, ECDSA/EdDSA signatures, ECDH key agreement, ML-KEM/ML-DSA post-quantum algorithms, HPKE encryption, Secure Enclave key types, key representations (raw/DER/PEM/x963), or Swift Crypto cross-platform parity. Covers complete CryptoKit API surface.
+license: MIT
+---
+
+# CryptoKit API Reference
+
+Complete API reference for Apple CryptoKit: hashing, HMAC, symmetric encryption, key agreement, digital signatures, post-quantum cryptography, HPKE, Secure Enclave, key derivation, and Swift Crypto cross-platform parity.
+
+## Quick Reference
+
+```swift
+import CryptoKit
+
+// Generate a symmetric key
+let key = SymmetricKey(size: .bits256)
+
+// AES-GCM encrypt
+let sealed = try AES.GCM.seal(plaintext, using: key)
+let combined = sealed.combined! // nonce + ciphertext + tag
+
+// AES-GCM decrypt
+let sealedBox = try AES.GCM.SealedBox(combined: combined)
+let decrypted = try AES.GCM.open(sealedBox, using: key)
+
+// ECDSA sign (P256)
+let signingKey = P256.Signing.PrivateKey()
+let signature = try signingKey.signature(for: data)
+let valid = signingKey.publicKey.isValidSignature(signature, for: data)
+
+// Secure Enclave key
+let seKey = try SecureEnclave.P256.Signing.PrivateKey()
+let seSignature = try seKey.signature(for: data)
+```
+
+---
+
+## Hashing
+
+### Hash Functions
+
+| Algorithm | Type | Output Size | Use |
+|-----------|------|-------------|-----|
+| SHA256 | SHA256 | 32 bytes | General purpose, most common |
+| SHA384 | SHA384 | 48 bytes | TLS, certificate chains |
+| SHA512 | SHA512 | 64 bytes | High-security contexts |
+| SHA3_256 | SHA3_256 | 32 bytes | NIST post-quantum companion |
+| SHA3_384 | SHA3_384 | 48 bytes | Post-quantum companion |
+| SHA3_512 | SHA3_512 | 64 bytes | Post-quantum companion |
+| Insecure.MD5 | Insecure.MD5 | 16 bytes | Legacy interop only |
+| Insecure.SHA1 | Insecure.SHA1 | 20 bytes | Legacy interop only |
+
+### Single-Call Hashing
+
+```swift
+let digest = SHA256.hash(data: data)
+// digest conforms to Sequence of UInt8
+let hex = digest.map { String(format: "%02x", $0) }.joined()
+```
+
+### Streaming (Incremental) Hashing
+
+```swift
+var hasher = SHA256()
+hasher.update(data: chunk1)
+hasher.update(data: chunk2)
+hasher.update(bufferPointer: unsafePointer)
+let digest = hasher.finalize() // SHA256Digest
+```
+
+### HashFunction Protocol
+
+All hash types conform to `HashFunction` with: `byteCount`, `blockByteCount`, `init()`, `update(data:)`, `update(bufferPointer:)`, `finalize()`, and `hash(data:)`.
+
+Digest conforms to `Sequence` (of `UInt8`), supports constant-time `==`, and converts to `Data(digest)` or `Array(digest)`. `description` returns hex string.
+
+---
+
+## Message Authentication (HMAC)
+
+### SymmetricKey
+
+```swift
+let key = SymmetricKey(size: .bits128) // .bits128, .bits192, .bits256
+let key = SymmetricKey(size: SymmetricKeySize(bitCount: 512)) // Custom size
+let key = SymmetricKey(data: existingKeyData) // From existing material
+
+key.bitCount // Key size in bits
+key.withUnsafeBytes { bytes in /* ... */ } // Only way to access raw bytes
+```
+
+### HMAC Generation and Verification
+
+```swift
+// HMAC is generic over HashFunction
+let authCode = HMAC.authenticationCode(for: data, using: key)
+// authCode: HMAC.MAC
+
+let valid = HMAC.isValidAuthenticationCode(authCode, authenticating: data, using: key)
+
+// Data representation
+let macData = Data(authCode)
+```
+
+### Iterative HMAC
+
+```swift
+var hmac = HMAC(key: key)
+hmac.update(data: chunk1)
+hmac.update(data: chunk2)
+let authCode = hmac.finalize()
+```
+
+---
+
+## Symmetric Encryption
+
+### AES-GCM
+
+```swift
+// Seal (encrypt + authenticate)
+let sealed = try AES.GCM.seal(plaintext, using: key)
+let sealed = try AES.GCM.seal(plaintext, using: key, nonce: customNonce)
+let sealed = try AES.GCM.seal(
+ plaintext,
+ using: key,
+ nonce: customNonce,
+ authenticating: associatedData // AAD — authenticated but not encrypted
+)
+
+// SealedBox properties
+sealed.nonce // AES.GCM.Nonce (12 bytes)
+sealed.ciphertext // Data
+sealed.tag // Data (16 bytes)
+sealed.combined // Data? (nonce + ciphertext + tag)
+
+// Open (decrypt + verify)
+let plaintext = try AES.GCM.open(sealedBox, using: key)
+let plaintext = try AES.GCM.open(sealedBox, using: key, authenticating: associatedData)
+```
+
+### AES-GCM SealedBox Construction
+
+```swift
+// From combined representation (nonce + ciphertext + tag)
+let box = try AES.GCM.SealedBox(combined: combinedData)
+
+// From components
+let box = try AES.GCM.SealedBox(
+ nonce: AES.GCM.Nonce(data: nonceData),
+ ciphertext: ciphertextData,
+ tag: tagData
+)
+```
+
+### AES-GCM Nonce
+
+```swift
+let nonce = AES.GCM.Nonce() // Random 12 bytes (recommended)
+let nonce = try AES.GCM.Nonce(data: nonceData) // Custom (MUST be unique per key)
+```
+
+### ChaChaPoly
+
+Identical interface to AES-GCM. Preferred for software-only environments without AES-NI.
+
+```swift
+let sealed = try ChaChaPoly.seal(plaintext, using: key)
+let sealed = try ChaChaPoly.seal(plaintext, using: key, authenticating: aad)
+
+let plaintext = try ChaChaPoly.open(sealed, using: key)
+let plaintext = try ChaChaPoly.open(sealed, using: key, authenticating: aad)
+
+// SealedBox, Nonce — same pattern as AES.GCM
+let box = try ChaChaPoly.SealedBox(combined: combined)
+let nonce = ChaChaPoly.Nonce()
+```
+
+### AES Key Wrapping
+
+```swift
+// Wrap a key with another key (RFC 3394)
+let wrapped = try AES.KeyWrap.wrap(keyToWrap, using: wrappingKey)
+// wrapped: Data
+
+// Unwrap
+let unwrapped = try AES.KeyWrap.unwrap(wrapped, using: wrappingKey)
+// unwrapped: SymmetricKey
+```
+
+---
+
+## Key Agreement (ECDH)
+
+### Supported Curves
+
+| Curve | Type Prefix | Key Size | Use |
+|-------|-------------|----------|-----|
+| Curve25519 | Curve25519.KeyAgreement | 32 bytes | Modern, fast, safe defaults |
+| P-256 | P256.KeyAgreement | 32 bytes | NIST standard, Secure Enclave |
+| P-384 | P384.KeyAgreement | 48 bytes | Higher security NIST |
+| P-521 | P521.KeyAgreement | 66 bytes | Maximum NIST security |
+
+### Private Key Creation
+
+```swift
+let privateKey = Curve25519.KeyAgreement.PrivateKey() // Random
+let privateKey = P256.KeyAgreement.PrivateKey() // Random
+let privateKey = P256.KeyAgreement.PrivateKey(compactRepresentable: true)
+
+// From serialized representations
+let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: rawData)
+let privateKey = try P256.KeyAgreement.PrivateKey(derRepresentation: derData)
+let privateKey = try P256.KeyAgreement.PrivateKey(pemRepresentation: pemString)
+let privateKey = try P256.KeyAgreement.PrivateKey(x963Representation: x963Data) // NIST only
+```
+
+### Public Key Representations
+
+```swift
+let publicKey = privateKey.publicKey
+publicKey.rawRepresentation // Data (all curves)
+publicKey.derRepresentation // Data — SubjectPublicKeyInfo (all curves)
+publicKey.pemRepresentation // String (all curves)
+publicKey.x963Representation // Data — uncompressed point (NIST only)
+publicKey.compactRepresentation // Data? (NIST only)
+publicKey.compressedRepresentation // Data (NIST only)
+```
+
+### Shared Secret Derivation
+
+```swift
+let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: peerPublicKey)
+// sharedSecret: SharedSecret — NOT directly usable as a key
+
+// Derive symmetric key with HKDF
+let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
+ using: SHA256.self,
+ salt: saltData, // Can be empty Data()
+ sharedInfo: infoData, // Context/label data
+ outputByteCount: 32 // Key size
+)
+
+// Derive with X9.63 KDF
+let symmetricKey = sharedSecret.x963DerivedSymmetricKey(
+ using: SHA256.self,
+ sharedInfo: infoData,
+ outputByteCount: 32
+)
+```
+
+---
+
+## Signatures (ECDSA/EdDSA)
+
+### Supported Algorithms
+
+| Curve | Algorithm | Type Prefix |
+|-------|-----------|-------------|
+| Curve25519 | Ed25519 (EdDSA) | Curve25519.Signing |
+| P-256 | ECDSA | P256.Signing |
+| P-384 | ECDSA | P384.Signing |
+| P-521 | ECDSA | P521.Signing |
+
+### Key Creation
+
+```swift
+let privateKey = P256.Signing.PrivateKey()
+let privateKey = Curve25519.Signing.PrivateKey()
+
+// Same representation constructors as KeyAgreement keys:
+// init(rawRepresentation:), init(derRepresentation:),
+// init(pemRepresentation:), init(x963Representation:) for NIST curves
+```
+
+### Sign and Verify
+
+```swift
+// Sign raw data
+let signature = try privateKey.signature(for: data)
+
+// Sign a digest (skip re-hashing already-hashed data)
+let digest = SHA256.hash(data: data)
+let signature = try privateKey.signature(for: digest) // NIST curves only
+
+// Verify
+let valid = privateKey.publicKey.isValidSignature(signature, for: data)
+let valid = privateKey.publicKey.isValidSignature(signature, for: digest)
+```
+
+### Signature Representations
+
+```swift
+// NIST curves (P256/P384/P521)
+signature.derRepresentation // Data — use for cross-platform interop
+signature.rawRepresentation // Data — r || s concatenated
+
+// Reconstruct from DER
+let sig = try P256.Signing.ECDSASignature(derRepresentation: derData)
+let sig = try P256.Signing.ECDSASignature(rawRepresentation: rawData)
+
+// Curve25519 — raw bytes only (64 bytes, no DER)
+signature.rawRepresentation
+```
+
+### Cross-Platform Encoding
+
+Use `derRepresentation` when exchanging signatures with non-CryptoKit systems (OpenSSL, Java, Go). Use `rawRepresentation` for CryptoKit-to-CryptoKit or when wire size matters (DER adds 6-8 bytes overhead).
+
+---
+
+## Post-Quantum Cryptography: ML-KEM
+
+Key Encapsulation Mechanism based on Module-Lattice (FIPS 203). iOS 26+.
+
+### Parameter Sets
+
+| Type | Security Level | Public Key | Ciphertext | Shared Secret |
+|------|---------------|------------|------------|---------------|
+| MLKEM768 | 128-bit (AES-128 equivalent) | 1,184 bytes | 1,088 bytes | 32 bytes |
+| MLKEM1024 | 256-bit (AES-256 equivalent) | 1,568 bytes | 1,568 bytes | 32 bytes |
+
+### Key Generation
+
+```swift
+let privateKey = MLKEM768.PrivateKey()
+let publicKey = privateKey.publicKey
+
+let privateKey = MLKEM1024.PrivateKey()
+```
+
+### Encapsulation and Decapsulation
+
+```swift
+// Sender: encapsulate with recipient's public key
+let result = try recipientPublicKey.encapsulate()
+// result.sharedSecret: SymmetricKey (32 bytes)
+// result.encapsulated: Data (ciphertext to send)
+
+// Recipient: decapsulate with private key
+let sharedSecret = try privateKey.decapsulate(result.encapsulated)
+// sharedSecret: SymmetricKey — matches sender's sharedSecret
+```
+
+### Key Representations
+
+```swift
+// Public key
+publicKey.rawRepresentation // Data
+
+// Private key
+privateKey.seedRepresentation // Data (compact seed)
+privateKey.integrityCheckedRepresentation // Data (seed + SHA3-256 hash)
+
+// Reconstruct
+let pk = try MLKEM768.PrivateKey(seedRepresentation: seedData, publicKey: publicKey)
+let pk = try MLKEM768.PrivateKey(integrityCheckedRepresentation: data)
+```
+
+---
+
+## Post-Quantum Cryptography: ML-DSA
+
+Digital Signature Algorithm based on Module-Lattice (FIPS 204). iOS 26+.
+
+### Parameter Sets
+
+| Type | Security Level | Public Key | Signature |
+|------|---------------|------------|-----------|
+| MLDSA65 | 128-bit | 1,952 bytes | 3,309 bytes |
+| MLDSA87 | 256-bit | 2,592 bytes | 4,627 bytes |
+
+### Key Generation
+
+```swift
+let privateKey = MLDSA65.PrivateKey()
+let publicKey = privateKey.publicKey
+
+let privateKey = MLDSA87.PrivateKey()
+```
+
+### Sign and Verify
+
+```swift
+// Sign — returns Data (not a typed Signature struct)
+let signatureData = try privateKey.signature(for: data)
+
+// Sign with context (domain separation)
+let signatureData = try privateKey.signature(for: data, context: contextData)
+
+// Verify — takes DataProtocol for signature parameter
+let valid = publicKey.isValidSignature(signatureData, for: data)
+let valid = publicKey.isValidSignature(signatureData, for: data, context: contextData)
+```
+
+### Key and Signature Representations
+
+```swift
+// Public key
+publicKey.rawRepresentation
+
+// Private key
+privateKey.seedRepresentation
+privateKey.integrityCheckedRepresentation
+
+// Reconstruct
+let pk = try MLDSA65.PrivateKey(seedRepresentation: seedData, publicKey: publicKey)
+let pk = try MLDSA65.PrivateKey(integrityCheckedRepresentation: data)
+
+// Signature is raw Data — no typed Signature struct
+// Store/transmit signatureData directly
+```
+
+---
+
+## Hybrid Post-Quantum: X-Wing KEM
+
+Combines ML-KEM768 + Curve25519 ECDH for hybrid post-quantum key exchange. If either algorithm holds, the combined scheme holds. iOS 26+.
+
+```swift
+let privateKey = XWingMLKEM768X25519.PrivateKey()
+let publicKey = privateKey.publicKey
+
+// Encapsulate
+let result = try publicKey.encapsulate()
+// result.sharedSecret, result.encapsulated
+
+// Decapsulate
+let sharedSecret = try privateKey.decapsulate(result.encapsulated)
+
+// Representations
+publicKey.rawRepresentation
+privateKey.seedRepresentation
+privateKey.integrityCheckedRepresentation
+```
+
+---
+
+## HPKE (Hybrid Public Key Encryption)
+
+Hybrid Public Key Encryption (RFC 9180). Combines KEM + KDF + AEAD into a single encryption scheme. iOS 17+ (classical ciphersuites). Post-quantum ciphersuites (XWing) require iOS 26+.
+
+### Predefined Ciphersuites
+
+| Ciphersuite | KEM | KDF | AEAD |
+|-------------|-----|-----|------|
+| `.XWingMLKEM768X25519_SHA256_AES_GCM_256` | X-Wing | HKDF-SHA256 | AES-256-GCM |
+| `.Curve25519_SHA256_ChachaPoly` | Curve25519 | HKDF-SHA256 | ChaCha20Poly1305 |
+| `.P256_SHA256_AES_GCM_256` | P-256 | HKDF-SHA256 | AES-256-GCM |
+| `.P384_SHA384_AES_GCM_256` | P-384 | HKDF-SHA384 | AES-256-GCM |
+| `.P521_SHA512_AES_GCM_256` | P-521 | HKDF-SHA512 | AES-256-GCM |
+
+### Custom Ciphersuite Composition
+
+```swift
+let ciphersuite = HPKE.Ciphersuite(
+ kem: .Curve25519_HKDF_SHA256,
+ kdf: .HKDF_SHA256,
+ aead: .AES_GCM_128
+)
+```
+
+#### KEM Options
+
+`.Curve25519_HKDF_SHA256`, `.P256_HKDF_SHA256`, `.P384_HKDF_SHA384`, `.P521_HKDF_SHA512`, `.XWingMLKEM768X25519` (iOS 26+)
+
+#### KDF Options
+
+`.HKDF_SHA256`, `.HKDF_SHA384`, `.HKDF_SHA512`
+
+#### AEAD Options
+
+`.AES_GCM_128`, `.AES_GCM_256`, `.chaChaPoly`, `.exportOnly`
+
+### Sender (Encrypt)
+
+```swift
+var sender = try HPKE.Sender(
+ recipientKey: recipientPublicKey,
+ ciphersuite: .Curve25519_SHA256_ChachaPoly,
+ info: infoData // Binding context (can be empty)
+)
+
+let ciphertext = try sender.seal(plaintext)
+let ciphertext = try sender.seal(plaintext, authenticating: aad)
+
+let encapsulatedKey = sender.encapsulatedKey // Send alongside ciphertext
+
+// Export secret (for key derivation without encryption)
+let exported = try sender.exportSecret(context: ctx, outputByteCount: 32)
+```
+
+### Recipient (Decrypt)
+
+```swift
+var recipient = try HPKE.Recipient(
+ privateKey: recipientPrivateKey,
+ ciphersuite: .Curve25519_SHA256_ChachaPoly,
+ info: infoData,
+ encapsulatedKey: encapsulatedKey // From sender
+)
+
+let plaintext = try recipient.open(ciphertext)
+let plaintext = try recipient.open(ciphertext, authenticating: aad)
+
+let exported = try recipient.exportSecret(context: ctx, outputByteCount: 32)
+```
+
+### Additional Modes
+
+Both Sender and Recipient accept optional authentication and PSK parameters:
+
+```swift
+// Authenticated mode — proves sender identity
+var sender = try HPKE.Sender(
+ recipientKey: recipientPublicKey, ciphersuite: ciphersuite, info: infoData,
+ authenticatedBy: senderPrivateKey
+)
+var recipient = try HPKE.Recipient(
+ privateKey: recipientPrivateKey, ciphersuite: ciphersuite, info: infoData,
+ encapsulatedKey: encapsulatedKey, authenticatedBy: senderPublicKey
+)
+
+// PSK mode — adds pre-shared key binding
+// Add to either Sender or Recipient init:
+// presharedKey: psk, // SymmetricKey
+// presharedKeyIdentifier: pskID // Data
+```
+
+### HPKE Error Types
+
+```swift
+HPKE.Errors.inconsistentParameters // Ciphersuite/key mismatch
+HPKE.Errors.inconsistentCiphersuiteAndKey // Key type doesn't match KEM
+HPKE.Errors.exportOnlyMode // Seal/open called in export-only mode
+HPKE.Errors.inconsistentPSKInputs // PSK and PSK ID must both be provided or neither
+HPKE.Errors.expectedPSK // PSK mode requires PSK
+HPKE.Errors.unexpectedPSK // Non-PSK mode given PSK
+HPKE.Errors.outOfRangeSequenceNumber // Sequence number overflow
+HPKE.Errors.ciphertextTooShort // Ciphertext shorter than tag size
+```
+
+---
+
+## Secure Enclave
+
+Hardware-backed key storage. Keys never leave the Secure Enclave chip. Device-bound and non-exportable.
+
+### Availability Check
+
+```swift
+SecureEnclave.isAvailable // false on Simulator, true on devices with SE
+```
+
+### Supported Key Types
+
+| Type | Use |
+|------|-----|
+| `SecureEnclave.P256.Signing.PrivateKey` | ECDSA signatures |
+| `SecureEnclave.P256.KeyAgreement.PrivateKey` | ECDH key agreement |
+| `SecureEnclave.MLKEM768.PrivateKey` | Post-quantum KEM (iOS 26+) |
+| `SecureEnclave.MLKEM1024.PrivateKey` | Post-quantum KEM (iOS 26+) |
+| `SecureEnclave.MLDSA65.PrivateKey` | Post-quantum signatures (iOS 26+) |
+| `SecureEnclave.MLDSA87.PrivateKey` | Post-quantum signatures (iOS 26+) |
+
+### Key Creation
+
+```swift
+let key = try SecureEnclave.P256.Signing.PrivateKey() // Default access control
+
+// With biometric access control
+let accessControl = SecAccessControlCreateWithFlags(
+ nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ [.privateKeyUsage, .biometryCurrentSet], nil
+)!
+let key = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)
+
+// With pre-prompted biometric context
+let context = LAContext()
+context.localizedReason = "Sign transaction"
+let key = try SecureEnclave.P256.Signing.PrivateKey(
+ accessControl: accessControl, authenticationContext: context
+)
+```
+
+### Persistence and Usage
+
+```swift
+// dataRepresentation is an opaque device-bound blob — store in Keychain
+let wrapped = key.dataRepresentation
+let restored = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: wrapped)
+let restored = try SecureEnclave.P256.Signing.PrivateKey(
+ dataRepresentation: wrapped, authenticationContext: context
+)
+
+// SE keys use the same sign/verify/agree API as software keys
+let signature = try seKey.signature(for: data)
+let valid = seKey.publicKey.isValidSignature(signature, for: data)
+let publicKeyData = seKey.publicKey.derRepresentation // Public key IS exportable
+```
+
+---
+
+## Key Derivation (HKDF)
+
+HMAC-based Key Derivation Function (RFC 5869).
+
+### One-Step Derivation
+
+```swift
+let derivedKey = HKDF.deriveKey(
+ inputKeyMaterial: SymmetricKey(data: ikm),
+ salt: saltData, // Optional, can be empty
+ info: infoData, // Context/label
+ outputByteCount: 32
+)
+// derivedKey: SymmetricKey
+```
+
+### Two-Step (Extract + Expand)
+
+Use two-step when deriving multiple keys from the same input: extract once, expand with different `info` values.
+
+```swift
+let prk = HKDF.extract(inputKeyMaterial: SymmetricKey(data: ikm), salt: saltData)
+let encKey = HKDF.expand(pseudoRandomKey: prk, info: Data("enc".utf8), outputByteCount: 32)
+let macKey = HKDF.expand(pseudoRandomKey: prk, info: Data("mac".utf8), outputByteCount: 32)
+```
+
+---
+
+## Error Types
+
+### CryptoKitError
+
+```swift
+CryptoKitError.incorrectKeySize // Key size doesn't match algorithm
+CryptoKitError.incorrectParameterSize // Parameter size invalid
+CryptoKitError.authenticationFailure // GCM/ChaCha tag verification failed, HMAC mismatch
+CryptoKitError.underlyingCoreCryptoError(error:) // Low-level failure
+CryptoKitError.wrapFailure // AES key wrap failed
+CryptoKitError.unwrapFailure // AES key unwrap failed
+```
+
+### CryptoKitASN1Error
+
+```swift
+CryptoKitASN1Error.invalidASN1Object // Malformed ASN.1 structure
+CryptoKitASN1Error.invalidASN1IntegerEncoding // Bad integer encoding
+CryptoKitASN1Error.truncatedASN1Field // Data ends prematurely
+CryptoKitASN1Error.invalidFieldIdentifier // Unknown ASN.1 tag
+CryptoKitASN1Error.unexpectedFieldType // Wrong ASN.1 type
+CryptoKitASN1Error.invalidObjectIdentifier // Bad OID
+CryptoKitASN1Error.invalidPEMDocument // PEM header/footer or Base64 invalid
+```
+
+### HPKE and KEM Errors
+
+```swift
+// HPKE.Errors — see HPKE section for full list of 8 cases
+HPKE.Errors.inconsistentParameters
+HPKE.Errors.ciphertextTooShort
+// ... (6 more)
+
+// KEM.Errors (iOS 26+)
+KEM.Errors.publicKeyMismatchDuringInitialization
+KEM.Errors.invalidSeed
+```
+
+---
+
+## Swift Crypto Cross-Platform Parity
+
+Apple's open-source [swift-crypto](https://github.com/apple/swift-crypto) provides CryptoKit APIs on Linux, Windows, and other platforms.
+
+### Import Difference
+
+```swift
+#if canImport(CryptoKit)
+import CryptoKit
+#else
+import Crypto // swift-crypto package
+#endif
+```
+
+### API Parity
+
+Everything maps 1:1 except `SecureEnclave.*` (requires Apple hardware). Hashing, HMAC, AES-GCM, ChaChaPoly, ECDH, ECDSA/EdDSA, ML-KEM, ML-DSA, X-Wing, HPKE, HKDF, and AES Key Wrap are all available cross-platform.
+
+```swift
+// Package.swift
+.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0")
+// Target: .product(name: "Crypto", package: "swift-crypto")
+```
+
+---
+
+## Resources
+
+**WWDC**: 2019-709, 2024-10120
+
+**Docs**: /cryptokit, /cryptokit/performing-common-cryptographic-operations, /security/certificate-key-and-trust-services/keys/storing-keys-in-the-secure-enclave
+
+**Skills**: axiom-cryptokit
diff --git a/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml b/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml
new file mode 100644
index 0000000..ac14252
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "CryptoKit Reference"
+ short_description: "Needing CryptoKit API details"
diff --git a/.claude/skills/axiom-cryptokit/.openskills.json b/.claude/skills/axiom-cryptokit/.openskills.json
new file mode 100644
index 0000000..cc1a709
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-cryptokit",
+ "installedAt": "2026-04-12T08:06:09.923Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-cryptokit/SKILL.md b/.claude/skills/axiom-cryptokit/SKILL.md
new file mode 100644
index 0000000..fe22e23
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit/SKILL.md
@@ -0,0 +1,431 @@
+---
+name: axiom-cryptokit
+description: Use when encrypting data, signing payloads, verifying signatures, generating keys, using Secure Enclave, migrating from CommonCrypto, or adopting quantum-secure cryptography. Covers CryptoKit design philosophy, AES-GCM, ECDSA, ECDH, Secure Enclave keys, HPKE, ML-KEM, ML-DSA, and cross-platform interop with Swift Crypto.
+license: MIT
+---
+
+# CryptoKit
+
+Authenticated encryption, digital signatures, key agreement, Secure Enclave key management, and quantum-secure cryptography for iOS apps.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Encrypt data at rest or in transit beyond what TLS/Data Protection provides
+- ☑ Sign payloads for integrity verification (receipts, tokens, API requests)
+- ☑ Generate or manage cryptographic keys (including Secure Enclave hardware keys)
+- ☑ Migrate from CommonCrypto C API to CryptoKit
+- ☑ Implement key agreement (ECDH) for end-to-end encryption
+- ☑ Use HPKE for modern asymmetric encryption
+- ☑ Adopt quantum-secure algorithms (ML-KEM, ML-DSA) for post-quantum readiness
+- ☑ Interoperate with server-side Swift Crypto or non-Apple platforms
+
+## Example Prompts
+
+"How do I encrypt user data with AES-GCM?"
+"How do I sign a payload and verify it on my server?"
+"How do I use the Secure Enclave to protect a signing key?"
+"I'm using CommonCrypto — should I migrate to CryptoKit?"
+"How do I do ECDH key agreement for end-to-end encryption?"
+"How do I make my app quantum-secure?"
+"My server can't verify signatures from my iOS app"
+"What's the difference between P256 and Curve25519?"
+
+## Red Flags
+
+Signs you're making this harder or less secure than it needs to be:
+
+- ❌ Using CommonCrypto C API when CryptoKit exists — Buffer management nightmares, no authenticated encryption, manual IV handling. CryptoKit provides memory-safe, authenticated encryption in one call. Migration takes minutes per call site.
+- ❌ AES-CBC without authentication — Ciphertext is malleable. Padding oracle attacks let an attacker decrypt data one byte at a time without the key. AES-GCM (CryptoKit's default) provides authenticated encryption — tampering is detected automatically.
+- ❌ Rolling your own crypto protocol — Timing attacks, padding oracles, nonce reuse. Unless you are a professional cryptographer, use CryptoKit's high-level APIs. Apple's security team designed them to prevent exactly these mistakes.
+- ❌ Ignoring Secure Enclave for signing keys — "Too complex" is wrong. The SE API is nearly identical to software P256. SE keys survive jailbreaks. If the key protects money, health data, or identity, SE is the correct choice at zero extra effort.
+- ❌ Using AES-128 when AES-256 costs nothing extra — Grover's algorithm halves symmetric key strength on quantum computers. AES-128 drops to 64-bit security. AES-256 drops to 128-bit — still safe. The performance difference is negligible.
+- ❌ Force-unwrapping crypto operations — `AES.GCM.open` and signature verification throw on failure. Force-unwrapping masks authentication failures and turns security errors into crashes. Always use `try`/`catch`.
+- ❌ Storing raw private keys in UserDefaults — Readable from device backups, accessible on jailbroken devices, visible in plaintext in the app's plist. Use Keychain for software keys, Secure Enclave for hardware-bound keys.
+
+## Do You Actually Need CryptoKit?
+
+```dot
+digraph need_cryptokit {
+ "What are you\nprotecting?" [shape=diamond];
+ "Data in transit\nvia HTTPS?" [shape=diamond];
+ "Data at rest\non device?" [shape=diamond];
+ "Credentials or\ntokens?" [shape=diamond];
+ "CloudKit or\niCloud?" [shape=diamond];
+ "Custom crypto\nneeded?" [shape=diamond];
+
+ "Use URLSession + TLS\n(system handles crypto)" [shape=box];
+ "Use Data Protection\n(.completeFileProtection)" [shape=box];
+ "Use Keychain\n(axiom-keychain skill)" [shape=box];
+ "Use CloudKit encryption\n(encryptedValues)" [shape=box];
+ "YES — Use CryptoKit" [shape=box, style=bold];
+
+ "What are you\nprotecting?" -> "Data in transit\nvia HTTPS?" [label="network"];
+ "What are you\nprotecting?" -> "Data at rest\non device?" [label="storage"];
+ "What are you\nprotecting?" -> "Credentials or\ntokens?" [label="secrets"];
+ "What are you\nprotecting?" -> "CloudKit or\niCloud?" [label="sync"];
+ "What are you\nprotecting?" -> "Custom crypto\nneeded?" [label="signatures, key exchange,\nE2E encryption"];
+
+ "Data in transit\nvia HTTPS?" -> "Use URLSession + TLS\n(system handles crypto)" [label="standard HTTPS"];
+ "Data in transit\nvia HTTPS?" -> "Custom crypto\nneeded?" [label="need E2E beyond TLS"];
+ "Data at rest\non device?" -> "Use Data Protection\n(.completeFileProtection)" [label="file-level"];
+ "Data at rest\non device?" -> "Custom crypto\nneeded?" [label="field-level encryption"];
+ "Credentials or\ntokens?" -> "Use Keychain\n(axiom-keychain skill)" [label="yes"];
+ "CloudKit or\niCloud?" -> "Use CloudKit encryption\n(encryptedValues)" [label="yes"];
+ "Custom crypto\nneeded?" -> "YES — Use CryptoKit" [label="yes"];
+}
+```
+
+From WWDC 2019-709: "We strongly recommend you rely on higher level system frameworks when you can." CryptoKit is for when system frameworks don't cover your use case — custom signatures, field-level encryption, key agreement, interop with non-Apple systems.
+
+## Secure Enclave Key Management
+
+The Secure Enclave is a hardware security module built into Apple devices. Keys generated in the SE never leave the hardware — not even Apple can extract them.
+
+### When to Use Secure Enclave
+
+| Scenario | Use SE? | Why |
+|----------|---------|-----|
+| Signing API requests | Yes | Key can't be extracted from device |
+| Biometric-gated decryption | Yes | SE ties key to Face ID/Touch ID |
+| End-to-end encryption key | Yes (key agreement) | Private key hardware-bound |
+| Encrypting data for another device | No | Key must be exportable |
+| Server-shared symmetric key | No | SE only does asymmetric (P256, ML-KEM, ML-DSA) |
+| Cross-device sync | No | SE keys are device-bound |
+
+### SE Key Generation with Biometric Gating
+
+```swift
+import CryptoKit
+import LocalAuthentication
+
+guard SecureEnclave.isAvailable else {
+ let softwareKey = P256.Signing.PrivateKey()
+ return
+}
+
+let context = LAContext()
+context.localizedReason = "Authenticate to sign transaction"
+
+guard let accessControl = SecAccessControlCreateWithFlags(
+ nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ [.privateKeyUsage, .biometryCurrentSet], nil
+) else {
+ throw CryptoKitError.underlyingCoreCryptoError(error: 0)
+}
+
+let seKey = try SecureEnclave.P256.Signing.PrivateKey(
+ compactRepresentable: false,
+ accessControl: accessControl,
+ authenticationContext: .init(context)
+)
+
+let signature = try seKey.signature(for: data)
+let publicKeyDER = seKey.publicKey.derRepresentation
+```
+
+### SE Key Lifecycle
+
+`seKey.dataRepresentation` is a **wrapped blob**, not raw key material. It contains an encrypted reference that only this specific Secure Enclave hardware can unwrap. Exporting it to another device produces an unusable blob.
+
+- **Survives**: App updates, device reboots
+- **Does NOT survive**: App reinstall, device restore, device erase, migration to new device
+- **Persistence**: Store `dataRepresentation` in Keychain. Restore with:
+
+```swift
+let restoredKey = try SecureEnclave.P256.Signing.PrivateKey(
+ dataRepresentation: savedKeyData, authenticationContext: .init(context)
+)
+```
+
+### SE Constraints
+
+- **Classical**: P256 only — no Curve25519, no P384/P521. One curve, two purposes: `SecureEnclave.P256.Signing` and `SecureEnclave.P256.KeyAgreement`
+- **Post-quantum (iOS 26+)**: `SecureEnclave.MLKEM768`, `SecureEnclave.MLKEM1024`, `SecureEnclave.MLDSA65`, `SecureEnclave.MLDSA87`
+- Signing and key agreement only — no direct encryption
+- Simulator has no SE — `SecureEnclave.isAvailable` returns false. Always provide a software fallback for testing.
+
+## Common Workflows
+
+### Hash Data Integrity
+
+```swift
+import CryptoKit
+
+let data = "Hello, world".data(using: .utf8)!
+let digest = SHA256.hash(data: data)
+guard digest == SHA256.hash(data: data) else { /* tampering detected */ }
+```
+
+Available: `SHA256`, `SHA384`, `SHA512`. Use `Insecure.MD5`/`Insecure.SHA1` only for legacy protocol compatibility, never for security.
+
+### HMAC Message Authentication
+
+```swift
+let key = SymmetricKey(size: .bits256)
+let mac = HMAC.authenticationCode(for: data, using: key)
+let isValid = HMAC.isValidAuthenticationCode(mac, authenticating: data, using: key)
+```
+
+Constant-time comparison built in — safe against timing attacks. Use HMAC when both parties share a symmetric key.
+
+### AES-GCM Encrypt/Decrypt
+
+```swift
+let key = SymmetricKey(size: .bits256)
+let plaintext = "Secret message".data(using: .utf8)!
+
+let sealedBox = try AES.GCM.seal(plaintext, using: key)
+let combined = sealedBox.combined!
+
+let restoredBox = try AES.GCM.SealedBox(combined: combined)
+let decrypted = try AES.GCM.open(restoredBox, using: key)
+```
+
+Authenticated encryption — tampering triggers `CryptoKitError.authenticationFailure`. No separate HMAC step needed. Nonce is auto-generated and prepended to `combined`.
+
+### ECDSA Sign/Verify
+
+```swift
+let privateKey = P256.Signing.PrivateKey()
+let signature = try privateKey.signature(for: data)
+let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
+```
+
+Curves: `P256` (secp256r1, most common), `P384`, `P521`, `Curve25519` (Ed25519, fastest). Use P256 for server interop. Use Curve25519 when you control both sides.
+
+### ECDH Key Agreement
+
+```swift
+let alicePrivate = P256.KeyAgreement.PrivateKey()
+let bobPrivate = P256.KeyAgreement.PrivateKey()
+
+let sharedSecret = try alicePrivate.sharedSecretFromKeyAgreement(
+ with: bobPrivate.publicKey
+)
+
+let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(
+ using: SHA256.self,
+ salt: "my-app-v1".data(using: .utf8)!,
+ sharedInfo: Data(),
+ outputByteCount: 32
+)
+```
+
+Never use the raw shared secret directly — always derive a symmetric key via HKDF. The raw secret has biased bits that weaken encryption.
+
+### HPKE End-to-End Encryption
+
+HPKE (Hybrid Public Key Encryption) combines key agreement and symmetric encryption in one step. Preferred over manual ECDH + AES-GCM for new protocols. Classical ciphersuites (P256, Curve25519) available iOS 17+. The quantum-secure XWing ciphersuite and ML-KEM/ML-DSA require iOS 26+.
+
+```swift
+let recipientPrivate = P256.KeyAgreement.PrivateKey()
+
+var sender = try HPKE.Sender(
+ recipientKey: recipientPrivate.publicKey,
+ ciphersuite: .P256_SHA256_AES_GCM_256,
+ info: "my-app-message-v1".data(using: .utf8)!
+)
+let ciphertext = try sender.seal(plaintext)
+
+var recipient = try HPKE.Recipient(
+ privateKey: recipientPrivate,
+ ciphersuite: .P256_SHA256_AES_GCM_256,
+ info: "my-app-message-v1".data(using: .utf8)!,
+ encapsulatedKey: sender.encapsulatedKey
+)
+let decrypted = try recipient.open(ciphertext)
+```
+
+## Quantum-Secure Migration
+
+From WWDC 2025-314: harvest-now-decrypt-later is not theoretical. Nation-state actors are recording encrypted traffic today to decrypt with future quantum computers. Data with sensitivity beyond 10 years needs post-quantum protection now.
+
+### Migration Priority
+
+1. **Quantum-secure TLS (automatic)** — iOS 26 URLSession and Network.framework use quantum-secure TLS by default. No code changes needed. iMessage has used PQ3 since iOS 17.4.
+2. **Custom protocol encryption** — If your app uses manual ECDH + AES or custom key exchange, those channels are NOT automatically upgraded. Replace with post-quantum HPKE.
+3. **Symmetric key upgrade** — Upgrade AES-128 to AES-256. Grover's algorithm halves symmetric key strength, so 256-bit keys provide 128-bit post-quantum security.
+
+### Post-Quantum HPKE (iOS 26+)
+
+Same HPKE API, different ciphersuite. Combines ML-KEM (quantum-safe) with X25519 (classical) for hybrid security — Apple's recommendation:
+
+```swift
+let recipientPrivate = XWingMLKEM768X25519.PrivateKey()
+
+var sender = try HPKE.Sender(
+ recipientKey: recipientPrivate.publicKey,
+ ciphersuite: .XWingMLKEM768X25519_SHA256_AES_GCM_256,
+ info: "my-app-pq-v1".data(using: .utf8)!
+)
+let ciphertext = try sender.seal(plaintext)
+
+var recipient = try HPKE.Recipient(
+ privateKey: recipientPrivate,
+ ciphersuite: .XWingMLKEM768X25519_SHA256_AES_GCM_256,
+ info: "my-app-pq-v1".data(using: .utf8)!,
+ encapsulatedKey: sender.encapsulatedKey
+)
+let decrypted = try recipient.open(ciphertext)
+```
+
+Hybrid constructions (classical + post-quantum) ensure security even if one algorithm is broken. The XWing construction pairs ML-KEM768 with X25519 so a classical break alone or a quantum break alone cannot compromise the exchange.
+
+### ML-KEM and ML-DSA Direct Usage (iOS 26+)
+
+```swift
+let kemPrivate = try MLKEM768.PrivateKey()
+let (sharedSecret, encapsulation) = try kemPrivate.publicKey.encapsulate()
+let derivedSecret = try kemPrivate.decapsulate(encapsulation)
+
+let dsaPrivate = try MLDSA65.PrivateKey()
+let signature = try dsaPrivate.signature(for: data)
+let isValid = dsaPrivate.publicKey.isValidSignature(signature, for: data)
+```
+
+Use ML-KEM for key encapsulation when building custom protocols. Use ML-DSA for signatures when P256/Ed25519 won't survive quantum analysis. Prefer HPKE with hybrid ciphersuites over raw ML-KEM for most applications.
+
+## Cross-Platform Considerations
+
+### Swift Crypto Parity
+
+On Linux/server: `import Crypto` (apple/swift-crypto) provides the same API minus Secure Enclave and Keychain. For shared code:
+
+```swift
+#if canImport(CryptoKit)
+import CryptoKit
+#else
+import Crypto
+#endif
+```
+
+### Signature Encoding Interop
+
+CryptoKit's ECDSA signatures use **raw format** (r || s concatenation, 64 bytes for P256). Most non-Apple platforms (OpenSSL, Java, .NET) expect **DER format** (ASN.1 encoded, variable length 70-72 bytes for P256).
+
+```swift
+let signature = try privateKey.signature(for: data)
+
+let raw = signature.rawRepresentation
+let der = signature.derRepresentation
+```
+
+When receiving signatures from a server:
+
+```swift
+let fromDER = try P256.Signing.ECDSASignature(derRepresentation: serverDER)
+let fromRaw = try P256.Signing.ECDSASignature(rawRepresentation: serverRaw)
+```
+
+### Key Export for Server Verification
+
+```swift
+let publicKey = privateKey.publicKey
+publicKey.derRepresentation
+publicKey.pemRepresentation
+publicKey.x963Representation
+```
+
+DER for most platforms, PEM for config files and REST APIs, X9.63 for compact JavaScript interop.
+
+### secp256r1 vs secp256k1
+
+CryptoKit uses **secp256r1** (P-256, prime256v1) — the NIST standard curve used by TLS, government systems, and enterprise software.
+
+Bitcoin and Ethereum use **secp256k1** (Koblitz curve) — a different curve entirely. These are **not interoperable**. A P-256 signature cannot be verified with a secp256k1 verifier. If you need secp256k1 for blockchain interop, use a dedicated library (libsecp256k1 wrapper), not CryptoKit.
+
+### SE Keys and Cross-Platform
+
+Secure Enclave keys are device-bound. `dataRepresentation` is a wrapped blob that only the originating hardware can unwrap. This means SE keys enable crypto-shredding by design — destroying the device or reinstalling the app makes all SE-encrypted data permanently irrecoverable. Plan key lifecycle accordingly. Store recovery paths (server-escrowed backup keys, multi-device key distribution) if data must survive device loss.
+
+## Anti-Rationalization
+
+| Rationalization | Why It Fails | Time Cost |
+|-----------------|--------------|-----------|
+| "CommonCrypto works fine, no need to migrate" | Buffer overflows, no authenticated encryption, manual IV management. One wrong buffer size = silent data corruption or security vulnerability. | 2-4 hours debugging subtle encryption failures that CryptoKit prevents by design |
+| "I'll add authentication to AES-CBC later" | Without authentication, ciphertext is malleable. Attackers modify data without detection. "Later" means after the vulnerability ships. | 4-8 hours incident response when tampered data is discovered in production |
+| "Nonces don't need to be random for my use case" | Nonce reuse with AES-GCM leaks plaintext via XOR of ciphertexts. There is no use case where fixed nonces are safe with GCM. | Catastrophic — full plaintext recovery of all messages encrypted with the reused nonce |
+| "Secure Enclave is overkill for this" | Software keys can be extracted from jailbroken devices and backups. SE keys cannot. If the key protects money, health data, or identity, SE is not overkill. | 0 extra development time (API is nearly identical to software P256) |
+| "Quantum computing is decades away" | Harvest-now-decrypt-later means data recorded today will be decrypted when quantum computers arrive. Apple already ships quantum-secure TLS in iOS 26. | 0 if you use iOS 26 defaults. 1-2 hours for custom protocol migration. |
+| "I'll just use my own encryption scheme" | Professional cryptographers spend years designing protocols. Timing side channels, padding oracles, nonce misuse are invisible without expert review. | Weeks to months of security audit + remediation |
+| "DER vs raw doesn't matter, it's the same signature" | Wrong encoding = server verification failure. The math is correct but the encoding is wrong. | 2-4 hours debugging interop that one `.derRepresentation` call prevents |
+
+## Pressure Scenarios
+
+### Scenario 1: "Just encrypt it with whatever, we ship today"
+
+**Context**: Feature deadline approaching. Developer needs to encrypt user data before persisting it.
+
+**Pressure**: "Don't overthink the crypto. AES-CBC, CommonCrypto, whatever gets it done by end of day."
+
+**Reality**: AES-CBC without authentication is vulnerable to padding oracle attacks. CommonCrypto requires manual buffer management where one wrong size creates silent data corruption. The migration from `CCCrypt` to `AES.GCM.seal` is 5-10 lines of code — less code than the CommonCrypto version — and eliminates an entire vulnerability class.
+
+**Correct action**: Replace `CCCrypt(kCCEncrypt, kCCAlgorithmAES, ...)` with `AES.GCM.seal(plaintext, using: key)`. Map existing key material to `SymmetricKey(data:)`. If migrating existing CBC-encrypted data, add a read path that decrypts old format and re-encrypts on first access.
+
+**Push-back template**: "AES-GCM via CryptoKit is actually fewer lines than CommonCrypto and provides authenticated encryption for free. The CBC code has a vulnerability class that GCM eliminates. Switching takes 10 minutes, not hours."
+
+### Scenario 2: "We need cross-platform, skip Secure Enclave"
+
+**Context**: Building an E2E encrypted feature that must work on iOS and Android. Developer proposes storing signing keys in Keychain (software) to keep parity with Android's software keystore.
+
+**Pressure**: "Android doesn't have a Secure Enclave equivalent with the same guarantees. Let's keep it simple and consistent across platforms."
+
+**Reality**: Android has hardware-backed Keystore (StrongBox) with similar guarantees. Even if the Android side uses software keys, that doesn't mean the iOS side should. SE protection is free on iOS — the API is nearly identical to software P256. Use SE on iOS, hardware Keystore on Android, software fallback where hardware is unavailable.
+
+**Correct action**: Use `SecureEnclave.P256.Signing.PrivateKey` with a fallback to `P256.Signing.PrivateKey`. The public key and signature formats are identical — the server doesn't know or care which generated them.
+
+**Push-back template**: "The SE API is the same as software P256 — no extra complexity. The server verifies the same public key format either way. We get hardware protection on devices that support it and graceful fallback on devices that don't. Both platforms can use their best available hardware."
+
+### Scenario 3: "Quantum-safe is premature optimization"
+
+**Context**: Designing a new E2E encrypted messaging protocol. Developer proposes classical ECDH + AES-GCM.
+
+**Pressure**: "Quantum computers are at least a decade away. We're over-engineering this."
+
+**Reality**: Harvest-now-decrypt-later means adversaries record encrypted traffic today and decrypt it when quantum computers arrive. For ephemeral data (session tokens), classical crypto is fine. For messages with long-term sensitivity (health records, financial data, private communications), post-quantum protection is warranted now. Apple already shipped PQ3 for iMessage and quantum-secure TLS in iOS 26.
+
+**Correct action**: Use `HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256` instead of `.P256_SHA256_AES_GCM_256`. The code change is one ciphersuite constant — same API, same complexity, hybrid classical + quantum-safe protection.
+
+**Push-back template**: "The code change is literally one ciphersuite constant. Apple already did this for iMessage (PQ3) and iOS 26 TLS. One line of code removes the harvest-now-decrypt-later risk for data that's still sensitive in 10 years."
+
+## Checklist
+
+Before shipping any custom cryptography:
+
+**Algorithm Selection**:
+- [ ] Using authenticated encryption (AES-GCM, not AES-CBC/ECB)
+- [ ] Hash function is SHA256+ (not MD5/SHA1) for security purposes
+- [ ] Curve appropriate for use case (P256 for interop, Curve25519 for performance)
+- [ ] Post-quantum ciphersuite considered for long-term sensitive data
+
+**Key Management**:
+- [ ] Private keys stored in Secure Enclave (if hardware available and key doesn't need export)
+- [ ] Fallback to Keychain with appropriate access control if SE unavailable
+- [ ] No private keys in UserDefaults, files, or hardcoded in source
+- [ ] Key rotation strategy defined for long-lived keys
+
+**Nonce/IV Handling**:
+- [ ] Using CryptoKit's automatic nonce generation (not custom)
+- [ ] Not reusing nonces across encryptions
+- [ ] Not storing or hardcoding nonces
+
+**Interop** (if communicating with non-Apple platforms):
+- [ ] Signature encoding matches server expectation (DER vs raw)
+- [ ] Public key format agreed upon (DER, PEM, or X9.63)
+- [ ] Curve matches server (secp256r1 not secp256k1)
+- [ ] Testing with actual server verification, not just local round-trip
+
+**Secure Enclave** (if used):
+- [ ] `SecureEnclave.isAvailable` checked with software fallback
+- [ ] `dataRepresentation` stored in Keychain for persistence
+- [ ] Access control flags match UX (biometric per-use vs session-based)
+- [ ] Key lifecycle documented (does not survive reinstall/restore)
+
+## Resources
+
+**WWDC**: 2019-709, 2025-314
+
+**Docs**: /cryptokit, /cryptokit/secureenclave, /security/certificate_key_and_trust_services
+
+**Skills**: axiom-cryptokit-ref, axiom-keychain
diff --git a/.claude/skills/axiom-cryptokit/agents/openai.yaml b/.claude/skills/axiom-cryptokit/agents/openai.yaml
new file mode 100644
index 0000000..cfd3ff9
--- /dev/null
+++ b/.claude/skills/axiom-cryptokit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "CryptoKit"
+ short_description: "Encrypting data, signing payloads, verifying signatures, generating keys, using Secure Enclave, migrating from Common..."
diff --git a/.claude/skills/axiom-database-migration/.openskills.json b/.claude/skills/axiom-database-migration/.openskills.json
new file mode 100644
index 0000000..21d6497
--- /dev/null
+++ b/.claude/skills/axiom-database-migration/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-database-migration",
+ "installedAt": "2026-04-12T08:06:11.159Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-database-migration/SKILL.md b/.claude/skills/axiom-database-migration/SKILL.md
new file mode 100644
index 0000000..321e7e7
--- /dev/null
+++ b/.claude/skills/axiom-database-migration/SKILL.md
@@ -0,0 +1,442 @@
+---
+name: axiom-database-migration
+description: Use when adding/modifying database columns, encountering "FOREIGN KEY constraint failed", "no such column", "cannot add NOT NULL column" errors, or creating schema migrations for SQLite/GRDB/SQLiteData - prevents data loss with safe migration patterns and testing workflows for iOS/macOS apps
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Database Migration
+
+## Overview
+
+Safe database schema evolution for production apps with user data. **Core principle** Migrations are immutable after shipping. Make them additive, idempotent, and thoroughly tested.
+
+## Example Prompts
+
+These are real questions developers ask that this skill is designed to answer:
+
+#### 1. "I need to add a new column to store user preferences, but the app is already live with user data. How do I do this safely?"
+→ The skill covers safe additive patterns for adding columns without losing existing data, including idempotency checks
+
+#### 2. "I'm getting 'cannot add NOT NULL column' errors when I try to migrate. What does this mean and how do I fix it?"
+→ The skill explains why NOT NULL columns fail with existing rows, and shows the safe pattern (nullable first, backfill later)
+
+#### 3. "I need to change a column from text to integer. Can I just ALTER the column type?"
+→ The skill demonstrates the safe pattern: add new column → migrate data → deprecate old (NEVER delete)
+
+#### 4. "I'm adding a foreign key relationship between tables. How do I add the relationship without breaking existing data?"
+→ The skill covers safe foreign key patterns: add column → populate data → add index (SQLite limitations explained)
+
+#### 5. "Users are reporting crashes after the last update. I changed a migration but the app is already in production. What do I do?"
+→ The skill explains migrations are immutable after shipping; shows how to create a new migration to fix the issue rather than modifying the old one
+
+---
+
+## ⛔ NEVER Do These (Data Loss Risk)
+
+#### These actions DESTROY user data in production
+
+❌ **NEVER use DROP TABLE** with user data
+❌ **NEVER modify shipped migrations** (create new one instead)
+❌ **NEVER recreate tables** to change schema (loses data)
+❌ **NEVER add NOT NULL column** without DEFAULT value
+❌ **NEVER delete columns** (SQLite doesn't support DROP COLUMN safely)
+
+#### If you're tempted to do any of these, STOP and use the safe patterns below.
+
+## Mandatory Rules
+
+#### ALWAYS follow these
+
+1. **Additive only** Add new columns/tables, never delete
+2. **Idempotent** Check existence before creating (safe to run twice)
+3. **Transactional** Wrap entire migration in single transaction
+4. **Test both paths** Fresh install AND migration from previous version
+5. **Nullable first** Add columns as NULL, backfill later if needed
+6. **Immutable** Once shipped to users, migrations cannot be changed
+
+## Safe Patterns
+
+### Adding Column (Most Common)
+
+```swift
+// ✅ Safe pattern
+func migration00X_AddNewColumn() throws {
+ try database.write { db in
+ // 1. Check if column exists (idempotency)
+ let hasColumn = try db.columns(in: "tableName")
+ .contains { $0.name == "newColumn" }
+
+ if !hasColumn {
+ // 2. Add as nullable (works with existing rows)
+ try db.execute(sql: """
+ ALTER TABLE tableName
+ ADD COLUMN newColumn TEXT
+ """)
+ }
+ }
+}
+```
+
+#### Why this works
+- Nullable columns don't require DEFAULT
+- Existing rows get NULL automatically
+- No data transformation needed
+- Safe for users upgrading from old versions
+
+### Adding Column with Default Value
+
+```swift
+// ✅ Safe pattern with default
+func migration00X_AddColumnWithDefault() throws {
+ try database.write { db in
+ let hasColumn = try db.columns(in: "tracks")
+ .contains { $0.name == "playCount" }
+
+ if !hasColumn {
+ try db.execute(sql: """
+ ALTER TABLE tracks
+ ADD COLUMN playCount INTEGER DEFAULT 0
+ """)
+ }
+ }
+}
+```
+
+### Changing Column Type (Advanced)
+
+**Pattern**: Add new column → migrate data → deprecate old (NEVER delete)
+
+```swift
+// ✅ Safe pattern for type change
+func migration00X_ChangeColumnType() throws {
+ try database.write { db in
+ // Step 1: Add new column with new type
+ try db.execute(sql: """
+ ALTER TABLE users
+ ADD COLUMN age_new INTEGER
+ """)
+
+ // Step 2: Migrate existing data
+ try db.execute(sql: """
+ UPDATE users
+ SET age_new = CAST(age_old AS INTEGER)
+ WHERE age_old IS NOT NULL
+ """)
+
+ // Step 3: Application code uses age_new going forward
+ // (Never delete age_old column - just stop using it)
+ }
+}
+```
+
+### Adding Foreign Key Constraint
+
+```swift
+// ✅ Safe pattern for foreign keys
+func migration00X_AddForeignKey() throws {
+ try database.write { db in
+ // Step 1: Add new column (nullable initially)
+ try db.execute(sql: """
+ ALTER TABLE tracks
+ ADD COLUMN album_id TEXT
+ """)
+
+ // Step 2: Populate the data
+ try db.execute(sql: """
+ UPDATE tracks
+ SET album_id = (
+ SELECT id FROM albums
+ WHERE albums.title = tracks.album_name
+ )
+ """)
+
+ // Step 3: Add index (helps query performance)
+ try db.execute(sql: """
+ CREATE INDEX IF NOT EXISTS idx_tracks_album_id
+ ON tracks(album_id)
+ """)
+
+ // Note: SQLite doesn't allow adding FK constraints to existing tables
+ // The foreign key relationship is enforced at the application level
+ }
+}
+```
+
+### Complex Schema Refactoring
+
+**Pattern**: Break into multiple migrations
+
+```swift
+// Migration 1: Add new structure
+func migration010_AddNewTable() throws {
+ try database.write { db in
+ try db.execute(sql: """
+ CREATE TABLE IF NOT EXISTS new_structure (
+ id TEXT PRIMARY KEY,
+ data TEXT
+ )
+ """)
+ }
+}
+
+// Migration 2: Copy data
+func migration011_MigrateData() throws {
+ try database.write { db in
+ try db.execute(sql: """
+ INSERT INTO new_structure (id, data)
+ SELECT id, data FROM old_structure
+ """)
+ }
+}
+
+// Migration 3: Add indexes
+func migration012_AddIndexes() throws {
+ try database.write { db in
+ try db.execute(sql: """
+ CREATE INDEX IF NOT EXISTS idx_new_structure_data
+ ON new_structure(data)
+ """)
+ }
+}
+
+// Old structure stays around (deprecated in code)
+```
+
+## Testing Checklist
+
+#### BEFORE deploying any migration
+
+```swift
+// Test 1: Migration path (CRITICAL - tests data preservation)
+@Test func migrationFromV1ToV2Succeeds() async throws {
+ let db = try Database(inMemory: true)
+
+ // Simulate v1 schema
+ try db.write { db in
+ try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)")
+ try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')")
+ }
+
+ // Run v2 migration
+ try db.runMigrations()
+
+ // Verify data survived + new column exists
+ try db.read { db in
+ let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
+ #expect(count == 1) // Data preserved
+
+ let columns = try db.columns(in: "tableName").map { $0.name }
+ #expect(columns.contains("newColumn")) // New column exists
+ }
+}
+```
+
+**Test 2** Fresh install (run all migrations, verify final schema)
+```swift
+@Test func freshInstallCreatesCorrectSchema() async throws {
+ let db = try Database(inMemory: true)
+
+ // Run all migrations
+ try db.runMigrations()
+
+ // Verify final schema
+ try db.read { db in
+ let tables = try db.tables()
+ #expect(tables.contains("tableName"))
+
+ let columns = try db.columns(in: "tableName").map { $0.name }
+ #expect(columns.contains("id"))
+ #expect(columns.contains("newColumn"))
+ }
+}
+```
+
+**Test 3** Idempotency (run migrations twice, should not throw)
+```swift
+@Test func migrationsAreIdempotent() async throws {
+ let db = try Database(inMemory: true)
+
+ // Run migrations twice
+ try db.runMigrations()
+ try db.runMigrations() // Should not throw
+
+ // Verify still correct
+ try db.read { db in
+ let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName")
+ #expect(count == 0) // No duplicate data
+ }
+}
+```
+
+#### Manual testing (before TestFlight)
+1. Install v(n-1) build on device → add real user data
+2. Install v(n) build (with new migration)
+3. Verify: App launches, data visible, no crashes
+
+## Decision Tree
+
+```
+What are you trying to do?
+├─ Add new column?
+│ └─ ALTER TABLE ADD COLUMN (nullable) → Done
+├─ Add column with default?
+│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done
+├─ Change column type?
+│ └─ Add new column → Migrate data → Deprecate old → Done
+├─ Delete column?
+│ └─ Mark as deprecated in code → Never delete from schema → Done
+├─ Rename column?
+│ └─ Add new column → Migrate data → Deprecate old → Done
+├─ Add foreign key?
+│ └─ Add column → Populate data → Add index → Done
+└─ Complex refactor?
+ └─ Break into multiple migrations → Test each step → Done
+```
+
+## Common Errors
+
+| Error | Fix |
+|-------|-----|
+| `FOREIGN KEY constraint failed` | Check parent row exists, or disable FK temporarily |
+| `no such column: columnName` | Add migration to create column |
+| `cannot add NOT NULL column` | Use nullable column first, backfill in separate migration |
+| `table tableName already exists` | Add `IF NOT EXISTS` clause |
+| `duplicate column name` | Check if column exists before adding (idempotency) |
+
+## Common Mistakes
+
+❌ **Adding NOT NULL without DEFAULT**
+```swift
+// ❌ Fails on existing data
+ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL
+```
+
+✅ **Correct: Add as nullable first**
+```swift
+ALTER TABLE albums ADD COLUMN rating INTEGER // NULL allowed
+// Backfill in separate migration if needed
+UPDATE albums SET rating = 0 WHERE rating IS NULL
+```
+
+❌ **Forgetting to check for existence** — Always add `IF NOT EXISTS` or manual check
+
+❌ **Modifying shipped migrations** — Create new migration instead
+
+❌ **Not testing migration path** — Always test upgrade from previous version
+
+## GRDB-Specific Patterns
+
+### DatabaseMigrator Setup
+
+```swift
+var migrator = DatabaseMigrator()
+
+// Migration 1
+migrator.registerMigration("v1") { db in
+ try db.execute(sql: """
+ CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL
+ )
+ """)
+}
+
+// Migration 2
+migrator.registerMigration("v2") { db in
+ let hasColumn = try db.columns(in: "users")
+ .contains { $0.name == "email" }
+
+ if !hasColumn {
+ try db.execute(sql: """
+ ALTER TABLE users
+ ADD COLUMN email TEXT
+ """)
+ }
+}
+
+// Apply migrations
+try migrator.migrate(dbQueue)
+```
+
+### Checking Migration Status
+
+```swift
+// Check which migrations have been applied
+let appliedMigrations = try dbQueue.read { db in
+ try migrator.appliedMigrations(db)
+}
+print("Applied migrations: \(appliedMigrations)")
+
+// Check if migrations are needed
+let hasBeenMigrated = try dbQueue.read { db in
+ try migrator.hasBeenMigrated(db)
+}
+```
+
+## SwiftData Migrations
+
+For SwiftData (iOS 17+), use `VersionedSchema` and `SchemaMigrationPlan`:
+
+```swift
+// Define schema versions
+enum MyAppSchemaV1: VersionedSchema {
+ static var versionIdentifier = Schema.Version(1, 0, 0)
+ static var models: [any PersistentModel.Type] {
+ [Track.self, Album.self]
+ }
+}
+
+enum MyAppSchemaV2: VersionedSchema {
+ static var versionIdentifier = Schema.Version(2, 0, 0)
+ static var models: [any PersistentModel.Type] {
+ [Track.self, Album.self, Playlist.self] // Added Playlist
+ }
+}
+
+// Define migration plan
+enum MyAppMigrationPlan: SchemaMigrationPlan {
+ static var schemas: [any VersionedSchema.Type] {
+ [MyAppSchemaV1.self, MyAppSchemaV2.self]
+ }
+
+ static var stages: [MigrationStage] {
+ [migrateV1toV2]
+ }
+
+ static let migrateV1toV2 = MigrationStage.custom(
+ fromVersion: MyAppSchemaV1.self,
+ toVersion: MyAppSchemaV2.self,
+ willMigrate: nil,
+ didMigrate: { context in
+ // Custom migration logic here
+ }
+ )
+}
+```
+
+## Real-World Impact
+
+**Before** Developer adds NOT NULL column → migration fails for 50% of users → emergency rollback → data inconsistency
+
+**After** Developer adds nullable column → tests both paths → smooth deployment → backfills data in v2
+
+**Key insight** Migrations can't be rolled back in production. Get them right the first time through thorough testing.
+
+## tvOS
+
+**tvOS migrations may run against a fresh database.** The system deletes local storage under pressure, so your app may launch with no database at all. Migrations must handle this gracefully — they effectively become both "create" and "upgrade" operations.
+
+**Key implications**:
+- Migrations must be idempotent (already a best practice, but critical here)
+- Don't assume previous data exists for backfill operations
+- Test the "fresh install" path as often as the "upgrade" path
+
+See `axiom-tvos` for full tvOS storage constraints.
+
+---
+
+**Last Updated**: 2025-11-28
+**Frameworks**: SQLite, GRDB, SwiftData
+**Status**: Production-ready patterns for safe schema evolution
diff --git a/.claude/skills/axiom-database-migration/agents/openai.yaml b/.claude/skills/axiom-database-migration/agents/openai.yaml
new file mode 100644
index 0000000..b6422ae
--- /dev/null
+++ b/.claude/skills/axiom-database-migration/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Database Migration"
+ short_description: "Adding/modifying database columns, encountering \"FOREIGN KEY constraint failed\", \"no such column\", \"cannot add NOT NU..."
diff --git a/.claude/skills/axiom-debug-tests/.openskills.json b/.claude/skills/axiom-debug-tests/.openskills.json
new file mode 100644
index 0000000..00754ca
--- /dev/null
+++ b/.claude/skills/axiom-debug-tests/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-debug-tests",
+ "installedAt": "2026-04-12T08:06:11.160Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-debug-tests/SKILL.md b/.claude/skills/axiom-debug-tests/SKILL.md
new file mode 100644
index 0000000..d7810f3
--- /dev/null
+++ b/.claude/skills/axiom-debug-tests/SKILL.md
@@ -0,0 +1,329 @@
+---
+name: axiom-debug-tests
+description: Use this agent for closed-loop test debugging - automatically analyzes test failures, suggests fixes, and re-runs tests until passing.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# Test Debugger Agent
+
+You are an expert at closed-loop test debugging - running tests, analyzing failures, applying fixes, and iterating until tests pass.
+
+## Core Principle
+
+**Closed-loop debugging flow:**
+```
+RUN → CAPTURE → ANALYZE → SUGGEST → FIX → VERIFY → REPORT
+ ↑ |
+ └──────────────── (if still failing) ─────────┘
+```
+
+## Your Mission
+
+1. Run the failing test(s)
+2. Capture failure evidence (screenshots, logs)
+3. Analyze failures using pattern recognition
+4. Suggest specific fixes
+5. Apply fixes (with user confirmation)
+6. Re-run to verify
+7. Report final status
+
+## Phase 1: Run Tests
+
+```bash
+# Get booted simulator
+BOOTED_UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booted") | .udid' | head -1)
+
+# Create result bundle
+RESULT_PATH="/tmp/debug-test-$(date +%s).xcresult"
+
+# Run specific failing tests
+xcodebuild test \
+ -scheme "UITests" \
+ -destination "platform=iOS Simulator,id=$BOOTED_UDID" \
+ -resultBundlePath "$RESULT_PATH" \
+ -only-testing:"//" \
+ 2>&1 | tee /tmp/xcodebuild-debug.log
+
+echo "Results: $RESULT_PATH"
+```
+
+## Phase 2: Capture Evidence
+
+```bash
+# Export failure attachments
+ATTACHMENTS_DIR="/tmp/debug-failures-$(date +%s)"
+mkdir -p "$ATTACHMENTS_DIR"
+
+xcrun xcresulttool export attachments \
+ --path "$RESULT_PATH" \
+ --output-path "$ATTACHMENTS_DIR" \
+ --only-failures
+
+# Read manifest
+cat "$ATTACHMENTS_DIR/manifest.json" | jq '.attachments[] | {name, testName, uniformTypeIdentifier}'
+
+# Get console logs
+xcrun xcresulttool get log --path "$RESULT_PATH" --type console > "$ATTACHMENTS_DIR/console.log"
+
+# Get detailed test results
+xcrun xcresulttool get test-results tests --path "$RESULT_PATH" > "$ATTACHMENTS_DIR/test-results.txt"
+```
+
+## Phase 3: Analyze Failures
+
+### Failure Pattern Recognition
+
+| Pattern | Error Message | Root Cause | Fix |
+|---------|---------------|------------|-----|
+| **Element Not Found (test bug)** | `Failed to find element` | Wrong query or missing accessibilityIdentifier | Fix query or add identifier |
+| **Element Not Found (app bug)** | `Failed to find element` | Element never implemented or in wrong view | Report: app code needs this element — do NOT rewrite test |
+| **Timeout** | `Timed out waiting for element` | Slow app, short timeout | Increase timeout, optimize app |
+| **State Mismatch** | `Expected X, got Y` | Race condition | Add explicit wait |
+| **Not Hittable** | `Element exists but not hittable` | Element obscured | Dismiss keyboard/sheet, scroll |
+| **Stale Element** | `Element no longer attached` | View refreshed | Re-query element |
+| **Wrong Query** | `Multiple matches found` | Ambiguous query | Use more specific identifier |
+
+### Analysis Workflow
+
+```bash
+# 1. Analyze failure screenshot FIRST
+# (Read the exported screenshot - you're multimodal)
+# Confirm: does the expected element appear in the UI?
+
+# 2. Check error message
+grep -A5 "Failure:" /tmp/xcodebuild-debug.log
+
+# 3. Find file and line
+grep -E "\.swift:[0-9]+" /tmp/xcodebuild-debug.log
+
+# 4. Read the test code
+# (Use Read tool on the file:line from above)
+```
+
+### Element Not Found Triage
+
+When a test can't find a UI element, determine whether the problem is in the test or the app BEFORE suggesting fixes:
+
+1. **Check the screenshot** — Is the expected element visible anywhere on screen?
+2. **If element is NOT visible**: Search the app source code for the element (grep for the expected text, identifier, or view name)
+ - Element not in source → **App bug**: element was never implemented. Report this — do NOT rewrite test queries. Do not search for partial matches or alternative element names. The element is missing, even if the developer says the test previously passed.
+ - Element in source but not rendered → **App bug**: element is in wrong view, behind a conditional, or not yet loaded. Report the specific issue. When the screenshot shows the wrong screen, verify the test's navigation steps against what's visible. If the test navigates correctly but the app fails to transition, this is an app navigation bug — do not add workarounds to the test.
+3. **If element IS visible**: The test query is wrong. Check accessibilityIdentifier, label text, element type.
+
+**Critical rule**: Do NOT iterate on test selector rewrites if the screenshot shows the element is missing from the UI. The test is correct — the app is incomplete.
+
+## Phase 4: Suggest Fixes
+
+Based on pattern analysis, suggest specific code changes:
+
+### Element Not Found Fix
+
+**If triage identified a test bug** (element visible but query wrong):
+
+```swift
+// BEFORE (missing identifier)
+Button("Login") { ... }
+
+// AFTER (with identifier)
+Button("Login") { ... }
+ .accessibilityIdentifier("loginButton")
+```
+
+**If triage identified an app bug** (element not in UI): Skip to Phase 7 — report the missing element as an app issue. Do not modify test code.
+
+### Timeout Fix
+
+```swift
+// BEFORE (might timeout)
+XCTAssertTrue(element.exists)
+
+// AFTER (explicit wait)
+XCTAssertTrue(element.waitForExistence(timeout: 10))
+```
+
+### Not Hittable Fix
+
+```swift
+// BEFORE (might be obscured)
+button.tap()
+
+// AFTER (wait for hittable)
+let predicate = NSPredicate(format: "isHittable == true")
+let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button)
+_ = XCTWaiter.wait(for: [expectation], timeout: 5)
+button.tap()
+
+// Or dismiss keyboard first
+if app.keyboards.count > 0 {
+ app.toolbars.buttons["Done"].tap()
+}
+```
+
+### Race Condition Fix
+
+```swift
+// BEFORE (race condition)
+button.tap()
+XCTAssertTrue(resultLabel.exists)
+
+// AFTER (wait for result)
+button.tap()
+XCTAssertTrue(resultLabel.waitForExistence(timeout: 5))
+```
+
+## Phase 5: Apply Fixes
+
+1. **Show proposed change** to user
+2. **Get confirmation** before editing
+3. **Apply edit** using Edit tool
+4. **Log the change** for verification
+
+```markdown
+## Proposed Fix
+
+**File**: `LoginTests.swift:47`
+**Issue**: Missing waitForExistence before tap
+**Change**:
+```diff
+- loginButton.tap()
++ XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
++ loginButton.tap()
+```
+
+Shall I apply this fix?
+```
+
+## Phase 6: Verify Fix
+
+```bash
+# Re-run ONLY the failing test
+xcodebuild test \
+ -scheme "UITests" \
+ -destination "platform=iOS Simulator,id=$BOOTED_UDID" \
+ -resultBundlePath "/tmp/verify-$(date +%s).xcresult" \
+ -only-testing:"//"
+
+# Check result
+xcrun xcresulttool get test-results summary --path /tmp/verify-*.xcresult
+```
+
+## Phase 7: Report
+
+```markdown
+## Test Debugging Complete
+
+### Original Failures
+- [TestClass/testMethod]: [original error]
+
+### Fixes Applied
+1. **LoginTests.swift:47** — Added waitForExistence before tap
+2. **ProfileTests.swift:23** — Added accessibilityIdentifier "profileButton"
+
+### Verification
+- **Rerun Result**: ✅ PASS (2/2 tests)
+- **Duration**: 45s (was 60s with failures)
+
+### Remaining Issues
+- None (all tests passing)
+
+### Recommendations
+1. Add accessibilityIdentifier to all interactive elements
+2. Always use waitForExistence before interactions
+3. Consider adding test helpers for common patterns
+```
+
+## Decision Tree
+
+```
+User reports test failure
+↓
+Run test with result bundle
+↓
+Check result:
+├─ Build failed → Delegate to build-fixer agent
+├─ Tests passed → Report success
+└─ Tests failed:
+ ├─ Export failure attachments
+ ├─ Read failure screenshot FIRST (multimodal analysis)
+ ├─ Analyze error pattern:
+ │ ├─ Element not found:
+ │ │ ├─ Screenshot shows element → Fix test query/identifier
+ │ │ └─ Screenshot missing element → Search app source
+ │ │ ├─ Not implemented → Report: app needs this element
+ │ │ └─ Wrong view/conditional → Report: app code bug
+ │ ├─ Timeout → Check wait/timeout values
+ │ ├─ Not hittable → Check for obscuring elements
+ │ └─ State mismatch → Check for race conditions
+ ├─ Read test source code
+ ├─ Suggest specific fix
+ ├─ Get user approval
+ ├─ Apply fix
+ └─ Re-run test (loop back if still failing)
+```
+
+## Integration with Other Skills
+
+When analyzing failures, consider:
+
+- **axiom-xctest-automation**: Best practices for element queries, waiting
+- **axiom-ui-testing**: Condition-based waiting patterns
+- **axiom-swift-concurrency**: Async test patterns, race conditions
+- **axiom-swiftui-debugging**: View update issues in UI tests
+
+## Guidelines
+
+1. **Always export attachments** - Screenshots are invaluable
+2. **Read screenshots** - You're multimodal, analyze them
+3. **One fix at a time** - Don't batch multiple changes
+4. **Verify each fix** - Re-run after each change
+5. **Get user confirmation** - Before editing code
+6. **Max 3 iterations** - If still failing, escalate to user
+7. **Log all changes** - For audit trail
+
+**Never**:
+- Apply fixes without analyzing the failure first
+- Edit code without user confirmation
+- Skip the verification re-run after a fix
+- Batch multiple fixes before verifying each one works
+- Continue beyond 3 failed iterations without escalating
+
+## Error Quick Reference
+
+| Symptom | Quick Check | Likely Fix |
+|---------|-------------|------------|
+| "Failed to find element" | Screenshot shows element? | YES: Add identifier. NO: Check app source — element may not exist |
+| "Timed out" | Check app loading | Increase timeout or optimize |
+| "Not hittable" | Keyboard visible? | Dismiss keyboard |
+| "Multiple matches" | Generic query? | Use specific identifier |
+| "Test hangs" | Infinite wait? | Add timeout, check deadlock |
+
+## Example Interaction
+
+**User**: "My testLoginWithValidCredentials keeps timing out"
+
+**Your response**:
+1. Run the specific test with result bundle
+2. Export failure screenshot
+3. Read screenshot - see if login form loaded
+4. Read test code - find the timeout line
+5. Analyze: timeout is 5s but app loads slowly
+6. Suggest: Increase timeout to 15s or add loading indicator check
+7. Get user confirmation
+8. Apply fix
+9. Re-run test
+10. Report pass/fail
+
+## Resources
+
+**WWDC**: 2019-413 (Testing in Xcode), 2025-344 (Record, replay, and review)
+
+**Skills**: axiom-ios-testing, axiom-xctest-automation
+
+## Related
+
+For test execution: `test-runner` agent
+For simulator issues: `simulator-tester` agent
+For build issues: `build-fixer` agent
diff --git a/.claude/skills/axiom-debug-tests/agents/openai.yaml b/.claude/skills/axiom-debug-tests/agents/openai.yaml
new file mode 100644
index 0000000..b0f55fd
--- /dev/null
+++ b/.claude/skills/axiom-debug-tests/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Debug Tests"
+ short_description: "Use this agent for closed-loop test debugging"
diff --git a/.claude/skills/axiom-deep-link-debugging/.openskills.json b/.claude/skills/axiom-deep-link-debugging/.openskills.json
new file mode 100644
index 0000000..3c6fb27
--- /dev/null
+++ b/.claude/skills/axiom-deep-link-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-deep-link-debugging",
+ "installedAt": "2026-04-12T08:06:11.752Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-deep-link-debugging/SKILL.md b/.claude/skills/axiom-deep-link-debugging/SKILL.md
new file mode 100644
index 0000000..a43d58f
--- /dev/null
+++ b/.claude/skills/axiom-deep-link-debugging/SKILL.md
@@ -0,0 +1,644 @@
+---
+name: axiom-deep-link-debugging
+description: Use when adding debug-only deep links for testing, enabling simulator navigation to specific screens, or integrating with automated testing workflows - enables closed-loop debugging without production deep link implementation
+license: MIT
+compatibility: iOS 13+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-08"
+---
+
+# Deep Link Debugging
+
+## When to Use This Skill
+
+Use when:
+- Adding debug-only deep links for simulator testing
+- Enabling automated navigation to specific screens for screenshot/testing
+- Integrating with `simulator-tester` agent or `/axiom:screenshot`
+- Need to navigate programmatically without production deep link implementation
+- Testing navigation flows without manual tapping
+
+**Do NOT use for**:
+- Production deep linking (use `axiom-swiftui-nav` skill instead)
+- Universal links or App Clips
+- Complex routing architectures
+
+## Example Prompts
+
+#### 1. "Claude Code can't navigate to specific screens for testing"
+→ Add debug-only URL scheme to enable `xcrun simctl openurl` navigation
+
+#### 2. "I want to take screenshots of different screens automatically"
+→ Create debug deep links for each screen, callable from simulator
+
+#### 3. "Automated testing needs to set up specific app states"
+→ Add debug links that navigate AND configure state
+
+---
+
+## Red Flags — When You Need Debug Deep Links
+
+If you're experiencing ANY of these, add debug deep links:
+
+**Testing friction**:
+- ❌ "I have to manually tap through 5 screens to test this feature"
+- ❌ "Screenshot capture can't show the screen I need to debug"
+- ❌ "Automated tests can't reach the error state without complex setup"
+
+**Debugging inefficiency**:
+- ❌ "I make a fix, rebuild, manually navigate, check — takes 3 minutes per iteration"
+- ❌ "Can't visually verify fixes because Claude Code can't navigate there"
+
+**Solution**: Add debug deep links that let you (and Claude Code) jump directly to any screen with any state configuration.
+
+---
+
+## Implementation
+
+### Pattern 1: Basic Debug URL Scheme (SwiftUI)
+
+Add a debug-only URL scheme that routes to screens.
+
+```swift
+import SwiftUI
+
+struct MyApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ #if DEBUG
+ .onOpenURL { url in
+ handleDebugURL(url)
+ }
+ #endif
+ }
+ }
+
+ #if DEBUG
+ private func handleDebugURL(_ url: URL) {
+ guard url.scheme == "debug" else { return }
+
+ // Route based on host
+ switch url.host {
+ case "settings":
+ // Navigate to settings
+ NotificationCenter.default.post(
+ name: .navigateToSettings,
+ object: nil
+ )
+
+ case "profile":
+ // Navigate to profile
+ let userID = url.queryItems?["id"] ?? "current"
+ NotificationCenter.default.post(
+ name: .navigateToProfile,
+ object: userID
+ )
+
+ case "reset":
+ // Reset app to initial state
+ resetApp()
+
+ default:
+ print("⚠️ Unknown debug URL: \(url)")
+ }
+ }
+ #endif
+}
+
+#if DEBUG
+extension Notification.Name {
+ static let navigateToSettings = Notification.Name("navigateToSettings")
+ static let navigateToProfile = Notification.Name("navigateToProfile")
+}
+
+extension URL {
+ var queryItems: [String: String]? {
+ guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
+ let items = components.queryItems else {
+ return nil
+ }
+ return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") })
+ }
+}
+#endif
+```
+
+**Usage**:
+```bash
+# From simulator
+xcrun simctl openurl booted "debug://settings"
+xcrun simctl openurl booted "debug://profile?id=123"
+xcrun simctl openurl booted "debug://reset"
+```
+
+---
+
+### Pattern 2: NavigationPath Integration (iOS 16+)
+
+Integrate debug deep links with NavigationStack for robust navigation.
+
+```swift
+import SwiftUI
+
+@MainActor
+class DebugRouter: ObservableObject {
+ @Published var path = NavigationPath()
+
+ #if DEBUG
+ func handleDebugURL(_ url: URL) {
+ guard url.scheme == "debug" else { return }
+
+ switch url.host {
+ case "settings":
+ path.append(Destination.settings)
+
+ case "recipe":
+ if let id = url.queryItems?["id"], let recipeID = Int(id) {
+ path.append(Destination.recipe(id: recipeID))
+ }
+
+ case "recipe-edit":
+ if let id = url.queryItems?["id"], let recipeID = Int(id) {
+ // Navigate to recipe, then to edit
+ path.append(Destination.recipe(id: recipeID))
+ path.append(Destination.recipeEdit(id: recipeID))
+ }
+
+ case "reset":
+ path = NavigationPath() // Pop to root
+
+ default:
+ print("⚠️ Unknown debug URL: \(url)")
+ }
+ }
+ #endif
+}
+
+struct ContentView: View {
+ @StateObject private var router = DebugRouter()
+
+ var body: some View {
+ NavigationStack(path: $router.path) {
+ HomeView()
+ .navigationDestination(for: Destination.self) { destination in
+ destinationView(for: destination)
+ }
+ }
+ #if DEBUG
+ .onOpenURL { url in
+ router.handleDebugURL(url)
+ }
+ #endif
+ }
+
+ @ViewBuilder
+ private func destinationView(for destination: Destination) -> some View {
+ switch destination {
+ case .settings:
+ SettingsView()
+ case .recipe(let id):
+ RecipeDetailView(recipeID: id)
+ case .recipeEdit(let id):
+ RecipeEditView(recipeID: id)
+ }
+ }
+}
+
+enum Destination: Hashable {
+ case settings
+ case recipe(id: Int)
+ case recipeEdit(id: Int)
+}
+```
+
+**Usage**:
+```bash
+# Navigate to settings
+xcrun simctl openurl booted "debug://settings"
+
+# Navigate to recipe #42
+xcrun simctl openurl booted "debug://recipe?id=42"
+
+# Navigate to recipe #42 edit screen
+xcrun simctl openurl booted "debug://recipe-edit?id=42"
+
+# Pop to root
+xcrun simctl openurl booted "debug://reset"
+```
+
+---
+
+### Pattern 3: State Configuration Links
+
+Debug links that both navigate AND configure state.
+
+```swift
+#if DEBUG
+extension DebugRouter {
+ func handleDebugURL(_ url: URL) {
+ guard url.scheme == "debug" else { return }
+
+ switch url.host {
+ case "login":
+ // Show login screen
+ path.append(Destination.login)
+
+ case "login-error":
+ // Show login screen WITH error state
+ path.append(Destination.login)
+ // Trigger error state
+ NotificationCenter.default.post(
+ name: .showLoginError,
+ object: "Invalid credentials"
+ )
+
+ case "recipe-empty":
+ // Show recipe list in empty state
+ UserDefaults.standard.set(true, forKey: "debug_emptyRecipeList")
+ path.append(Destination.recipes)
+
+ case "recipe-error":
+ // Show recipe list with network error
+ UserDefaults.standard.set(true, forKey: "debug_networkError")
+ path.append(Destination.recipes)
+
+ default:
+ print("⚠️ Unknown debug URL: \(url)")
+ }
+ }
+}
+#endif
+```
+
+**Usage**:
+```bash
+# Test login error state
+xcrun simctl openurl booted "debug://login-error"
+
+# Test empty recipe list
+xcrun simctl openurl booted "debug://recipe-empty"
+
+# Test network error handling
+xcrun simctl openurl booted "debug://recipe-error"
+```
+
+---
+
+### Pattern 4: Info.plist Configuration (DEBUG only)
+
+Register the debug URL scheme ONLY in debug builds.
+
+**Step 1**: Add scheme to Info.plist
+
+```xml
+CFBundleURLTypes
+
+
+ CFBundleURLSchemes
+
+ debug
+
+ CFBundleURLName
+ com.example.debug
+
+
+```
+
+**Step 2**: Strip from release builds
+
+Add a Run Script phase to your target's Build Phases (runs BEFORE "Copy Bundle Resources"):
+
+```bash
+# Strip debug URL scheme from Release builds
+if [ "${CONFIGURATION}" = "Release" ]; then
+ echo "Removing debug URL scheme from Info.plist"
+
+ /usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes:0" "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}" 2>/dev/null || true
+fi
+```
+
+**Alternative**: Use separate Info.plist files for Debug vs Release configurations in Build Settings.
+
+---
+
+## Integration with Simulator Testing
+
+### With `/axiom:screenshot` Command
+
+```bash
+# 1. Navigate to screen
+xcrun simctl openurl booted "debug://settings"
+
+# 2. Wait for navigation
+sleep 1
+
+# 3. Capture screenshot
+/axiom:screenshot
+```
+
+### With `simulator-tester` Agent
+
+Simply tell the agent:
+- "Navigate to Settings and take a screenshot"
+- "Open the recipe editor and verify the layout"
+- "Go to the error state and show me what it looks like"
+
+The agent will use your debug deep links to navigate.
+
+---
+
+## Mandatory First Steps
+
+**ALWAYS complete these steps** before adding debug deep links:
+
+### Step 1: Define Navigation Needs
+
+List all screens you need to reach for testing:
+```
+- Settings screen
+- Profile screen (with specific user ID)
+- Recipe detail (with specific recipe ID)
+- Error states (login error, network error, etc.)
+- Empty states (no recipes, no favorites)
+```
+
+### Step 2: Choose URL Scheme Pattern
+
+```
+debug://screen-name # Simple screen navigation
+debug://screen-name?param=value # Navigation with parameters
+debug://state-name # State configuration
+```
+
+### Step 3: Add URL Handler
+
+Use `#if DEBUG` to ensure code is stripped from release builds.
+
+### Step 4: Test Deep Links
+
+```bash
+# Boot simulator
+xcrun simctl boot "iPhone 16 Pro"
+
+# Launch app
+xcrun simctl launch booted com.example.YourApp
+
+# Test each deep link
+xcrun simctl openurl booted "debug://settings"
+xcrun simctl openurl booted "debug://profile?id=123"
+```
+
+---
+
+## Common Mistakes
+
+### ❌ WRONG — Hardcoding navigation in URL handler
+
+```swift
+#if DEBUG
+func handleDebugURL(_ url: URL) {
+ if url.host == "settings" {
+ // ❌ WRONG — Creates tight coupling
+ self.showingSettings = true
+ }
+}
+#endif
+```
+
+**Problem**: URL handler now owns navigation logic, duplicating coordinator/router patterns.
+
+**✅ RIGHT — Use existing navigation system**:
+```swift
+#if DEBUG
+func handleDebugURL(_ url: URL) {
+ if url.host == "settings" {
+ // Use existing NavigationPath
+ path.append(Destination.settings)
+ }
+}
+#endif
+```
+
+---
+
+### ❌ WRONG — Leaving debug code in production
+
+```swift
+// ❌ WRONG — No #if DEBUG
+func handleDebugURL(_ url: URL) {
+ // This ships to users!
+}
+```
+
+**Problem**: Debug endpoints exposed in production. Security risk.
+
+**✅ RIGHT — Wrap in #if DEBUG**:
+```swift
+#if DEBUG
+func handleDebugURL(_ url: URL) {
+ // Stripped from release builds
+}
+#endif
+```
+
+---
+
+### ❌ WRONG — Using query parameters without validation
+
+```swift
+#if DEBUG
+case "profile":
+ let userID = Int(url.queryItems?["id"] ?? "0")! // ❌ Force unwrap
+ path.append(Destination.profile(id: userID))
+#endif
+```
+
+**Problem**: Crashes if `id` is missing or invalid.
+
+**✅ RIGHT — Validate parameters**:
+```swift
+#if DEBUG
+case "profile":
+ guard let idString = url.queryItems?["id"],
+ let userID = Int(idString) else {
+ print("⚠️ Invalid profile ID")
+ return
+ }
+ path.append(Destination.profile(id: userID))
+#endif
+```
+
+---
+
+## Testing Checklist
+
+Before using debug deep links in automated workflows:
+
+- [ ] URL handler wrapped in `#if DEBUG`
+- [ ] All deep links tested manually in simulator
+- [ ] Parameters validated (don't force unwrap)
+- [ ] Deep links integrate with existing navigation (don't duplicate logic)
+- [ ] URL scheme stripped from Release builds (script or separate Info.plist)
+- [ ] Documented in README or comments for other developers
+- [ ] Works with `/axiom:screenshot` command
+- [ ] Works with `simulator-tester` agent
+
+---
+
+## Real-World Example
+
+**Scenario**: You're debugging a recipe app layout issue in the editor screen.
+
+**Before** (manual testing):
+1. Build app → 30 seconds
+2. Launch simulator
+3. Tap "Recipes" → wait for load
+4. Scroll to recipe #42
+5. Tap to open detail
+6. Tap "Edit"
+7. Check if layout is fixed
+8. Make change, rebuild → repeat from step 1
+**Total**: 2-3 minutes per iteration
+
+**After** (with debug deep links):
+1. Build app → 30 seconds
+2. Run: `xcrun simctl openurl booted "debug://recipe-edit?id=42"`
+3. Run: `/axiom:screenshot`
+4. Claude analyzes screenshot and confirms layout fix
+5. Make change if needed, rebuild → repeat from step 2
+**Total**: 45 seconds per iteration
+
+**Time savings**: 60-75% faster iteration with visual verification
+
+---
+
+## Integration with Existing Navigation
+
+### For Apps Using NavigationStack
+
+Add debug URL handler that appends to existing NavigationPath:
+
+```swift
+router.path.append(Destination.fromDebugURL(url))
+```
+
+### For Apps Using Coordinator Pattern
+
+Trigger coordinator methods from debug URL handler:
+
+```swift
+coordinator.navigate(to: .fromDebugURL(url))
+```
+
+### For Apps Using Custom Routing
+
+Integrate with your router's navigation API:
+
+```swift
+AppRouter.shared.push(Screen.fromDebugURL(url))
+```
+
+**Key principle**: Debug deep links should USE existing navigation, not replace it.
+
+---
+
+## Advanced Patterns
+
+### Pattern 5: Parameterized State Setup
+
+```swift
+#if DEBUG
+case "test-scenario":
+ // Parse complex test scenario from URL
+ // Example: debug://test-scenario?user=premium&recipes=empty&network=slow
+
+ if let userType = url.queryItems?["user"] {
+ configureUser(type: userType) // "premium", "free", "trial"
+ }
+
+ if let recipesState = url.queryItems?["recipes"] {
+ configureRecipes(state: recipesState) // "empty", "full", "error"
+ }
+
+ if let networkState = url.queryItems?["network"] {
+ configureNetwork(state: networkState) // "fast", "slow", "offline"
+ }
+
+ // Now navigate
+ path.append(Destination.recipes)
+#endif
+```
+
+**Usage**:
+```bash
+# Test premium user with empty recipe list
+xcrun simctl openurl booted "debug://test-scenario?user=premium&recipes=empty"
+
+# Test slow network with error handling
+xcrun simctl openurl booted "debug://test-scenario?network=slow&recipes=error"
+```
+
+---
+
+### Pattern 6: Screenshot Automation Helper
+
+Create a single URL that sets up AND captures state:
+
+```swift
+#if DEBUG
+case "screenshot":
+ // Parse screen and configuration
+ guard let screen = url.queryItems?["screen"] else { return }
+
+ // Configure state
+ if let state = url.queryItems?["state"] {
+ applyState(state)
+ }
+
+ // Navigate
+ navigate(to: screen)
+
+ // Post notification for external capture
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ NotificationCenter.default.post(
+ name: .readyForScreenshot,
+ object: screen
+ )
+ }
+#endif
+```
+
+**Usage**:
+```bash
+# Navigate to login screen with error state, wait, then screenshot
+xcrun simctl openurl booted "debug://screenshot?screen=login&state=error"
+sleep 2
+xcrun simctl io booted screenshot login-error.png
+```
+
+---
+
+## Related Skills
+
+- `axiom-swiftui-nav` — Production deep linking and NavigationStack patterns
+- `simulator-tester` — Automated simulator testing using debug deep links
+- `axiom-xcode-debugging` — Environment-first debugging workflows
+
+---
+
+## Summary
+
+Debug deep links enable:
+- **Closed-loop debugging** with visual verification
+- **60-75% faster iteration** on visual fixes
+- **Automated testing** without manual navigation
+- **Screenshot automation** for any app state
+
+**Remember**:
+1. Wrap ALL debug code in `#if DEBUG`
+2. Strip URL scheme from release builds
+3. Integrate with existing navigation, don't duplicate
+4. Validate all parameters (no force unwraps)
+5. Document for team members
diff --git a/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml b/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml
new file mode 100644
index 0000000..8c70bf3
--- /dev/null
+++ b/.claude/skills/axiom-deep-link-debugging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Deep Link Debugging"
+ short_description: "Adding debug-only deep links for testing, enabling simulator navigation to specific screens, or integrating with auto..."
diff --git a/.claude/skills/axiom-display-performance/.openskills.json b/.claude/skills/axiom-display-performance/.openskills.json
new file mode 100644
index 0000000..16afe6d
--- /dev/null
+++ b/.claude/skills/axiom-display-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-display-performance",
+ "installedAt": "2026-04-12T08:06:12.352Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-display-performance/SKILL.md b/.claude/skills/axiom-display-performance/SKILL.md
new file mode 100644
index 0000000..4a3029a
--- /dev/null
+++ b/.claude/skills/axiom-display-performance/SKILL.md
@@ -0,0 +1,633 @@
+---
+name: axiom-display-performance
+description: Use when app runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops. Covers MTKView, CADisplayLink, CAMetalDisplayLink, frame pacing, hitches, system caps.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Display Performance
+
+Systematic diagnosis for frame rate issues on variable refresh rate displays (ProMotion, iPad Pro, future devices). Covers render loop configuration, frame pacing, hitch mechanics, and production telemetry.
+
+**Key insight**: "ProMotion available" does NOT mean your app automatically runs at 120Hz. You must configure it correctly, account for system caps, and ensure proper frame pacing.
+
+---
+
+## Part 1: Why You're Stuck at 60fps
+
+### Diagnostic Order
+
+Check these in order when stuck at 60fps on ProMotion:
+
+1. **Info.plist key missing?** (iPhone only) → Part 2
+2. **Render loop configured for 60?** (MTKView defaults, CADisplayLink) → Part 3
+3. **System caps enabled?** (Low Power Mode, Limit Frame Rate, Thermal) → Part 5
+4. **Frame time > 8.33ms?** (Can't sustain 120fps) → Part 6
+5. **Frame pacing issues?** (Micro-stuttering despite good FPS) → Part 7
+6. **Measuring wrong thing?** (UIScreen vs actual presentation) → Part 9
+
+---
+
+## Part 2: Enabling ProMotion on iPhone
+
+**Critical**: Core Animation won't access frame rates above 60Hz on iPhone unless you add this key.
+
+```xml
+
+CADisableMinimumFrameDurationOnPhone
+
+```
+
+Without this key:
+- Your `preferredFrameRateRange` hints are ignored above 60Hz
+- Other animations may affect your CADisplayLink callback rate
+- iPad Pro does NOT require this key
+
+**When to add**: Any iPhone app that needs >60Hz for games, animations, or smooth scrolling.
+
+---
+
+## Part 3: Render Loop Configuration
+
+### MTKView Defaults to 60fps
+
+**This is the most common cause.** MTKView's `preferredFramesPerSecond` defaults to 60.
+
+```swift
+// ❌ WRONG: Implicit 60fps (default)
+let mtkView = MTKView(frame: frame, device: device)
+mtkView.delegate = self
+// Running at 60fps even on ProMotion!
+
+// ✅ CORRECT: Explicit 120fps request
+let mtkView = MTKView(frame: frame, device: device)
+mtkView.preferredFramesPerSecond = 120
+mtkView.isPaused = false
+mtkView.enableSetNeedsDisplay = false // Continuous, not on-demand
+mtkView.delegate = self
+```
+
+**Critical settings for continuous high-rate rendering:**
+
+| Property | Value | Why |
+|----------|-------|-----|
+| `preferredFramesPerSecond` | `120` | Request max rate |
+| `isPaused` | `false` | Don't pause the render loop |
+| `enableSetNeedsDisplay` | `false` | Continuous mode, not on-demand |
+
+### CADisplayLink Configuration (iOS 15+)
+
+Apple explicitly recommends CADisplayLink (not timers) for custom render loops.
+
+```swift
+// ❌ WRONG: Timer-based render loop (drifts, wastes frame time)
+Timer.scheduledTimer(withTimeInterval: 1.0/120.0, repeats: true) { _ in
+ self.render()
+}
+
+// ❌ WRONG: Default CADisplayLink (may hint 60)
+let displayLink = CADisplayLink(target: self, selector: #selector(render))
+displayLink.add(to: .main, forMode: .common)
+
+// ✅ CORRECT: Explicit frame rate range
+let displayLink = CADisplayLink(target: self, selector: #selector(render))
+displayLink.preferredFrameRateRange = CAFrameRateRange(
+ minimum: 80, // Minimum acceptable
+ maximum: 120, // Preferred maximum
+ preferred: 120 // What you want
+)
+displayLink.add(to: .main, forMode: .common)
+```
+
+**Special priority for games**: iOS 15+ gives 30Hz and 60Hz special priority. If targeting these rates:
+
+```swift
+// 30Hz and 60Hz get priority scheduling
+let prioritizedRange = CAFrameRateRange(
+ minimum: 30,
+ maximum: 60,
+ preferred: 60
+)
+displayLink.preferredFrameRateRange = prioritizedRange
+```
+
+### Suggested Frame Rates by Content Type
+
+| Content Type | Suggested Rate | Notes |
+|--------------|----------------|-------|
+| Video playback | 24-30 Hz | Match content frame rate |
+| Scrolling UI | 60-120 Hz | Higher = smoother |
+| Fast games | 60-120 Hz | Match rendering capability |
+| Slow animations | 30-60 Hz | Save power |
+| Static content | 10-24 Hz | Minimal updates needed |
+
+---
+
+## Part 4: CAMetalDisplayLink (iOS 17+)
+
+For Metal apps needing precise timing control, `CAMetalDisplayLink` provides more control than CADisplayLink.
+
+```swift
+class MetalRenderer: NSObject, CAMetalDisplayLinkDelegate {
+ var displayLink: CAMetalDisplayLink?
+ var metalLayer: CAMetalLayer!
+
+ func setupDisplayLink() {
+ displayLink = CAMetalDisplayLink(metalLayer: metalLayer)
+ displayLink?.delegate = self
+ displayLink?.preferredFrameRateRange = CAFrameRateRange(
+ minimum: 60,
+ maximum: 120,
+ preferred: 120
+ )
+ // Control render latency (in frames)
+ displayLink?.preferredFrameLatency = 2
+ displayLink?.add(to: .main, forMode: .common)
+ }
+
+ func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
+ // update.drawable - The drawable to render to
+ // update.targetTimestamp - Deadline to finish rendering
+ // update.targetPresentationTimestamp - When frame will display
+
+ guard let drawable = update.drawable else { return }
+
+ let workingTime = update.targetTimestamp - CACurrentMediaTime()
+ // workingTime = seconds available before deadline
+
+ // Render to drawable...
+ renderFrame(to: drawable)
+ }
+}
+```
+
+**Key differences from CADisplayLink:**
+
+| Feature | CADisplayLink | CAMetalDisplayLink |
+|---------|---------------|-------------------|
+| Drawable access | Manual via layer | Provided in callback |
+| Latency control | None | `preferredFrameLatency` |
+| Target timing | timestamp/targetTimestamp | + targetPresentationTimestamp |
+| Use case | General animation | Metal-specific rendering |
+
+**When to use CAMetalDisplayLink:**
+- Need precise control over render timing window
+- Want to minimize input latency
+- Building games or intensive Metal apps
+- iOS 17+ only deployment
+
+---
+
+## Part 5: System Caps
+
+System states can force 60fps even when your code requests 120:
+
+### Low Power Mode
+
+**Caps ProMotion devices to 60fps.**
+
+```swift
+// Check programmatically
+if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ // System caps display to 60Hz
+}
+
+// Observe changes
+NotificationCenter.default.addObserver(
+ forName: .NSProcessInfoPowerStateDidChange,
+ object: nil,
+ queue: .main
+) { _ in
+ let isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
+ self.adjustRenderingForPowerState(isLowPower)
+}
+```
+
+### Limit Frame Rate (Accessibility)
+
+**Settings → Accessibility → Motion → Limit Frame Rate** caps to 60fps.
+
+No API to detect. If user reports 60fps despite configuration, have them check this setting.
+
+### Thermal Throttling
+
+System restricts 120Hz when device overheats.
+
+```swift
+// Check thermal state
+switch ProcessInfo.processInfo.thermalState {
+case .nominal, .fair:
+ preferredFramesPerSecond = 120
+case .serious, .critical:
+ preferredFramesPerSecond = 60 // Reduce proactively
+@unknown default:
+ break
+}
+
+// Observe thermal changes
+NotificationCenter.default.addObserver(
+ forName: ProcessInfo.thermalStateDidChangeNotification,
+ object: nil,
+ queue: .main
+) { _ in
+ self.adjustForThermalState()
+}
+```
+
+### Adaptive Power (iOS 26+, iPhone 17)
+
+**New in iOS 26**: Adaptive Power is ON by default on iPhone 17/17 Pro. Can throttle even at 60% battery.
+
+**User action for testing**: Settings → Battery → Power Mode → disable **Adaptive Power**.
+
+No public API to detect Adaptive Power state.
+
+---
+
+## Part 6: Performance Budget
+
+### Frame Time Budgets
+
+| Target FPS | Frame Budget | Vsync Interval |
+|------------|--------------|----------------|
+| 120 | 8.33ms | Every vsync |
+| 90 | 11.11ms | — |
+| 60 | 16.67ms | Every 2nd vsync |
+| 30 | 33.33ms | Every 4th vsync |
+
+**If you consistently exceed budget, system drops to next sustainable rate.**
+
+### Measuring GPU Frame Time
+
+```swift
+func draw(in view: MTKView) {
+ guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }
+
+ // Your rendering code...
+
+ commandBuffer.addCompletedHandler { buffer in
+ let gpuTime = buffer.gpuEndTime - buffer.gpuStartTime
+ let gpuMs = gpuTime * 1000
+
+ if gpuMs > 8.33 {
+ print("⚠️ GPU: \(String(format: "%.2f", gpuMs))ms exceeds 120Hz budget")
+ }
+ }
+
+ commandBuffer.commit()
+}
+```
+
+### Can't Sustain 120? Target Lower Rate Evenly
+
+**Critical**: Uneven frame pacing looks worse than consistent lower rate.
+
+```swift
+// If you can't sustain 8.33ms, explicitly target 60 for smooth cadence
+if averageGpuTime > 8.33 && averageGpuTime <= 16.67 {
+ mtkView.preferredFramesPerSecond = 60
+}
+```
+
+---
+
+## Part 7: Frame Pacing
+
+### The Micro-Stuttering Problem
+
+Even with good average FPS, inconsistent frame timing causes visible jitter.
+
+```
+// BAD: Inconsistent intervals despite ~40 FPS average
+Frame 1: 25ms
+Frame 2: 40ms ← stutter
+Frame 3: 25ms
+Frame 4: 40ms ← stutter
+
+// GOOD: Consistent intervals at 30 FPS
+Frame 1: 33ms
+Frame 2: 33ms
+Frame 3: 33ms
+Frame 4: 33ms
+```
+
+**Presenting immediately after rendering causes this.** Use explicit timing control.
+
+### Frame Pacing APIs
+
+#### present(afterMinimumDuration:) — Recommended
+
+Ensures consistent spacing between frames:
+
+```swift
+func draw(in view: MTKView) {
+ guard let commandBuffer = commandQueue.makeCommandBuffer(),
+ let drawable = view.currentDrawable else { return }
+
+ // Render to drawable...
+
+ // Present with minimum 33ms between frames (30 FPS target)
+ commandBuffer.present(drawable, afterMinimumDuration: 0.033)
+ commandBuffer.commit()
+}
+```
+
+#### present(at:) — Precise Timing
+
+Schedule presentation at specific time:
+
+```swift
+// Present at specific Mach absolute time
+let presentTime = CACurrentMediaTime() + 0.033
+commandBuffer.present(drawable, atTime: presentTime)
+```
+
+#### presentedTime — Verify Actual Presentation
+
+Check when frames actually appeared:
+
+```swift
+drawable.addPresentedHandler { drawable in
+ let actualTime = drawable.presentedTime
+ if actualTime == 0.0 {
+ // Frame was dropped!
+ print("⚠️ Frame dropped")
+ } else {
+ print("Frame presented at: \(actualTime)")
+ }
+}
+```
+
+### Frame Pacing Pattern
+
+```swift
+class SmoothRenderer: NSObject, MTKViewDelegate {
+ private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0 // 60 FPS target
+
+ func draw(in view: MTKView) {
+ guard let commandBuffer = commandQueue.makeCommandBuffer(),
+ let drawable = view.currentDrawable else { return }
+
+ renderScene(to: drawable)
+
+ // Use frame pacing to ensure consistent intervals
+ commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
+ commandBuffer.commit()
+ }
+
+ func adjustTargetFrameRate(canSustain fps: Int) {
+ switch fps {
+ case 90...:
+ targetFrameDuration = 1.0 / 120.0
+ case 50...:
+ targetFrameDuration = 1.0 / 60.0
+ default:
+ targetFrameDuration = 1.0 / 30.0
+ }
+ }
+}
+```
+
+---
+
+## Part 8: Understanding Hitches
+
+### Render Loop Phases
+
+Frame lifecycle: **Begin Time → Commit Deadline → Presentation Time**
+
+1. **App Process (CPU)**: Handle events, compute UI updates, Core Animation commit
+2. **Render Server (CPU+GPU)**: Transform UI to bitmap, render to buffer
+3. **Display Driver**: Swap buffer to screen at vsync
+
+At 120Hz, each phase has ~8.33ms. Miss any deadline = hitch.
+
+### Commit Hitch vs Render Hitch
+
+**Commit Hitch**: App process misses commit deadline
+- Cause: Main thread work takes too long
+- Fix: Move work off main thread, reduce view complexity
+
+**Render Hitch**: Render server misses presentation deadline
+- Cause: GPU work too complex (blur, shadows, layers)
+- Fix: Simplify visual effects, reduce overdraw
+
+### Double vs Triple Buffering
+
+**Double Buffer (default)**:
+- Frame lifetime: 2 vsync intervals
+- Tighter deadlines
+- Lower latency
+
+**Triple Buffer (system may enable)**:
+- Frame lifetime: 3 vsync intervals
+- Render server gets 2 vsync intervals
+- Higher latency but more headroom
+
+The system automatically switches to triple buffering to recover from render hitches.
+
+### Hitch Duration
+
+```
+Expected Frame Lifetime = Begin Time → Presentation Time
+Actual Frame Lifetime = Begin Time → Actual Vsync
+
+Hitch Duration = Actual - Expected
+```
+
+If hitch duration > 0, the frame was late and previous frame stayed onscreen longer.
+
+---
+
+## Part 9: Measurement
+
+### UIScreen Lies, Actual Presentation Tells Truth
+
+```swift
+// ❌ This says 120 even when system caps you to 60
+let maxFPS = UIScreen.main.maximumFramesPerSecond
+// Reports capability, not actual rate!
+
+// ✅ Measure from CADisplayLink timing
+@objc func displayLinkCallback(_ link: CADisplayLink) {
+ // Time available to prepare next frame
+ let workingTime = link.targetTimestamp - CACurrentMediaTime()
+
+ // Actual interval since last callback
+ if lastTimestamp > 0 {
+ let interval = link.timestamp - lastTimestamp
+ let actualFPS = 1.0 / interval
+ }
+ lastTimestamp = link.timestamp
+}
+```
+
+### Metal Performance HUD
+
+Enable on-device real-time performance overlay:
+
+**Via Xcode scheme:**
+1. Edit Scheme → Run → Diagnostics
+2. Enable "Show Graphics Overview"
+3. Optionally enable "Log Graphics Overview"
+
+**Via environment variable:**
+```bash
+MTL_HUD_ENABLED=1
+```
+
+**Via device settings:**
+Settings → Developer → Graphics HUD → Show Graphics HUD
+
+**HUD shows:**
+- FPS (average)
+- GPU time per frame
+- Frame interval chart (last 120 frames)
+- Memory usage
+
+### Production Telemetry with MetricKit
+
+Monitor hitches in production:
+
+```swift
+import MetricKit
+
+class MetricsManager: NSObject, MXMetricManagerSubscriber {
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ if let animationMetrics = payload.animationMetrics {
+ // Ratio of time spent hitching during scroll
+ let scrollHitchRatio = animationMetrics.scrollHitchTimeRatio
+
+ // Ratio of time spent hitching in all animations
+ if #available(iOS 17.0, *) {
+ let hitchRatio = animationMetrics.hitchTimeRatio
+ }
+
+ analyzeHitchMetrics(scrollHitchRatio: scrollHitchRatio)
+ }
+ }
+ }
+}
+
+// Register for metrics
+MXMetricManager.shared.add(metricsManager)
+```
+
+**What to track:**
+- `scrollHitchTimeRatio`: Time spent hitching while scrolling (UIScrollView only)
+- `hitchTimeRatio` (iOS 17+): Time spent hitching in all tracked animations
+
+---
+
+## Part 10: Quick Diagnostic Checklist
+
+When debugging frame rate issues:
+
+| Step | Check | Fix |
+|------|-------|-----|
+| 1 | Info.plist key present? (iPhone) | Add `CADisableMinimumFrameDurationOnPhone` |
+| 2 | Limit Frame Rate off? | Settings → Accessibility → Motion |
+| 3 | Low Power Mode off? | Settings → Battery |
+| 4 | Adaptive Power off? (iPhone 17+) | Settings → Battery → Power Mode |
+| 5 | preferredFramesPerSecond = 120? | Set explicitly on MTKView |
+| 6 | preferredFrameRateRange set? | Configure on CADisplayLink |
+| 7 | GPU frame time < 8.33ms? | Profile with Metal HUD or Instruments |
+| 8 | Frame pacing consistent? | Use present(afterMinimumDuration:) |
+| 9 | Hitches in production? | Monitor with MetricKit |
+
+---
+
+## Part 11: Common Patterns
+
+### Pattern: Adaptive Frame Rate with Thermal Awareness
+
+```swift
+class AdaptiveRenderer: NSObject, MTKViewDelegate {
+ private var recentFrameTimes: [Double] = []
+ private let sampleCount = 30
+ private var targetFrameDuration: CFTimeInterval = 1.0 / 60.0
+
+ func draw(in view: MTKView) {
+ guard let commandBuffer = commandQueue.makeCommandBuffer(),
+ let drawable = view.currentDrawable else { return }
+
+ let startTime = CACurrentMediaTime()
+ renderScene(to: drawable)
+ let frameTime = (CACurrentMediaTime() - startTime) * 1000
+
+ updateTargetRate(frameTime: frameTime, view: view)
+
+ commandBuffer.present(drawable, afterMinimumDuration: targetFrameDuration)
+ commandBuffer.commit()
+ }
+
+ private func updateTargetRate(frameTime: Double, view: MTKView) {
+ recentFrameTimes.append(frameTime)
+ if recentFrameTimes.count > sampleCount {
+ recentFrameTimes.removeFirst()
+ }
+
+ let avgFrameTime = recentFrameTimes.reduce(0, +) / Double(recentFrameTimes.count)
+ let thermal = ProcessInfo.processInfo.thermalState
+ let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
+
+ // Constrain based on what we can sustain AND system state
+ if lowPower || thermal >= .serious {
+ view.preferredFramesPerSecond = 30
+ targetFrameDuration = 1.0 / 30.0
+ } else if avgFrameTime < 7.0 && thermal == .nominal {
+ view.preferredFramesPerSecond = 120
+ targetFrameDuration = 1.0 / 120.0
+ } else if avgFrameTime < 14.0 {
+ view.preferredFramesPerSecond = 60
+ targetFrameDuration = 1.0 / 60.0
+ } else {
+ view.preferredFramesPerSecond = 30
+ targetFrameDuration = 1.0 / 30.0
+ }
+ }
+}
+```
+
+### Pattern: Frame Drop Detection
+
+```swift
+class FrameDropMonitor {
+ private var expectedPresentTime: CFTimeInterval = 0
+ private var dropCount = 0
+
+ func trackFrame(drawable: MTLDrawable, expectedInterval: CFTimeInterval) {
+ drawable.addPresentedHandler { [weak self] drawable in
+ guard let self = self else { return }
+
+ if drawable.presentedTime == 0.0 {
+ self.dropCount += 1
+ print("⚠️ Frame dropped (total: \(self.dropCount))")
+ } else if self.expectedPresentTime > 0 {
+ let actualInterval = drawable.presentedTime - self.expectedPresentTime
+ let variance = abs(actualInterval - expectedInterval)
+
+ if variance > expectedInterval * 0.5 {
+ print("⚠️ Frame timing variance: \(variance * 1000)ms")
+ }
+ }
+
+ self.expectedPresentTime = drawable.presentedTime
+ }
+ }
+}
+```
+
+---
+
+## Resources
+
+**WWDC**: 2021-10147, 2018-612, 2022-10083, 2023-10123
+
+**Tech Talks**: 10855, 10856, 10857 (Hitch deep dives)
+
+**Docs**: /quartzcore/cadisplaylink, /quartzcore/cametaldisplaylink, /quartzcore/optimizing-iphone-and-ipad-apps-to-support-promotion-displays, /xcode/understanding-hitches-in-your-app, /metal/mtldrawable/present(afterminimumduration:), /metrickit/mxanimationmetric
+
+**Skills**: axiom-energy, axiom-ios-graphics, axiom-metal-migration-ref, axiom-performance-profiling
diff --git a/.claude/skills/axiom-display-performance/agents/openai.yaml b/.claude/skills/axiom-display-performance/agents/openai.yaml
new file mode 100644
index 0000000..3fa7fe4
--- /dev/null
+++ b/.claude/skills/axiom-display-performance/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Display Performance"
+ short_description: "App runs at unexpected frame rate, stuck at 60fps on ProMotion, frame pacing issues, or configuring render loops"
diff --git a/.claude/skills/axiom-energy-diag/.openskills.json b/.claude/skills/axiom-energy-diag/.openskills.json
new file mode 100644
index 0000000..0142492
--- /dev/null
+++ b/.claude/skills/axiom-energy-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-energy-diag",
+ "installedAt": "2026-04-12T08:06:13.493Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-energy-diag/SKILL.md b/.claude/skills/axiom-energy-diag/SKILL.md
new file mode 100644
index 0000000..8da5e7f
--- /dev/null
+++ b/.claude/skills/axiom-energy-diag/SKILL.md
@@ -0,0 +1,378 @@
+---
+name: axiom-energy-diag
+description: Symptom-based energy troubleshooting - decision trees for 'app at top of battery settings', 'phone gets hot', 'background drain', 'high cellular usage', with time-cost analysis for each diagnosis path
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Energy Diagnostics
+
+Symptom-based troubleshooting for energy issues. Start with your symptom, follow the decision tree, get the fix.
+
+**Related skills**: `axiom-energy` (patterns, checklists), `axiom-energy-ref` (API reference)
+
+---
+
+## Symptom 1: App at Top of Battery Settings
+
+Users or you notice your app consuming significant battery.
+
+### Diagnosis Decision Tree
+
+```
+App at top of Battery Settings?
+│
+├─ Step 1: Run Power Profiler (15 min)
+│ ├─ CPU Power Impact high?
+│ │ ├─ Continuous? → Timer leak or polling loop
+│ │ │ └─ Fix: Check timers, add tolerance, convert to push
+│ │ └─ Spikes during actions? → Eager loading or repeated parsing
+│ │ └─ Fix: Use LazyVStack, cache parsed data
+│ │
+│ ├─ Network Power Impact high?
+│ │ ├─ Many small requests? → Batching issue
+│ │ │ └─ Fix: Batch requests, use discretionary URLSession
+│ │ └─ Regular intervals? → Polling pattern
+│ │ └─ Fix: Convert to push notifications
+│ │
+│ ├─ GPU Power Impact high?
+│ │ ├─ Animations? → Running when not visible
+│ │ │ └─ Fix: Stop in viewWillDisappear
+│ │ └─ Blur effects? → Over dynamic content
+│ │ └─ Fix: Remove or use static backgrounds
+│ │
+│ └─ Display Power Impact high?
+│ └─ Light backgrounds on OLED?
+│ └─ Fix: Implement Dark Mode (up to 70% savings)
+│
+└─ Step 2: Check background section in Battery Settings
+ ├─ High background time?
+ │ ├─ Location icon visible? → Continuous location
+ │ │ └─ Fix: Switch to significant-change monitoring
+ │ ├─ Audio active? → Session not deactivated
+ │ │ └─ Fix: Deactivate audio session when not playing
+ │ └─ BGTasks running long? → Not completing promptly
+ │ └─ Fix: Call setTaskCompleted sooner
+ │
+ └─ Background time appropriate?
+ └─ Issue is in foreground usage → Focus on CPU/GPU fixes above
+```
+
+### Time-Cost Analysis
+
+| Approach | Time | Accuracy |
+|----------|------|----------|
+| Run Power Profiler, identify subsystem | 15-20 min | High |
+| Guess and optimize random areas | 4+ hours | Low |
+| Read all code looking for issues | 2+ hours | Medium |
+
+**Recommendation**: Always use Power Profiler first. It costs 15 minutes but guarantees you optimize the right subsystem.
+
+---
+
+## Symptom 2: Device Gets Hot
+
+Device temperature increases noticeably during app use.
+
+### Diagnosis Decision Tree
+
+```
+Device gets hot during app use?
+│
+├─ Hot during specific action?
+│ │
+│ ├─ During video/camera use?
+│ │ ├─ Video encoding? → Expected, but check efficiency
+│ │ │ └─ Fix: Use hardware encoding, reduce resolution if possible
+│ │ └─ Camera active unnecessarily? → Not releasing session
+│ │ └─ Fix: Call stopRunning() when done
+│ │
+│ ├─ During scroll/animation?
+│ │ ├─ GPU-intensive effects? → Blur, shadows, many layers
+│ │ │ └─ Fix: Reduce effects, cache rendered content
+│ │ └─ High frame rate? → Unnecessary 120fps
+│ │ └─ Fix: Use CADisplayLink preferredFrameRateRange
+│ │
+│ └─ During data processing?
+│ ├─ JSON parsing? → Repeated or large payloads
+│ │ └─ Fix: Cache parsed results, paginate
+│ └─ Image processing? → Synchronous on main thread
+│ └─ Fix: Move to background, cache results
+│
+├─ Hot during normal use (no specific action)?
+│ │
+│ ├─ Run Power Profiler to identify:
+│ │ ├─ CPU high continuously → Timer, polling, tight loop
+│ │ ├─ GPU high continuously → Animation leak
+│ │ └─ Network high continuously → Polling pattern
+│ │
+│ └─ Check for infinite loops or runaway recursion
+│ └─ Use Time Profiler in Instruments
+│
+└─ Hot only in background?
+ ├─ Location updates continuous? → High accuracy or no stop
+ │ └─ Fix: Reduce accuracy, stop when done
+ ├─ Audio session active? → Hardware kept powered
+ │ └─ Fix: Deactivate when not playing
+ └─ BGTask running too long? → System may throttle
+ └─ Fix: Complete tasks faster, use requiresExternalPower
+```
+
+### Time-Cost Analysis
+
+| Approach | Time | Outcome |
+|----------|------|---------|
+| Power Profiler + Time Profiler | 20-30 min | Identifies exact cause |
+| Check code for obvious issues | 1-2 hours | May miss non-obvious causes |
+| Wait for user complaints | N/A | Reputation damage |
+
+---
+
+## Symptom 3: Background Battery Drain
+
+App drains battery even when user isn't actively using it.
+
+### Diagnosis Decision Tree
+
+```
+High background battery usage?
+│
+├─ Step 1: Check Info.plist background modes
+│ │
+│ ├─ "location" enabled?
+│ │ ├─ Actually need background location?
+│ │ │ ├─ YES → Use significant-change, lowest accuracy
+│ │ │ └─ NO → Remove background mode, use when-in-use only
+│ │ └─ Check: Is stopUpdatingLocation called?
+│ │
+│ ├─ "audio" enabled?
+│ │ ├─ Audio playing? → Expected
+│ │ ├─ Audio NOT playing? → Session still active
+│ │ │ └─ Fix: Deactivate session, use autoShutdownEnabled
+│ │ └─ Playing silent audio? → Anti-pattern for keeping app alive
+│ │ └─ Fix: Use proper background API (BGTask)
+│ │
+│ ├─ "fetch" enabled?
+│ │ └─ Check: Is earliestBeginDate reasonable? (not too frequent)
+│ │
+│ └─ "remote-notification" enabled?
+│ └─ Expected for push updates, check didReceiveRemoteNotification efficiency
+│
+├─ Step 2: Check BGTaskScheduler usage
+│ │
+│ ├─ BGAppRefreshTask scheduled too frequently?
+│ │ └─ Fix: Increase earliestBeginDate interval
+│ │
+│ ├─ BGProcessingTask not using requiresExternalPower?
+│ │ └─ Fix: Add requiresExternalPower = true for non-urgent work
+│ │
+│ └─ Tasks not completing? (setTaskCompleted not called)
+│ └─ Fix: Always call setTaskCompleted, implement expirationHandler
+│
+└─ Step 3: Check beginBackgroundTask usage
+ │
+ ├─ endBackgroundTask called promptly?
+ │ └─ Fix: Call immediately after work completes, not at expiration
+ │
+ └─ Multiple overlapping background tasks?
+ └─ Fix: Track task IDs, ensure each is ended
+```
+
+### Common Background Drain Patterns
+
+| Pattern | Power Profiler Signature | Fix |
+|---------|-------------------------|-----|
+| Continuous location | CPU lane + location icon | significant-change |
+| Audio session leak | CPU lane steady | setActive(false) |
+| Timer not invalidated | CPU spikes at intervals | invalidate in background |
+| Polling from background | Network lane at intervals | Push notifications |
+| BGTask too long | CPU sustained | Faster completion |
+
+### Time-Cost Analysis
+
+| Approach | Time | Outcome |
+|----------|------|---------|
+| Check Info.plist + BGTask code | 30 min | Finds common issues |
+| On-device Power Profiler trace | 1-2 hours (real usage) | Captures real behavior |
+| User-collected trace | Variable | Best for unreproducible issues |
+
+---
+
+## Symptom 4: High Energy Only on Cellular
+
+Battery drains faster on cellular than WiFi.
+
+### Diagnosis Decision Tree
+
+```
+High battery drain on cellular only?
+│
+├─ Expected: Cellular radio uses more power than WiFi
+│ └─ But: Excessive drain indicates optimization opportunity
+│
+├─ Check URLSession configuration
+│ │
+│ ├─ allowsExpensiveNetworkAccess = true (default)?
+│ │ └─ Fix: Set to false for non-urgent requests
+│ │
+│ ├─ isDiscretionary = false (default)?
+│ │ └─ Fix: Set to true for background downloads
+│ │
+│ └─ waitsForConnectivity = false (default)?
+│ └─ Fix: Set to true to avoid failed connection retries
+│
+├─ Check request patterns
+│ │
+│ ├─ Many small requests? → High connection overhead
+│ │ └─ Fix: Batch into fewer larger requests
+│ │
+│ ├─ Polling? → Radio stays active
+│ │ └─ Fix: Push notifications
+│ │
+│ └─ Large downloads in foreground? → Could wait for WiFi
+│ └─ Fix: Use background URLSession with discretionary
+│
+└─ Check Low Data Mode handling
+ ├─ Respecting allowsConstrainedNetworkAccess?
+ │ └─ Fix: Set to false for non-essential requests
+ │
+ └─ Checking ProcessInfo.processInfo.isLowDataModeEnabled?
+ └─ Fix: Reduce payload sizes, defer non-essential transfers
+```
+
+### Time-Cost Analysis
+
+| Approach | Time | Outcome |
+|----------|------|---------|
+| Review URLSession configs | 15 min | Quick wins |
+| Add discretionary flags | 30 min | Significant savings |
+| Convert poll to push | 2-4 hours | Largest impact |
+
+---
+
+## Symptom 5: Energy Spike During Specific Action
+
+Noticeable battery drain or heat when performing particular operation.
+
+### Diagnosis Decision Tree
+
+```
+Energy spike during specific action?
+│
+├─ Step 1: Record Power Profiler during action
+│ └─ Note which subsystem spikes (CPU/GPU/Network/Display)
+│
+├─ CPU spike?
+│ │
+│ ├─ Is it parsing data?
+│ │ ├─ Same data parsed repeatedly?
+│ │ │ └─ Fix: Cache parsed results (lazy var)
+│ │ └─ Large JSON/XML payload?
+│ │ └─ Fix: Paginate, stream parse, or use binary format
+│ │
+│ ├─ Is it creating views?
+│ │ ├─ Many views at once?
+│ │ │ └─ Fix: Use LazyVStack/LazyHStack
+│ │ └─ Complex view hierarchies?
+│ │ └─ Fix: Simplify, use drawingGroup()
+│ │
+│ └─ Is it image processing?
+│ ├─ On main thread?
+│ │ └─ Fix: Move to background queue
+│ └─ No caching?
+│ └─ Fix: Cache processed images
+│
+├─ GPU spike?
+│ │
+│ ├─ Starting animation?
+│ │ └─ Fix: Ensure frame rate appropriate
+│ │
+│ ├─ Showing blur effect?
+│ │ └─ Fix: Use solid color or pre-rendered blur
+│ │
+│ └─ Complex render? (shadows, masks, many layers)
+│ └─ Fix: Simplify, use shouldRasterize, cache
+│
+├─ Network spike?
+│ │
+│ ├─ Large download started?
+│ │ └─ Fix: Use background URLSession, show progress
+│ │
+│ ├─ Many parallel requests?
+│ │ └─ Fix: Limit concurrency, batch
+│ │
+│ └─ Retrying failed requests?
+│ └─ Fix: Exponential backoff, waitsForConnectivity
+│
+└─ Display spike?
+ └─ Unusual unless changing brightness programmatically
+ └─ Fix: Don't modify brightness, let system control
+```
+
+### Time-Cost Analysis
+
+| Approach | Time | Outcome |
+|----------|------|---------|
+| Power Profiler during action | 5-10 min | Identifies subsystem |
+| Time Profiler for CPU details | 10-15 min | Identifies function |
+| Code review without profiling | 1+ hours | May miss actual cause |
+
+---
+
+## Quick Diagnostic Checklist
+
+Use this when you need fast answers:
+
+### 30-Second Check
+- [ ] Device plugged in? (Power metrics show 0)
+- [ ] Debug build? (Less optimized than release)
+- [ ] Low Power Mode on? (May affect measurements)
+
+### 5-Minute Check (Power Profiler)
+- [ ] Which subsystem is dominant? (CPU/GPU/Network/Display)
+- [ ] Sustained or spiky?
+- [ ] Foreground or background?
+
+### 15-Minute Investigation
+- [ ] If CPU: Run Time Profiler to identify function
+- [ ] If Network: Check request frequency and size
+- [ ] If GPU: Check animation frame rates
+- [ ] If Background: Check Info.plist modes
+
+### Common Quick Fixes
+
+| Finding | Quick Fix | Time |
+|---------|-----------|------|
+| Timer without tolerance | Add `.tolerance = 0.1` | 1 min |
+| VStack with large ForEach | Change to LazyVStack | 1 min |
+| allowsExpensiveNetworkAccess = true | Set to false | 1 min |
+| Missing stopUpdatingLocation | Add stop call | 2 min |
+| No Dark Mode | Add asset variants | 30 min |
+| Audio session always active | Add setActive(false) | 5 min |
+
+---
+
+## When to Escalate
+
+### Use `axiom-energy` skill when
+- Need full audit checklist
+- Want comprehensive patterns with code
+- Planning proactive optimization
+
+### Use `axiom-energy-ref` skill when
+- Need specific API details
+- Want complete code examples
+- Implementing from scratch
+
+### Use `energy-auditor` agent when
+- Want automated codebase scan
+- Looking for anti-patterns at scale
+- Pre-release energy audit
+
+Run: `/axiom:audit energy`
+
+---
+
+**Last Updated**: 2025-12-26
+**Platforms**: iOS 26+, iPadOS 26+
diff --git a/.claude/skills/axiom-energy-diag/agents/openai.yaml b/.claude/skills/axiom-energy-diag/agents/openai.yaml
new file mode 100644
index 0000000..02b068d
--- /dev/null
+++ b/.claude/skills/axiom-energy-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Energy Diagnostics"
+ short_description: "Symptom-based energy troubleshooting"
diff --git a/.claude/skills/axiom-energy-ref/.openskills.json b/.claude/skills/axiom-energy-ref/.openskills.json
new file mode 100644
index 0000000..1794f52
--- /dev/null
+++ b/.claude/skills/axiom-energy-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-energy-ref",
+ "installedAt": "2026-04-12T08:06:14.104Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-energy-ref/SKILL.md b/.claude/skills/axiom-energy-ref/SKILL.md
new file mode 100644
index 0000000..af7feee
--- /dev/null
+++ b/.claude/skills/axiom-energy-ref/SKILL.md
@@ -0,0 +1,1103 @@
+---
+name: axiom-energy-ref
+description: Complete energy optimization API reference - Power Profiler workflows, timer/network/location/background APIs, iOS 26 BGContinuedProcessingTask, MetricKit monitoring, with all WWDC code examples
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Energy Optimization Reference
+
+Complete API reference for iOS energy optimization, with code examples from WWDC sessions and Apple documentation.
+
+**Related skills**: `axiom-energy` (decision trees, patterns), `axiom-energy-diag` (troubleshooting)
+
+---
+
+## Part 1: Power Profiler Workflow
+
+### Recording a Trace with Instruments
+
+#### Tethered Recording (Connected to Mac)
+
+```
+1. Connect iPhone wirelessly to Xcode
+ - Xcode → Window → Devices and Simulators
+ - Enable "Connect via network" for your device
+
+2. Profile your app
+ - Xcode → Product → Profile (Cmd+I)
+ - Select Blank template
+ - Click "+" → Add "Power Profiler"
+ - Optionally add "CPU Profiler" for correlation
+
+3. Record
+ - Select your app from target dropdown
+ - Click Record (red button)
+ - Use app normally for 2-3 minutes
+ - Click Stop
+
+4. Analyze
+ - Expand Power Profiler track
+ - Examine per-app lanes: CPU, GPU, Display, Network
+```
+
+**Important**: Use wireless debugging. When device is charging via cable, system power usage shows 0.
+
+#### On-Device Recording (Without Mac)
+
+From WWDC25-226: Capture traces in real-world conditions.
+
+```
+1. Enable Developer Mode
+ Settings → Privacy & Security → Developer Mode → Enable
+
+2. Enable Performance Trace
+ Settings → Developer → Performance Trace → Enable
+ Set tracing mode to "Power Profiler"
+ Toggle ON your app in the app list
+
+3. Add Control Center shortcut
+ Control Center → Tap "+" → Add a Control → Performance Trace
+
+4. Record
+ Swipe down → Tap Performance Trace icon → Start
+ Use app (can record up to 10 hours)
+ Tap Performance Trace icon → Stop
+
+5. Share trace
+ Settings → Developer → Performance Trace
+ Tap Share button next to trace file
+ AirDrop to Mac or email to developer
+```
+
+### Interpreting Power Profiler Metrics
+
+| Lane | Meaning | What High Values Indicate |
+|------|---------|--------------------------|
+| System Power | Overall battery drain rate | General energy consumption |
+| CPU Power Impact | Processor activity score | Computation, timers, parsing |
+| GPU Power Impact | Graphics rendering score | Animations, blur, Metal |
+| Display Power Impact | Screen power usage | Brightness, content type |
+| Network Power Impact | Radio activity score | Requests, downloads, polling |
+
+**Key insight**: Values are scores for comparison, not absolute measurements. Compare before/after traces on the same device.
+
+### Comparing Before/After (Example from WWDC25-226)
+
+```swift
+// Before optimization: CPU Power Impact = 21
+VStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video)
+ }
+}
+
+// After optimization: CPU Power Impact = 4.3
+LazyVStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video)
+ }
+}
+```
+
+---
+
+## Part 2: Timer Efficiency APIs
+
+### NSTimer with Tolerance
+
+```swift
+// Basic timer with tolerance
+let timer = Timer.scheduledTimer(
+ withTimeInterval: 1.0,
+ repeats: true
+) { [weak self] _ in
+ self?.updateUI()
+}
+timer.tolerance = 0.1 // 10% minimum recommended
+
+// Add to run loop (if not using scheduledTimer)
+RunLoop.current.add(timer, forMode: .common)
+
+// Always invalidate when done
+deinit {
+ timer.invalidate()
+}
+```
+
+### Combine Timer Publisher
+
+```swift
+import Combine
+
+class ViewModel: ObservableObject {
+ private var cancellables = Set()
+
+ func startPolling() {
+ Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
+ .autoconnect()
+ .sink { [weak self] _ in
+ self?.refresh()
+ }
+ .store(in: &cancellables)
+ }
+
+ func stopPolling() {
+ cancellables.removeAll()
+ }
+}
+```
+
+### Dispatch Timer Source (Low-Level)
+
+From Energy Efficiency Guide:
+
+```swift
+let queue = DispatchQueue(label: "com.app.timer")
+let timer = DispatchSource.makeTimerSource(queue: queue)
+
+// Set interval with leeway (tolerance)
+timer.schedule(
+ deadline: .now(),
+ repeating: .seconds(1),
+ leeway: .milliseconds(100) // 10% tolerance
+)
+
+timer.setEventHandler { [weak self] in
+ self?.performWork()
+}
+
+timer.resume()
+
+// Cancel when done
+timer.cancel()
+```
+
+> For DispatchSourceTimer lifecycle safety and crash prevention, see `axiom-timer-patterns`.
+
+### Event-Driven Alternative to Timers
+
+From Energy Efficiency Guide: Prefer dispatch sources over polling.
+
+```swift
+// Monitor file changes instead of polling
+let fileDescriptor = open(filePath.path, O_EVTONLY)
+let source = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fileDescriptor,
+ eventMask: [.write, .delete],
+ queue: .main
+)
+
+source.setEventHandler { [weak self] in
+ self?.handleFileChange()
+}
+
+source.setCancelHandler {
+ close(fileDescriptor)
+}
+
+source.resume()
+```
+
+---
+
+## Part 3: Network Efficiency APIs
+
+### URLSession Configuration
+
+```swift
+// Standard configuration with energy-conscious settings
+let config = URLSessionConfiguration.default
+config.waitsForConnectivity = true // Don't fail immediately
+config.allowsExpensiveNetworkAccess = false // Prefer WiFi
+config.allowsConstrainedNetworkAccess = false // Respect Low Data Mode
+
+let session = URLSession(configuration: config)
+```
+
+### Discretionary Background Downloads
+
+From WWDC22-10083:
+
+```swift
+// Background session for non-urgent downloads
+let config = URLSessionConfiguration.background(
+ withIdentifier: "com.app.downloads"
+)
+config.isDiscretionary = true // System chooses optimal time
+config.sessionSendsLaunchEvents = true
+
+// Set timeouts
+config.timeoutIntervalForResource = 24 * 60 * 60 // 24 hours
+config.timeoutIntervalForRequest = 60
+
+let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
+
+// Create download task with scheduling hints
+let task = session.downloadTask(with: url)
+task.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 60 * 60) // 2 hours from now
+task.countOfBytesClientExpectsToSend = 200 // Small request
+task.countOfBytesClientExpectsToReceive = 500_000 // 500KB response
+
+task.resume()
+```
+
+### Background Session Delegate
+
+```swift
+class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
+ func urlSession(
+ _ session: URLSession,
+ downloadTask: URLSessionDownloadTask,
+ didFinishDownloadingTo location: URL
+ ) {
+ // Move file from temp location
+ let destination = FileManager.default.urls(
+ for: .documentDirectory,
+ in: .userDomainMask
+ )[0].appendingPathComponent("downloaded.data")
+
+ try? FileManager.default.moveItem(at: location, to: destination)
+ }
+
+ func urlSessionDidFinishEvents(
+ forBackgroundURLSession session: URLSession
+ ) {
+ // Notify app delegate to call completion handler
+ DispatchQueue.main.async {
+ if let handler = AppDelegate.shared.backgroundCompletionHandler {
+ handler()
+ AppDelegate.shared.backgroundCompletionHandler = nil
+ }
+ }
+ }
+}
+```
+
+---
+
+## Part 4: Location Efficiency APIs
+
+### CLLocationManager Configuration
+
+```swift
+import CoreLocation
+
+class LocationService: NSObject, CLLocationManagerDelegate {
+ private let manager = CLLocationManager()
+
+ func configure() {
+ manager.delegate = self
+
+ // Use appropriate accuracy
+ manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
+
+ // Reduce update frequency
+ manager.distanceFilter = 100 // Update every 100 meters
+
+ // Allow indicator pause when stationary
+ manager.pausesLocationUpdatesAutomatically = true
+
+ // For background updates (if needed)
+ manager.allowsBackgroundLocationUpdates = true
+ manager.showsBackgroundLocationIndicator = true
+ }
+
+ func startTracking() {
+ manager.requestWhenInUseAuthorization()
+ manager.startUpdatingLocation()
+ }
+
+ func startSignificantChangeTracking() {
+ // Much more energy efficient for background
+ manager.startMonitoringSignificantLocationChanges()
+ }
+
+ func stopTracking() {
+ manager.stopUpdatingLocation()
+ manager.stopMonitoringSignificantLocationChanges()
+ }
+}
+```
+
+### iOS 26+ CLLocationUpdate (Modern Async API)
+
+```swift
+import CoreLocation
+
+func trackLocation() async throws {
+ for try await update in CLLocationUpdate.liveUpdates() {
+ // Check if device became stationary
+ if update.stationary {
+ // System pauses updates automatically
+ // Consider switching to region monitoring
+ break
+ }
+
+ if let location = update.location {
+ handleLocation(location)
+ }
+ }
+}
+```
+
+### CLMonitor for Significant Changes
+
+```swift
+import CoreLocation
+
+func setupRegionMonitoring() async {
+ let monitor = CLMonitor("significant-changes")
+
+ // Add condition to monitor
+ let condition = CLMonitor.CircularGeographicCondition(
+ center: currentLocation.coordinate,
+ radius: 500 // 500 meter radius
+ )
+ await monitor.add(condition, identifier: "home-region")
+
+ // React to events
+ for try await event in monitor.events {
+ switch event.state {
+ case .satisfied:
+ // Entered region
+ handleRegionEntry()
+ case .unsatisfied:
+ // Exited region
+ handleRegionExit()
+ default:
+ break
+ }
+ }
+}
+```
+
+### Location Accuracy Options
+
+| Constant | Accuracy | Battery Impact | Use Case |
+|----------|----------|----------------|----------|
+| `kCLLocationAccuracyBestForNavigation` | ~1m | Extreme | Turn-by-turn only |
+| `kCLLocationAccuracyBest` | ~10m | Very High | Fitness tracking |
+| `kCLLocationAccuracyNearestTenMeters` | ~10m | High | Precise positioning |
+| `kCLLocationAccuracyHundredMeters` | ~100m | Medium | Store locators |
+| `kCLLocationAccuracyKilometer` | ~1km | Low | Weather, general |
+| `kCLLocationAccuracyThreeKilometers` | ~3km | Very Low | Regional content |
+
+---
+
+## Part 5: Background Execution APIs
+
+### beginBackgroundTask (Short Tasks)
+
+```swift
+class AppDelegate: UIResponder, UIApplicationDelegate {
+ var backgroundTask: UIBackgroundTaskIdentifier = .invalid
+
+ func applicationDidEnterBackground(_ application: UIApplication) {
+ backgroundTask = application.beginBackgroundTask(withName: "Save State") {
+ // Expiration handler - clean up
+ self.endBackgroundTask()
+ }
+
+ // Perform quick work
+ saveState()
+
+ // End immediately when done
+ endBackgroundTask()
+ }
+
+ private func endBackgroundTask() {
+ guard backgroundTask != .invalid else { return }
+ UIApplication.shared.endBackgroundTask(backgroundTask)
+ backgroundTask = .invalid
+ }
+}
+```
+
+### BGAppRefreshTask
+
+```swift
+import BackgroundTasks
+
+// Register at app launch
+func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+
+ BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.app.refresh",
+ using: nil
+ ) { task in
+ self.handleAppRefresh(task: task as! BGAppRefreshTask)
+ }
+
+ return true
+}
+
+// Schedule refresh
+func scheduleAppRefresh() {
+ let request = BGAppRefreshTaskRequest(identifier: "com.app.refresh")
+ request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
+
+ try? BGTaskScheduler.shared.submit(request)
+}
+
+// Handle refresh
+func handleAppRefresh(task: BGAppRefreshTask) {
+ scheduleAppRefresh() // Schedule next refresh
+
+ let fetchTask = Task {
+ do {
+ let hasNewData = try await fetchLatestData()
+ task.setTaskCompleted(success: hasNewData)
+ } catch {
+ task.setTaskCompleted(success: false)
+ }
+ }
+
+ task.expirationHandler = {
+ fetchTask.cancel()
+ }
+}
+```
+
+### BGProcessingTask
+
+```swift
+import BackgroundTasks
+
+// Register
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.app.maintenance",
+ using: nil
+) { task in
+ self.handleMaintenance(task: task as! BGProcessingTask)
+}
+
+// Schedule with requirements
+func scheduleMaintenance() {
+ let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
+ request.requiresNetworkConnectivity = true
+ request.requiresExternalPower = true // Only when charging
+
+ try? BGTaskScheduler.shared.submit(request)
+}
+
+// Handle
+func handleMaintenance(task: BGProcessingTask) {
+ let operation = MaintenanceOperation()
+
+ task.expirationHandler = {
+ operation.cancel()
+ }
+
+ operation.completionBlock = {
+ task.setTaskCompleted(success: !operation.isCancelled)
+ }
+
+ OperationQueue.main.addOperation(operation)
+}
+```
+
+### iOS 26+ BGContinuedProcessingTask
+
+From WWDC25-227: Continue user-initiated tasks with system UI.
+
+```swift
+import BackgroundTasks
+
+// Info.plist: Add identifier to BGTaskSchedulerPermittedIdentifiers
+// "com.app.export" or "com.app.exports.*" for wildcards
+
+// Register handler (can be dynamic, not just at launch)
+func setupExportHandler() {
+ BGTaskScheduler.shared.register("com.app.export") { task in
+ let continuedTask = task as! BGContinuedProcessingTask
+
+ var shouldContinue = true
+ continuedTask.expirationHandler = {
+ shouldContinue = false
+ }
+
+ // Report progress
+ continuedTask.progress.totalUnitCount = 100
+ continuedTask.progress.completedUnitCount = 0
+
+ // Perform work
+ for i in 0..<100 {
+ guard shouldContinue else { break }
+
+ performExportStep(i)
+ continuedTask.progress.completedUnitCount = Int64(i + 1)
+ }
+
+ continuedTask.setTaskCompleted(success: shouldContinue)
+ }
+}
+
+// Submit request
+func startExport() {
+ let request = BGContinuedProcessingTaskRequest(
+ identifier: "com.app.export",
+ title: "Exporting Photos",
+ subtitle: "0 of 100 photos"
+ )
+
+ // Submission strategy
+ request.strategy = .fail // Fail if can't start immediately
+ // or default: queue if can't start
+
+ do {
+ try BGTaskScheduler.shared.submit(request)
+ } catch {
+ // Handle submission failure
+ showExportNotAvailable()
+ }
+}
+```
+
+### EMRCA Principles (from WWDC25-227)
+
+Background tasks must be:
+
+| Principle | Meaning | Implementation |
+|-----------|---------|----------------|
+| **E**fficient | Lightweight, purpose-driven | Do one thing well |
+| **M**inimal | Keep work to minimum | Don't expand scope |
+| **R**esilient | Save progress, handle expiration | Checkpoint frequently |
+| **C**ourteous | Honor preferences | Check Low Power Mode |
+| **A**daptive | Work with system | Don't fight constraints |
+
+---
+
+## Part 6: Display & GPU Efficiency APIs
+
+### Dark Mode Support
+
+```swift
+// Check current appearance
+let isDarkMode = traitCollection.userInterfaceStyle == .dark
+
+// React to appearance changes
+override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+ super.traitCollectionDidChange(previousTraitCollection)
+
+ if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
+ updateColorsForAppearance()
+ }
+}
+
+// Use dynamic colors
+let dynamicColor = UIColor { traitCollection in
+ switch traitCollection.userInterfaceStyle {
+ case .dark:
+ return UIColor.black // OLED: True black = pixels off = 0 power
+ default:
+ return UIColor.white
+ }
+}
+```
+
+### Frame Rate Control with CADisplayLink
+
+From WWDC22-10083:
+
+```swift
+class AnimationController {
+ private var displayLink: CADisplayLink?
+
+ func startAnimation() {
+ displayLink = CADisplayLink(target: self, selector: #selector(update))
+
+ // Control frame rate
+ displayLink?.preferredFrameRateRange = CAFrameRateRange(
+ minimum: 10, // Minimum acceptable
+ maximum: 30, // Maximum needed
+ preferred: 30 // Ideal rate
+ )
+
+ displayLink?.add(to: .current, forMode: .default)
+ }
+
+ @objc private func update(_ displayLink: CADisplayLink) {
+ // Update animation
+ updateAnimationFrame()
+ }
+
+ func stopAnimation() {
+ displayLink?.invalidate()
+ displayLink = nil
+ }
+}
+```
+
+### Stop Animations When Not Visible
+
+```swift
+class AnimatedViewController: UIViewController {
+ private var animator: UIViewPropertyAnimator?
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ startAnimations()
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopAnimations() // Critical for energy
+ }
+
+ private func stopAnimations() {
+ animator?.stopAnimation(true)
+ animator = nil
+ }
+}
+```
+
+---
+
+## Part 7: Disk I/O Efficiency APIs
+
+### Batch Writes
+
+```swift
+// BAD: Multiple small writes
+for item in items {
+ let data = try JSONEncoder().encode(item)
+ try data.write(to: fileURL) // Writes each item separately
+}
+
+// GOOD: Single batched write
+let allData = try JSONEncoder().encode(items)
+try allData.write(to: fileURL) // One write operation
+```
+
+### SQLite WAL Mode
+
+```swift
+import SQLite3
+
+// Enable Write-Ahead Logging
+var db: OpaquePointer?
+sqlite3_open(dbPath, &db)
+
+var statement: OpaquePointer?
+sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &statement, nil)
+sqlite3_step(statement)
+sqlite3_finalize(statement)
+```
+
+### XCTStorageMetric for Testing
+
+```swift
+import XCTest
+
+class DiskWriteTests: XCTestCase {
+ func testDiskWritePerformance() {
+ measure(metrics: [XCTStorageMetric()]) {
+ // Code that writes to disk
+ saveUserData()
+ }
+ }
+}
+```
+
+---
+
+## Part 8: Low Power Mode & Thermal Response APIs
+
+### Low Power Mode Detection
+
+```swift
+import Foundation
+
+class PowerStateManager {
+ private var cancellables = Set()
+
+ init() {
+ // Check initial state
+ updateForPowerState()
+
+ // Observe changes
+ NotificationCenter.default.publisher(
+ for: .NSProcessInfoPowerStateDidChange
+ )
+ .sink { [weak self] _ in
+ self?.updateForPowerState()
+ }
+ .store(in: &cancellables)
+ }
+
+ private func updateForPowerState() {
+ if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ reduceEnergyUsage()
+ } else {
+ restoreNormalOperation()
+ }
+ }
+
+ private func reduceEnergyUsage() {
+ // Increase timer intervals
+ // Reduce animation frame rates
+ // Defer network requests
+ // Stop location updates if not critical
+ // Reduce refresh frequency
+ }
+}
+```
+
+### Thermal State Response
+
+```swift
+import Foundation
+
+class ThermalManager {
+ init() {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(thermalStateChanged),
+ name: ProcessInfo.thermalStateDidChangeNotification,
+ object: nil
+ )
+ }
+
+ @objc private func thermalStateChanged() {
+ switch ProcessInfo.processInfo.thermalState {
+ case .nominal:
+ // Normal operation
+ restoreFullFunctionality()
+
+ case .fair:
+ // Slightly elevated, minor reduction
+ reduceNonEssentialWork()
+
+ case .serious:
+ // Significant reduction needed
+ suspendBackgroundTasks()
+ reduceAnimationQuality()
+
+ case .critical:
+ // Maximum reduction
+ minimizeAllActivity()
+ showThermalWarningIfAppropriate()
+
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+---
+
+## Part 9: MetricKit Monitoring APIs
+
+### Basic Setup
+
+```swift
+import MetricKit
+
+class MetricsManager: NSObject, MXMetricManagerSubscriber {
+ static let shared = MetricsManager()
+
+ func startMonitoring() {
+ MXMetricManager.shared.add(self)
+ }
+
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ processPayload(payload)
+ }
+ }
+
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
+ for payload in payloads {
+ processDiagnostic(payload)
+ }
+ }
+}
+```
+
+### Processing Energy Metrics
+
+```swift
+func processPayload(_ payload: MXMetricPayload) {
+ // CPU metrics
+ if let cpu = payload.cpuMetrics {
+ let foregroundTime = cpu.cumulativeCPUTime
+ let backgroundTime = cpu.cumulativeCPUInstructions
+ logMetric("cpu_foreground", value: foregroundTime)
+ }
+
+ // Location metrics
+ if let location = payload.locationActivityMetrics {
+ let backgroundLocationTime = location.cumulativeBackgroundLocationTime
+ logMetric("background_location_seconds", value: backgroundLocationTime)
+ }
+
+ // Network metrics
+ if let network = payload.networkTransferMetrics {
+ let cellularUpload = network.cumulativeCellularUpload
+ let cellularDownload = network.cumulativeCellularDownload
+ let wifiUpload = network.cumulativeWiFiUpload
+ let wifiDownload = network.cumulativeWiFiDownload
+
+ logMetric("cellular_upload", value: cellularUpload)
+ logMetric("cellular_download", value: cellularDownload)
+ }
+
+ // Disk metrics
+ if let disk = payload.diskIOMetrics {
+ let writes = disk.cumulativeLogicalWrites
+ logMetric("disk_writes", value: writes)
+ }
+
+ // GPU metrics
+ if let gpu = payload.gpuMetrics {
+ let gpuTime = gpu.cumulativeGPUTime
+ logMetric("gpu_time", value: gpuTime)
+ }
+}
+```
+
+### Xcode Organizer Integration
+
+View field metrics in Xcode:
+1. Window → Organizer
+2. Select your app
+3. Click "Battery Usage" in sidebar
+4. Compare versions, filter by device/OS
+
+Categories shown:
+- Audio
+- Networking
+- Processing (CPU + GPU)
+- Display
+- Bluetooth
+- Location
+- Camera
+- Torch
+- NFC
+- Other
+
+---
+
+## Part 10: Push Notifications APIs
+
+### Alert Notifications Setup
+
+From WWDC20-10095:
+
+```swift
+import UserNotifications
+
+class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
+
+ func setup() {
+ UNUserNotificationCenter.current().delegate = self
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+
+ func requestPermission() {
+ UNUserNotificationCenter.current().requestAuthorization(
+ options: [.alert, .sound, .badge]
+ ) { granted, error in
+ print("Permission granted: \(granted)")
+ }
+ }
+}
+
+// AppDelegate
+func application(
+ _ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
+) {
+ let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
+ sendTokenToServer(token)
+}
+
+func application(
+ _ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error
+) {
+ print("Failed to register: \(error)")
+}
+```
+
+### Background Push Notifications
+
+```swift
+// Handle background notification
+func application(
+ _ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+) {
+ // Check for content-available flag
+ guard let aps = userInfo["aps"] as? [String: Any],
+ aps["content-available"] as? Int == 1 else {
+ completionHandler(.noData)
+ return
+ }
+
+ Task {
+ do {
+ let hasNewData = try await fetchLatestContent()
+ completionHandler(hasNewData ? .newData : .noData)
+ } catch {
+ completionHandler(.failed)
+ }
+ }
+}
+```
+
+### Server Payload Examples
+
+```json
+// Alert notification (user-visible)
+{
+ "aps": {
+ "alert": {
+ "title": "New Message",
+ "body": "You have a new message from John"
+ },
+ "sound": "default",
+ "badge": 1
+ },
+ "message_id": "12345"
+}
+
+// Background notification (silent)
+{
+ "aps": {
+ "content-available": 1
+ },
+ "update_type": "new_content"
+}
+```
+
+### Push Priority Headers
+
+| Priority | Header | Use Case |
+|----------|--------|----------|
+| High (10) | `apns-priority: 10` | Time-sensitive alerts |
+| Low (5) | `apns-priority: 5` | Deferrable updates |
+
+**Energy tip**: Use priority 5 for all non-urgent notifications. System batches low-priority pushes for energy efficiency.
+
+---
+
+## Troubleshooting Checklist
+
+### Issue: App at Top of Battery Settings
+
+- [ ] Run Power Profiler to identify dominant subsystem
+- [ ] Check for timers without tolerance
+- [ ] Check for polling patterns
+- [ ] Check for continuous location
+- [ ] Check for background audio session
+- [ ] Verify BGTasks complete promptly
+
+### Issue: Device Gets Hot
+
+- [ ] Check GPU Power Impact for sustained high values
+- [ ] Look for continuous animations
+- [ ] Check for blur effects over dynamic content
+- [ ] Verify Metal frame limiting
+- [ ] Check CPU for tight loops
+
+### Issue: Background Battery Drain
+
+- [ ] Audit background modes in Info.plist
+- [ ] Verify audio session deactivated when not playing
+- [ ] Check location accuracy and stop calls
+- [ ] Verify beginBackgroundTask calls end promptly
+- [ ] Review BGTask scheduling
+
+### Issue: High Cellular Usage
+
+- [ ] Check allowsExpensiveNetworkAccess setting
+- [ ] Verify discretionary flag on background downloads
+- [ ] Look for polling patterns
+- [ ] Check for large automatic downloads
+
+---
+
+## Expert Review Checklist
+
+### Timers (10 items)
+- [ ] Tolerance ≥10% on all timers
+- [ ] Timers invalidated in deinit
+- [ ] No timers running when app backgrounded
+- [ ] Using Combine Timer where possible
+- [ ] No sub-second intervals without justification
+- [ ] Event-driven alternatives considered
+- [ ] No synchronization via timer polling
+- [ ] Timer invalidated before creating new one
+- [ ] Repeating timers have clear stop condition
+- [ ] Background timer usage justified
+
+### Network (10 items)
+- [ ] waitsForConnectivity = true
+- [ ] allowsExpensiveNetworkAccess appropriate
+- [ ] allowsConstrainedNetworkAccess appropriate
+- [ ] Non-urgent downloads use discretionary
+- [ ] Push notifications instead of polling
+- [ ] Requests batched where possible
+- [ ] Payloads compressed
+- [ ] Background URLSession for large transfers
+- [ ] Retry logic has exponential backoff
+- [ ] Connection reuse via single URLSession
+
+### Location (10 items)
+- [ ] Accuracy appropriate for use case
+- [ ] distanceFilter set
+- [ ] Updates stopped when not needed
+- [ ] pausesLocationUpdatesAutomatically = true
+- [ ] Background location only if essential
+- [ ] Significant-change for background
+- [ ] CLMonitor for region monitoring
+- [ ] Location permission matches actual need
+- [ ] Stationary detection utilized
+- [ ] Location icon explained to users
+
+### Background Execution (10 items)
+- [ ] endBackgroundTask called promptly
+- [ ] Expiration handlers implemented
+- [ ] BGTasks use requiresExternalPower when possible
+- [ ] EMRCA principles followed
+- [ ] Background modes limited to needed
+- [ ] Audio session deactivated when idle
+- [ ] Progress saved incrementally
+- [ ] Tasks complete within time limits
+- [ ] Low Power Mode checked before heavy work
+- [ ] Thermal state monitored
+
+### Display/GPU (10 items)
+- [ ] Dark Mode supported
+- [ ] Animations stop when view hidden
+- [ ] Frame rates appropriate for content
+- [ ] Secondary animations lower priority
+- [ ] Blur effects minimized
+- [ ] Metal has frame limiting
+- [ ] Brightness-independent design
+- [ ] No hidden animations consuming power
+- [ ] GPU-intensive work has visibility checks
+- [ ] ProMotion considered in frame rate decisions
+
+---
+
+## WWDC Session Reference
+
+| Session | Year | Topic |
+|---------|------|-------|
+| 226 | 2025 | Power Profiler workflow, on-device tracing |
+| 227 | 2025 | BGContinuedProcessingTask, EMRCA principles |
+| 10083 | 2022 | Dark Mode, frame rates, deferral |
+| 10095 | 2020 | Push notifications primer |
+| 707 | 2019 | Background execution advances |
+| 417 | 2019 | Battery life, MetricKit |
+
+---
+
+**Last Updated**: 2025-12-26
+**Platforms**: iOS 26+, iPadOS 26+
diff --git a/.claude/skills/axiom-energy-ref/agents/openai.yaml b/.claude/skills/axiom-energy-ref/agents/openai.yaml
new file mode 100644
index 0000000..d15b40e
--- /dev/null
+++ b/.claude/skills/axiom-energy-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Energy Reference"
+ short_description: "Complete energy optimization API reference"
diff --git a/.claude/skills/axiom-energy/.openskills.json b/.claude/skills/axiom-energy/.openskills.json
new file mode 100644
index 0000000..36edc27
--- /dev/null
+++ b/.claude/skills/axiom-energy/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-energy",
+ "installedAt": "2026-04-12T08:06:12.950Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-energy/SKILL.md b/.claude/skills/axiom-energy/SKILL.md
new file mode 100644
index 0000000..1648111
--- /dev/null
+++ b/.claude/skills/axiom-energy/SKILL.md
@@ -0,0 +1,851 @@
+---
+name: axiom-energy
+description: Use when app drains battery, device gets hot, users report energy issues, or auditing power consumption - systematic Power Profiler diagnosis, subsystem identification (CPU/GPU/Network/Location/Display), anti-pattern fixes for iOS/iPadOS
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Energy Optimization
+
+## Overview
+
+Energy issues manifest as battery drain, hot devices, and poor App Store reviews. **Core principle**: Measure before optimizing. Use Power Profiler to identify the dominant subsystem (CPU/GPU/Network/Location/Display), then apply targeted fixes.
+
+**Key insight**: Developers often don't know where to START auditing. This skill provides systematic diagnosis, not guesswork.
+
+**Requirements**: iOS 26+, Xcode 26+, Power Profiler in Instruments
+
+## Example Prompts
+
+Real questions developers ask that this skill answers:
+
+#### 1. "My app is always at the top of Battery Settings. How do I find what's draining power?"
+→ The skill covers Power Profiler workflow to identify dominant subsystem and targeted fixes
+
+#### 2. "Users report my app makes their phone hot. Where do I start debugging?"
+→ The skill provides decision tree: CPU vs GPU vs Network diagnosis with specific patterns
+
+#### 3. "I have timers and location updates. Are they causing battery drain?"
+→ The skill covers timer tolerance, location accuracy trade-offs, and audit checklists
+
+#### 4. "My app drains battery in the background even when users aren't using it."
+→ The skill covers background execution patterns, BGTasks, and EMRCA principles
+
+#### 5. "How do I measure if my optimization actually improved battery life?"
+→ The skill demonstrates before/after Power Profiler comparison workflow
+
+---
+
+## Red Flags — High Energy Likely
+
+If you see ANY of these, suspect energy inefficiency:
+
+- **Battery Settings**: Your app consistently at top of battery consumers
+- **Device temperature**: Phone gets warm during normal app use
+- **User reviews**: Mentions of "battery drain", "hot phone", "kills my battery"
+- **Xcode Energy Gauge**: Shows sustained high or very high impact
+- **Background runtime**: App runs longer than expected when not visible
+- **Network activity**: Frequent small requests instead of batched operations
+- **Location icon**: Appears in status bar when app shouldn't need location
+
+#### Difference from normal energy use
+- **Normal**: App uses energy during active use, minimal when backgrounded
+- **Problem**: App uses significant energy even when user isn't interacting
+
+## Mandatory First Steps
+
+**ALWAYS run Power Profiler FIRST** before optimizing code:
+
+### Step 1: Record a Power Trace (5 minutes)
+
+```
+1. Connect iPhone wirelessly to Xcode (wireless debugging)
+2. Xcode → Product → Profile (Cmd+I)
+3. Select Blank template
+4. Click "+" → Add "Power Profiler" instrument
+5. Optional: Add "CPU Profiler" for correlation
+6. Click Record
+7. Use your app normally for 2-3 minutes
+8. Click Stop
+```
+
+**Why wireless**: When device is charging via cable, power metrics show 0. Use wireless debugging for accurate readings.
+
+### Step 2: Identify Dominant Subsystem
+
+Expand the Power Profiler track and examine per-app metrics:
+
+| Lane | Meaning | High Value Indicates |
+|------|---------|---------------------|
+| CPU Power Impact | Processor activity | Computation, timers, parsing |
+| GPU Power Impact | Graphics rendering | Animations, blur, Metal |
+| Display Power Impact | Screen usage | Brightness, always-on content |
+| Network Power Impact | Radio activity | Requests, downloads, polling |
+
+**Look for**: Which subsystem shows highest sustained values during your app's usage.
+
+### Step 3: Branch to Subsystem-Specific Fixes
+
+Once you identify the dominant subsystem, use the decision trees below.
+
+#### What this tells you
+- **CPU dominant** → Check timers, polling, JSON parsing, eager loading
+- **GPU dominant** → Check animations, blur effects, frame rates
+- **Network dominant** → Check request frequency, polling vs push
+- **Display dominant** → Check Dark Mode, brightness, screen-on time
+- **Location** (shown in CPU) → Check accuracy, update frequency
+
+#### Why diagnostics first
+- Finding root cause with Power Profiler: **15-20 minutes**
+- Guessing and testing random optimizations: **4+ hours, often wrong subsystem**
+
+---
+
+## Energy Decision Tree
+
+```
+User reports energy issue?
+│
+├─ CPU Power Impact dominant?
+│ ├─ Continuous high impact?
+│ │ ├─ Timers running? → Pattern 1: Timer Efficiency
+│ │ ├─ Polling data? → Pattern 2: Push vs Poll
+│ │ └─ Processing in loop? → Pattern 3: Lazy Loading
+│ ├─ Spikes during specific actions?
+│ │ ├─ JSON parsing? → Cache parsed results
+│ │ ├─ Image processing? → Move to background, cache
+│ │ └─ Database queries? → Index, batch, prefetch
+│ └─ High background CPU?
+│ ├─ Location updates? → Pattern 4: Location Efficiency
+│ ├─ BGTasks running too long? → Pattern 5: Background Execution
+│ └─ Audio session active? → Stop when not playing
+│
+├─ Network Power Impact dominant?
+│ ├─ Many small requests?
+│ │ └─ Batch into fewer large requests
+│ ├─ Polling pattern detected?
+│ │ └─ Convert to push notifications → Pattern 2
+│ ├─ Downloads in foreground?
+│ │ └─ Use discretionary background URLSession
+│ └─ High cellular usage?
+│ └─ Defer to WiFi when possible
+│
+├─ GPU Power Impact dominant?
+│ ├─ Continuous animations?
+│ │ └─ Stop when view not visible
+│ ├─ Blur effects (UIVisualEffectView)?
+│ │ └─ Reduce or remove, use solid colors
+│ ├─ High frame rate animations?
+│ │ └─ Audit secondary frame rates → Pattern 6
+│ └─ Metal rendering?
+│ └─ Implement frame limiting
+│
+├─ Display Power Impact dominant?
+│ ├─ Light backgrounds on OLED?
+│ │ └─ Implement Dark Mode (up to 70% savings)
+│ ├─ High brightness content?
+│ │ └─ Use darker UI elements
+│ └─ Screen always on?
+│ └─ Allow screen to sleep when appropriate
+│
+└─ Location causing drain? (check CPU lane + location icon)
+ ├─ Continuous updates?
+ │ └─ Switch to significant-change monitoring
+ ├─ High accuracy (kCLLocationAccuracyBest)?
+ │ └─ Reduce to kCLLocationAccuracyHundredMeters
+ └─ Background location?
+ └─ Evaluate if truly needed → Pattern 4
+```
+
+---
+
+## Common Energy Patterns (With Fixes)
+
+### Pattern 1: Timer Efficiency
+
+**Problem**: Timers wake the CPU from idle states, consuming significant energy.
+
+#### ❌ Anti-Pattern — Timer without tolerance
+```swift
+// BAD: Timer fires exactly every 1.0 seconds
+// Prevents system from batching with other timers
+Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ self.updateUI()
+}
+```
+
+#### ✅ Fix — Set tolerance for timer batching
+```swift
+// GOOD: 10% tolerance allows system to batch timers
+let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ self.updateUI()
+}
+timer.tolerance = 0.1 // 10% tolerance minimum
+
+// BETTER: Use Combine Timer with tolerance
+Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
+ .autoconnect()
+ .sink { [weak self] _ in
+ self?.updateUI()
+ }
+ .store(in: &cancellables)
+```
+
+#### ✅ Best — Use event-driven instead of polling
+```swift
+// BEST: Don't use timer at all — react to events
+NotificationCenter.default.publisher(for: .dataDidUpdate)
+ .sink { [weak self] _ in
+ self?.updateUI()
+ }
+ .store(in: &cancellables)
+```
+
+**Key points**:
+- Set tolerance to **at least 10%** of interval
+- Timer tolerance allows system to batch multiple timers into single wake
+- Prefer event-driven patterns over polling timers
+- Always invalidate timers when no longer needed
+
+---
+
+### Pattern 2: Push vs Poll
+
+**Problem**: Polling (checking server every N seconds) keeps radios active and drains battery.
+
+#### ❌ Anti-Pattern — Polling every 5 seconds
+```swift
+// BAD: Polls server every 5 seconds
+// Radio stays active, massive battery drain
+Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
+ self?.fetchLatestData() // Network request every 5 seconds
+}
+```
+
+#### ✅ Fix — Use background push notifications
+```swift
+// GOOD: Server pushes when data changes
+// Radio only active when there's actual new data
+
+// 1. Register for remote notifications
+UIApplication.shared.registerForRemoteNotifications()
+
+// 2. Handle background notification
+func application(_ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
+
+ guard let _ = userInfo["content-available"] else {
+ completionHandler(.noData)
+ return
+ }
+
+ Task {
+ do {
+ let hasNewData = try await fetchLatestData()
+ completionHandler(hasNewData ? .newData : .noData)
+ } catch {
+ completionHandler(.failed)
+ }
+ }
+}
+```
+
+**Server payload for background push**:
+```json
+{
+ "aps": {
+ "content-available": 1
+ },
+ "custom-data": "your-payload"
+}
+```
+
+**Key points**:
+- Background pushes are **discretionary** — system delivers at optimal time
+- Use `apns-priority: 5` for non-urgent updates (energy efficient)
+- Use `apns-priority: 10` only for time-sensitive alerts
+- Polling every 5 seconds uses **100x more energy** than push
+
+---
+
+### Pattern 3: Lazy Loading & Caching
+
+**Problem**: Loading all data upfront causes CPU spikes and memory pressure.
+
+#### ❌ Anti-Pattern — Eager loading (from WWDC25-226)
+```swift
+// BAD: Creates and renders ALL views upfront
+// From WWDC25-226: This caused CPU spike and hang
+VStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video) // Creates ALL thumbnails immediately
+ }
+}
+```
+
+#### ✅ Fix — Lazy loading
+```swift
+// GOOD: Only creates visible views
+// From WWDC25-226: Reduced CPU power impact from 21 to 4.3
+LazyVStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video) // Creates on-demand
+ }
+}
+```
+
+#### ❌ Anti-Pattern — Repeated parsing (from WWDC25-226)
+```swift
+// BAD: Parses JSON file on every location update
+// From WWDC25-226: Caused continuous CPU drain during commute
+func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
+ // Called every location change!
+ let data = try? Data(contentsOf: rulesFileURL)
+ let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
+ return filteredVideos(using: rules)
+}
+```
+
+#### ✅ Fix — Cache parsed data
+```swift
+// GOOD: Parse once, reuse cached result
+// From WWDC25-226: Eliminated CPU drain
+private lazy var cachedRules: [RecommendationRule] = {
+ let data = try? Data(contentsOf: rulesFileURL)
+ return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
+}()
+
+func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
+ return filteredVideos(using: cachedRules) // No parsing!
+}
+```
+
+**Key points**:
+- Use `LazyVStack`, `LazyHStack`, `LazyVGrid` for large collections
+- Cache parsed JSON, decoded data, computed results
+- Move expensive operations out of frequently-called methods
+
+---
+
+### Pattern 4: Location Efficiency
+
+**Problem**: Continuous location updates keep GPS active, draining battery rapidly.
+
+#### ❌ Anti-Pattern — Continuous high-accuracy updates
+```swift
+// BAD: Continuous updates with best accuracy
+// GPS stays active constantly, massive battery drain
+let locationManager = CLLocationManager()
+locationManager.desiredAccuracy = kCLLocationAccuracyBest
+locationManager.startUpdatingLocation() // Never stops!
+```
+
+#### ✅ Fix — Appropriate accuracy and significant-change
+```swift
+// GOOD: Reduced accuracy, significant-change monitoring
+let locationManager = CLLocationManager()
+
+// Use appropriate accuracy (100m is fine for most apps)
+locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
+
+// Use distance filter to reduce updates
+locationManager.distanceFilter = 100 // Only update every 100 meters
+
+// For background: Use significant-change monitoring
+locationManager.startMonitoringSignificantLocationChanges()
+
+// Stop when done
+func stopTracking() {
+ locationManager.stopUpdatingLocation()
+ locationManager.stopMonitoringSignificantLocationChanges()
+}
+```
+
+#### ✅ Better — iOS 26+ CLLocationUpdate with stationary detection
+```swift
+// BEST: Modern async API with automatic stationary detection
+for try await update in CLLocationUpdate.liveUpdates() {
+ if update.stationary {
+ // Device stopped moving — system pauses updates automatically
+ // Switch to CLMonitor for region monitoring
+ break
+ }
+ handleLocation(update.location)
+}
+```
+
+**Accuracy comparison (battery impact)**:
+| Accuracy | Battery Impact | Use Case |
+|----------|---------------|----------|
+| `kCLLocationAccuracyBest` | Very High | Navigation apps only |
+| `kCLLocationAccuracyNearestTenMeters` | High | Fitness tracking |
+| `kCLLocationAccuracyHundredMeters` | Medium | Store locators |
+| `kCLLocationAccuracyKilometer` | Low | Weather apps |
+| Significant-change | Very Low | Background updates |
+
+---
+
+### Pattern 5: Background Execution (EMRCA)
+
+**Problem**: Background tasks that run too long or too often drain battery.
+
+#### EMRCA Principles (from WWDC25-227)
+
+Your background work must be:
+- **E**fficient — Design lightweight, purpose-driven tasks
+- **M**inimal — Keep background work to a minimum
+- **R**esilient — Save incremental progress; respond to expiration signals
+- **C**ourteous — Honor user preferences and system conditions
+- **A**daptive — Understand and adapt to system priorities
+
+#### ❌ Anti-Pattern — Long-running background task
+```swift
+// BAD: Requests unlimited background time
+// System will terminate after ~30 seconds anyway
+var backgroundTask: UIBackgroundTaskIdentifier = .invalid
+
+func applicationDidEnterBackground(_ application: UIApplication) {
+ backgroundTask = application.beginBackgroundTask {
+ // Expiration handler — but task runs too long
+ }
+
+ // Long operation that may not complete
+ performLongOperation()
+}
+```
+
+#### ✅ Fix — Proper background task handling
+```swift
+// GOOD: Finish quickly, save progress, notify system
+var backgroundTask: UIBackgroundTaskIdentifier = .invalid
+
+func applicationDidEnterBackground(_ application: UIApplication) {
+ backgroundTask = application.beginBackgroundTask(withName: "Save State") { [weak self] in
+ // Expiration handler — clean up immediately
+ self?.saveProgress()
+ if let task = self?.backgroundTask {
+ application.endBackgroundTask(task)
+ }
+ self?.backgroundTask = .invalid
+ }
+
+ // Quick operation
+ saveEssentialState()
+
+ // End task as soon as done — don't wait for expiration
+ application.endBackgroundTask(backgroundTask)
+ backgroundTask = .invalid
+}
+```
+
+#### ✅ For Long Operations — Use BGProcessingTask
+```swift
+// BEST: Let system schedule at optimal time (charging, WiFi)
+func scheduleBackgroundProcessing() {
+ let request = BGProcessingTaskRequest(identifier: "com.app.maintenance")
+ request.requiresNetworkConnectivity = true
+ request.requiresExternalPower = true // Only when charging
+
+ try? BGTaskScheduler.shared.submit(request)
+}
+
+// Register handler at app launch
+BGTaskScheduler.shared.register(
+ forTaskWithIdentifier: "com.app.maintenance",
+ using: nil
+) { task in
+ self.handleMaintenance(task: task as! BGProcessingTask)
+}
+```
+
+#### ✅ iOS 26+ — BGContinuedProcessingTask for user-initiated work
+```swift
+// NEW iOS 26: Continue user-initiated tasks with progress UI
+let request = BGContinuedProcessingTaskRequest(
+ identifier: "com.app.export",
+ title: "Exporting Photos",
+ subtitle: "23 of 100 photos"
+)
+
+try? BGTaskScheduler.shared.submit(request)
+```
+
+---
+
+### Pattern 6: Frame Rate Auditing
+
+**Problem**: Secondary animations running at higher frame rates than needed increase GPU power.
+
+#### ❌ Anti-Pattern — Uncontrolled frame rates
+```swift
+// BAD: Secondary animation runs at 60fps
+// When primary content only needs 30fps, this wastes power
+UIView.animate(withDuration: 2.0, delay: 0, options: [.repeat]) {
+ self.subtitleLabel.alpha = 0.5
+} completion: { _ in
+ self.subtitleLabel.alpha = 1.0
+}
+```
+
+#### ✅ Fix — Control frame rate with CADisplayLink
+```swift
+// GOOD: Explicitly set preferred frame rate
+let displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation))
+displayLink.preferredFrameRateRange = CAFrameRateRange(
+ minimum: 10,
+ maximum: 30, // Match primary content
+ preferred: 30
+)
+displayLink.add(to: .current, forMode: .default)
+```
+
+**From WWDC22-10083**: Up to **20% battery savings** by aligning secondary animation frame rates with primary content.
+
+---
+
+## Audit Checklists
+
+### Timer Audit
+- [ ] All timers have tolerance set (≥10% of interval)?
+- [ ] Timers invalidated when no longer needed?
+- [ ] Using Combine Timer instead of NSTimer where possible?
+- [ ] No polling patterns that could use push notifications?
+- [ ] Timers stopped when app enters background?
+
+### Network Audit
+- [ ] Requests batched instead of many small requests?
+- [ ] Using discretionary URLSession for non-urgent downloads?
+- [ ] `waitsForConnectivity` set to avoid failed connection attempts?
+- [ ] `allowsExpensiveNetworkAccess` set to false for deferrable work?
+- [ ] Push notifications instead of polling?
+
+### Location Audit
+- [ ] Using appropriate accuracy (not `kCLLocationAccuracyBest` unless navigation)?
+- [ ] `distanceFilter` set to reduce update frequency?
+- [ ] Stopping updates when no longer needed?
+- [ ] Using significant-change for background updates?
+- [ ] Background location justified and explained to users?
+
+### Background Execution Audit
+- [ ] `endBackgroundTask` called promptly when work completes?
+- [ ] Long operations use `BGProcessingTask` with `requiresExternalPower`?
+- [ ] Background modes in Info.plist limited to what's actually needed?
+- [ ] Audio session deactivated when not playing?
+- [ ] EMRCA principles followed?
+
+### Display/GPU Audit
+- [ ] Dark Mode supported (70% OLED power savings)?
+- [ ] Animations stopped when view not visible?
+- [ ] Secondary animations use appropriate frame rates?
+- [ ] Blur effects minimized or removed?
+- [ ] Metal rendering has frame limiting?
+
+### Disk I/O Audit
+- [ ] Writes batched instead of frequent small writes?
+- [ ] SQLite using WAL journaling mode?
+- [ ] Avoiding rapid file creation/deletion?
+- [ ] Using SwiftData/Core Data instead of serialized files for frequent updates?
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Just poll every 5 seconds for real-time updates"
+
+**The temptation**: "Push notifications are complex. Polling is simpler."
+
+**The reality**:
+- Polling every 5 seconds: Radio active **100% of time**
+- Push notifications: Radio active **only when data changes**
+- Users WILL see your app at top of Battery Settings
+- App Store reviews WILL mention "battery hog"
+
+**Time cost comparison**:
+- Implement polling: 30 minutes
+- Implement push: 2-4 hours
+- Fix bad reviews + reputation damage: Weeks
+
+**Pushback template**: "Push notification setup takes a few hours, but polling will guarantee we're at the top of Battery Settings. Users actively uninstall apps that drain battery. The 2-hour investment prevents ongoing reputation damage."
+
+---
+
+### Scenario 2: "Use continuous location for best accuracy"
+
+**The temptation**: "Users expect accurate location. Let's use `kCLLocationAccuracyBest`."
+
+**The reality**:
+- `kCLLocationAccuracyBest`: GPS + WiFi + Cellular triangulation = **massive drain**
+- `kCLLocationAccuracyHundredMeters`: Good enough for 95% of use cases
+- Location icon in status bar = users checking Battery Settings
+
+**Time cost comparison**:
+- Implement high accuracy: 10 minutes
+- Debug "why does my app drain battery" complaints: Hours
+- Refactor to appropriate accuracy: 30 minutes
+
+**Pushback template**: "100-meter accuracy is sufficient for [use case]. Navigation apps like Google Maps need best accuracy, but we're showing [store locations / weather / general area]. The accuracy difference is imperceptible to users, but battery difference is massive."
+
+---
+
+### Scenario 3: "Keep animations running, users expect smooth UI"
+
+**The temptation**: "Animations make the app feel alive and polished."
+
+**The reality**:
+- Animations running when view not visible = pure waste
+- High frame rate secondary animations = GPU drain
+- GPU power is significant portion of total device power
+
+**Time cost comparison**:
+- Add animation: 15 minutes
+- Add visibility checks: 5 minutes extra
+- Debug "phone gets hot" reports: Hours
+
+**Pushback template**: "We can keep the animation, but should pause it when the view isn't visible. This is a 5-minute change that prevents GPU drain when users aren't looking at the screen."
+
+---
+
+### Scenario 4: "Ship now, optimize later"
+
+**The temptation**: "Energy optimization is polish. We can do it in v1.1."
+
+**The reality**:
+- Battery drain is **immediately visible** to users
+- First impressions drive reviews
+- "Battery hog" reputation is hard to shake
+- Power Profiler baseline takes **15 minutes**
+
+**Time cost comparison**:
+- Power Profiler check before launch: 15 minutes
+- Fix energy issues post-launch: Days (plus reputation damage)
+- Regain user trust: Months
+
+**Pushback template**: "A 15-minute Power Profiler session before launch catches major energy issues. If we ship with battery problems, users will see us at top of Battery Settings on day one and leave 1-star reviews. Let me do a quick check — it's faster than damage control."
+
+---
+
+## Real-World Examples
+
+### Example 1: Video Streaming App with Eager Loading (WWDC25-226)
+
+**Symptom**: CPU power impact jumped from 1 to 21 when opening Library pane. UI hung.
+
+**Diagnosis using Power Profiler**:
+1. Recorded trace while opening Library pane
+2. CPU Power Impact lane showed massive spike
+3. Time Profiler showed `VideoCardView` body called hundreds of times
+4. Root cause: `VStack` creating ALL video thumbnails upfront
+
+**Fix**:
+```swift
+// Before: VStack (eager)
+VStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video)
+ }
+}
+
+// After: LazyVStack (on-demand)
+LazyVStack {
+ ForEach(videos) { video in
+ VideoCardView(video: video)
+ }
+}
+```
+
+**Result**: CPU power impact dropped from 21 to 4.3. UI no longer hung.
+
+---
+
+### Example 2: Location-Based Suggestions with Repeated Parsing (WWDC25-226)
+
+**Symptom**: User commuting reported massive battery drain. Developer couldn't reproduce at desk.
+
+**Diagnosis using on-device Power Profiler**:
+1. User collected trace during commute (Settings → Developer → Performance Trace)
+2. Trace showed periodic CPU spikes correlating with movement
+3. Time Profiler showed `videoSuggestionsForLocation` consuming CPU
+4. Root cause: JSON file parsed on EVERY location update
+
+**Fix**:
+```swift
+// Before: Parse on every call
+func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
+ let data = try? Data(contentsOf: rulesFileURL)
+ let rules = try? JSONDecoder().decode([RecommendationRule].self, from: data)
+ return filteredVideos(using: rules)
+}
+
+// After: Parse once, cache
+private lazy var cachedRules: [RecommendationRule] = {
+ let data = try? Data(contentsOf: rulesFileURL)
+ return (try? JSONDecoder().decode([RecommendationRule].self, from: data)) ?? []
+}()
+
+func videoSuggestionsForLocation(_ location: CLLocation) -> [Video] {
+ return filteredVideos(using: cachedRules)
+}
+```
+
+**Result**: Eliminated CPU spikes during movement. Battery drain resolved.
+
+---
+
+### Example 3: Music App with Always-Active Audio Session
+
+**Symptom**: App drains battery even when not playing music.
+
+**Diagnosis**:
+1. Power Profiler showed sustained background CPU activity
+2. Audio session remained active after playback stopped
+3. System kept audio hardware powered on
+
+**Fix**:
+```swift
+// Before: Never deactivate
+func playTrack(_ track: Track) {
+ try? AVAudioSession.sharedInstance().setActive(true)
+ player.play()
+}
+
+func stopPlayback() {
+ player.stop()
+ // Audio session still active!
+}
+
+// After: Deactivate when done
+func stopPlayback() {
+ player.stop()
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+}
+
+// Even better: Use AVAudioEngine auto-shutdown
+let engine = AVAudioEngine()
+engine.isAutoShutdownEnabled = true // Automatically powers down when idle
+```
+
+**Result**: Background audio hardware powered down. Battery drain eliminated.
+
+---
+
+## Responding to Low Power Mode
+
+Detect and adapt when user enables Low Power Mode:
+
+```swift
+// Check current state
+if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ reduceEnergyUsage()
+}
+
+// React to changes
+NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)
+ .sink { [weak self] _ in
+ if ProcessInfo.processInfo.isLowPowerModeEnabled {
+ self?.reduceEnergyUsage()
+ } else {
+ self?.restoreNormalOperation()
+ }
+ }
+ .store(in: &cancellables)
+
+func reduceEnergyUsage() {
+ // Pause optional activities
+ // Reduce animation frame rates
+ // Increase timer intervals
+ // Defer network requests
+ // Stop location updates if not critical
+}
+```
+
+---
+
+## Monitoring Energy in Production
+
+### MetricKit Setup
+
+```swift
+import MetricKit
+
+class EnergyMetricsManager: NSObject, MXMetricManagerSubscriber {
+ static let shared = EnergyMetricsManager()
+
+ func startMonitoring() {
+ MXMetricManager.shared.add(self)
+ }
+
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ if let cpuMetrics = payload.cpuMetrics {
+ // Monitor CPU time
+ let foregroundCPU = cpuMetrics.cumulativeCPUTime
+ logMetric("foreground_cpu", value: foregroundCPU)
+ }
+
+ if let locationMetrics = payload.locationActivityMetrics {
+ // Monitor location usage
+ let backgroundLocation = locationMetrics.cumulativeBackgroundLocationTime
+ logMetric("background_location", value: backgroundLocation)
+ }
+ }
+ }
+}
+```
+
+### Xcode Organizer
+
+Check **Battery Usage** pane in Xcode Organizer for field data:
+- Foreground vs background energy breakdown
+- Category breakdown (Audio, Networking, Processing, Display, etc.)
+- Version comparison to detect regressions
+
+---
+
+## Quick Reference
+
+### Power Profiler Workflow
+```
+1. Connect device wirelessly
+2. Product → Profile → Blank → Add Power Profiler
+3. Record 2-3 minutes of usage
+4. Identify dominant subsystem (CPU/GPU/Network/Display)
+5. Apply targeted fix from patterns above
+6. Record again to verify improvement
+```
+
+### Key Energy Savings
+| Optimization | Potential Savings |
+|--------------|------------------|
+| Dark Mode on OLED | Up to 70% display power |
+| Frame rate alignment | Up to 20% GPU power |
+| Push vs poll | 100x network efficiency |
+| Location accuracy reduction | 50-90% GPS power |
+| Timer tolerance | Significant CPU savings |
+| Lazy loading | Eliminates startup CPU spikes |
+
+### Related Skills
+- `axiom-energy-ref` — Complete API reference with all code examples
+- `axiom-energy-diag` — Symptom-based troubleshooting decision trees
+- `axiom-background-processing` — Background task mechanics (why tasks don't run)
+- `axiom-performance-profiling` — General Instruments workflows
+- `axiom-memory-debugging` — Memory leak diagnosis (often related to energy)
+- `axiom-networking` — Network optimization patterns
+- `axiom-timer-patterns` — Timer crash prevention and lifecycle safety
+
+---
+
+## WWDC Sessions
+
+- **WWDC25-226** "Profile and optimize power usage in your app" — Power Profiler workflow
+- **WWDC25-227** "Finish tasks in the background" — BGContinuedProcessingTask, EMRCA
+- **WWDC22-10083** "Power down: Improve battery consumption" — Dark Mode, frame rates, deferral
+- **WWDC20-10095** "The Push Notifications primer" — Push vs poll
+- **WWDC19-417** "Improving Battery Life and Performance" — MetricKit
+
+---
+
+**Last Updated**: 2025-12-26
+**Platforms**: iOS 26+, iPadOS 26+
+**Status**: Production-ready energy optimization patterns
diff --git a/.claude/skills/axiom-energy/agents/openai.yaml b/.claude/skills/axiom-energy/agents/openai.yaml
new file mode 100644
index 0000000..cfe5b2c
--- /dev/null
+++ b/.claude/skills/axiom-energy/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Energy"
+ short_description: "App drains battery, device gets hot, users report energy issues, or auditing power consumption"
diff --git a/.claude/skills/axiom-eventkit-ref/.openskills.json b/.claude/skills/axiom-eventkit-ref/.openskills.json
new file mode 100644
index 0000000..0a707e3
--- /dev/null
+++ b/.claude/skills/axiom-eventkit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-eventkit-ref",
+ "installedAt": "2026-04-12T08:06:15.285Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-eventkit-ref/SKILL.md b/.claude/skills/axiom-eventkit-ref/SKILL.md
new file mode 100644
index 0000000..5f9600a
--- /dev/null
+++ b/.claude/skills/axiom-eventkit-ref/SKILL.md
@@ -0,0 +1,611 @@
+---
+name: axiom-eventkit-ref
+description: Use when needing EventKit API details — EKEventStore, EKEvent, EKReminder, EventKitUI view controllers, EKCalendarChooser, authorization methods, predicate-based fetching, recurrence rules, Siri Event Suggestions donation, EKVirtualConferenceProvider, location-based reminders, and EKErrorDomain codes
+license: MIT
+---
+
+# EventKit API Reference
+
+## Overview
+
+EventKit provides programmatic access to the Calendar and Reminders databases. EventKitUI provides system view controllers for calendar UI. This reference covers the complete API surface for both frameworks.
+
+For access tier decision tree and best practices, see the **eventkit** discipline skill.
+
+**Platform**: iOS 4.0+, iPadOS 4.0+, macOS 10.8+, Mac Catalyst 13.1+, watchOS 2.0+, visionOS 1.0+
+
+---
+
+# Part 1: EKEventStore
+
+The central hub for all calendar and reminder operations. Create one per app and reuse it.
+
+## Initialization
+
+```swift
+let store = EKEventStore() // Standard
+let store = EKEventStore(sources: [source]) // Scoped to specific sources
+```
+
+## Authorization (iOS 17+)
+
+```swift
+// Events
+try await store.requestWriteOnlyAccessToEvents() // Returns Bool
+try await store.requestFullAccessToEvents() // Returns Bool
+
+// Reminders (full access only)
+try await store.requestFullAccessToReminders() // Returns Bool
+
+// Check status
+let status = EKEventStore.authorizationStatus(for: .event) // Static method
+// Returns: .notDetermined, .restricted, .denied, .fullAccess, .writeOnly
+// Deprecated: .authorized (maps to .fullAccess conceptually)
+```
+
+## Info.plist Keys
+
+| Key | When Required |
+|-----|---------------|
+| `NSCalendarsWriteOnlyAccessUsageDescription` | Write-only access, iOS 17+ |
+| `NSCalendarsFullAccessUsageDescription` | Full event access, iOS 17+ |
+| `NSRemindersFullAccessUsageDescription` | Reminder access, iOS 17+ |
+| `NSCalendarsUsageDescription` | Calendar access, iOS 10-16 (keep for backward compat) |
+| `NSRemindersUsageDescription` | Reminder access, iOS 10-16 (keep for backward compat) |
+| `NSContactsUsageDescription` | Required if using EventKitUI on iOS <17 |
+
+**Missing key on iOS 17+**: Silent denial (no prompt, no error, no crash).
+**Missing key on iOS 10-16**: Crash.
+
+## Calendar & Source Access
+
+```swift
+store.calendars(for: .event) // [EKCalendar] — all event calendars
+store.calendars(for: .reminder) // [EKCalendar] — all reminder calendars
+store.calendar(withIdentifier: id) // EKCalendar?
+store.defaultCalendarForNewEvents // EKCalendar? — user's default
+store.defaultCalendarForNewReminders() // EKCalendar?
+store.sources // [EKSource] — all accounts
+store.delegateSources // [EKSource] — delegate accounts
+```
+
+## Calendar Management
+
+```swift
+try store.saveCalendar(calendar, commit: true)
+try store.removeCalendar(calendar, commit: true)
+```
+
+## Event Operations
+
+```swift
+// Fetch by identifier
+store.event(withIdentifier: id) // EKEvent? — first occurrence for recurring
+store.calendarItem(withIdentifier: id) // EKCalendarItem? — event or reminder
+store.calendarItems(withExternalIdentifier: extId) // [EKCalendarItem]
+
+// Save and remove
+try store.save(event, span: .thisEvent, commit: true)
+try store.remove(event, span: .thisEvent, commit: true)
+// span: .thisEvent | .futureEvents (controls recurring event behavior)
+```
+
+## Event Fetching (Synchronous — run on background thread)
+
+```swift
+let predicate = store.predicateForEvents(
+ withStart: startDate, end: endDate, calendars: nil // nil = all calendars
+)
+let events = store.events(matching: predicate)
+// Results are NOT sorted — sort manually:
+let sorted = events.sorted { $0.compareStartDate(with: $1) == .orderedAscending }
+```
+
+**Only Apple-provided predicates work.** Custom `NSPredicate` instances are rejected.
+
+## Reminder Fetching (Asynchronous)
+
+```swift
+// Predicates
+store.predicateForReminders(in: calendars) // nil = all
+store.predicateForIncompleteReminders(
+ withDueDateStarting: start, ending: end, calendars: nil
+)
+store.predicateForCompletedReminders(
+ withCompletionDateStarting: start, ending: end, calendars: nil
+)
+
+// Fetch (async callback)
+let fetchId = store.fetchReminders(matching: predicate) { reminders in
+ // reminders: [EKReminder]?
+}
+store.cancelFetchRequest(fetchId) // Cancel if needed
+```
+
+## Batch Operations
+
+```swift
+try store.save(event1, span: .thisEvent, commit: false)
+try store.save(event2, span: .thisEvent, commit: false)
+try store.commit() // Atomic commit
+store.reset() // Rollback on failure
+```
+
+## Change Notifications
+
+```swift
+NotificationCenter.default.addObserver(
+ self, selector: #selector(storeChanged),
+ name: .EKEventStoreChanged, object: store
+)
+// Posted when external processes modify the calendar database
+// Call event.refresh() on cached objects — returns false if deleted
+```
+
+---
+
+# Part 2: EKEvent
+
+Represents a calendar event. Inherits from `EKCalendarItem`.
+
+## Creation
+
+```swift
+let event = EKEvent(eventStore: store)
+```
+
+## Key Properties
+
+| Property | Type | Notes |
+|----------|------|-------|
+| `title` | `String` | Required for save |
+| `startDate` | `Date` | Required for save |
+| `endDate` | `Date` | Required for save |
+| `calendar` | `EKCalendar` | Required for direct save (not EventKitUI) |
+| `isAllDay` | `Bool` | |
+| `timeZone` | `TimeZone?` | Defaults to system time zone |
+| `location` | `String?` | Full address enables Maps features |
+| `structuredLocation` | `EKStructuredLocation?` | Geo-precise location |
+| `notes` | `String?` | |
+| `url` | `URL?` | |
+| `eventIdentifier` | `String` | Stable across fetches |
+| `status` | `EKEventStatus` | `.none`, `.confirmed`, `.tentative`, `.canceled` |
+| `availability` | `EKEventAvailability` | `.notSupported` (default), `.busy`, `.free`, `.tentative`, `.unavailable` |
+| `occurrenceDate` | `Date` | For recurring event instances |
+| `isDetached` | `Bool` | True if modified from recurring series |
+| `organizer` | `EKParticipant?` | Read-only |
+| `birthdayContactIdentifier` | `String?` | For birthday calendar events |
+
+## Inherited from EKCalendarItem
+
+| Property | Type | Notes |
+|----------|------|-------|
+| `calendarItemIdentifier` | `String` | Unique identifier |
+| `calendarItemExternalIdentifier` | `String` | External (sync) identifier |
+| `creationDate` | `Date?` | |
+| `lastModifiedDate` | `Date?` | |
+| `alarms` | `[EKAlarm]?` | |
+| `recurrenceRules` | `[EKRecurrenceRule]?` | |
+| `hasAlarms` | `Bool` | |
+| `hasRecurrenceRules` | `Bool` | |
+| `attendees` | `[EKParticipant]?` | Read-only |
+
+## Methods
+
+```swift
+event.compareStartDate(with: otherEvent) // ComparisonResult
+event.refresh() // Bool — false if deleted
+```
+
+---
+
+# Part 3: EKReminder
+
+Represents a reminder. Inherits from `EKCalendarItem`.
+
+## Creation
+
+```swift
+let reminder = EKReminder(eventStore: store)
+reminder.title = "Review PR"
+reminder.calendar = store.defaultCalendarForNewReminders() // Required
+```
+
+## Key Properties
+
+| Property | Type | Notes |
+|----------|------|-------|
+| `startDateComponents` | `DateComponents?` | Task start |
+| `dueDateComponents` | `DateComponents?` | Due date — use `DateComponents`, NOT `Date` |
+| `isCompleted` | `Bool` | Setting true auto-populates `completionDate` |
+| `completionDate` | `Date?` | Auto-set when `isCompleted = true` |
+| `priority` | `Int` | Use `EKReminderPriority` raw values |
+
+## EKReminderPriority
+
+| Case | Raw Value |
+|------|-----------|
+| `.none` | 0 |
+| `.high` | 1 |
+| `.medium` | 5 |
+| `.low` | 9 |
+
+## Save/Remove
+
+```swift
+try store.save(reminder, commit: true)
+try store.remove(reminder, commit: true)
+// No span parameter — reminders don't have recurring instances like events
+```
+
+---
+
+# Part 4: EKAlarm
+
+Notification alarm for events or reminders.
+
+```swift
+// Time-based
+let absoluteAlarm = EKAlarm(absoluteDate: date) // Specific date/time
+let relativeAlarm = EKAlarm(relativeOffset: -3600) // 1 hour before (seconds)
+
+// Location-based (EKAlarm.proximity available since iOS 6.0+)
+let location = EKStructuredLocation(title: "Office")
+location.geoLocation = CLLocation(latitude: 37.33, longitude: -122.03)
+location.radius = 500 // meters
+
+let locationAlarm = EKAlarm()
+locationAlarm.structuredLocation = location
+locationAlarm.proximity = .enter // .enter or .leave
+
+reminder.addAlarm(locationAlarm)
+```
+
+---
+
+# Part 5: EKRecurrenceRule
+
+```swift
+let rule = EKRecurrenceRule(
+ recurrenceWith: .weekly, // .daily, .weekly, .monthly, .yearly
+ interval: 1, // Every 1 week
+ daysOfTheWeek: [EKRecurrenceDayOfWeek(.monday), EKRecurrenceDayOfWeek(.wednesday)],
+ daysOfTheMonth: nil,
+ monthsOfTheYear: nil,
+ weeksOfTheYear: nil,
+ daysOfTheYear: nil,
+ setPositions: nil,
+ end: EKRecurrenceEnd(occurrenceCount: 10) // or EKRecurrenceEnd(end: Date)
+)
+event.addRecurrenceRule(rule)
+```
+
+---
+
+# Part 6: EKCalendar and EKSource
+
+## EKCalendar Properties
+
+| Property | Type | Notes |
+|----------|------|-------|
+| `title` | `String` | |
+| `color` | `UIColor` / `cgColor: CGColor` | |
+| `type` | `EKCalendarType` | `.local`, `.calDAV`, `.exchange`, `.subscription`, `.birthday` |
+| `allowsContentModifications` | `Bool` | Can write to this calendar? |
+| `isImmutable` | `Bool` | System calendar (birthday, holidays) |
+| `source` | `EKSource` | Parent account |
+
+## EKSource Properties
+
+| Property | Type |
+|----------|------|
+| `title` | `String` |
+| `sourceType` | `EKSourceType` — `.local`, `.exchange`, `.calDAV`, `.mobileMe`, `.subscribed`, `.birthdays` |
+| `sourceIdentifier` | `String` |
+
+---
+
+# Part 7: EventKitUI View Controllers
+
+## EKEventEditViewController
+
+Create/edit events. **No permission required on iOS 17+** (renders out-of-process).
+
+**Inherits from**: `UINavigationController` (NOT `UIViewController`)
+
+```swift
+let editVC = EKEventEditViewController()
+editVC.event = event // nil = new event
+editVC.eventStore = store // Required
+editVC.editViewDelegate = self
+present(editVC, animated: true)
+```
+
+### EKEventEditViewDelegate
+
+```swift
+func eventEditViewController(
+ _ controller: EKEventEditViewController,
+ didCompleteWith action: EKEventEditViewAction
+) {
+ // action: .canceled, .saved, .deleted
+ dismiss(animated: true)
+}
+
+func eventEditViewControllerDefaultCalendar(
+ forNewEvents controller: EKEventEditViewController
+) -> EKCalendar {
+ return store.defaultCalendarForNewEvents!
+}
+```
+
+## EKEventViewController
+
+Display event details. **Requires full access**.
+
+**Inherits from**: `UIViewController` (can push onto nav stack)
+
+```swift
+let viewVC = EKEventViewController()
+viewVC.event = event // Required
+viewVC.allowsEditing = true
+viewVC.allowsCalendarPreview = true
+viewVC.delegate = self
+navigationController?.pushViewController(viewVC, animated: true)
+```
+
+### EKEventViewDelegate
+
+```swift
+func eventViewController(
+ _ controller: EKEventViewController,
+ didCompleteWith action: EKEventViewAction
+) {
+ // action: .done, .responded, .deleted
+}
+```
+
+**Note**: `EKEventViewController` automatically handles `EKEventStoreChanged` notifications — no manual refresh needed.
+
+## EKCalendarChooser
+
+Calendar selection UI. **Requires write-only or full access**.
+
+```swift
+let chooser = EKCalendarChooser(
+ selectionStyle: .single, // .single or .multiple
+ displayStyle: .writableCalendarsOnly, // .allCalendars or .writableCalendarsOnly
+ entityType: .event, // .event or .reminder
+ eventStore: store
+)
+chooser.selectedCalendars = [store.defaultCalendarForNewEvents!]
+chooser.showsDoneButton = true
+chooser.delegate = self
+present(UINavigationController(rootViewController: chooser), animated: true)
+```
+
+**Gotcha**: Under write-only access, `displayStyle` is ignored — always shows writable only.
+
+---
+
+# Part 8: Virtual Conference Extension
+
+For apps supporting voice/video calls — integrates directly into Calendar's location picker.
+
+## Extension Setup
+
+1. Add Virtual Conference Extension target in Xcode
+2. Extension point: `com.apple.calendar.virtualconference`
+3. Template generates `EKVirtualConferenceProvider` subclass
+
+## EKVirtualConferenceProvider
+
+**Platform**: iOS 15.0+, macOS 12.0+, watchOS 8.0+, visionOS 1.0+
+
+```swift
+class MyConferenceProvider: EKVirtualConferenceProvider {
+ override func fetchAvailableRoomTypes() async throws
+ -> [EKVirtualConferenceRoomTypeDescriptor] {
+ return [
+ EKVirtualConferenceRoomTypeDescriptor(
+ title: "Personal Room",
+ identifier: "personal_room"
+ )
+ ]
+ }
+
+ override func fetchVirtualConference(
+ identifier: EKVirtualConferenceRoomTypeIdentifier
+ ) async throws -> EKVirtualConferenceDescriptor {
+ let url = EKVirtualConferenceURLDescriptor(
+ title: nil, // Optional — useful when multiple join URLs
+ url: URL(string: "https://myapp.com/join/\(roomId)")!
+ )
+ return EKVirtualConferenceDescriptor(
+ title: nil, // Optional — distinguishes multiple room types
+ urlDescriptors: [url],
+ conferenceDetails: "Enter code 12345 to join"
+ )
+ }
+}
+```
+
+**Use Universal Links** for join URLs so your app opens directly.
+
+**Syncing**: Events with virtual conference info sync to devices where your app may not be installed.
+
+---
+
+# Part 9: Siri Event Suggestions
+
+Add reservation-style events to Calendar without requesting any permission. Events appear in the Calendar inbox like invitations.
+
+**Supported types**: restaurant, hotel, flight, train, bus, boat, rental car, ticketed events
+
+```swift
+// 1. Create reservation reference
+let reference = INSpeakableString(
+ vocabularyIdentifier: "booking-\(reservationId)",
+ spokenPhrase: "Dinner at Caffè Macs",
+ pronunciationHint: nil
+)
+
+// 2. Create reservation
+let duration = INDateComponentsRange(start: startComponents, end: endComponents)
+let location = MKPlacemark(coordinate: clLocation.coordinate, postalAddress: address)
+
+let reservation = INRestaurantReservation(
+ itemReference: reference,
+ reservationStatus: .confirmed,
+ reservationHolderName: "Jane Appleseed",
+ reservationDuration: duration,
+ restaurantLocation: location
+)
+
+// 3. Create intent + response
+let intent = INGetReservationDetailsIntent(
+ reservationContainerReference: reference
+)
+let response = INGetReservationDetailsIntentResponse(code: .success, userActivity: nil)
+response.reservations = [reservation]
+
+// 4. Donate interaction
+let interaction = INInteraction(intent: intent, response: response)
+interaction.donate()
+```
+
+### Reservation Types
+
+| Type | Class |
+|------|-------|
+| Restaurant | `INRestaurantReservation` |
+| Hotel | `INLodgingReservation` |
+| Flight | `INFlightReservation` |
+| Train | `INTrainReservation` |
+| Bus | `INBusReservation` (iOS 14+) |
+| Boat | `INBoatReservation` (iOS 14+) |
+| Rental Car | `INRentalCarReservation` |
+| Ticketed Event | `INTicketedEventReservation` |
+
+### Update/Cancel
+
+Use the same `reservationId` across donations:
+- **Update**: Donate with updated details, same `reservationId`
+- **Cancel**: Set `reservationStatus = .canceled` and re-donate
+
+### Web Markup (iOS 14+)
+
+Embed schema.org JSON-LD or Microdata in HTML for Safari and Mail:
+```html
+
+```
+
+**Requires**: Domain registration with Apple, HTTPS, valid DKIM for emails.
+
+### Show in App / Show in Safari
+
+- When app is installed: Calendar shows "Show in App" button — launches app with `INGetReservationDetailsIntent`
+- When app is not installed: If `url` property is set on `INReservation`, Calendar shows "Show in Safari"
+
+---
+
+# Part 10: Location-Based Reminders
+
+**Platform**: iOS 6.0+ (EKAlarm.proximity, EKStructuredLocation)
+
+**Required permissions**: Location When In Use + Full Reminders Access
+
+```swift
+// Create location-triggered reminder
+let reminder = EKReminder(eventStore: store)
+reminder.title = "Pick up dry cleaning"
+reminder.calendar = store.defaultCalendarForNewReminders()
+
+let location = EKStructuredLocation(title: "Dry Cleaners")
+location.geoLocation = CLLocation(latitude: 37.33, longitude: -122.03)
+location.radius = 200 // meters
+
+let alarm = EKAlarm()
+alarm.structuredLocation = location
+alarm.proximity = .enter // .enter or .leave
+reminder.addAlarm(alarm)
+
+try store.save(reminder, commit: true)
+```
+
+### Fetching Location Reminders
+
+```swift
+let predicate = store.predicateForReminders(in: nil)
+let allReminders = try await fetchReminders(matching: predicate)
+let locationReminders = allReminders.filter { reminder in
+ reminder.alarms?.contains { alarm in
+ alarm.structuredLocation != nil && alarm.proximity != .none
+ } ?? false
+}
+```
+
+---
+
+# Part 11: Error Reference
+
+## EKErrorDomain Codes
+
+| Code | Name | Meaning |
+|------|------|---------|
+| 0 | `eventNotMutable` | Event is read-only |
+| 1 | `noCalendar` | Calendar property not set |
+| 2 | `noStartDate` | Missing start date |
+| 3 | `noEndDate` | Missing end date |
+| 4 | `datesInverted` | End date before start date |
+| 12 | `calendarReadOnly` | Calendar doesn't allow modifications |
+| 13 | `calendarIsImmutable` | System calendar (birthday, etc.) |
+| 15 | `sourceDoesNotAllowCalendarAddDelete` | Can't create/delete calendars on this source |
+| 18 | `recurringReminderRequiresDueDate` | Recurring reminders need due date |
+| 19 | `structuredLocationsNotSupported` | Location alarms not supported |
+| 21 | `alarmProximityNotSupported` | Proximity alarms not supported |
+| 22 | `eventStoreNotAuthorized` | No permission |
+| 24 | `objectBelongsToDifferentStore` | Cross-store object usage |
+| 25 | `invitesCannotBeMoved` | Can't move events with attendees |
+| 26 | `invalidSpan` | Invalid span value |
+
+---
+
+# Part 12: Platform Availability Matrix
+
+| API | iOS | macOS | watchOS | visionOS |
+|-----|-----|-------|---------|----------|
+| EKEventStore | 4.0+ | 10.8+ | 2.0+ | 1.0+ |
+| Write-only access | 17.0+ | 14.0+ | 10.0+ | 1.0+ |
+| Full access (new API) | 17.0+ | 14.0+ | 10.0+ | 1.0+ |
+| EKEventEditViewController | 4.0+ | (Catalyst 13.1+) | — | 1.0+ |
+| EKEventViewController | 4.0+ | (Catalyst 13.1+) | — | 1.0+ |
+| EKCalendarChooser | 4.0+ | (Catalyst 13.0+) | — | 1.0+ |
+| EKVirtualConferenceProvider | 15.0+ | 12.0+ | 8.0+ | 1.0+ |
+| Location-based reminders | 6.0+ | 10.8+ | — | — |
+| Siri Event Suggestions | 12.0+ | 11.0+ (Catalyst) | — | — |
+| Schema.org markup | 14.0+ | 11.0+ (Safari/Mail) | — | — |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10052, 2020-10197
+
+**Docs**: /eventkit, /eventkitui, /eventkit/ekeventstore, /eventkit/ekevent, /eventkit/ekreminder, /eventkit/ekvirtualconferenceprovider, /technotes/tn3152, /technotes/tn3153
+
+**Skills**: eventkit, contacts-ref, extensions-widgets-ref
diff --git a/.claude/skills/axiom-eventkit-ref/agents/openai.yaml b/.claude/skills/axiom-eventkit-ref/agents/openai.yaml
new file mode 100644
index 0000000..23d3590
--- /dev/null
+++ b/.claude/skills/axiom-eventkit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "EventKit Reference"
+ short_description: "Needing EventKit API details"
diff --git a/.claude/skills/axiom-eventkit/.openskills.json b/.claude/skills/axiom-eventkit/.openskills.json
new file mode 100644
index 0000000..ef4ec09
--- /dev/null
+++ b/.claude/skills/axiom-eventkit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-eventkit",
+ "installedAt": "2026-04-12T08:06:14.700Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-eventkit/SKILL.md b/.claude/skills/axiom-eventkit/SKILL.md
new file mode 100644
index 0000000..43106cf
--- /dev/null
+++ b/.claude/skills/axiom-eventkit/SKILL.md
@@ -0,0 +1,351 @@
+---
+name: axiom-eventkit
+description: Use when working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller. Covers access tiers (no-access, write-only, full), permission migration from pre-iOS 17, store lifecycle, reminder patterns, EventKitUI controller selection, Siri Event Suggestions, virtual conference extensions.
+license: MIT
+---
+
+# EventKit — Discipline
+
+## Core Philosophy
+
+> "Request the minimum access needed, and only when it's needed."
+
+**Mental model**: EventKit has three access tiers. Most apps need only the first (no access + system UI). Requesting more than you need means more users deny your request, and more code to maintain.
+
+## When to Use This Skill
+
+Use this skill when:
+- Adding events or reminders to the user's calendar
+- Choosing between EventKitUI, write-only, or full access
+- Requesting calendar or reminder permissions
+- Fetching, querying, or displaying existing events
+- Migrating from pre-iOS 17 permission APIs
+- Creating virtual conference extensions
+- Implementing Siri Event Suggestions for reservations
+- Debugging "access denied" or missing events
+
+Do NOT use this skill for:
+- Contacts framework questions (use **contacts**)
+- General SwiftUI architecture (use **swiftui-architecture**)
+- Background task scheduling (use **background-processing**)
+
+## Related Skills
+
+- **eventkit-ref** — Complete EventKit/EventKitUI API reference
+- **contacts** — Contacts framework discipline skill
+- **privacy-ux** — General iOS privacy patterns and Permission UX
+- **extensions-widgets** — WidgetKit if combining calendar with widgets
+- **background-processing** — If scheduling background calendar sync
+
+---
+
+## Access Tier Decision Tree
+
+```dot
+digraph access_decision {
+ rankdir=TB;
+ "What does your app need?" [shape=diamond];
+ "Add single events to Calendar?" [shape=diamond];
+ "Show custom create/edit UI?" [shape=diamond];
+ "Read existing events/calendars?" [shape=diamond];
+
+ "No access + EventKitUI" [shape=box, label="Tier 1: No Access\nPresent EKEventEditViewController\nNo permission prompt needed"];
+ "No access + Siri Suggestions" [shape=box, label="Tier 1: No Access\nSiri Event Suggestions\nFor reservations only"];
+ "Write-only access" [shape=box, label="Tier 2: Write-Only\nrequestWriteOnlyAccessToEvents()\nCan save but not read"];
+ "Full access" [shape=box, label="Tier 3: Full Access\nrequestFullAccessToEvents()\nor requestFullAccessToReminders()"];
+
+ "What does your app need?" -> "Add single events to Calendar?" [label="events"];
+ "What does your app need?" -> "Full access" [label="reminders\n(always full)"];
+ "Add single events to Calendar?" -> "No access + EventKitUI" [label="yes, one at a time"];
+ "Add single events to Calendar?" -> "Show custom create/edit UI?" [label="no, batch or silent"];
+ "Show custom create/edit UI?" -> "Write-only access" [label="yes, or batch save"];
+ "Show custom create/edit UI?" -> "Read existing events/calendars?" [label="no"];
+ "Read existing events/calendars?" -> "Full access" [label="yes"];
+ "Read existing events/calendars?" -> "Write-only access" [label="no"];
+ "Add single events to Calendar?" -> "No access + Siri Suggestions" [label="reservation-style\n(restaurant, flight, hotel)"];
+}
+```
+
+**Key rule**: Reminders ALWAYS require full access. There is no write-only tier for reminders.
+
+---
+
+## The Three Access Tiers
+
+### Tier 1: No Access (Preferred)
+
+Present `EKEventEditViewController` — it runs out-of-process on iOS 17+ and requires zero permissions.
+
+```swift
+let store = EKEventStore()
+let event = EKEvent(eventStore: store)
+event.title = "Team Standup"
+event.startDate = startDate
+event.endDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate) ?? startDate
+event.timeZone = TimeZone(identifier: "America/Los_Angeles")
+event.location = "Conference Room A"
+
+let editVC = EKEventEditViewController()
+editVC.event = event
+editVC.eventStore = store
+editVC.editViewDelegate = self
+present(editVC, animated: true)
+```
+
+**Why this is best**: No permission prompt. No denial risk. System handles Calendar selection and save. Works on iOS 4+.
+
+For reservations (restaurant, flight, hotel, event tickets), use **Siri Event Suggestions** instead — events appear in Calendar inbox without any permission. See the eventkit-ref skill for the INReservation donation pattern.
+
+### Tier 2: Write-Only Access (iOS 17+)
+
+Use only when you need: custom editing UI, batch saves, or silent event creation.
+
+```swift
+let store = EKEventStore()
+guard try await store.requestWriteOnlyAccessToEvents() else {
+ // User denied — handle gracefully
+ return
+}
+let event = EKEvent(eventStore: store)
+event.calendar = store.defaultCalendarForNewEvents // REQUIRED for write-only
+event.title = "Recurring Standup"
+event.startDate = startDate
+event.endDate = endDate
+try store.save(event, span: .thisEvent)
+```
+
+**Write-only constraints**:
+- Returns a single virtual calendar, not the user's real calendars
+- Event queries return empty results
+- System chooses destination calendar for created events
+- Cannot read events back, even ones your app created
+
+**Info.plist required**: `NSCalendarsWriteOnlyAccessUsageDescription`
+
+### Tier 3: Full Access
+
+Use only when your app's core feature requires reading, modifying, or deleting existing events.
+
+```swift
+let store = EKEventStore()
+guard try await store.requestFullAccessToEvents() else { return }
+
+// Now you can fetch events
+let interval = Calendar.current.dateInterval(of: .month, for: Date())!
+let predicate = store.predicateForEvents(withStart: interval.start, end: interval.end, calendars: nil)
+let events = store.events(matching: predicate)
+ .sorted { $0.compareStartDate(with: $1) == .orderedAscending }
+```
+
+**Info.plist required**: `NSCalendarsFullAccessUsageDescription`
+
+For reminders:
+```swift
+guard try await store.requestFullAccessToReminders() else { return }
+```
+
+**Info.plist required**: `NSRemindersFullAccessUsageDescription`
+
+---
+
+## Anti-Patterns
+
+| Pattern | Time Cost | Why It's Wrong | Fix |
+|---------|-----------|----------------|-----|
+| Requesting full access for "add to calendar" | 1-2 sprint days recovering denied users | Full access prompts are denied 30%+ of the time — users distrust reading ALL calendar data | Use EventKitUI or write-only |
+| Missing Info.plist key on iOS 17+ | 1-2 hours debugging | Automatic silent denial, no crash, no error, no prompt | Add the correct usage description key |
+| Missing Info.plist key on iOS 16 and below | Immediate crash | App crashes on permission request | Add `NSCalendarsUsageDescription` |
+| Calling deprecated `requestAccess(to:)` on iOS 17 | Throws error | The old API throws, does not prompt | Use `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
+| Creating multiple EKEventStore instances | Stale data bugs | Objects from one store cannot be used with another | Create one store, reuse it |
+| Using `Date` math instead of `DateComponents` for durations | DST bugs | Adding 3600 seconds doesn't always equal 1 hour | Use `Calendar.current.date(byAdding:)` |
+| Not sorting `events(matching:)` results | Wrong display order | Results are NOT chronologically ordered | Sort with `compareStartDate(with:)` |
+| Setting `dueDateComponents` with `Date` instead of `DateComponents` | Silent failure | Reminders use `DateComponents`, not `Date` | Convert via `Calendar.current.dateComponents(...)` |
+| Not registering for `EKEventStoreChanged` notification | Stale UI | External Calendar changes are invisible | Register and refetch on notification |
+| Ignoring `EKSpan` on recurring events | Modifying all occurrences | `.thisEvent` vs `.futureEvents` controls scope | Always choose explicitly |
+
+---
+
+## Reminder Patterns
+
+Reminders ALWAYS require `requestFullAccessToReminders()`.
+
+### Creating a Reminder
+
+```swift
+let reminder = EKReminder(eventStore: store)
+reminder.title = "Review PR"
+reminder.calendar = store.defaultCalendarForNewReminders() // Required
+
+// Due dates use DateComponents, NOT Date
+if let dueDate = dueDate {
+ reminder.dueDateComponents = Calendar.current.dateComponents(
+ [.year, .month, .day, .hour, .minute], from: dueDate
+ )
+}
+
+reminder.priority = EKReminderPriority.medium.rawValue
+try store.save(reminder, commit: true)
+```
+
+### Fetching Reminders (Async)
+
+Unlike events, reminder fetches are asynchronous:
+
+```swift
+let predicate = store.predicateForReminders(in: nil) // nil = all calendars
+let reminders = try await withCheckedThrowingContinuation { continuation in
+ store.fetchReminders(matching: predicate) { reminders in
+ if let reminders {
+ continuation.resume(returning: reminders)
+ } else {
+ continuation.resume(throwing: TodayError.failedReadingReminders)
+ }
+ }
+}
+```
+
+### Creating Reminder Lists
+
+Reminder lists are `EKCalendar` objects filtered by entity type:
+
+```swift
+let newList = EKCalendar(for: .reminder, eventStore: store)
+newList.title = "Sprint Tasks"
+
+// Source selection matters — prefer .local or .calDAV
+guard let source = store.sources.first(where: {
+ $0.sourceType == .local || $0.sourceType == .calDAV
+}) ?? store.defaultCalendarForNewReminders()?.source else {
+ throw EventKitError.noValidSource
+}
+
+newList.source = source
+try store.saveCalendar(newList, commit: true)
+```
+
+---
+
+## Store Lifecycle
+
+### Singleton Pattern
+
+Create one `EKEventStore` and reuse it. Objects from one store instance cannot be used with another.
+
+### Change Notifications
+
+```swift
+NotificationCenter.default.addObserver(
+ self, selector: #selector(storeChanged),
+ name: .EKEventStoreChanged, object: store
+)
+
+@objc func storeChanged(_ notification: Notification) {
+ // Refetch your current date range
+ // Individual objects: call refresh() — if false, refetch
+}
+```
+
+### Batch Operations
+
+```swift
+// Pass commit: false for batch, then commit once
+try store.save(event1, span: .thisEvent, commit: false)
+try store.save(event2, span: .thisEvent, commit: false)
+try store.commit() // Atomic save
+// On failure: store.reset() to rollback
+```
+
+---
+
+## Migration from Pre-iOS 17
+
+| Before iOS 17 | iOS 17+ Replacement |
+|----------------|---------------------|
+| `requestAccess(to: .event)` | `requestFullAccessToEvents()` or `requestWriteOnlyAccessToEvents()` |
+| `requestAccess(to: .reminder)` | `requestFullAccessToReminders()` |
+| `NSCalendarsUsageDescription` | `NSCalendarsFullAccessUsageDescription` or `NSCalendarsWriteOnlyAccessUsageDescription` |
+| `NSRemindersUsageDescription` | `NSRemindersFullAccessUsageDescription` |
+| `authorizationStatus == .authorized` | Check for `.fullAccess` or `.writeOnly` |
+
+**Runtime compatibility**:
+```swift
+if #available(iOS 17.0, *) {
+ granted = try await store.requestFullAccessToEvents()
+} else {
+ granted = try await store.requestAccess(to: .event)
+}
+```
+
+**Keep old Info.plist keys** alongside new ones to support iOS 16 and below.
+
+**Gotcha**: Apps built with older Xcode SDKs map both `.writeOnly` and `.fullAccess` to `.authorized`. This means an app linked against an old SDK may fail to fetch events even after users granted full access — because the app sees `.authorized` but the system gave `.writeOnly`.
+
+---
+
+## EventKitUI Decision Guide
+
+| Controller | Purpose | Permission Required |
+|------------|---------|---------------------|
+| `EKEventEditViewController` | Create/edit events | None (iOS 17+ out-of-process) |
+| `EKEventViewController` | Display event details | Full access |
+| `EKCalendarChooser` | Calendar selection | Write-only or full |
+
+**Gotcha**: `EKEventEditViewController` inherits from `UINavigationController`, not `UIViewController`. Do NOT embed it inside another navigation controller.
+
+**Gotcha**: `EKEventViewController` inherits from `UIViewController` and CAN be pushed onto a navigation stack.
+
+**Gotcha**: Under write-only access, `EKCalendarChooser` ignores `displayStyle` and always shows writable calendars only.
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Just request full access, we might need it later"
+
+**Pressure**: Product manager asks for full access "just in case."
+
+**Why resist**: Full access prompts are denied 30%+ of the time. Write-only or EventKitUI gets you event creation with near-zero denials. You can always upgrade later if a reading feature is added.
+
+**Response**: "Full access shows a scary prompt about reading ALL calendar data. For adding events, EventKitUI needs no prompt at all. Let's start there and upgrade if we ship a feature that reads events."
+
+### Scenario 2: "The deprecated API still works, we'll migrate later"
+
+**Pressure**: Deadline pressure to skip migration from `requestAccess(to:)`.
+
+**Why resist**: On iOS 17, calling `requestAccess(to: .event)` throws an error — no prompt, no access, broken feature. Users on iOS 17+ get a silent failure.
+
+**Response**: "The deprecated API throws on iOS 17. It's not 'deprecated but works' — it's broken. The fix is a 3-line `#available` check."
+
+### Scenario 3: "Just create a new EKEventStore for each screen"
+
+**Pressure**: Different view controllers each create their own store for isolation.
+
+**Why resist**: Objects from one store cannot be used with another. Events fetched from store A cannot be saved by store B. Change notifications only fire on the store that's registered.
+
+**Response**: "EventKit requires a single shared store. Objects are bound to the store that created them. Create one and inject it."
+
+---
+
+## Error Handling
+
+Key `EKErrorDomain` codes to handle:
+
+| Code | Meaning | Fix |
+|------|---------|-----|
+| `eventStoreNotAuthorized` | No permission | Check and request access first |
+| `noCalendar` | Calendar not set on event | Set `event.calendar` before save |
+| `noStartDate` / `noEndDate` | Missing dates | Set both before save |
+| `datesInverted` | End before start | Validate date order |
+| `calendarReadOnly` / `calendarIsImmutable` | Can't write to this calendar | Use `allowsContentModifications` check |
+| `objectBelongsToDifferentStore` | Cross-store usage | Use single store instance |
+| `recurringReminderRequiresDueDate` | Recurring reminder missing due date | Set `dueDateComponents` |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10052, 2020-10197
+
+**Docs**: /eventkit, /eventkitui, /technotes/tn3152, /technotes/tn3153
+
+**Skills**: eventkit-ref, contacts, privacy-ux, extensions-widgets
diff --git a/.claude/skills/axiom-eventkit/agents/openai.yaml b/.claude/skills/axiom-eventkit/agents/openai.yaml
new file mode 100644
index 0000000..01d446c
--- /dev/null
+++ b/.claude/skills/axiom-eventkit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "EventKit"
+ short_description: "Working with ANY calendar event, reminder, EventKit permission, or EventKitUI controller"
diff --git a/.claude/skills/axiom-extensions-widgets-ref/.openskills.json b/.claude/skills/axiom-extensions-widgets-ref/.openskills.json
new file mode 100644
index 0000000..5c9d83a
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-extensions-widgets-ref",
+ "installedAt": "2026-04-12T08:06:16.419Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-extensions-widgets-ref/SKILL.md b/.claude/skills/axiom-extensions-widgets-ref/SKILL.md
new file mode 100644
index 0000000..2088135
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets-ref/SKILL.md
@@ -0,0 +1,1286 @@
+---
+name: axiom-extensions-widgets-ref
+description: Use when implementing widgets, Live Activities, Control Center controls, or app extensions - comprehensive API reference for WidgetKit, ActivityKit, App Groups, and extension lifecycle for iOS 14+
+license: MIT
+compatibility: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+
+metadata:
+ version: "1.0.0"
+---
+
+# Extensions & Widgets API Reference
+
+## Overview
+
+This skill provides comprehensive API reference for Apple's widget and extension ecosystem:
+
+- **Standard Widgets** (iOS 14+) — Home Screen, Lock Screen, StandBy widgets
+- **Interactive Widgets** (iOS 17+) — Buttons and toggles with App Intents
+- **Live Activities** (iOS 16.1+) — Real-time updates on Lock Screen and Dynamic Island
+- **Control Center Widgets** (iOS 18+) — System-wide quick controls
+- **Liquid Glass Widgets** (iOS 26+) — Accented rendering, glass effects, container backgrounds
+- **visionOS Widgets** (visionOS 2+) — Mounting styles, textures, proximity awareness
+- **App Extensions** — Shared data, lifecycle, entitlements
+
+Widgets are SwiftUI **archived snapshots** rendered on a timeline by the system. Extensions are sandboxed executables bundled with your app.
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Implementing any type of widget (Home Screen, Lock Screen, StandBy)
+- Creating Live Activities for ongoing events
+- Building Control Center controls
+- Sharing data between app and extensions
+- Understanding widget timelines and refresh policies
+- Integrating widgets with App Intents
+- Adopting Liquid Glass rendering in widgets
+- Supporting watchOS or visionOS widgets
+- Implementing visionOS mounting styles, textures, or proximity awareness
+
+❌ **Do NOT use this skill for**:
+- Pure App Intents questions (use **app-intents-ref** skill)
+- SwiftUI layout issues (use **swiftui-layout** skill)
+- Performance optimization (use **swiftui-performance** skill)
+- Debugging crashes (use **xcode-debugging** skill)
+
+## Related Skills
+
+- **app-intents-ref** — App Intents for interactive widgets and configuration
+- **swift-concurrency** — Async/await patterns for widget data loading
+- **swiftui-performance** — Optimizing widget rendering
+- **swiftui-layout** — Complex widget layouts
+- **extensions-widgets** — Discipline skill with anti-patterns and debugging
+
+## Key Terminology
+
+- **Timeline** — Series of entries defining when/what content to display; system shows entries at specified times
+- **TimelineProvider** — Protocol supplying timeline entries (placeholder, snapshot, timeline generation)
+- **TimelineEntry** — Struct with widget data + display date
+- **Timeline Budget** — Daily limit (40-70) for timeline reloads
+- **Budget-Exempt** — Reloads that don't count (user-initiated, app foregrounding, system-initiated)
+- **Widget Family** — Size/shape (systemSmall, systemMedium, accessoryCircular, etc.)
+- **App Groups** — Entitlement for shared data container between app and extensions
+- **ActivityAttributes** — Static data (set once) + dynamic ContentState (updated during lifecycle)
+- **ContentState** — Changing part of ActivityAttributes; must be under 4KB total
+- **Dynamic Island** — iPhone 14 Pro+ Live Activity display; compact, minimal, and expanded sizes
+- **ControlWidget** — iOS 18+ widgets for Control Center, Lock Screen, and Action Button
+- **Supplemental Activity Families** — Enables Live Activities on Apple Watch or CarPlay
+
+---
+
+# Part 1: Standard Widgets (iOS 14+)
+
+## Widget Configuration Types
+
+### StaticConfiguration
+
+For widgets that don't require user configuration.
+
+```swift
+@main
+struct MyWidget: Widget {
+ let kind: String = "MyWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: Provider()) { entry in
+ MyWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("My Widget")
+ .description("This widget displays...")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+```
+
+### AppIntentConfiguration (iOS 17+)
+
+For widgets with user configuration using App Intents.
+
+```swift
+struct MyConfigurableWidget: Widget {
+ let kind: String = "MyConfigurableWidget"
+
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(
+ kind: kind,
+ intent: SelectProjectIntent.self,
+ provider: Provider()
+ ) { entry in
+ MyWidgetEntryView(entry: entry)
+ }
+ .configurationDisplayName("Project Status")
+ .description("Shows your selected project")
+ }
+}
+```
+
+**Migration from IntentConfiguration**: iOS 16 and earlier used `IntentConfiguration` with SiriKit intents. Migrate to `AppIntentConfiguration` for iOS 17+.
+
+### ActivityConfiguration
+
+For Live Activities (covered in Live Activities section).
+
+## Choosing the Right Configuration
+
+No user configuration needed? Use `StaticConfiguration`. Simple static options? Use `AppIntentConfiguration` with `WidgetConfigurationIntent`. Dynamic options from app data? Use `AppIntentConfiguration` + `EntityQuery`.
+
+**Quick Reference**:
+- **StaticConfiguration** — No customization (weather, battery status)
+- **AppIntentConfiguration** (simple) — Fixed options (timer presets, theme selection)
+- **AppIntentConfiguration** (EntityQuery) — Dynamic list from app data (project/contact/playlist picker)
+- **ActivityConfiguration** — Live ongoing events (delivery tracking, workout progress, sports scores)
+
+## Widget Families
+
+### System Families (Home Screen)
+- **`systemSmall`** (~170×170, iOS 14+) — Single piece of info, icon
+- **`systemMedium`** (~360×170, iOS 14+) — Multiple data points, chart
+- **`systemLarge`** (~360×380, iOS 14+) — Detailed view, list
+- **`systemExtraLarge`** (~720×380, iOS 15+ iPad only) — Rich layouts, multiple views
+
+### Accessory Families (Lock Screen, iOS 16+)
+- **`accessoryCircular`** (~48×48pt) — Circular complication, icon or gauge
+- **`accessoryRectangular`** (~160×72pt) — Above clock, text + icon
+- **`accessoryInline`** (single line) — Above date, text only
+
+### Example: Supporting Multiple Families
+
+```swift
+struct MyWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
+ if #available(iOSApplicationExtension 16.0, *) {
+ switch entry.family {
+ case .systemSmall:
+ SmallWidgetView(entry: entry)
+ case .systemMedium:
+ MediumWidgetView(entry: entry)
+ case .accessoryCircular:
+ CircularWidgetView(entry: entry)
+ case .accessoryRectangular:
+ RectangularWidgetView(entry: entry)
+ default:
+ Text("Unsupported")
+ }
+ } else {
+ LegacyWidgetView(entry: entry)
+ }
+ }
+ .supportedFamilies([
+ .systemSmall,
+ .systemMedium,
+ .accessoryCircular,
+ .accessoryRectangular
+ ])
+ }
+}
+```
+
+## Timeline System
+
+### TimelineProvider Protocol
+
+Provides entries that define when the system should render your widget.
+
+```swift
+struct Provider: TimelineProvider {
+ // Placeholder while loading
+ func placeholder(in context: Context) -> SimpleEntry {
+ SimpleEntry(date: Date(), emoji: "😀")
+ }
+
+ // Shown in widget gallery
+ func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
+ let entry = SimpleEntry(date: Date(), emoji: "📷")
+ completion(entry)
+ }
+
+ // Actual timeline
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
+ var entries: [SimpleEntry] = []
+ let currentDate = Date()
+
+ // Create entry every hour for 5 hours
+ for hourOffset in 0 ..< 5 {
+ let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
+ let entry = SimpleEntry(date: entryDate, emoji: "⏰")
+ entries.append(entry)
+ }
+
+ let timeline = Timeline(entries: entries, policy: .atEnd)
+ completion(timeline)
+ }
+}
+```
+
+### TimelineReloadPolicy
+
+Controls when the system requests a new timeline:
+- **`.atEnd`** — Reload after last entry
+- **`.after(date)`** — Reload at specific date
+- **`.never`** — No automatic reload (manual only)
+
+### Manual Reload
+
+```swift
+import WidgetKit
+
+// Reload all widgets of this kind
+WidgetCenter.shared.reloadAllTimelines()
+
+// Reload specific kind
+WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
+```
+
+## Performance & Budget Quick Reference
+
+### Timeline Refresh Budget
+- **Daily budget**: 40-70 reloads/day (varies by system load and engagement)
+- **Budget-exempt**: User-initiated reload, app foregrounding, widget added, system reboot
+- **Strategic** (4x/hour) — ~48 reloads/day, low battery impact
+- **Aggressive** (12x/hour) — Budget exhausted by 6 PM, high impact
+- **On-demand only** — 5-10 reloads/day, minimal impact
+- Reload on significant data changes and time-based events. Avoid speculative or cosmetic reloads.
+
+```swift
+// ✅ GOOD: Strategic intervals (15-60 min)
+let entries = (0..<8).map { offset in
+ let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
+ return SimpleEntry(date: date, data: data)
+}
+```
+
+### Memory Limits
+- ~30MB for standard widgets, ~50MB for Live Activities — system terminates if exceeded
+- Load only what you need (e.g., `loadRecentItems(limit: 10)`, not entire database)
+
+### Network Requests
+**Never make network requests in widget views** — they won't complete before rendering. Fetch data in `getTimeline()` instead.
+
+### Timeline Generation
+Complete `getTimeline()` in under 5 seconds. Cache expensive computations in the main app, read pre-computed data from shared container, limit to 10-20 entries.
+
+### View Rendering
+Precompute everything in `TimelineEntry`, keep views simple. No expensive operations in `body`.
+
+### Images
+- Use asset catalog images or SF Symbols (fast)
+- Small images from shared container are acceptable
+- `AsyncImage` does NOT work in widgets
+- Large images cause memory termination
+
+---
+
+# Part 2: Interactive Widgets (iOS 17+)
+
+## Button and Toggle
+
+Interactive widgets use SwiftUI `Button` and `Toggle` with App Intents.
+
+### Button with App Intent
+
+```swift
+Button(intent: IncrementIntent()) {
+ Label("Increment", systemImage: "plus.circle")
+}
+```
+
+The intent updates shared data via App Groups in its `perform()` method. See **axiom-app-intents-ref** for full `AppIntent` definition syntax.
+
+### Toggle with App Intent
+
+Same pattern as Button — use a `Toggle` bound to state, invoke intent on change:
+
+```swift
+Toggle(isOn: $isEnabled) {
+ Text("Feature")
+}
+.onChange(of: isEnabled) { newValue in
+ Task { try? await ToggleFeatureIntent(enabled: newValue).perform() }
+}
+```
+
+The intent follows the same `AppIntent` structure with a `@Parameter(title: "Enabled") var enabled: Bool`. See **axiom-app-intents-ref** for full `AppIntent` definition syntax.
+
+## invalidatableContent Modifier
+
+Provides visual feedback during App Intent execution.
+
+```swift
+struct MyWidgetView: View {
+ var entry: Provider.Entry
+
+ var body: some View {
+ VStack {
+ Text(entry.status)
+ .invalidatableContent() // Dims during intent execution
+
+ Button(intent: RefreshIntent()) {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ }
+}
+```
+
+**Effect**: Content with `.invalidatableContent()` becomes slightly transparent while the associated intent executes, providing user feedback.
+
+## Animation System
+
+### contentTransition for Numeric Text
+
+```swift
+Text("\(entry.value)")
+ .contentTransition(.numericText(value: Double(entry.value)))
+```
+
+**Effect**: Numbers smoothly count up or down instead of instantly changing.
+
+### View Transitions
+
+```swift
+VStack {
+ if entry.showDetail {
+ DetailView()
+ .transition(.scale.combined(with: .opacity))
+ }
+}
+.animation(.spring(response: 0.3), value: entry.showDetail)
+```
+
+---
+
+# Part 3: Configurable Widgets (iOS 17+)
+
+## WidgetConfigurationIntent
+
+Define configuration parameters for your widget.
+
+```swift
+import AppIntents
+
+struct SelectProjectIntent: WidgetConfigurationIntent {
+ static var title: LocalizedStringResource = "Select Project"
+ static var description = IntentDescription("Choose which project to display")
+
+ @Parameter(title: "Project")
+ var project: ProjectEntity?
+
+ // Provide default value
+ static var parameterSummary: some ParameterSummary {
+ Summary("Show \(\.$project)")
+ }
+}
+```
+
+## Entity and EntityQuery
+
+Provide dynamic options for configuration.
+
+```swift
+struct ProjectEntity: AppEntity {
+ var id: String
+ var name: String
+
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(name)")
+ }
+}
+
+struct ProjectQuery: EntityQuery {
+ func entities(for identifiers: [String]) async throws -> [ProjectEntity] {
+ // Return projects matching these IDs
+ return await ProjectStore.shared.projects(withIDs: identifiers)
+ }
+
+ func suggestedEntities() async throws -> [ProjectEntity] {
+ // Return all available projects
+ return await ProjectStore.shared.allProjects()
+ }
+}
+```
+
+## Using Configuration in Provider
+
+```swift
+struct Provider: AppIntentTimelineProvider {
+ func timeline(for configuration: SelectProjectIntent, in context: Context) async -> Timeline {
+ let project = configuration.project // Use selected project
+ let entries = await generateEntries(for: project)
+ return Timeline(entries: entries, policy: .atEnd)
+ }
+}
+```
+
+---
+
+# Part 4: Live Activities (iOS 16.1+)
+
+## ActivityAttributes
+
+Defines static and dynamic data for a Live Activity.
+
+```swift
+import ActivityKit
+
+struct PizzaDeliveryAttributes: ActivityAttributes {
+ // Static data - set when activity starts, never changes
+ struct ContentState: Codable, Hashable {
+ // Dynamic data - updated throughout activity lifecycle
+ var status: DeliveryStatus
+ var estimatedDeliveryTime: Date
+ var driverName: String?
+ }
+
+ // Static attributes
+ var orderNumber: String
+ var pizzaType: String
+}
+```
+
+**Key constraint**: `ActivityAttributes` total data size must be under **4KB** to start successfully.
+
+## Starting Activities
+
+### Request Authorization
+
+```swift
+import ActivityKit
+
+let authorizationInfo = ActivityAuthorizationInfo()
+let areActivitiesEnabled = authorizationInfo.areActivitiesEnabled
+```
+
+### Start an Activity
+
+```swift
+let attributes = PizzaDeliveryAttributes(
+ orderNumber: "12345",
+ pizzaType: "Pepperoni"
+)
+
+let initialState = PizzaDeliveryAttributes.ContentState(
+ status: .preparing,
+ estimatedDeliveryTime: Date().addingTimeInterval(30 * 60)
+)
+
+let activity = try Activity.request(
+ attributes: attributes,
+ content: ActivityContent(state: initialState, staleDate: nil),
+ pushType: nil // or .token for push notifications
+)
+```
+
+## Error Handling
+
+### Common Activity Errors
+
+Always check `ActivityAuthorizationInfo().areActivitiesEnabled` before requesting. Handle these errors from `Activity.request()`:
+
+- **`ActivityAuthorizationError`** — User denied Live Activities permission
+- **`ActivityError.dataTooLarge`** — ActivityAttributes exceeds 4KB; reduce attribute size
+- **`ActivityError.tooManyActivities`** — System limit reached (typically 2-3 simultaneous)
+
+Store `activity.id` after successful request for later updates.
+
+## Updating Activities
+
+### Update with New Content
+
+```swift
+// Find active activity by stored ID
+guard let activity = Activity.activities
+ .first(where: { $0.id == storedActivityID }) else { return }
+
+let updatedState = PizzaDeliveryAttributes.ContentState(
+ status: .onTheWay,
+ estimatedDeliveryTime: Date().addingTimeInterval(10 * 60),
+ driverName: "John"
+)
+
+await activity.update(
+ ActivityContent(
+ state: updatedState,
+ staleDate: Date().addingTimeInterval(60) // Mark stale after 1 min
+ )
+)
+```
+
+### Alert Configuration
+
+```swift
+await activity.update(updatedContent, alertConfiguration: AlertConfiguration(
+ title: "Pizza is here!",
+ body: "Your \(attributes.pizzaType) pizza has arrived",
+ sound: .default
+))
+```
+
+### Monitoring Activity Lifecycle
+
+Use `activity.activityStateUpdates` async sequence to observe state changes (`.active`, `.ended`, `.dismissed`, `.stale`). Clean up stored activity IDs on `.ended` or `.dismissed`. Cancel the monitoring task in `deinit`.
+
+## Ending Activities
+
+### Dismissal Policies
+
+```swift
+await activity.end(
+ ActivityContent(state: finalState, staleDate: nil),
+ dismissalPolicy: .default
+)
+```
+
+Dismissal policy options:
+- **`.immediate`** — Removes instantly
+- **`.default`** — Stays on Lock Screen for ~4 hours
+- **`.after(date)`** — Removes at specific time (e.g., `.after(Date().addingTimeInterval(3600))`)
+
+## Push Notifications for Live Activities
+
+### Request Push Token
+
+```swift
+let activity = try Activity.request(
+ attributes: attributes,
+ content: initialContent,
+ pushType: .token // Request push token
+)
+
+// Monitor for push token
+for await pushToken in activity.pushTokenUpdates {
+ let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
+ // Send to your server
+ await sendTokenToServer(tokenString, activityID: activity.id)
+}
+```
+
+### Frequent Push Updates (iOS 18.2+)
+
+Standard limit is ~10-12 pushes/hour. For live events (sports, stocks), add the `com.apple.developer.activity-push-notification-frequent-updates` entitlement for significantly higher limits.
+
+---
+
+# Part 5: Dynamic Island (iOS 16.1+)
+
+## Presentation Types
+
+Live Activities appear in the Dynamic Island with three size classes:
+
+### Compact (Leading + Trailing)
+
+Shown when another Live Activity is expanded or when multiple activities are active.
+
+```swift
+DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Image(systemName: "timer")
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ Text("\(entry.timeRemaining)")
+ }
+ // ...
+} compactLeading: {
+ Image(systemName: "timer")
+} compactTrailing: {
+ Text("\(entry.timeRemaining)")
+ .frame(width: 40)
+}
+```
+
+### Minimal
+
+Shown when more than two Live Activities are active (circular avatar).
+
+```swift
+DynamicIsland {
+ // ...
+} minimal: {
+ Image(systemName: "timer")
+ .foregroundStyle(.tint)
+}
+```
+
+### Expanded
+
+Shown when user long-presses the compact view.
+
+```swift
+DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Image(systemName: "timer")
+ .font(.title)
+ }
+
+ DynamicIslandExpandedRegion(.trailing) {
+ VStack(alignment: .trailing) {
+ Text("\(entry.timeRemaining)")
+ .font(.title2.monospacedDigit())
+ Text("remaining")
+ .font(.caption)
+ }
+ }
+
+ DynamicIslandExpandedRegion(.center) {
+ // Optional center content
+ }
+
+ DynamicIslandExpandedRegion(.bottom) {
+ HStack {
+ Button(intent: PauseIntent()) {
+ Label("Pause", systemImage: "pause.fill")
+ }
+ Button(intent: StopIntent()) {
+ Label("Stop", systemImage: "stop.fill")
+ }
+ }
+ }
+}
+```
+
+## Design Principles (From WWDC 2023-10194)
+
+### Concentric Alignment
+
+Content should nest concentrically inside the Dynamic Island's rounded shape with even margins. Use `Circle()` or `RoundedRectangle(cornerRadius:)` — never sharp `Rectangle()` which pokes into corners.
+
+### Biological Motion
+
+Dynamic Island animations should feel organic and elastic. Use `.spring(response: 0.6, dampingFraction: 0.7)` or `.interpolatingSpring(stiffness: 300, damping: 25)` instead of linear animations.
+
+---
+
+# Part 6: Control Center Widgets (iOS 18+)
+
+## ControlWidget Protocol
+
+Controls appear in Control Center, Lock Screen, and Action Button (iPhone 15 Pro+).
+
+### StaticControlConfiguration
+
+For simple controls without configuration.
+
+```swift
+import WidgetKit
+import AppIntents
+
+struct TorchControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "TorchControl") {
+ ControlWidgetButton(action: ToggleTorchIntent()) {
+ Label("Flashlight", systemImage: "flashlight.on.fill")
+ }
+ }
+ .displayName("Flashlight")
+ .description("Toggle flashlight")
+ }
+}
+```
+
+### AppIntentControlConfiguration
+
+For configurable controls.
+
+```swift
+struct TimerControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ AppIntentControlConfiguration(
+ kind: "TimerControl",
+ intent: ConfigureTimerIntent.self
+ ) { configuration in
+ ControlWidgetButton(action: StartTimerIntent(duration: configuration.duration)) {
+ Label("\(configuration.duration)m Timer", systemImage: "timer")
+ }
+ }
+ }
+}
+```
+
+## ControlWidgetButton
+
+For discrete actions (one-shot operations).
+
+```swift
+ControlWidgetButton(action: PlayMusicIntent()) {
+ Label("Play", systemImage: "play.fill")
+}
+.tint(.purple)
+```
+
+## ControlWidgetToggle
+
+For boolean state.
+
+```swift
+struct AirplaneModeControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "AirplaneModeControl") {
+ ControlWidgetToggle(
+ isOn: AirplaneModeIntent.isEnabled,
+ action: AirplaneModeIntent()
+ ) { isOn in
+ Label(isOn ? "On" : "Off", systemImage: "airplane")
+ }
+ }
+ }
+}
+```
+
+## Value Providers (Async State)
+
+For controls needing async state, pass a `ControlValueProvider` to `StaticControlConfiguration`:
+
+```swift
+struct ThermostatProvider: ControlValueProvider {
+ func currentValue() async throws -> ThermostatValue {
+ let temp = try await HomeManager.shared.currentTemperature()
+ return ThermostatValue(temperature: temp)
+ }
+ var previewValue: ThermostatValue { ThermostatValue(temperature: 72) }
+}
+```
+
+The provider value is passed to your control's closure: `{ value in ControlWidgetButton(...) }`.
+
+## Configurable Controls
+
+Use `AppIntentControlConfiguration` with a `WidgetConfigurationIntent` (same pattern as configurable widgets). Add `.promptsForUserConfiguration()` to show configuration UI when the user adds the control.
+
+## Control Refinements
+
+- `.controlWidgetActionHint("Toggles flashlight")` — VoiceOver accessibility hint
+- `.displayName("My Control")` / `.description("...")` — Shown in Control Center UI
+
+---
+
+# Part 7: iOS 18+ Updates
+
+## Accented Rendering and Liquid Glass
+
+Widget rendering modes span multiple iOS versions: `widgetAccentable()` (iOS 16+), `WidgetAccentedRenderingMode` (iOS 18+), and Liquid Glass effects like `glassEffect()` and `GlassEffectContainer` (iOS 26+). Detect the mode and adapt layout accordingly.
+
+### Detecting Rendering Mode
+
+```swift
+struct MyWidgetView: View {
+ @Environment(\.widgetRenderingMode) var renderingMode
+
+ var body: some View {
+ if renderingMode == .accented {
+ // Simplified layout — opaque images tinted white, background replaced with glass
+ } else {
+ // Standard full-color layout
+ }
+ }
+}
+```
+
+### widgetAccentable(_:)
+
+Marks views as part of the **accent group**. In accented mode, accent-group views are tinted separately from primary-group views, creating visual hierarchy.
+
+```swift
+HStack {
+ VStack(alignment: .leading) {
+ Text("Title")
+ .font(.headline)
+ .widgetAccentable() // Accent group — tinted in accented mode
+ Text("Subtitle")
+ // Primary group by default
+ }
+ Image(systemName: "star.fill")
+ .widgetAccentable() // Also accent group
+}
+```
+
+### WidgetAccentedRenderingMode
+
+Controls how images render in accented mode. Apply to `Image` views:
+
+```swift
+Image("myPhoto")
+ .widgetAccentedRenderingMode(.accented) // Tinted with accent color
+Image("myIcon")
+ .widgetAccentedRenderingMode(.monochrome) // Rendered as monochrome
+Image("myBadge")
+ .widgetAccentedRenderingMode(.fullColor) // Keeps original colors (opt-out)
+```
+
+**Best practices**: Display full-color images only in `.fullColor` rendering mode. Use `.widgetAccentable()` strategically for visual hierarchy. Test with multiple accent colors and background images.
+
+### Container Backgrounds
+
+```swift
+VStack { /* content */ }
+ .containerBackground(for: .widget) {
+ Color.blue.opacity(0.2)
+ }
+```
+
+In accented mode, the system removes the background and replaces it with themed glass. To prevent removal (excludes widget from iPad Lock Screen, StandBy):
+
+```swift
+.containerBackgroundRemovable(false)
+```
+
+### Liquid Glass in Custom Widget Elements
+
+```swift
+Text("Label")
+ .padding()
+ .glassEffect() // Default capsule shape
+
+Image(systemName: "star.fill")
+ .frame(width: 60, height: 60)
+ .glassEffect(.regular, in: .rect(cornerRadius: 12))
+
+Button("Action") { }
+ .buttonStyle(.glass)
+```
+
+Combine multiple glass elements with `GlassEffectContainer`:
+
+```swift
+GlassEffectContainer(spacing: 20.0) {
+ HStack(spacing: 20.0) {
+ Image(systemName: "cloud")
+ .frame(width: 60, height: 60)
+ .glassEffect()
+ Image(systemName: "sun")
+ .frame(width: 60, height: 60)
+ .glassEffect()
+ }
+}
+```
+
+## Cross-Platform Support
+
+### visionOS Widgets (visionOS 2+)
+
+visionOS widgets are 3D objects placed in physical space — mounted on surfaces or floating. They support unique spatial features.
+
+#### Mounting Styles
+
+Widgets can be elevated (on top of surfaces) or recessed (embedded into vertical surfaces like walls):
+
+```swift
+.supportedMountingStyles([.elevated, .recessed]) // Default is both
+// .supportedMountingStyles([.recessed]) // Wall-only widget
+```
+
+If limited to `.recessed`, users cannot place the widget on horizontal surfaces.
+
+#### Widget Textures
+
+Two visual textures for spatial appearance:
+
+```swift
+.widgetTexture(.glass) // Default — transparent glass-like appearance
+.widgetTexture(.paper) // Poster-like look, effective with extra-large sizes
+```
+
+#### Proximity Awareness (levelOfDetail)
+
+Widgets adapt to user distance automatically. The system animates transitions between detail levels:
+
+```swift
+@Environment(\.levelOfDetail) var levelOfDetail
+
+var body: some View {
+ VStack {
+ Text(entry.value)
+ .font(levelOfDetail == .simplified ? .largeTitle : .title)
+ }
+}
+```
+
+Values: `.default` (close viewing) and `.simplified` (distance viewing — use larger text, fewer details).
+
+#### visionOS Widget Families
+
+visionOS supports all system families plus extra-large sizes:
+
+```swift
+.supportedFamilies([
+ .systemSmall, .systemMedium, .systemLarge,
+ .systemExtraLarge,
+ .systemExtraLargePortrait // visionOS-specific portrait orientation
+])
+```
+
+Extra-large families are particularly effective with `.widgetTexture(.paper)` for poster-like displays.
+
+#### Background Detection
+
+Detect whether the widget background is visible (removed in accented mode):
+
+```swift
+@Environment(\.showsWidgetContainerBackground) var showsBackground
+```
+
+### CarPlay (iOS 18+)
+Add `.supplementalActivityFamilies([.medium])` to `ActivityConfiguration`. Uses StandBy-style full-width dashboard presentation.
+
+### macOS Menu Bar
+Live Activities from paired iPhone appear automatically in macOS Sequoia+ menu bar. No code changes required.
+
+### watchOS Controls (11+)
+`ControlWidget` works identically on watchOS — available in Control Center, Action Button, and Smart Stack. Same `StaticControlConfiguration` / `ControlWidgetButton` pattern as iOS.
+
+## Relevance Widgets (iOS 18+)
+
+Use `.relevanceConfiguration(for:score:attributes:)` to help the system promote widgets in Smart Stack. Attributes include `.location(CLLocation)`, `.timeOfDay(DateInterval)`, and `.activity(String)` for context-aware ranking.
+
+## Push Notification Updates (iOS 18+)
+
+Implement `PKPushRegistryDelegate` and handle `.widgetKit` push type to receive server-to-widget pushes. Update shared container data and call `WidgetCenter.shared.reloadAllTimelines()`. Pushes to iPhone automatically sync to Apple Watch and CarPlay.
+
+---
+
+# Part 8: App Groups & Data Sharing
+
+## App Groups Entitlement
+
+Required for sharing data between your app and extensions.
+
+### Configuration
+
+1. Xcode: Targets → Signing & Capabilities → Add "App Groups"
+2. Identifier format: `group.com.company.appname`
+3. Enable for BOTH main app target AND extension target
+
+## Shared Containers
+
+### Access Shared Container
+
+```swift
+let sharedContainer = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
+)!
+
+let dataFileURL = sharedContainer.appendingPathComponent("widgetData.json")
+```
+
+### UserDefaults with App Groups
+
+```swift
+// Main app - write data
+let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
+shared.set("Updated value", forKey: "myKey")
+
+// Widget extension - read data
+let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
+let value = shared.string(forKey: "myKey")
+```
+
+### Core Data with App Groups
+
+Point `NSPersistentStoreDescription` at the shared container URL:
+
+```swift
+let sharedStoreURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "group.com.mycompany.myapp"
+)!.appendingPathComponent("MyApp.sqlite")
+
+let description = NSPersistentStoreDescription(url: sharedStoreURL)
+container.persistentStoreDescriptions = [description]
+```
+
+## IPC Communication
+
+- **Background URL Session** — Set `config.sharedContainerIdentifier` to your App Group ID for downloads accessible by extensions
+- **Darwin Notification Center** — Use `CFNotificationCenterPostNotification` / `CFNotificationCenterAddObserver` with `CFNotificationCenterGetDarwinNotifyCenter()` for simple cross-process signals (e.g., notify widget to call `WidgetCenter.shared.reloadAllTimelines()`)
+
+---
+
+# Part 9: watchOS Integration
+
+## supplementalActivityFamilies (watchOS 11+)
+
+Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration` to show Live Activities on Apple Watch Smart Stack (same modifier used for CarPlay with `.medium`).
+
+## activityFamily Environment
+
+Use `@Environment(\.activityFamily)` to adapt layout — check for `.small` (watchOS) vs iPhone layout.
+
+## Always On Display
+
+Use `@Environment(\.isLuminanceReduced)` to simplify views for Always On Display — reduce detail, use white text, larger fonts. Combine with `@Environment(\.colorScheme)` for proper dark mode handling.
+
+## Update Budgeting (watchOS)
+
+watchOS updates sync automatically with iPhone via push notifications. Updates may be delayed if watch is out of Bluetooth range.
+
+---
+
+# Part 10: Practical Workflows
+
+## Building Your First Widget
+
+For a complete step-by-step tutorial with working code examples, see Apple's [Building Widgets Using WidgetKit and SwiftUI](https://developer.apple.com/documentation/widgetkit/building-widgets-using-widgetkit-and-swiftui) sample project.
+
+**Key steps**: Add widget extension target, configure App Groups, implement TimelineProvider, design SwiftUI view, update from main app. See Expert Review Checklist below for production requirements.
+
+---
+
+## Expert Review Checklist
+
+### Before Shipping Widgets
+
+**Architecture**:
+- [ ] App Groups entitlement configured in app AND extension
+- [ ] Group identifier matches exactly in both targets
+- [ ] Shared container used for ALL data sharing
+- [ ] No `UserDefaults.standard` in widget code
+
+**Performance**:
+- [ ] Timeline generation completes in < 5 seconds
+- [ ] No network requests in widget views
+- [ ] Timeline has reasonable refresh intervals (≥ 15 min)
+- [ ] Entry count reasonable (< 20-30 entries)
+- [ ] Memory usage under limits (~30MB widgets, ~50MB activities)
+- [ ] Images optimized (asset catalog or SF Symbols preferred)
+
+**Data & State**:
+- [ ] Widget handles missing/nil data gracefully
+- [ ] Entry dates in chronological order
+- [ ] Placeholder view looks reasonable
+- [ ] Snapshot view representative of actual use
+
+**User Experience**:
+- [ ] Widget appears in widget gallery
+- [ ] configurationDisplayName clear and concise
+- [ ] description explains widget purpose
+- [ ] All supported families tested and look correct
+- [ ] Text readable on both light and dark backgrounds
+- [ ] Interactive elements (buttons/toggles) work correctly
+
+**Live Activities** (if applicable):
+- [ ] ActivityAttributes under 4KB
+- [ ] Authorization checked before starting
+- [ ] Activity ends when event completes
+- [ ] Proper dismissal policy set
+- [ ] watchOS support configured if relevant (supplementalActivityFamilies)
+- [ ] Dynamic Island layouts tested (compact, minimal, expanded)
+
+**Liquid Glass** (if applicable):
+- [ ] `widgetAccentable()` applied for visual hierarchy in accented mode
+- [ ] `WidgetAccentedRenderingMode` set on images (`.accented`, `.monochrome`, or `.fullColor`)
+- [ ] Tested with multiple accent colors and background images
+- [ ] Container background configured with `.containerBackground(for: .widget)`
+
+**visionOS** (if applicable):
+- [ ] Mounting styles configured (`.elevated`, `.recessed`, or both)
+- [ ] Widget texture chosen (`.glass` or `.paper`)
+- [ ] `levelOfDetail` handled for proximity-aware layouts
+- [ ] Extra-large families supported if appropriate (`.systemExtraLarge`, `.systemExtraLargePortrait`)
+- [ ] Tested at different distances for proximity transitions
+
+**Control Center Widgets** (if applicable):
+- [ ] ControlValueProvider async and fast (< 1 second)
+- [ ] previewValue provides reasonable fallback
+- [ ] displayName and description set
+- [ ] Tested in Control Center, Lock Screen, Action Button
+
+**Testing**:
+- [ ] Tested on actual device (not just simulator)
+- [ ] Tested adding/removing widget
+- [ ] Tested app data changes → widget updates
+- [ ] Tested force-quit app → widget still works
+- [ ] Tested low memory scenarios
+- [ ] Tested all iOS versions you support
+- [ ] Tested with no internet connection
+
+---
+
+## Testing Guidance
+
+### Unit Testing Pattern
+
+Test `placeholder()`, `getSnapshot()`, and `getTimeline()` methods. Save test data to shared container, call `getTimeline()` with a mock context, assert entries are non-empty and contain expected data. Use `waitForExpectations(timeout: 5.0)` for async timeline generation.
+
+### Manual Testing Checklist
+- Add widget to Home Screen, verify widget gallery, all supported sizes, data matches app
+- Change data in main app, observe widget updates, force-quit app, reboot device
+- Delete all app data (graceful handling), disable network (offline), Low Power Mode, multiple instances
+- Monitor memory in Xcode Debug Navigator, check timeline generation time in Console, test on older devices
+
+### Debugging Tips
+- Add `print()` logging in `getTimeline()` to verify it's being called and data is loaded
+- Verify App Groups: print `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)` in both app and widget — paths must match
+- After data changes in main app, call `WidgetCenter.shared.reloadAllTimelines()`
+
+---
+
+# Part 11: Troubleshooting
+
+**Widget not appearing in gallery**: Check `WidgetBundle` includes it, verify `supportedFamilies()`, check extension's "Skip Install" = NO, verify deployment target matches app.
+
+## Widget Not Refreshing
+
+**Symptoms**: Widget shows stale data, doesn't update
+
+**Diagnostic Steps**:
+1. Check timeline policy (`.atEnd` vs `.after()` vs `.never`)
+2. Verify you're not exceeding daily budget (40-70 reloads)
+3. Check if `getTimeline()` is being called (add logging)
+4. Ensure App Groups configured correctly for shared data
+
+**Solution**:
+```swift
+// Manual reload from main app when data changes
+import WidgetKit
+
+WidgetCenter.shared.reloadAllTimelines()
+// or
+WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")
+```
+
+## Data Not Shared Between App and Widget
+
+**Symptoms**: Widget shows default/empty data
+
+**Diagnostic Steps**:
+1. Verify App Groups entitlement in BOTH targets
+2. Check group identifier matches exactly
+3. Ensure using same suiteName in both targets
+4. Check file path if using shared container
+
+**Solution**:
+```swift
+// Both app AND extension must use:
+let shared = UserDefaults(suiteName: "group.com.mycompany.myapp")!
+
+// NOT:
+let shared = UserDefaults.standard // ❌ Different containers
+```
+
+## Live Activity Won't Start
+
+**Symptoms**: `Activity.request()` throws error
+
+**Common Errors**:
+
+**"Activity size exceeds 4KB"**:
+```swift
+// ❌ BAD: Large images in attributes
+struct MyAttributes: ActivityAttributes {
+ var productImage: UIImage // Too large!
+}
+
+// ✅ GOOD: Use asset catalog names
+struct MyAttributes: ActivityAttributes {
+ var productImageName: String // Reference to asset
+}
+```
+
+**"Activities not enabled"**:
+```swift
+// Check authorization first
+let authInfo = ActivityAuthorizationInfo()
+guard authInfo.areActivitiesEnabled else {
+ throw ActivityError.notEnabled
+}
+```
+
+## Interactive Widget Button Not Working
+
+**Symptoms**: Tapping button does nothing
+
+**Diagnostic Steps**:
+1. Verify App Intent's `perform()` returns `IntentResult`
+2. Check intent is imported in widget target
+3. Ensure button uses `intent:` parameter, not `action:`
+4. Check Console for intent execution errors
+
+**Solution**:
+```swift
+// ✅ CORRECT: Use intent parameter
+Button(intent: MyIntent()) {
+ Label("Action", systemImage: "star")
+}
+
+// ❌ WRONG: Don't use action closure
+Button(action: { /* This won't work in widgets */ }) {
+ Label("Action", systemImage: "star")
+}
+```
+
+**Control Center widget slow**: Use async in `ControlValueProvider.currentValue()`, never block with `Thread.sleep`. Provide fast `previewValue` fallback.
+
+**Widget shows wrong size**: Switch on `@Environment(\.widgetFamily)` in view, adapt layout per family, avoid hardcoded sizes.
+
+**Timeline entries out of order**: Ensure entry dates are chronological. Use incrementing offsets from `Date()`.
+
+**watchOS Live Activity not showing**: Add `.supplementalActivityFamilies([.small])` to `ActivityConfiguration`, verify watchOS 11+, check Bluetooth/pairing.
+
+## Performance Issues
+
+**Symptoms**: Widget rendering slow, battery drain
+
+**Common Causes**:
+- Too many timeline entries (> 100)
+- Network requests in view code
+- Heavy computation in `getTimeline()`
+- Refresh intervals too frequent (< 15 min)
+
+**Solution**:
+```swift
+// ✅ GOOD: Strategic intervals
+let entries = (0..<8).map { offset in
+ let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: now)!
+ return SimpleEntry(date: date, data: precomputedData)
+}
+
+// ❌ BAD: Too frequent, too many entries
+let entries = (0..<100).map { offset in
+ let date = Calendar.current.date(byAdding: .minute, value: offset, to: now)!
+ return SimpleEntry(date: date, data: fetchFromNetwork()) // Network in timeline
+}
+```
+
+---
+
+## Debugging Widgets
+
+### Simulator vs Device
+
+- **Simulator**: Widgets refresh immediately; no budget limits apply. Useful for layout testing but misleading for refresh behavior.
+- **Device**: Budget-limited (40-70 reloads/day). Test on device before shipping to verify real-world refresh timing.
+- **Xcode Previews**: Work for layout but skip `getTimeline()`. Test timeline logic with unit tests or device runs.
+
+### Common Debugging Workflow
+
+1. Add `print()` in `getTimeline()` — verify it's called and data loads
+2. Check Console.app filtered by widget extension process name
+3. Use `WidgetCenter.shared.getCurrentConfigurations()` to verify registration
+4. If widget shows old data after app update, verify App Groups container paths match
+
+### Data Sharing Patterns
+
+**SwiftData in Widgets** (iOS 17+):
+- Create `ModelContainer` in widget with same schema as main app
+- Use shared App Groups container: `ModelConfiguration(url: containerURL)`
+- Widget reads only — never write from widget to avoid conflicts
+- Main app calls `WidgetCenter.shared.reloadAllTimelines()` after writes
+
+**GRDB/SQLite in Widgets**:
+- Share database file via App Groups container
+- Use `DatabasePool` (not `DatabaseQueue`) for concurrent reads
+- Widget opens read-only connection: `try DatabasePool(path: dbPath, configuration: readOnlyConfig)`
+- Set `configuration.readonly = true` in widget to prevent accidental writes
+
+---
+
+## Resources
+
+**WWDC**: 2025-278, 2024-10157, 2024-10068, 2024-10098, 2023-10028, 2023-10194, 2022-10184, 2022-10185
+
+**Docs**: /widgetkit, /activitykit, /appintents
+
+**Skills**: axiom-app-intents-ref, axiom-swift-concurrency, axiom-swiftui-performance, axiom-swiftui-layout, axiom-extensions-widgets
+
+---
+
+**Version**: 0.9 | **Platforms**: iOS 14+, iPadOS 14+, watchOS 9+, macOS 11+, visionOS 2+
diff --git a/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml b/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml
new file mode 100644
index 0000000..8e8c100
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Extensions Widgets Reference"
+ short_description: "Implementing widgets, Live Activities, Control Center controls, or app extensions"
diff --git a/.claude/skills/axiom-extensions-widgets/.openskills.json b/.claude/skills/axiom-extensions-widgets/.openskills.json
new file mode 100644
index 0000000..e071183
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-extensions-widgets",
+ "installedAt": "2026-04-12T08:06:15.831Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-extensions-widgets/SKILL.md b/.claude/skills/axiom-extensions-widgets/SKILL.md
new file mode 100644
index 0000000..7e087ba
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets/SKILL.md
@@ -0,0 +1,1054 @@
+---
+name: axiom-extensions-widgets
+description: Use when implementing widgets, Live Activities, or Control Center controls - enforces correct patterns for timeline management, data sharing, and extension lifecycle to prevent common crashes and memory issues
+license: MIT
+compatibility: iOS 14+, iPadOS 14+, watchOS 9+
+metadata:
+ version: "1.0.0"
+---
+
+# Extensions & Widgets — Discipline
+
+## Core Philosophy
+
+> "Widgets are not mini apps. They're glanceable views into your app's data, rendered at strategic moments and displayed by the system. Extensions run in sandboxed environments with limited memory and execution time."
+
+**Mental model**: Think of widgets as **archived snapshots** on a timeline, not live views. Your widget doesn't "run" continuously — it renders, gets archived, and the system displays the snapshot.
+
+**Extension sandboxing**: Extensions have:
+- Limited memory (~30MB)
+- No network access in widget views (fetch in TimelineProvider only)
+- Separate bundle container from main app
+- Require App Groups for data sharing
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Implementing any widget (Home Screen, Lock Screen, StandBy, Control Center)
+- Creating Live Activities
+- Debugging why widgets show stale data
+- Widget not appearing in gallery
+- Interactive buttons not responding
+- Live Activity fails to start
+- Control Center control is unresponsive
+- Sharing data between app and widget/extension
+
+❌ **Do NOT use this skill for**:
+- Pure App Intents implementation (use **app-intents-ref**)
+- SwiftUI layout questions (use **swiftui-layout**)
+- Performance profiling (use **swiftui-performance**)
+- General debugging (use **xcode-debugging**)
+
+## Related Skills
+
+- **extensions-widgets-ref** — Comprehensive API reference
+- **app-intents-ref** — App Intents for interactive widgets
+- **swift-concurrency** — Async patterns for data fetching
+- **swiftdata** — Using SwiftData with App Groups
+
+## Example Prompts
+
+#### 1. "My widget isn't updating"
+→ This skill covers timeline policies, refresh budgets, manual reload, and App Groups configuration
+
+#### 2. "How do I share data between app and widget?"
+→ This skill explains App Groups entitlement, shared UserDefaults, and container URLs
+
+#### 3. "Widget shows old data even after I update the app"
+→ This skill covers container paths, UserDefaults suite names, and WidgetCenter reload
+
+#### 4. "Live Activity fails to start"
+→ This skill covers 4KB data limit, ActivityAttributes constraints, authorization checks
+
+#### 5. "Control Center control takes forever to respond"
+→ This skill covers async ValueProvider patterns and optimistic UI
+
+#### 6. "Interactive widget button does nothing"
+→ This skill covers App Intent perform() implementation and WidgetCenter reload
+
+---
+
+# Red Flags / Anti-Patterns
+
+## Pattern 1: Network Calls in Widget View
+
+**Time cost**: 2-4 hours debugging why widgets are blank or show errors
+
+### Symptom
+- Widget renders but shows no data
+- Console errors: "NSURLSession not available in widget extension"
+- Widget appears blank intermittently
+
+### ❌ BAD Code
+
+```swift
+struct MyWidgetView: View {
+ @State private var data: String?
+
+ var body: some View {
+ VStack {
+ if let data = data {
+ Text(data)
+ }
+ }
+ .onAppear {
+ // ❌ WRONG — Network in widget view
+ Task {
+ let (data, _) = try await URLSession.shared.data(from: apiURL)
+ self.data = String(data: data, encoding: .utf8)
+ }
+ }
+ }
+}
+```
+
+**Why it fails**: Widget views are rendered, archived, and reused. Network calls in views are unreliable and may not execute.
+
+### ✅ GOOD Code
+
+```swift
+// Main app — prefetch and save
+func updateWidgetData() async {
+ let data = try await fetchFromAPI()
+ let shared = UserDefaults(suiteName: "group.com.myapp")!
+ shared.set(data, forKey: "widgetData")
+
+ WidgetCenter.shared.reloadAllTimelines()
+}
+
+// Widget TimelineProvider — read from shared storage
+struct Provider: TimelineProvider {
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
+ let shared = UserDefaults(suiteName: "group.com.myapp")!
+ let data = shared.string(forKey: "widgetData") ?? "No data"
+
+ let entry = SimpleEntry(date: Date(), data: data)
+ let timeline = Timeline(entries: [entry], policy: .atEnd)
+ completion(timeline)
+ }
+}
+```
+
+**Pattern**: Fetch data in main app, save to shared storage, read in widget.
+
+**Can TimelineProvider make network requests?**
+
+Yes, but with important caveats:
+
+```swift
+struct Provider: TimelineProvider {
+ func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
+ Task {
+ // ✅ Network requests ARE allowed here
+ let data = try await fetchFromAPI()
+ let entry = SimpleEntry(date: Date(), data: data)
+ completion(Timeline(entries: [entry], policy: .atEnd))
+ }
+ }
+}
+```
+
+**Constraints**:
+- **30-second timeout** - System kills extension if getTimeline() doesn't complete
+- **No background sessions** - Can't download large files
+- **Battery cost** - Every timeline reload uses battery
+- **Not guaranteed** - May fail on poor connections
+
+**Best practice**: Prefetch in main app (faster, more reliable), use TimelineProvider network as fallback only.
+
+---
+
+## Pattern 2: Missing App Groups
+
+**Time cost**: 1-2 hours debugging why widget shows empty/default data
+
+### Symptom
+- Widget always shows placeholder or default values
+- Changes in main app don't reflect in widget
+- UserDefaults reads return nil in widget
+
+### ❌ BAD Code
+
+```swift
+// Main app
+UserDefaults.standard.set("Updated", forKey: "myKey")
+
+// Widget extension
+let value = UserDefaults.standard.string(forKey: "myKey") // Returns nil!
+```
+
+**Why it fails**: `UserDefaults.standard` accesses different containers in app vs. extension.
+
+### ✅ GOOD Code
+
+```swift
+// 1. Enable App Groups entitlement in BOTH targets:
+// - Main app target: Signing & Capabilities → + App Groups → "group.com.myapp"
+// - Widget extension target: Same group identifier
+
+// 2. Main app
+let shared = UserDefaults(suiteName: "group.com.myapp")!
+shared.set("Updated", forKey: "myKey")
+
+// 3. Widget extension
+let shared = UserDefaults(suiteName: "group.com.myapp")!
+let value = shared.string(forKey: "myKey") // Returns "Updated"
+```
+
+**Verification**:
+```swift
+let containerURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "group.com.myapp"
+)
+print("Shared container: \(containerURL?.path ?? "MISSING")")
+// Should print path, not "MISSING"
+```
+
+---
+
+## Pattern 3: Over-Refreshing (Budget Exhaustion)
+
+**Time cost**: Poor user experience, battery drain, widgets stop updating
+
+### Symptom
+- Widget updates frequently at first, then stops
+- Console logs: "Timeline reload budget exhausted"
+- Widget becomes stale after a few hours
+
+### ❌ BAD Code
+
+```swift
+func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
+ var entries: [SimpleEntry] = []
+
+ // ❌ WRONG — 60 entries at 1-minute intervals
+ for minuteOffset in 0..<60 {
+ let date = Calendar.current.date(byAdding: .minute, value: minuteOffset, to: Date())!
+ entries.append(SimpleEntry(date: date, data: "Data"))
+ }
+
+ let timeline = Timeline(entries: entries, policy: .atEnd)
+ completion(timeline)
+}
+```
+
+**Why it's bad**: System gives 40-70 reloads/day. This approach uses 24 reloads/hour → exhausts budget in 2-3 hours.
+
+### ✅ GOOD Code
+
+```swift
+func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
+ var entries: [SimpleEntry] = []
+
+ // ✅ CORRECT — 8 entries at 15-minute intervals (2 hours coverage)
+ for offset in 0..<8 {
+ let date = Calendar.current.date(byAdding: .minute, value: offset * 15, to: Date())!
+ entries.append(SimpleEntry(date: date, data: getData()))
+ }
+
+ let timeline = Timeline(entries: entries, policy: .atEnd)
+ completion(timeline)
+}
+```
+
+**Guidelines**:
+- 15-60 minute intervals for most widgets
+- 5-15 minutes for time-sensitive data (stocks, sports)
+- Use `.atEnd` policy for automatic reload
+- Let system decide optimal refresh based on user engagement
+
+---
+
+## Pattern 4: Blocking Main Thread in Controls
+
+**Time cost**: Control Center control unresponsive, poor UX
+
+### Symptom
+- Tapping control in Control Center shows spinner for seconds
+- Control seems "stuck" or frozen
+- No immediate visual feedback
+
+### ❌ BAD Code
+
+```swift
+struct ThermostatControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "Thermostat") {
+ ControlWidgetButton(action: GetTemperatureIntent()) {
+ // ❌ WRONG — Synchronous fetch blocks UI
+ let temp = HomeManager.shared.currentTemperature() // Blocking call
+ Label("\(temp)°", systemImage: "thermometer")
+ }
+ }
+ }
+}
+```
+
+**Why it's bad**: Button renders on main thread. Blocking network/database calls freeze UI.
+
+### ✅ GOOD Code
+
+```swift
+struct ThermostatProvider: ControlValueProvider {
+ func currentValue() async throws -> ThermostatValue {
+ // ✅ CORRECT — Async fetch, non-blocking
+ let temp = try await HomeManager.shared.fetchTemperature()
+ return ThermostatValue(temperature: temp)
+ }
+
+ var previewValue: ThermostatValue {
+ ThermostatValue(temperature: 72) // Instant fallback
+ }
+}
+
+struct ThermostatValue: ControlValueProviderValue {
+ var temperature: Int
+}
+
+struct ThermostatControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "Thermostat", provider: ThermostatProvider()) { value in
+ ControlWidgetButton(action: AdjustTemperatureIntent()) {
+ Label("\(value.temperature)°", systemImage: "thermometer")
+ }
+ }
+ }
+}
+```
+
+**Pattern**: Use `ControlValueProvider` for async data, provide instant `previewValue` fallback.
+
+---
+
+## Pattern 5: Missing Dismissal Policy (Zombie Live Activities)
+
+**Time cost**: User annoyance, negative reviews
+
+### Symptom
+- Live Activities stay on Lock Screen for hours after event ends
+- Users must manually dismiss completed activities
+- Activity shows "Delivered" but won't disappear
+
+### ❌ BAD Code
+
+```swift
+// Start activity
+let activity = try Activity.request(attributes: attributes, content: initialContent)
+
+// Later... event completes
+// ❌ WRONG — Never call .end()
+// Activity stays forever until user dismisses
+```
+
+**Why it's bad**: Activities persist indefinitely unless explicitly ended.
+
+### ✅ GOOD Code
+
+```swift
+// When event completes
+let finalState = DeliveryAttributes.ContentState(
+ status: .delivered,
+ deliveredAt: Date()
+)
+
+await activity.end(
+ ActivityContent(state: finalState, staleDate: nil),
+ dismissalPolicy: .default // Removes after ~4 hours
+)
+
+// Or for immediate removal
+await activity.end(nil, dismissalPolicy: .immediate)
+
+// Or remove at specific time
+let dismissTime = Date().addingTimeInterval(30 * 60) // 30 min
+await activity.end(nil, dismissalPolicy: .after(dismissTime))
+```
+
+**Best practices**:
+- `.immediate` — Transient events (timer completed, song finished)
+- `.default` — Most activities (shows "completed" state for ~4 hours)
+- `.after(date)` — Specific end time (meeting ends, flight lands)
+
+---
+
+## Pattern 6: Exceeding 4KB Data Limit (Live Activities)
+
+**Time cost**: Activity fails to start silently, hard to debug
+
+### Symptom
+- `Activity.request()` throws error
+- Console: "Activity attributes exceed size limit"
+- Activity never appears on Lock Screen
+
+### ❌ BAD Code
+
+```swift
+struct GameAttributes: ActivityAttributes {
+ struct ContentState: Codable, Hashable {
+ var teamALogo: Data // ❌ Large image data
+ var teamBLogo: Data
+ var playByPlay: [String] // ❌ Unbounded array
+ var statistics: [String: Any] // ❌ Large dictionary
+ }
+
+ var gameID: String
+ var venueName: String
+}
+
+// Fails if total size > 4KB
+let activity = try Activity.request(attributes: attrs, content: content)
+```
+
+**Why it fails**: ActivityAttributes + ContentState combined must be < 4KB.
+
+### ✅ GOOD Code
+
+```swift
+struct GameAttributes: ActivityAttributes {
+ struct ContentState: Codable, Hashable {
+ var teamAScore: Int // ✅ Small primitives
+ var teamBScore: Int
+ var quarter: Int
+ var timeRemaining: String // "2:34"
+ var lastPlay: String? // Single most recent play
+ }
+
+ var gameID: String // ✅ Reference, not full data
+ var teamAName: String
+ var teamBName: String
+}
+
+// Use asset catalog for images in view
+struct GameLiveActivityView: View {
+ var context: ActivityViewContext
+
+ var body: some View {
+ HStack {
+ Image(context.attributes.teamAName) // Asset catalog
+ Text("\(context.state.teamAScore)")
+ // ...
+ }
+ }
+}
+```
+
+**Strategies**:
+- Store IDs/references, not full objects
+- Use asset catalogs for images (not embedded Data)
+- Keep ContentState minimal (only changeable data)
+- Use computed properties in views for derived data
+
+### Size Targets (Safety Margins)
+
+**Hard limit**: 4096 bytes (4KB)
+
+**Target guidance**:
+- ✅ **< 2KB**: Safe with room to grow - recommended for v1.0
+- ⚠️ **2-3KB**: Acceptable but monitor closely as you add features
+- 🔴 **3.5KB+**: Risky - future fields may push you over limit
+
+**Why safety margins matter**: You'll add fields later (new features, more data). Starting at 3.8KB leaves zero room for growth.
+
+**Checking size**:
+```swift
+let attributes = GameAttributes(gameID: "123", teamAName: "Hawks", teamBName: "Eagles")
+let state = GameAttributes.ContentState(teamAScore: 14, teamBScore: 10, quarter: 2, timeRemaining: "5:23", lastPlay: nil)
+
+let encoder = JSONEncoder()
+if let attributesData = try? encoder.encode(attributes),
+ let stateData = try? encoder.encode(state) {
+ let totalSize = attributesData.count + stateData.count
+ print("Total size: \(totalSize) bytes")
+
+ if totalSize < 2048 {
+ print("✅ Safe with room to grow")
+ } else if totalSize < 3072 {
+ print("⚠️ Acceptable but monitor")
+ } else if totalSize < 3584 {
+ print("🔴 Risky - optimize now")
+ } else {
+ print("❌ CRITICAL - will likely fail")
+ }
+}
+```
+
+**Optimization priorities** (when over 2KB):
+1. Replace `String` descriptions with enums (if fixed set)
+2. Shorten string values ("Team A" → "A")
+3. Use smaller types (Int → Int8 if range allows)
+4. Remove optional fields that are rarely used
+
+---
+
+## Pattern 7: Widget Not Appearing in Gallery
+
+**Time cost**: 30 minutes debugging invisible widget
+
+### Symptom
+- Widget builds successfully
+- No errors in console
+- Widget doesn't appear in widget picker/gallery
+- Can't add to Home Screen
+
+### ❌ BAD Code
+
+```swift
+@main
+struct MyWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
+ MyWidgetView(entry: entry)
+ }
+ .configurationDisplayName("My Widget")
+ .description("Shows data")
+ // ❌ MISSING: supportedFamilies() — widget won't appear!
+ }
+}
+```
+
+**Why it fails**: Without supportedFamilies(), system doesn't know which sizes to offer.
+
+### ✅ GOOD Code
+
+```swift
+@main
+struct MyWidget: Widget {
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
+ MyWidgetView(entry: entry)
+ }
+ .configurationDisplayName("My Widget")
+ .description("Shows data")
+ .supportedFamilies([.systemSmall, .systemMedium]) // ✅ Required
+ }
+}
+```
+
+**Other common causes**:
+- Widget target's "Skip Install" set to YES (should be NO)
+- Widget extension not added to app's "Embed App Extensions"
+- Clean build folder needed (`Cmd+Shift+K`)
+
+---
+
+# Decision Tree
+
+```
+Widget/Extension Issue?
+│
+├─ Widget not appearing in gallery?
+│ ├─ Check WidgetBundle registered in @main
+│ ├─ Verify supportedFamilies() includes intended families
+│ └─ Clean build folder, restart Xcode
+│
+├─ Widget not refreshing?
+│ ├─ Timeline policy set to .never?
+│ │ └─ Change to .atEnd or .after(date)
+│ ├─ Budget exhausted? (too frequent reloads)
+│ │ └─ Increase interval between entries (15-60 min)
+│ └─ Manual reload
+│ └─ WidgetCenter.shared.reloadAllTimelines()
+│
+├─ Widget shows empty/old data?
+│ ├─ App Groups configured in BOTH targets?
+│ │ ├─ No → Add "App Groups" entitlement
+│ │ └─ Yes → Verify same group ID
+│ ├─ Using UserDefaults.standard?
+│ │ └─ Change to UserDefaults(suiteName: "group.com.myapp")
+│ └─ Shared container path correct?
+│ └─ Print containerURL, verify not nil
+│
+├─ Interactive button not working?
+│ ├─ App Intent perform() returns value?
+│ │ └─ Must return IntentResult
+│ ├─ perform() updates shared data?
+│ │ └─ Update App Group storage
+│ └─ Calls WidgetCenter.reloadTimelines()?
+│ └─ Reload to reflect changes
+│
+├─ Live Activity fails to start?
+│ ├─ Data size > 4KB?
+│ │ └─ Reduce ActivityAttributes + ContentState
+│ ├─ Authorization enabled?
+│ │ └─ Check ActivityAuthorizationInfo().areActivitiesEnabled
+│ └─ pushType correct?
+│ └─ nil for local updates, .token for push
+│
+├─ Control Center control unresponsive?
+│ ├─ Async operation blocking UI?
+│ │ └─ Use ControlValueProvider with async currentValue()
+│ └─ Provide previewValue for instant fallback
+│
+└─ watchOS Live Activity not showing?
+ ├─ supplementalActivityFamilies includes .small?
+ └─ Apple Watch paired and in range?
+```
+
+---
+
+# Mandatory First Steps
+
+Before debugging any widget or extension issue, complete this checklist:
+
+## Widget Debugging Checklist
+
+- ☐ **App Groups enabled** in BOTH main app AND extension targets
+ ```bash
+ # Verify entitlements
+ codesign -d --entitlements - /path/to/YourApp.app
+ # Should show com.apple.security.application-groups
+ ```
+
+- ☐ **Widget in Widget Gallery** (not just on Home Screen)
+ - Long-press Home Screen → + button → Find your widget
+ - Verify it appears with correct name and description
+
+- ☐ **Console logs** for timeline errors
+ ```bash
+ # Xcode Console
+ # Filter: "widget" OR "timeline"
+ # Look for: "Timeline reload failed", "Budget exhausted"
+ ```
+
+- ☐ **Manual reload test**
+ ```swift
+ WidgetCenter.shared.reloadAllTimelines()
+ ```
+ - If this fixes it → problem is timeline policy or refresh budget
+
+- ☐ **Shared container accessible**
+ ```swift
+ let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "group.com.myapp"
+ )
+ print("Container: \(container?.path ?? "NIL")")
+ // Must print valid path, not "NIL"
+ ```
+
+## Live Activity Debugging Checklist
+
+- ☐ **ActivityAttributes < 4KB**
+ ```swift
+ let encoded = try JSONEncoder().encode(attributes)
+ print("Size: \(encoded.count) bytes") // Must be < 4096
+ ```
+
+- ☐ **Authorization check**
+ ```swift
+ let authInfo = ActivityAuthorizationInfo()
+ print("Enabled: \(authInfo.areActivitiesEnabled)")
+ ```
+
+- ☐ **pushType matches server integration**
+ - `nil` → local updates only
+ - `.token` → expects push notifications
+
+- ☐ **Dismissal policy implemented**
+ - Every activity.end() must specify policy
+
+## Control Center Widget Checklist
+
+- ☐ **ControlValueProvider for async data**
+- ☐ **previewValue provides instant fallback**
+- ☐ **App Intent perform() is async**
+- ☐ **No blocking network/database calls in views**
+
+---
+
+# Pressure Scenarios
+
+## Scenario 1: "Widget shows wrong data in production"
+
+### Situation
+- App released to App Store
+- Users report widget displaying incorrect/stale information
+- Works fine in development
+
+### Pressure Signals
+- 🚨 **App Store reviews** — 1-star reviews mentioning broken widget
+- ⏰ **Time pressure** — Need hotfix ASAP
+- 👔 **Executive visibility** — Management asking for status updates
+
+### Rationalization Traps (DO NOT)
+
+1. *"Just force a timeline reload more often"*
+ - **Why it fails**: Exhausts budget, makes problem worse
+
+2. *"The widget worked in testing"*
+ - **Why it fails**: Development vs. production App Groups mismatch
+
+3. *"Users should just restart their phone"*
+ - **Why it fails**: Not a fix, damages reputation
+
+### MANDATORY Systematic Fix
+
+#### Step 1: Verify App Groups (30 min)
+
+```swift
+// Add logging to BOTH app and widget
+let group = "group.com.myapp.production" // Must match exactly
+let container = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: group
+)
+
+print("[\(Bundle.main.bundleIdentifier ?? "?")] Container: \(container?.path ?? "NIL")")
+
+// Log EVERY read/write
+let shared = UserDefaults(suiteName: group)!
+print("Writing key 'lastUpdate' = \(Date())")
+shared.set(Date(), forKey: "lastUpdate")
+```
+
+**Verify**: Run app, then widget. Both should print SAME container path.
+
+#### Step 2: Check Container Paths
+
+```bash
+# Device logs (Xcode → Window → Devices and Simulators → View Device Logs)
+# Filter: Your app bundle ID
+# Look for: Container path mismatches
+```
+
+Common issues:
+- App uses `group.com.myapp.dev`
+- Widget uses `group.com.myapp.production`
+- **Fix**: Ensure EXACT same group ID in both .entitlements files
+
+#### Step 3: Add Version Stamp
+
+```swift
+// Main app — stamp every write
+struct WidgetData: Codable {
+ var value: String
+ var timestamp: Date
+ var appVersion: String
+}
+
+let data = WidgetData(
+ value: "Latest",
+ timestamp: Date(),
+ appVersion: Bundle.main.appVersion
+)
+shared.set(try JSONEncoder().encode(data), forKey: "widgetData")
+
+// Widget — verify version
+if let data = shared.data(forKey: "widgetData"),
+ let decoded = try? JSONDecoder().decode(WidgetData.self, from: data) {
+ print("Widget reading data from app version: \(decoded.appVersion)")
+}
+```
+
+#### Step 4: Force Reload on App Launch
+
+```swift
+// AppDelegate / @main App
+func applicationDidBecomeActive(_ application: UIApplication) {
+ WidgetCenter.shared.reloadAllTimelines()
+}
+```
+
+### Communication Template
+
+**To stakeholders**:
+```
+Status: Investigating widget data sync issue
+
+Root cause: App Groups configuration mismatch between app and widget extension in production build
+
+Fix: Updated both targets to use identical group identifier, added logging to prevent recurrence
+
+Timeline: Hotfix submitted to App Store review (24-48h)
+
+Workaround for users: Force-quit app and relaunch (triggers widget refresh)
+```
+
+### Time Saved
+- **Without systematic fix**: 4-8 hours of trial-and-error, multiple resubmissions
+- **With this process**: 1-2 hours to identify, fix, and verify
+
+---
+
+## Scenario 2: "Live Activity must update instantly"
+
+### Situation
+- Sports score app
+- Users expect scores to update within seconds of real game events
+- Current timeline-based approach too slow
+
+### Pressure
+- **Competitive**: "Other apps update faster"
+- **Deadline**: Marketing promised "real-time" updates
+
+### Rationalization Traps (DO NOT)
+
+1. *"Just create entries every 5 seconds"*
+ - **Why it fails**: Not real-time, exhausts battery, doesn't scale
+
+2. *"Add WebSocket to widget view"*
+ - **Why it fails**: Extensions can't maintain persistent connections
+
+3. *"Lower refresh interval to 1 second"*
+ - **Why it fails**: Timeline system not designed for sub-minute updates
+
+### MANDATORY Solution: Phased Approach
+
+**Critical reality check**: Push notification entitlement approval takes **3-7 days**. Never promise features before approval.
+
+#### Phase 1: Ship with Local Updates (No Approval Required)
+
+**Ship immediately** with app-driven updates:
+
+```swift
+// Start activity WITHOUT push (no entitlement needed)
+let activity = try Activity.request(
+ attributes: attributes,
+ content: initialContent,
+ pushType: nil // Local updates only
+)
+
+// In your app when data changes (user opens app, pulls to refresh)
+await activity.update(ActivityContent(
+ state: updatedState,
+ staleDate: nil
+))
+```
+
+**Set expectations**: Updates occur when user interacts with app. This is **acceptable** for v1.0 and requires zero approval.
+
+#### Phase 2: Add Push After Approval (3-7 Days)
+
+**After entitlement approved**, switch to push:
+
+#### Step 1: Enable Push for Live Activities
+
+```swift
+// 1. Entitlement: "com.apple.developer.activity-push-notification"
+
+// 2. Request activity with push token
+let activity = try Activity.request(
+ attributes: attributes,
+ content: initialContent,
+ pushType: .token
+)
+
+// 3. Monitor for token
+Task {
+ for await pushToken in activity.pushTokenUpdates {
+ let tokenString = pushToken.map { String(format: "%02x", $0) }.joined()
+ await sendTokenToServer(activityID: activity.id, token: tokenString)
+ }
+}
+```
+
+#### Step 2: Server-Side Push (Phase 2 Only)
+
+```json
+{
+ "aps": {
+ "timestamp": 1633046400,
+ "event": "update",
+ "content-state": {
+ "teamAScore": 14,
+ "teamBScore": 10,
+ "quarter": 2,
+ "timeRemaining": "5:23"
+ },
+ "alert": {
+ "title": "Touchdown!",
+ "body": "Team A scores"
+ }
+ }
+}
+```
+
+**Standard push limit**: ~10-12 per hour
+
+#### Step 3: Request Frequent Updates Entitlement (Phase 2, iOS 18.2+)
+
+For apps requiring more frequent pushes (sports, stocks):
+
+```xml
+com.apple.developer.activity-push-notification-frequent-updates
+
+```
+
+**Requires justification** in App Store Connect: "Live sports scores require immediate updates for user engagement"
+
+#### Verification
+
+```swift
+// Log push receipt in Live Activity widget
+#if DEBUG
+let logURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: "group.com.myapp"
+)!.appendingPathComponent("push_log.txt")
+
+let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
+try! "\(timestamp): Received push\n".append(to: logURL)
+#endif
+```
+
+### Communication Template
+
+**To marketing/exec (Phase 1)**:
+```
+Launch Timeline:
+- Phase 1 (immediate): Live Activities with app-driven updates. Updates appear when users open app or pull to refresh.
+- Phase 2 (3-7 days): Push notification integration after Apple approval. Updates arrive within 1-3 seconds of server events.
+
+Recommendation: Launch Phase 1 to market, communicate Phase 2 as "coming soon" once approved.
+```
+
+**To marketing/exec (Phase 2)**:
+```
+"Real-time" positioning requires clarification:
+
+Technical: Live Activities update via push notifications with 1-3 second latency from server to device
+
+Constraints: Apple's push system has rate limits (~10/hour standard, axiom-higher with special entitlement)
+
+Competitive analysis: Competitors likely use same system with similar limitations
+
+Recommendation: Position as "near real-time" (accurate) vs "instant" (misleading)
+```
+
+### Reality Check
+- Push notifications are fastest mechanism available
+- 1-3 second latency is normal
+- Budget limits exist for battery optimization
+- Users prefer longer battery life over millisecond-faster scores
+
+> For comprehensive push notification setup (APNs auth, payload format, token management, service extensions), see axiom-push-notifications and axiom-push-notifications-ref. This skill covers the ActivityKit UI and state management side.
+
+---
+
+## Scenario 3: "Control Center control is slow"
+
+### Situation
+- Smart home control for lights
+- Tapping control in Control Center takes 3-5 seconds to respond
+- Users expect instant feedback
+
+### MANDATORY Fix: Optimistic UI + Async Value Provider
+
+#### Problem Code
+
+```swift
+struct LightControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "Light") {
+ ControlWidgetToggle(
+ isOn: LightManager.shared.isOn, // ❌ Blocking fetch
+ action: ToggleLightIntent()
+ ) { isOn in
+ Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
+ }
+ }
+ }
+}
+```
+
+#### Fixed Code
+
+```swift
+// 1. Value Provider for async state
+struct LightProvider: ControlValueProvider {
+ func currentValue() async throws -> LightValue {
+ // Async fetch from HomeKit/server
+ let isOn = try await HomeManager.shared.fetchLightState()
+ return LightValue(isOn: isOn)
+ }
+
+ var previewValue: LightValue {
+ // Instant fallback from cache
+ let shared = UserDefaults(suiteName: "group.com.myapp")!
+ return LightValue(isOn: shared.bool(forKey: "lastKnownLightState"))
+ }
+}
+
+struct LightValue: ControlValueProviderValue {
+ var isOn: Bool
+}
+
+// 2. Optimistic Intent
+struct ToggleLightIntent: AppIntent {
+ static var title: LocalizedStringResource = "Toggle Light"
+
+ func perform() async throws -> some IntentResult {
+ // Immediately update cache (optimistic)
+ let shared = UserDefaults(suiteName: "group.com.myapp")!
+ let currentState = shared.bool(forKey: "lastKnownLightState")
+ let newState = !currentState
+ shared.set(newState, forKey: "lastKnownLightState")
+
+ // Then update actual device (async)
+ try await HomeManager.shared.setLight(isOn: newState)
+
+ return .result()
+ }
+}
+
+// 3. Control with provider
+struct LightControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "Light", provider: LightProvider()) { value in
+ ControlWidgetToggle(
+ isOn: value.isOn,
+ action: ToggleLightIntent()
+ ) { isOn in
+ Label(isOn ? "On" : "Off", systemImage: "lightbulb.fill")
+ .tint(isOn ? .yellow : .gray)
+ }
+ }
+ }
+}
+```
+
+**Result**: Control responds instantly with cached state, actual device updates in background.
+
+---
+
+# Final Checklist
+
+Before shipping widgets or Live Activities:
+
+## Pre-Release
+- ☐ App Groups entitlement in BOTH targets (app + extension)
+- ☐ Shared UserDefaults uses `suiteName` (not `.standard`)
+- ☐ Timeline entries ≥ 5 minutes apart (avoid budget exhaustion)
+- ☐ No network calls in widget views (only in TimelineProvider)
+- ☐ ActivityAttributes + ContentState < 4KB
+- ☐ Live Activities call `.end()` with appropriate dismissal policy
+- ☐ Control Center controls use ControlValueProvider for async data
+- ☐ Tested on actual device (not just simulator) — **Required because**:
+ - Simulator doesn't enforce timeline budget limits
+ - Push notifications don't work in simulator
+ - App Groups container paths differ (simulator vs device)
+ - Memory limits not enforced in simulator
+ - Background refresh behavior different
+- ☐ Tested all supported widget families
+- ☐ Verified widget appears in Widget Gallery
+
+## Post-Release Monitoring
+- ☐ Monitor for "Timeline reload budget exhausted" errors
+- ☐ Track widget data staleness in analytics
+- ☐ Watch App Store reviews for widget-related complaints
+- ☐ Log App Group container access for debugging
+
+## Common Failure Modes
+- Missing App Groups → Widget shows default data
+- Wrong group ID → App and widget can't communicate
+- Over-refreshing → Widget stops updating after hours
+- Network in view → Widget renders blank
+- No dismissal policy → Zombie Live Activities
+- Blocking main thread → Unresponsive controls
+
+---
+
+**Remember**: Widgets are NOT mini apps. They're glanceable snapshots rendered by the system. Extensions run in sandboxed environments with strict resource limits. Follow the patterns in this skill to avoid the most common pitfalls.
+
+---
+
+## Resources
+
+**Skills**: axiom-extensions-widgets-ref, axiom-push-notifications, axiom-push-notifications-ref, axiom-background-processing
diff --git a/.claude/skills/axiom-extensions-widgets/agents/openai.yaml b/.claude/skills/axiom-extensions-widgets/agents/openai.yaml
new file mode 100644
index 0000000..2c59882
--- /dev/null
+++ b/.claude/skills/axiom-extensions-widgets/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Extensions Widgets"
+ short_description: "Implementing widgets, Live Activities, or Control Center controls"
diff --git a/.claude/skills/axiom-file-protection-ref/.openskills.json b/.claude/skills/axiom-file-protection-ref/.openskills.json
new file mode 100644
index 0000000..84bd9ab
--- /dev/null
+++ b/.claude/skills/axiom-file-protection-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-file-protection-ref",
+ "installedAt": "2026-04-12T08:06:16.975Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-file-protection-ref/SKILL.md b/.claude/skills/axiom-file-protection-ref/SKILL.md
new file mode 100644
index 0000000..9fe48e2
--- /dev/null
+++ b/.claude/skills/axiom-file-protection-ref/SKILL.md
@@ -0,0 +1,544 @@
+---
+name: axiom-file-protection-ref
+description: Use when asking about 'FileProtectionType', 'file encryption iOS', 'NSFileProtection', 'data protection', 'secure file storage', 'encrypt files at rest', 'complete protection', 'file security' - comprehensive reference for iOS file encryption and data protection APIs
+license: MIT
+compatibility: iOS 4.0+, iPadOS 4.0+, macOS 10.0+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-12"
+---
+
+# iOS File Protection Reference
+
+**Purpose**: Comprehensive reference for file encryption and data protection APIs
+**Availability**: iOS 4.0+ (all protection levels), latest enhancements in iOS 26
+**Context**: Built on iOS Data Protection architecture using hardware encryption
+
+## When to Use This Skill
+
+Use this skill when you need to:
+- Protect sensitive user data at rest
+- Choose appropriate FileProtectionType for files
+- Understand when files are accessible/encrypted
+- Debug "file not accessible" errors after device lock
+- Implement secure file storage
+- Compare Keychain vs file protection approaches
+- Handle background file access requirements
+
+## Overview
+
+iOS Data Protection provides **hardware-accelerated file encryption** tied to the device passcode. When a user sets a passcode, every file can be encrypted with keys protected by that passcode.
+
+**Key concepts**:
+- Files are encrypted **automatically** when protection is enabled
+- Encryption keys are derived from device hardware + user passcode
+- Files become **inaccessible** when device is locked (depending on protection level)
+- No performance cost (hardware AES encryption)
+
+---
+
+## Protection Levels Comparison
+
+| Level | Encrypted Until | Accessible When | Use For | Background Access |
+|-------|-----------------|-----------------|---------|-------------------|
+| **complete** | Device unlocked | Only while unlocked | Sensitive data (health, finances) | ❌ No |
+| **completeUnlessOpen** | File closed | After first unlock, while open | Large downloads, videos | ✅ If already open |
+| **completeUntilFirstUserAuthentication** | First unlock after boot | After first unlock | Most app data | ✅ Yes |
+| **none** | Never | Always | Public caches, temp files | ✅ Yes |
+
+### Detailed Level Descriptions
+
+#### .complete
+
+**Full Description**:
+> "The file is stored in an encrypted format on disk and cannot be read from or written to while the device is locked or booting."
+
+**Use For**:
+- User health data
+- Financial information
+- Password vaults
+- Sensitive documents
+- Personal photos (if app requires maximum security)
+
+**Behavior**:
+- Encrypted: ✅ Always
+- Accessible: Only when device unlocked
+- Background access: ❌ No (app can't read while locked)
+- Available after boot: ❌ No (until user unlocks)
+
+**Code Example**:
+
+```swift
+// ✅ CORRECT: Maximum security for sensitive data
+func saveSensitiveData(_ data: Data, to url: URL) throws {
+ try data.write(to: url, options: .completeFileProtection)
+}
+
+// Or set on existing file
+try FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.complete],
+ ofItemAtPath: url.path
+)
+```
+
+**Tradeoffs**:
+- ✅ Maximum security
+- ❌ Can't access in background
+- ❌ User sees errors if app tries to access while locked
+
+#### .completeUnlessOpen
+
+**Full Description**:
+> "The file is stored in an encrypted format on disk after it is closed."
+
+**Use For**:
+- Large file downloads (continue in background)
+- Video files being played
+- Documents being edited
+- Any file that needs background access while open
+
+**Behavior**:
+- Encrypted: ✅ When closed
+- Accessible: After first unlock, remains accessible while open
+- Background access: ✅ Yes (if file was already open)
+- Available after boot: ❌ No (until first unlock)
+
+**Code Example**:
+
+```swift
+// ✅ CORRECT: Download in background, but encrypted when closed
+func startBackgroundDownload(url: URL, destination: URL) throws {
+ try Data().write(to: destination, options: .completeFileProtectionUnlessOpen)
+
+ // Open file handle for writing
+ let fileHandle = try FileHandle(forWritingTo: destination)
+
+ // Download continues in background
+ // File remains accessible because it's open
+ // When closed, file becomes encrypted
+
+ // Later, when download complete:
+ try fileHandle.close() // Now encrypted until next unlock
+}
+```
+
+**Tradeoffs**:
+- ✅ Good security (encrypted when not in use)
+- ✅ Background access (if already open)
+- ⚠️ Vulnerable while open
+
+#### .completeUntilFirstUserAuthentication
+
+**Full Description**:
+> "The file is stored in an encrypted format on disk and cannot be accessed until after the device has booted."
+
+**Use For**:
+- Most application data
+- User preferences
+- Downloaded content
+- Database files
+- Anything that needs background access
+
+**Behavior**:
+- Encrypted: ✅ Always
+- Accessible: After first unlock following boot
+- Background access: ✅ Yes (after first unlock)
+- Available after boot: ❌ No (until user unlocks once)
+
+**This is the recommended default for most files.**
+
+**Code Example**:
+
+```swift
+// ✅ CORRECT: Balanced security for most app data
+func saveAppData(_ data: Data, to url: URL) throws {
+ try data.write(
+ to: url,
+ options: .completeFileProtectionUntilFirstUserAuthentication
+ )
+}
+
+// ✅ This file can be accessed in background after first unlock
+func backgroundTaskCanAccessFile() {
+ // This works even if device is locked (after first unlock)
+ let data = try? Data(contentsOf: url)
+}
+```
+
+**Tradeoffs**:
+- ✅ Protected during boot (device stolen while off)
+- ✅ Background access (normal operation)
+- ⚠️ Accessible while locked (less protection than .complete)
+
+#### .none
+
+**Full Description**:
+> "The file has no special protections associated with it."
+
+**Use For**:
+- Public cache data
+- Temporary files
+- Non-sensitive downloads
+- Thumbnails
+- Only when absolutely necessary
+
+**Behavior**:
+- Encrypted: ❌ Never
+- Accessible: ✅ Always
+- Background access: ✅ Always
+- Available after boot: ✅ Always
+
+**Code Example**:
+
+```swift
+// ⚠️ USE SPARINGLY: Only for truly non-sensitive data
+func cachePublicThumbnail(_ data: Data, to url: URL) throws {
+ try data.write(to: url, options: .noFileProtection)
+}
+```
+
+**Tradeoffs**:
+- ✅ Always accessible
+- ❌ No encryption
+- ❌ Vulnerable if device is stolen
+
+---
+
+## Setting File Protection
+
+### At File Creation
+
+```swift
+// ✅ RECOMMENDED: Set protection when writing
+let sensitiveData = userData.jsonData()
+try sensitiveData.write(
+ to: fileURL,
+ options: .completeFileProtection
+)
+```
+
+### On Existing Files
+
+```swift
+// ✅ CORRECT: Change protection on existing file
+try FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.complete],
+ ofItemAtPath: fileURL.path
+)
+```
+
+### Default Protection for Directory
+
+```swift
+// ✅ CORRECT: Set default protection for directory
+// New files inherit this protection
+try FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: directoryURL.path
+)
+```
+
+### Checking Current Protection
+
+```swift
+// ✅ Check file's current protection level
+func checkFileProtection(at url: URL) throws -> FileProtectionType? {
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
+ return attributes[.protectionKey] as? FileProtectionType
+}
+
+// Usage
+if let protection = try? checkFileProtection(at: fileURL) {
+ switch protection {
+ case .complete:
+ print("Maximum protection")
+ case .completeUntilFirstUserAuthentication:
+ print("Standard protection")
+ default:
+ print("Other protection")
+ }
+}
+```
+
+---
+
+## File Protection vs Keychain
+
+### Decision Matrix
+
+| Use Case | Recommended | Why |
+|----------|-------------|-----|
+| Passwords, tokens, keys | **Keychain** | Designed for small secrets |
+| Small sensitive values (1 KB | **File Protection** | Keychain not designed for large data |
+| User documents | **File Protection** | Natural file-based storage |
+| Structured secrets | **Keychain** | Query by key, access control |
+
+### Code Comparison
+
+```swift
+// ✅ CORRECT: Small secrets in Keychain
+let passwordData = password.data(using: .utf8)!
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: "userPassword",
+ kSecValueData as String: passwordData,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
+]
+SecItemAdd(query as CFDictionary, nil)
+
+// ✅ CORRECT: Files with file protection
+let userData = try JSONEncoder().encode(user)
+try userData.write(to: fileURL, options: .completeFileProtection)
+```
+
+**Keychain advantages**:
+- More granular access control (Face ID/Touch ID)
+- Separate encryption (not tied to file system)
+- Survives app deletion (if configured)
+
+**File protection advantages**:
+- Works with existing file operations
+- Handles large data efficiently
+- Automatic with minimal code
+
+---
+
+## Background Access Considerations
+
+### iOS Background Modes and File Protection
+
+```swift
+// ❌ WRONG: .complete files can't be accessed in background
+class BackgroundTask {
+ func performBackgroundSync() {
+ // This FAILS if file has .complete protection and device is locked
+ let data = try? Data(contentsOf: sensitiveFileURL)
+ // data will be nil if device locked
+ }
+}
+
+// ✅ CORRECT: Use .completeUntilFirstUserAuthentication
+// Files accessible in background after first unlock
+try data.write(
+ to: fileURL,
+ options: .completeFileProtectionUntilFirstUserAuthentication
+)
+```
+
+### Handling Protection Errors
+
+```swift
+// ✅ CORRECT: Handle protection errors gracefully
+func readFile(at url: URL) -> Data? {
+ do {
+ return try Data(contentsOf: url)
+ } catch let error as NSError {
+ if error.domain == NSCocoaErrorDomain &&
+ error.code == NSFileReadNoPermissionError {
+ // File is protected and device is locked
+ print("File protected, device locked")
+ return nil
+ }
+ throw error
+ }
+}
+```
+
+---
+
+## iCloud and File Protection
+
+### How Protection Works with iCloud
+
+**Local file protection**:
+- Applied to local cached copies
+- Does NOT affect iCloud-stored versions
+- iCloud has its own encryption (in transit and at rest)
+
+**iCloud encryption**:
+- All iCloud data encrypted at rest (Apple-managed keys)
+- End-to-end encryption available for some data types (Advanced Data Protection)
+- File protection only affects local device
+
+```swift
+// ✅ CORRECT: Protection on iCloud file affects local copy only
+func saveToICloud(data: Data, filename: String) throws {
+ guard let iCloudURL = FileManager.default.url(
+ forUbiquityContainerIdentifier: nil
+ ) else { return }
+
+ let fileURL = iCloudURL.appendingPathComponent(filename)
+
+ // This protection applies to local cached copy
+ try data.write(to: fileURL, options: .completeFileProtection)
+
+ // iCloud has separate encryption for cloud storage
+}
+```
+
+---
+
+## Common Patterns
+
+### Pattern 1: Default Protection for New Apps
+
+```swift
+// ✅ RECOMMENDED: Set default protection at app launch
+func configureDefaultFileProtection() {
+ let fileManager = FileManager.default
+
+ let directories: [FileManager.SearchPathDirectory] = [
+ .documentDirectory,
+ .applicationSupportDirectory
+ ]
+
+ for directory in directories {
+ guard let url = fileManager.urls(
+ for: directory,
+ in: .userDomainMask
+ ).first else { continue }
+
+ try? fileManager.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: url.path
+ )
+ }
+}
+
+// Call during app initialization
+func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
+ configureDefaultFileProtection()
+ return true
+}
+```
+
+### Pattern 2: Encrypting Database Files
+
+```swift
+// ✅ CORRECT: Protect SwiftData/SQLite database
+let appSupportURL = FileManager.default.urls(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask
+)[0]
+
+let databaseURL = appSupportURL.appendingPathComponent("app.sqlite")
+
+// Set protection before creating database
+try? FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: appSupportURL.path
+)
+
+// Now create database - it inherits protection
+let container = try ModelContainer(
+ for: MyModel.self,
+ configurations: ModelConfiguration(url: databaseURL)
+)
+```
+
+### Pattern 3: Downgrading Protection for Background Tasks
+
+```swift
+// ⚠️ SOMETIMES NECESSARY: Lower protection for background access
+func enableBackgroundAccess(for url: URL) throws {
+ try FileManager.default.setAttributes(
+ [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
+ ofItemAtPath: url.path
+ )
+}
+
+// Only do this if:
+// 1. Background access is truly required
+// 2. Data sensitivity allows it
+// 3. You've considered security tradeoffs
+```
+
+---
+
+## Debugging File Protection Issues
+
+### Issue: File Not Accessible in Background
+
+**Symptom**: Background tasks fail to read files
+
+```swift
+// Debug: Check current protection
+if let protection = try? FileManager.default.attributesOfItem(
+ atPath: url.path
+)[.protectionKey] as? FileProtectionType {
+ print("Protection: \(protection)")
+ if protection == .complete {
+ print("❌ Can't access in background when locked")
+ }
+}
+```
+
+**Solution**: Use `.completeUntilFirstUserAuthentication` instead
+
+### Issue: Files Inaccessible After Restart
+
+**Symptom**: App can't access files immediately after device reboot
+
+**Cause**: Using `.complete` or `.completeUntilFirstUserAuthentication` (works as designed)
+
+**Solution**: This is expected behavior. Either:
+1. Wait for user to unlock device
+2. Handle gracefully with appropriate UI
+3. Use `.none` for files that must be accessible (security tradeoff)
+
+---
+
+## Entitlements
+
+File protection generally works without special entitlements, but some features require:
+
+### Data Protection Entitlement
+
+```xml
+
+com.apple.developer.default-data-protection
+NSFileProtectionComplete
+```
+
+**When needed**:
+- Using `.complete` protection
+- Some iOS versions for any protection (check documentation)
+
+**How to add**:
+1. Xcode → Target → Signing & Capabilities
+2. "+ Capability" → Data Protection
+3. Select protection level
+
+---
+
+## Quick Reference Table
+
+| Scenario | Recommended Protection | Accessible When Locked? | Background Access? |
+|----------|------------------------|-------------------------|---------------------|
+| User health data | `.complete` | ❌ No | ❌ No |
+| Financial records | `.complete` | ❌ No | ❌ No |
+| Most app data | `.completeUntilFirstUserAuthentication` | ✅ Yes (after first unlock) | ✅ Yes |
+| Downloads (large files) | `.completeUnlessOpen` | ✅ While open | ✅ While open |
+| Database files | `.completeUntilFirstUserAuthentication` | ✅ Yes | ✅ Yes |
+| Downloaded images | `.completeUntilFirstUserAuthentication` | ✅ Yes | ✅ Yes |
+| Public caches | `.none` | ✅ Yes | ✅ Yes |
+| Temp files | `.none` | ✅ Yes | ✅ Yes |
+
+---
+
+## Related Skills
+
+- `axiom-storage` — Decide when to use file protection vs other security measures
+- `axiom-storage-management-ref` — File lifecycle, purging, and disk management
+- `axiom-storage-diag` — Debug file access issues
+- `axiom-keychain` — Secure credential storage (tokens, passwords, keys)
+- `axiom-keychain-ref` — Complete SecItem API reference
+- `axiom-cryptokit` — Encryption and signing with CryptoKit
+
+---
+
+**Last Updated**: 2025-12-12
+**Skill Type**: Reference
+**Minimum iOS**: 4.0 (all protection levels)
+**Latest Updates**: iOS 26
diff --git a/.claude/skills/axiom-file-protection-ref/agents/openai.yaml b/.claude/skills/axiom-file-protection-ref/agents/openai.yaml
new file mode 100644
index 0000000..5f3990a
--- /dev/null
+++ b/.claude/skills/axiom-file-protection-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "File Protection Reference"
+ short_description: "Asking about 'FileProtectionType', 'file encryption iOS', 'NSFileProtection', 'data protection', 'secure file storage..."
diff --git a/.claude/skills/axiom-fix-build/.openskills.json b/.claude/skills/axiom-fix-build/.openskills.json
new file mode 100644
index 0000000..3f3cc6c
--- /dev/null
+++ b/.claude/skills/axiom-fix-build/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-fix-build",
+ "installedAt": "2026-04-12T08:06:16.977Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-fix-build/SKILL.md b/.claude/skills/axiom-fix-build/SKILL.md
new file mode 100644
index 0000000..0a905a6
--- /dev/null
+++ b/.claude/skills/axiom-fix-build/SKILL.md
@@ -0,0 +1,436 @@
+---
+name: axiom-fix-build
+description: Use when the user mentions Xcode build failures, build errors, or environment issues.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# Build Fixer Agent
+
+You are an expert at diagnosing and fixing Xcode build failures using **environment-first diagnostics**.
+
+## Core Principle
+
+**80% of "mysterious" Xcode issues are environment problems (stale Derived Data, stuck simulators, zombie processes), not code bugs.**
+
+Environment cleanup takes 2-5 minutes. Code debugging for environment issues wastes 30-120 minutes.
+
+## Your Mission
+
+When the user reports a build failure:
+1. Run mandatory environment checks FIRST (never skip)
+2. Identify the specific issue type
+3. Apply the appropriate fix automatically
+4. Verify the fix worked
+5. Report results clearly
+
+## Mandatory First Steps
+
+**ALWAYS run these diagnostic commands FIRST** before any investigation:
+
+```bash
+# Optional: Detect CI/CD environment (adjusts diagnostics)
+echo "CI env: ${CI:-not set}, GitHub Actions: ${GITHUB_ACTIONS:-not set}"
+
+# 0. Verify you're in the project directory
+ls -la | grep -E "\.xcodeproj|\.xcworkspace"
+# If nothing shows, you're in wrong directory
+
+# 1. Check for zombie xcodebuild processes (with elapsed time)
+ps -eo pid,etime,command | grep -E "xcodebuild|Simulator" | grep -v grep
+# Format: PID ELAPSED COMMAND
+# ELAPSED shows how long process has been running (e.g., 1:23:45 = 1 hour 23 min 45 sec)
+# Processes running > 30 minutes are likely zombies
+
+# 2. Check Derived Data size (>10GB = stale)
+du -sh ~/Library/Developer/Xcode/DerivedData
+
+# 3. Check simulator states (stuck Booting?) - JSON for reliable parsing
+xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.state == "Booted" or .state == "Booting" or .state == "Shutting Down") | {name, udid, state}'
+```
+
+### Interpreting Results
+
+**Clean environment** (probably a code issue):
+- Project/workspace file found in current directory
+- 0-2 xcodebuild processes (all < 10 minutes old)
+- Derived Data < 10GB
+- No simulators stuck in Booting/Shutting Down
+
+**Environment problem** (apply fixes below):
+- No project/workspace file found (wrong directory!)
+- 10+ xcodebuild processes OR any process > 30 minutes old (zombies)
+- Derived Data > 10GB (stale cache)
+- Simulators stuck in Booting state
+- Any intermittent failures
+
+## Red Flags: Environment Not Code
+
+If user mentions ANY of these, it's definitely an environment issue:
+- "It works on my machine but not CI"
+- "Tests passed yesterday, failing today with no code changes"
+- "Build succeeds but old code executes"
+- "Build sometimes succeeds, sometimes fails"
+- "Simulator stuck at splash screen"
+- "Unable to install app"
+
+## CI/CD Environment Detection
+
+When running in CI/CD environments, some diagnostics don't apply and fixes need adjustment.
+
+### Detecting CI/CD Context
+
+Check for environment variables that indicate CI/CD:
+
+```bash
+# Check if running in CI/CD
+if [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ] || [ -n "$JENKINS_URL" ] || [ -n "$GITLAB_CI" ]; then
+ echo "Running in CI/CD environment"
+else
+ echo "Running on local machine"
+fi
+```
+
+### CI/CD-Specific Adjustments
+
+**When in CI/CD:**
+
+1. **Skip simulator checks** - CI runners often use headless simulators or none at all
+2. **Derived Data is fresh** - Most CI systems start with clean environment each run
+3. **Focus on:**
+ - SPM cache issues (common in CI)
+ - Package resolution failures
+ - Xcode version mismatches
+ - Missing provisioning profiles
+ - Code signing issues
+
+**CI/CD-Specific Fixes:**
+
+```bash
+# For CI/CD package resolution issues
+rm -rf .build/
+rm -rf ~/Library/Caches/org.swift.swiftpm/
+xcodebuild -resolvePackageDependencies -scheme
+
+# For CI/CD build failures
+xcodebuild clean build -scheme \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
+ -allowProvisioningUpdates
+```
+
+**Downloading Simulator Runtimes (CI/CD Setup):**
+
+For CI/CD environments that need specific simulator runtimes:
+
+```bash
+# Download iOS simulator runtime for current Xcode
+xcodebuild -downloadPlatform iOS
+
+# Download specific iOS version
+xcodebuild -downloadPlatform iOS -buildVersion 18.0
+
+# Download to specific location (for caching/sharing)
+xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
+
+# Download universal variant (works on Intel + Apple Silicon)
+xcodebuild -downloadPlatform iOS -architectureVariant universal
+
+# Download all platforms at once
+xcodebuild -downloadAllPlatforms
+
+# After downloading, install with three steps:
+# 1. Select Xcode version
+xcode-select -s /Applications/Xcode.app
+
+# 2. Run first launch setup
+xcodebuild -runFirstLaunch
+
+# 3. Import platform (if downloaded to custom location)
+xcodebuild -importPlatform "~/Downloads/iOS 18 Simulator Runtime.dmg"
+
+# Check for newer components between releases
+xcodebuild -runFirstLaunch -checkForNewerComponents
+```
+
+**Use for**: CI/CD initial setup, missing simulator errors, version-specific testing
+
+**Red Flags for CI/CD:**
+- "Works locally but fails in CI" → Usually SPM cache or Xcode version mismatch
+- "Intermittent CI failures" → Network issues downloading packages
+- "CI hangs indefinitely" → Timeout on package resolution, check network
+
+### When to Report CI/CD Context
+
+If running in CI/CD, mention this in your diagnosis:
+
+```markdown
+### Environment Context
+- Running in: [GitHub Actions/Jenkins/GitLab CI/Local]
+- Diagnostics adjusted for CI/CD environment
+```
+
+## Fix Workflows
+
+### 1. For Zombie Processes
+
+If you see 10+ xcodebuild processes OR any processes with elapsed time > 30 minutes:
+
+```bash
+# First, review process ages from the check above
+# Look for ELAPSED times like 35:12 (35 min) or 1:23:45 (1 hr 23 min) - these are zombies
+
+# Kill all xcodebuild processes
+killall -9 xcodebuild
+
+# Verify they're gone (with elapsed time)
+ps -eo pid,etime,command | grep xcodebuild | grep -v grep
+
+# Also kill stuck Simulator processes if needed
+killall -9 Simulator
+```
+
+### 2. For Stale Derived Data / "No such module" Errors
+
+If Derived Data is large OR user reports "No such module" OR intermittent failures:
+
+```bash
+# First, find the scheme name
+xcodebuild -list
+
+# If xcodebuild -list fails, check:
+# 1. Are you in the project directory? (should have .xcodeproj or .xcworkspace)
+# 2. Run: ls -la | grep -E "\.xcodeproj|\.xcworkspace"
+# 3. If missing, cd to correct directory
+# 4. If .xcworkspace exists, use: xcodebuild -list -workspace YourApp.xcworkspace
+# 5. If .xcodeproj exists, use: xcodebuild -list -project YourApp.xcodeproj
+
+# Clean everything (use the actual scheme name from above)
+xcodebuild clean -scheme
+rm -rf ~/Library/Developer/Xcode/DerivedData/*
+rm -rf .build/ build/
+
+# Rebuild with appropriate destination
+xcodebuild build -scheme \
+ -destination 'platform=iOS Simulator,name=iPhone 16'
+```
+
+**CRITICAL**:
+- Use the actual scheme name from `xcodebuild -list`, not a placeholder
+- If `xcodebuild -list` fails, verify you're in the correct directory with a workspace/project file
+
+### 3. For SPM Cache Issues / "No such module" with Swift Packages
+
+If user reports "No such module" with Swift Package Manager dependencies OR packages won't resolve:
+
+```bash
+# Clean SPM cache (this fixes 90% of SPM issues)
+rm -rf ~/Library/Caches/org.swift.swiftpm/
+rm -rf ~/Library/Developer/Xcode/DerivedData/*
+rm -rf .build/
+
+# Reset package resolution
+xcodebuild -resolvePackageDependencies -scheme
+
+# Verify packages resolved
+xcodebuild -list
+
+# Rebuild
+xcodebuild build -scheme \
+ -destination 'platform=iOS Simulator,name=iPhone 16'
+```
+
+**When to use this**:
+- "No such module" errors for Swift Package dependencies
+- Package resolution failures
+- "Package.resolved" conflicts
+- After switching git branches with different package versions
+
+### 4. For Simulator Issues
+
+If user reports "Unable to boot simulator" or simulators stuck:
+
+```bash
+# Shutdown all simulators
+xcrun simctl shutdown all
+
+# List devices with JSON for reliable parsing
+xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid, state}'
+
+# Get UUID for a specific device (e.g., iPhone 16) using JSON
+UDID=$(xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.name | contains("iPhone 16")) | select(.isAvailable == true) | .udid' | head -1)
+
+if [ -z "$UDID" ]; then
+ echo "No iPhone 16 simulator found. Available simulators:"
+ xcrun simctl list devices -j | jq '.devices | to_entries[] | .value[] | select(.isAvailable == true) | {name, udid}'
+else
+ echo "iPhone 16 UUID: $UDID"
+ # Erase the stuck simulator using the extracted UUID
+ xcrun simctl erase "$UDID"
+fi
+
+# Find and erase all simulators stuck in Booting state
+xcrun simctl list devices -j | jq -r '.devices | to_entries[] | .value[] | select(.state == "Booting") | .udid' | while read UDID; do
+ echo "Erasing stuck simulator: $UDID"
+ xcrun simctl erase "$UDID"
+done
+
+# Nuclear option if nothing works
+killall -9 Simulator
+```
+
+### 5. For Test Failures (No Code Changes)
+
+If tests are failing but user hasn't changed code:
+
+```bash
+# Clean Derived Data first
+rm -rf ~/Library/Developer/Xcode/DerivedData/*
+
+# Run tests again
+xcodebuild test -scheme \
+ -destination 'platform=iOS Simulator,name=iPhone 16'
+```
+
+### 6. For Old Code Executing
+
+If build succeeds but old code runs:
+
+```bash
+# This is ALWAYS a Derived Data issue
+rm -rf ~/Library/Developer/Xcode/DerivedData/*
+
+# Force clean rebuild
+xcodebuild clean build -scheme
+```
+
+## Decision Tree
+
+Use this to determine which fix to apply:
+
+```
+User reports build failure
+↓
+Run mandatory checks (directory, processes, Derived Data, simulators)
+↓
+Identify issue:
+├─ No project/workspace file → Report "wrong directory" to user
+├─ (following checks apply if directory verified)
+↓
+├─ 10+ xcodebuild processes OR any process > 30min → Kill zombie processes (§1)
+├─ Derived Data > 10GB → Clean Derived Data + rebuild (§2)
+├─ "No such module" (SPM) → Clean SPM cache + resolve packages (§3)
+├─ "No such module" (local) → Clean Derived Data + rebuild (§2)
+├─ Package resolution failures → Clean SPM cache (§3)
+├─ Intermittent failures → Clean Derived Data + rebuild (§2)
+├─ Old code executing → Clean Derived Data + rebuild (§6)
+├─ "Unable to boot simulator" → Shutdown/erase simulator (§4)
+├─ Tests failing (no code changes) → Clean + retest (§5)
+└─ All checks clean → Report "environment is clean, likely code issue"
+```
+
+## Output Format
+
+Provide a clear, structured report:
+
+```markdown
+## Build Failure Diagnosis Complete
+
+### Environment Context
+- Running in: [Local/GitHub Actions/Jenkins/GitLab CI/etc.]
+- CI/CD detected: [yes/no]
+
+### Environment Check Results
+- Project directory: [verified/not found]
+- Xcodebuild processes: [count] (oldest: [elapsed time]) (clean/zombie)
+- Derived Data size: [size] (clean/stale)
+- Simulator state: [status] (clean/stuck) (skip if CI/CD)
+
+### Issue Identified
+[Specific issue type]
+
+### Fix Applied
+1. [Command 1 with actual output]
+2. [Command 2 with actual output]
+3. [Command 3 with actual output]
+
+### Verification
+[Result of rebuild/retest - success or needs more work]
+
+### Next Steps
+[What user should do next]
+```
+
+## Audit Guidelines
+
+1. **ALWAYS run the 4 mandatory checks first** - never skip (directory, processes, Derived Data, simulators)
+2. **Detect CI/CD context** - check for CI environment variables and adjust diagnostics
+3. **Check process elapsed time** - processes > 30 minutes are zombies, kill them
+4. **Use actual scheme names** from `xcodebuild -list` - never use placeholders
+5. **Handle xcodebuild -list failures** - verify directory and provide recovery steps
+6. **Show command output** - don't just say "I ran X", show the result
+7. **Verify fixes worked** - run the build/test again to confirm
+8. **If fix doesn't work** - escalate to user with specific next steps
+
+## When to Stop and Report
+
+If you encounter:
+- Permission denied errors → Report to user
+- Xcode not installed → Report to user
+- `xcodebuild -list` fails (no workspace/project found) → Report to user, verify correct directory
+- Network issues preventing package resolution → Report to user
+- Workspace file corruption → Report to user (needs manual intervention)
+- All environment checks clean + fix attempts fail → Report "environment is clean, recommend systematic code debugging"
+
+## Error Pattern Recognition
+
+Common errors and their fixes:
+
+| Error Message | Fix | Section |
+|---------------|-----|---------|
+| `xcodebuild: error: Could not resolve package dependencies` | Wrong directory or Clean SPM cache | §0/§3 |
+| `The workspace named "X" does not contain a scheme` | Wrong directory, verify location | §0 |
+| `BUILD FAILED` (no details) | Clean Derived Data | §2 |
+| `No such module: ` (SPM package) | Clean SPM cache + resolve | §3 |
+| `No such module: ` (local) | Clean Derived Data | §2 |
+| `Package resolution failed` | Clean SPM cache | §3 |
+| `Unable to boot simulator` | Erase simulator (skip in CI/CD) | §4 |
+| `Command PhaseScriptExecution failed` | Clean Derived Data | §2 |
+| `Multiple commands produce` | Check for duplicate files (manual) | - |
+| Old code executing | Delete Derived Data | §6 |
+| Tests hang indefinitely | Reboot simulator (or timeout in CI/CD) | §4 |
+| `Works locally but fails in CI` | SPM cache or Xcode version mismatch | §3/CI |
+| `Intermittent CI failures` | Network issues, retry package download | CI |
+
+## Example Interaction
+
+**User**: "My build is failing with MODULE_NOT_FOUND"
+
+**Your response**:
+1. Run 3 mandatory checks
+2. Identify: Derived Data issue (common with "No such module" errors)
+3. Apply fix: Clean Derived Data, clean build, rebuild
+4. Verify: Run build command, show success/failure
+5. Report results
+
+**Never**:
+- Guess without running diagnostics
+- Skip the verification step
+- Leave user without clear next steps
+- Use placeholder scheme names in commands
+
+## Resources
+
+**WWDC**: 2019-413 (Testing in Xcode)
+
+**Docs**: /xcode/downloading-and-installing-additional-xcode-components, /xcode/troubleshooting-simulator
+
+**Tech Notes**: TN2339 (Building from Command Line with Xcode)
+
+## Related
+
+For test execution: `test-runner` agent
+For test debugging: `test-debugger` agent
+For simulator testing: `simulator-tester` agent
+For SPM conflicts: `spm-conflict-resolver` agent
diff --git a/.claude/skills/axiom-fix-build/agents/openai.yaml b/.claude/skills/axiom-fix-build/agents/openai.yaml
new file mode 100644
index 0000000..8f1e54a
--- /dev/null
+++ b/.claude/skills/axiom-fix-build/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Fix Build"
+ short_description: "The user mentions Xcode build failures, build errors, or environment issues."
diff --git a/.claude/skills/axiom-foundation-models-diag/.openskills.json b/.claude/skills/axiom-foundation-models-diag/.openskills.json
new file mode 100644
index 0000000..47fd9a7
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-foundation-models-diag",
+ "installedAt": "2026-04-12T08:06:18.203Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-foundation-models-diag/SKILL.md b/.claude/skills/axiom-foundation-models-diag/SKILL.md
new file mode 100644
index 0000000..50d30f9
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-diag/SKILL.md
@@ -0,0 +1,1030 @@
+---
+name: axiom-foundation-models-diag
+description: Use when debugging Foundation Models issues — context exceeded, guardrail violations, slow generation, availability problems, unsupported language, or unexpected output. Systematic diagnostics with production crisis defense.
+license: MIT
+compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-03"
+---
+
+# Foundation Models Diagnostics
+
+## Overview
+
+Foundation Models issues manifest as context window exceeded errors, guardrail violations, slow generation, availability failures, and unexpected output. **Core principle** 80% of Foundation Models problems stem from misunderstanding model capabilities (3B parameter device-scale model, not world knowledge), context limits (4096 tokens), or availability requirements—not framework bugs.
+
+## Red Flags — Suspect Foundation Models Issue
+
+If you see ANY of these, suspect a Foundation Models misunderstanding, not framework breakage:
+- Generation takes >5 seconds
+- Error: `exceededContextWindowSize`
+- Error: `guardrailViolation`
+- Error: `unsupportedLanguageOrLocale`
+- Model gives hallucinated/wrong output
+- UI freezes during generation
+- Feature works in simulator but not on device
+- ❌ **FORBIDDEN** "Foundation Models is broken, we need a different AI"
+ - Foundation Models powers Apple Intelligence across millions of devices
+ - Wrong output = wrong use case (world knowledge vs summarization)
+ - Do not rationalize away the issue—diagnose it
+
+**Critical distinction** Foundation Models is a **device-scale model** (3B parameters) optimized for summarization, extraction, classification—NOT world knowledge or complex reasoning. Using it for the wrong task guarantees poor results.
+
+## Mandatory First Steps
+
+**ALWAYS run these FIRST** (before changing code):
+
+```swift
+// 1. Check availability
+let availability = SystemLanguageModel.default.availability
+
+switch availability {
+case .available:
+ print("✅ Available")
+case .unavailable(let reason):
+ print("❌ Unavailable: \(reason)")
+ // Possible reasons:
+ // - Device not Apple Intelligence-capable
+ // - Region not supported
+ // - User not opted in
+}
+
+// Record: "Available? Yes/no, reason if not"
+
+// 2. Check supported languages
+let supported = SystemLanguageModel.default.supportedLanguages
+print("Supported languages: \(supported)")
+print("Current locale: \(Locale.current.language)")
+
+if !supported.contains(Locale.current.language) {
+ print("⚠️ Current language not supported!")
+}
+
+// Record: "Language supported? Yes/no"
+
+// 3. Check context usage
+let session = LanguageModelSession()
+// After some interactions:
+print("Transcript entries: \(session.transcript.entries.count)")
+
+// Rough estimation (not exact):
+let transcriptText = session.transcript.entries
+ .map { $0.content }
+ .joined()
+print("Approximate chars: \(transcriptText.count)")
+print("Rough token estimate: \(transcriptText.count / 3)")
+// 4096 token limit ≈ 12,000 characters
+
+// Record: "Approaching context limit? Yes/no"
+
+// 4. Profile with Instruments
+// Run with Foundation Models Instrument template
+// Check:
+// - Initial model load time
+// - Token counts (input/output)
+// - Generation time per request
+// - Areas for optimization
+
+// Record: "Latency profile: [numbers from Instruments]"
+
+// 5. Inspect transcript for debugging
+print("Full transcript:")
+for entry in session.transcript.entries {
+ print("Entry: \(entry.content.prefix(100))...")
+}
+
+// Record: "Any unusual entries? Repeated content?"
+```
+
+#### What this tells you
+- **Unavailable** → Proceed to Pattern 1a/1b/1c (availability issues)
+- **Context exceeded** → Proceed to Pattern 2a (token limit)
+- **Guardrail error** → Proceed to Pattern 2b (content policy)
+- **Language error** → Proceed to Pattern 2c (unsupported language)
+- **Wrong output** → Proceed to Pattern 3a/3b/3c (output quality)
+- **Slow generation** → Proceed to Pattern 4a/4b/4c/4d (performance)
+- **UI frozen** → Proceed to Pattern 5a (main thread blocking)
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, identify ONE of these:
+
+1. If `availability = .unavailable` → Device/region/opt-in issue (not code bug)
+2. If error is `exceededContextWindowSize` → Too many tokens (condense transcript)
+3. If error is `guardrailViolation` → Content policy triggered (not model failure)
+4. If error is `unsupportedLanguageOrLocale` → Language not supported (check supported list)
+5. If output is hallucinated → Wrong use case (world knowledge vs extraction)
+6. If generation >5 seconds → Not streaming or need optimization
+7. If UI frozen → Calling on main thread (use Task {})
+
+#### If diagnostics are contradictory or unclear
+- STOP. Do NOT proceed to patterns yet
+- Add detailed logging to every `respond()` call
+- Run with Instruments Foundation Models template
+- Establish baseline: what's actually happening vs what you assumed
+
+## Decision Tree
+
+```
+Foundation Models problem?
+│
+├─ Won't start?
+│ ├─ .unavailable → Availability issue
+│ │ ├─ Device not capable? → Pattern 1a (device requirement)
+│ │ ├─ Region restriction? → Pattern 1b (regional availability)
+│ │ └─ User not opted in? → Pattern 1c (Settings check)
+│ │
+├─ Generation fails?
+│ ├─ exceededContextWindowSize → Context limit
+│ │ └─ Long conversation or verbose prompts? → Pattern 2a (condense)
+│ │
+│ ├─ guardrailViolation → Content policy
+│ │ └─ Sensitive or inappropriate content? → Pattern 2b (handle gracefully)
+│ │
+│ ├─ unsupportedLanguageOrLocale → Language issue
+│ │ └─ Non-English or unsupported language? → Pattern 2c (language check)
+│ │
+│ └─ Other error → General error handling
+│ └─ Unknown error type? → Pattern 2d (catch-all)
+│
+├─ Output wrong?
+│ ├─ Hallucinated facts → Wrong model use
+│ │ └─ Asking for world knowledge? → Pattern 3a (use case mismatch)
+│ │
+│ ├─ Wrong structure → Parsing issue
+│ │ └─ Manual JSON parsing? → Pattern 3b (use @Generable)
+│ │
+│ ├─ Missing data → Tool needed
+│ │ └─ Need external information? → Pattern 3c (tool calling)
+│ │
+│ └─ Inconsistent output → Sampling issue
+│ └─ Different results each time? → Pattern 3d (temperature/greedy)
+│
+├─ Too slow?
+│ ├─ Initial delay (1-2s) → Model loading
+│ │ └─ First request slow? → Pattern 4a (prewarm)
+│ │
+│ ├─ Long wait for results → Not streaming
+│ │ └─ User waits 3-5s? → Pattern 4b (streaming)
+│ │
+│ ├─ Verbose schema → Token overhead
+│ │ └─ Large @Generable type? → Pattern 4c (includeSchemaInPrompt)
+│ │
+│ └─ Complex prompt → Too much processing
+│ └─ Massive prompt or task? → Pattern 4d (break down)
+│
+└─ UI frozen?
+ └─ Main thread blocked → Async issue
+ └─ App unresponsive during generation? → Pattern 5a (Task {})
+```
+
+## Diagnostic Patterns
+
+### Pattern 1a: Device Not Capable
+
+**Symptom**:
+- `SystemLanguageModel.default.availability = .unavailable`
+- Reason: Device not Apple Intelligence-capable
+
+**Diagnosis**:
+```swift
+let availability = SystemLanguageModel.default.availability
+
+switch availability {
+case .available:
+ print("✅ Available")
+case .unavailable(let reason):
+ print("❌ Reason: \(reason)")
+ // Check if device-related
+}
+```
+
+**Fix**:
+```swift
+// ❌ BAD - No availability UI
+let session = LanguageModelSession() // Crashes on unsupported devices
+
+// ✅ GOOD - Graceful UI
+struct AIFeatureView: View {
+ @State private var availability = SystemLanguageModel.default.availability
+
+ var body: some View {
+ switch availability {
+ case .available:
+ AIContentView()
+ case .unavailable:
+ VStack {
+ Image(systemName: "cpu")
+ Text("AI features require Apple Intelligence")
+ .font(.headline)
+ Text("Available on iPhone 15 Pro and later")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+```
+
+**Time cost**: 5-10 minutes to add UI
+
+---
+
+### Pattern 1b: Regional Availability
+
+**Symptom**:
+- Feature works for some users, not others
+- .unavailable due to region restrictions
+
+**Diagnosis**:
+Foundation Models requires:
+- Supported region (e.g., US, UK, Australia initially)
+- May expand over time
+
+**Fix**:
+```swift
+// ✅ GOOD - Clear messaging
+switch SystemLanguageModel.default.availability {
+case .available:
+ // proceed
+case .unavailable(let reason):
+ // Show region-specific message
+ Text("AI features not yet available in your region")
+ Text("Check Settings → Apple Intelligence for availability")
+}
+```
+
+**Time cost**: 5 minutes
+
+---
+
+### Pattern 1c: User Not Opted In
+
+**Symptom**:
+- Device capable, region supported
+- Still .unavailable
+
+**Diagnosis**:
+User must opt in to Apple Intelligence in Settings
+
+**Fix**:
+```swift
+// ✅ GOOD - Direct user to settings
+switch SystemLanguageModel.default.availability {
+case .available:
+ // proceed
+case .unavailable:
+ VStack {
+ Text("Enable Apple Intelligence")
+ Text("Settings → Apple Intelligence → Enable")
+ Button("Open Settings") {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ }
+ }
+}
+```
+
+**Time cost**: 10 minutes
+
+---
+
+### Pattern 2a: Context Window Exceeded
+
+**Symptom**:
+```
+Error: LanguageModelSession.GenerationError.exceededContextWindowSize
+```
+
+**Diagnosis**:
+- 4096 token limit (input + output)
+- Long conversations accumulate tokens
+- Verbose prompts eat into limit
+
+**Fix**:
+```swift
+// ❌ BAD - Unhandled error
+let response = try await session.respond(to: prompt)
+// Crashes after ~10-15 turns
+
+// ✅ GOOD - Condense transcript
+var session = LanguageModelSession()
+
+do {
+ let response = try await session.respond(to: prompt)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // Condense and continue
+ session = condensedSession(from: session)
+ let response = try await session.respond(to: prompt)
+}
+
+func condensedSession(from previous: LanguageModelSession) -> LanguageModelSession {
+ let entries = previous.transcript.entries
+
+ guard entries.count > 2 else {
+ return LanguageModelSession(transcript: previous.transcript)
+ }
+
+ // Keep: first (instructions) + last (recent context)
+ var condensed = [entries.first!, entries.last!]
+
+ let transcript = Transcript(entries: condensed)
+ return LanguageModelSession(transcript: transcript)
+}
+```
+
+**Time cost**: 15-20 minutes to implement condensing
+
+---
+
+### Pattern 2b: Guardrail Violation
+
+**Symptom**:
+```
+Error: LanguageModelSession.GenerationError.guardrailViolation
+```
+
+**Diagnosis**:
+- User input triggered content policy
+- Violence, hate speech, illegal activities
+- Model refuses to generate
+
+**Fix**:
+```swift
+// ✅ GOOD - Graceful handling
+do {
+ let response = try await session.respond(to: userInput)
+ print(response.content)
+} catch LanguageModelSession.GenerationError.guardrailViolation {
+ // Show user-friendly message
+ print("I can't help with that request")
+ // Log for review (but don't show user input to avoid storing harmful content)
+}
+```
+
+**Time cost**: 5-10 minutes
+
+---
+
+### Pattern 2c: Unsupported Language
+
+**Symptom**:
+```
+Error: LanguageModelSession.GenerationError.unsupportedLanguageOrLocale
+```
+
+**Diagnosis**:
+User input in language model doesn't support
+
+**Fix**:
+```swift
+// ❌ BAD - No language check
+let response = try await session.respond(to: userInput)
+// Crashes if unsupported language
+
+// ✅ GOOD - Check first
+let supported = SystemLanguageModel.default.supportedLanguages
+
+guard supported.contains(Locale.current.language) else {
+ // Show disclaimer
+ print("Language not supported. Currently supports: \(supported)")
+ return
+}
+
+// Also handle errors
+do {
+ let response = try await session.respond(to: userInput)
+} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale {
+ print("Please use English or another supported language")
+}
+```
+
+**Time cost**: 10 minutes
+
+---
+
+### Pattern 2d: General Error Handling
+
+**Symptom**:
+Unknown error types
+
+**Fix**:
+```swift
+// ✅ GOOD - Comprehensive error handling
+do {
+ let response = try await session.respond(to: prompt)
+ print(response.content)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // Handle context overflow
+ session = condensedSession(from: session)
+} catch LanguageModelSession.GenerationError.guardrailViolation {
+ // Handle content policy
+ showMessage("Cannot generate that content")
+} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale {
+ // Handle language issue
+ showMessage("Language not supported")
+} catch {
+ // Catch-all for unexpected errors
+ print("Unexpected error: \(error)")
+ showMessage("Something went wrong. Please try again.")
+}
+```
+
+**Time cost**: 10-15 minutes
+
+---
+
+### Pattern 3a: Hallucinated Output (Wrong Use Case)
+
+**Symptom**:
+- Model gives factually incorrect answers
+- Makes up information
+
+**Diagnosis**:
+Using model for world knowledge (wrong use case)
+
+**Fix**:
+```swift
+// ❌ BAD - Wrong use case
+let prompt = "Who is the president of France?"
+let response = try await session.respond(to: prompt)
+// Will hallucinate or give outdated info
+
+// ✅ GOOD - Use server LLM for world knowledge
+// Foundation Models is for:
+// - Summarization
+// - Extraction
+// - Classification
+// - Content generation
+
+// OR: Use Tool calling with external data source
+struct GetFactTool: Tool {
+ let name = "getFact"
+ let description = "Fetch factual information from verified source"
+
+ @Generable
+ struct Arguments {
+ let query: String
+ }
+
+ func call(arguments: Arguments) async throws -> ToolOutput {
+ // Fetch from Wikipedia API, news API, etc.
+ let fact = await fetchFactFromAPI(arguments.query)
+ return ToolOutput(fact)
+ }
+}
+```
+
+**Time cost**: 20-30 minutes to implement tool OR switch to appropriate AI
+
+---
+
+### Pattern 3b: Wrong Structure (Not Using @Generable)
+
+**Symptom**:
+- Parsing errors
+- Invalid JSON
+- Wrong keys
+
+**Diagnosis**:
+Manual JSON parsing instead of @Generable
+
+**Fix**:
+```swift
+// ❌ BAD - Manual parsing
+let prompt = "Generate person as JSON"
+let response = try await session.respond(to: prompt)
+let data = response.content.data(using: .utf8)!
+let person = try JSONDecoder().decode(Person.self, from: data) // CRASHES
+
+// ✅ GOOD - @Generable
+@Generable
+struct Person {
+ let name: String
+ let age: Int
+}
+
+let response = try await session.respond(
+ to: "Generate a person",
+ generating: Person.self
+)
+// response.content is type-safe Person, guaranteed structure
+```
+
+**Time cost**: 10 minutes to convert to @Generable
+
+---
+
+### Pattern 3c: Missing Data (Need Tool)
+
+**Symptom**:
+- Model doesn't have required information
+- Output is vague or generic
+
+**Diagnosis**:
+Need external data (weather, locations, contacts)
+
+**Fix**:
+```swift
+// ❌ BAD - No external data
+let response = try await session.respond(
+ to: "What's the weather in Tokyo?"
+)
+// Will make up weather data
+
+// ✅ GOOD - Tool calling
+import WeatherKit
+
+struct GetWeatherTool: Tool {
+ let name = "getWeather"
+ let description = "Get current weather for a city"
+
+ @Generable
+ struct Arguments {
+ let city: String
+ }
+
+ func call(arguments: Arguments) async throws -> ToolOutput {
+ // Fetch real weather
+ let weather = await WeatherService.shared.weather(for: arguments.city)
+ return ToolOutput("Temperature: \(weather.temperature)°F")
+ }
+}
+
+let session = LanguageModelSession(tools: [GetWeatherTool()])
+let response = try await session.respond(to: "What's the weather in Tokyo?")
+// Uses real weather data
+```
+
+**Time cost**: 20-30 minutes to implement tool
+
+---
+
+### Pattern 3d: Inconsistent Output (Sampling)
+
+**Symptom**:
+- Different output every time for same prompt
+- Need consistent results for testing
+
+**Diagnosis**:
+Random sampling (default behavior)
+
+**Fix**:
+```swift
+// Default: Random sampling
+let response1 = try await session.respond(to: "Write a haiku")
+let response2 = try await session.respond(to: "Write a haiku")
+// Different every time
+
+// ✅ For deterministic output (testing/demos)
+let response = try await session.respond(
+ to: "Write a haiku",
+ options: GenerationOptions(sampling: .greedy)
+)
+// Same output for same prompt (given same model version)
+
+// ✅ For low variance
+let response = try await session.respond(
+ to: "Classify this article",
+ options: GenerationOptions(temperature: 0.5)
+)
+// Slightly varied but focused
+
+// ✅ For high creativity
+let response = try await session.respond(
+ to: "Write a creative story",
+ options: GenerationOptions(temperature: 2.0)
+)
+// Very diverse output
+```
+
+**Time cost**: 2-5 minutes
+
+---
+
+### Pattern 4a: Initial Latency (Prewarm)
+
+**Symptom**:
+- First generation takes 1-2 seconds to start
+- Subsequent requests faster
+
+**Diagnosis**:
+Model loading time
+
+**Fix**:
+```swift
+// ❌ BAD - Load on user interaction
+Button("Generate") {
+ Task {
+ let session = LanguageModelSession() // 1-2s delay here
+ let response = try await session.respond(to: prompt)
+ }
+}
+
+// ✅ GOOD - Prewarm on init
+class ViewModel: ObservableObject {
+ private var session: LanguageModelSession?
+
+ init() {
+ // Prewarm before user interaction
+ Task {
+ self.session = LanguageModelSession(instructions: "...")
+ }
+ }
+
+ func generate(prompt: String) async throws -> String {
+ guard let session = session else {
+ // Fallback if not ready
+ self.session = LanguageModelSession()
+ return try await self.session!.respond(to: prompt).content
+ }
+ return try await session.respond(to: prompt).content
+ }
+}
+```
+
+**Time cost**: 10 minutes
+**Latency saved**: 1-2 seconds on first request
+
+---
+
+### Pattern 4b: Long Generation (Streaming)
+
+**Symptom**:
+- User waits 3-5 seconds seeing nothing
+- Then entire result appears at once
+
+**Diagnosis**:
+Not streaming long generations
+
+**Fix**:
+```swift
+// ❌ BAD - No streaming
+let response = try await session.respond(
+ to: "Generate 5-day itinerary",
+ generating: Itinerary.self
+)
+// User waits 4 seconds seeing nothing
+
+// ✅ GOOD - Streaming
+@Generable
+struct Itinerary {
+ var destination: String
+ var days: [DayPlan]
+}
+
+let stream = session.streamResponse(
+ to: "Generate 5-day itinerary to Tokyo",
+ generating: Itinerary.self
+)
+
+for try await partial in stream {
+ // Update UI incrementally
+ self.itinerary = partial
+}
+// User sees destination in 0.5s, then days progressively
+```
+
+**Time cost**: 15-20 minutes
+**Perceived latency**: 0.5s vs 4s
+
+---
+
+### Pattern 4c: Large Schema Overhead
+
+**Symptom**:
+- Subsequent requests with same @Generable type slow
+
+**Diagnosis**:
+Schema re-inserted into prompt every time
+
+**Fix**:
+```swift
+// First request - schema inserted automatically
+let first = try await session.respond(
+ to: "Generate first person",
+ generating: Person.self
+)
+
+// ✅ Subsequent requests - skip schema insertion
+let second = try await session.respond(
+ to: "Generate another person",
+ generating: Person.self,
+ options: GenerationOptions(includeSchemaInPrompt: false)
+)
+```
+
+**Time cost**: 2 minutes
+**Latency saved**: 10-20% per request
+
+---
+
+### Pattern 4d: Complex Prompt (Break Down)
+
+**Symptom**:
+- Generation takes >5 seconds
+- Poor quality results
+
+**Diagnosis**:
+Prompt too complex for single generation
+
+**Fix**:
+```swift
+// ❌ BAD - One massive prompt
+let prompt = """
+ Generate complete 7-day itinerary with hotels, restaurants,
+ activities, transportation, budget, tips, and local customs
+ """
+// 5-8 seconds, poor quality
+
+// ✅ GOOD - Break into steps
+let overview = try await session.respond(
+ to: "Generate high-level 7-day plan for Tokyo"
+)
+
+var dayDetails: [DayPlan] = []
+for day in 1...7 {
+ let detail = try await session.respond(
+ to: "Detail activities and restaurants for day \(day) in Tokyo",
+ generating: DayPlan.self
+ )
+ dayDetails.append(detail.content)
+}
+// Total time similar, but better quality and progressive results
+```
+
+**Time cost**: 20-30 minutes
+**Quality improvement**: Significantly better
+
+---
+
+### Pattern 5a: UI Frozen (Main Thread Blocking)
+
+**Symptom**:
+- App unresponsive during generation
+- UI freezes for seconds
+
+**Diagnosis**:
+Calling `respond()` on main thread synchronously
+
+**Fix**:
+```swift
+// ❌ BAD - Blocking main thread
+Button("Generate") {
+ let response = try await session.respond(to: prompt)
+ // UI frozen for 2-5 seconds!
+}
+
+// ✅ GOOD - Async task
+Button("Generate") {
+ Task {
+ do {
+ let response = try await session.respond(to: prompt)
+ // Update UI on main thread
+ await MainActor.run {
+ self.result = response.content
+ }
+ } catch {
+ print("Error: \(error)")
+ }
+ }
+}
+```
+
+**Time cost**: 5 minutes
+**UX improvement**: Massive (no frozen UI)
+
+---
+
+## Production Crisis Scenario
+
+### Context
+
+**Situation**: You just launched an AI-powered feature using Foundation Models. Within 2 hours:
+- 20% of users report "AI feature doesn't work"
+- App Store reviews dropping: "New AI broken"
+- VP of Product emailing: "What's the ETA on fix?"
+- Engineering manager: "Should we roll back?"
+
+**Pressure Signals**:
+- 🚨 **Revenue impact**: Feature is key selling point for new app version
+- ⏰ **Time pressure**: "Fix it NOW"
+- 👔 **Executive visibility**: VP watching
+- 📉 **Public reputation**: App Store reviews visible to all
+
+### Rationalization Traps
+
+**DO NOT** fall into these traps:
+
+1. **"Disable the feature"**
+ - Loses product differentiation
+ - Admits defeat
+ - Doesn't learn what went wrong
+
+2. **"Roll back to previous version"**
+ - Loses weeks of work
+ - Doesn't fix root cause
+ - Users still angry
+
+3. **"It works for me"**
+ - Simulator ≠ real devices
+ - Your device ≠ all devices
+ - Ignores real problem
+
+4. **"Switch to ChatGPT API"**
+ - Violates privacy
+ - Expensive at scale
+ - Doesn't address availability issue
+
+### MANDATORY Protocol
+
+#### Phase 1: Identify (5 minutes)
+
+```swift
+// Check error distribution
+// What percentage seeing what error?
+
+// Run this on test devices:
+let availability = SystemLanguageModel.default.availability
+
+switch availability {
+case .available:
+ print("✅ Available")
+case .unavailable(let reason):
+ print("❌ Unavailable: \(reason)")
+}
+
+// Hypothesis:
+// - If 20% unavailable → Availability issue (device/region/opt-in)
+// - If 20% getting errors → Code bug
+// - If 20% seeing wrong results → Use case mismatch
+```
+
+**Results**: Discover that 20% of users have devices without Apple Intelligence support.
+
+---
+
+#### Phase 2: Confirm (5 minutes)
+
+```swift
+// Check which devices affected
+// iPhone 15 Pro+ = ✅ Available
+// iPhone 15 = ❌ Unavailable
+// iPhone 14 = ❌ Unavailable
+
+// Conclusion: Availability issue, not code bug
+```
+
+**Root cause**: Feature assumes all users have Apple Intelligence. 20% don't.
+
+---
+
+#### Phase 3: Device Requirements (5 minutes)
+
+Verify:
+- Apple Intelligence requires iPhone 15 Pro or later
+- Or iPad with M1+ chip
+- Or Mac with Apple silicon
+
+#### 20% of user base = older devices
+
+---
+
+#### Phase 4: Implement Fix (15 minutes)
+
+```swift
+// ✅ Add availability check + graceful UI
+struct AIFeatureView: View {
+ @State private var availability = SystemLanguageModel.default.availability
+
+ var body: some View {
+ switch availability {
+ case .available:
+ // Show AI feature
+ AIContentView()
+
+ case .unavailable:
+ // Graceful fallback
+ VStack {
+ Image(systemName: "sparkles")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+
+ Text("AI-Powered Features")
+ .font(.headline)
+
+ Text("Available on iPhone 15 Pro and later")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+
+ // Offer alternative
+ Button("Use Standard Mode") {
+ // Show non-AI fallback
+ }
+ }
+ }
+ }
+}
+```
+
+---
+
+#### Phase 5: Deploy (20 minutes)
+
+1. Test on multiple devices (15 min)
+ - iPhone 15 Pro: ✅ Shows AI feature
+ - iPhone 14: ✅ Shows graceful message
+ - iPad Pro M1: ✅ Shows AI feature
+
+2. Submit hotfix build (5 min)
+
+---
+
+### Communication Template
+
+**To VP of Product (immediate)**:
+```
+Root cause identified:
+
+The AI feature requires Apple Intelligence (iPhone 15 Pro+).
+20% of our users have older devices. We didn't check availability.
+
+Fix: Added availability check with graceful fallback UI.
+
+Timeline:
+- Hotfix ready: Now
+- TestFlight: 10 minutes
+- App Store submission: 30 minutes
+- Review: 24-48 hours (requesting expedited)
+
+Impact mitigation:
+- 80% of users see working AI feature
+- 20% see clear message + standard mode fallback
+- No functionality lost, just graceful degradation
+```
+
+**To Engineering Team**:
+```
+Post-mortem items:
+1. Add availability check to launch checklist
+2. Test on non-Apple-Intelligence devices
+3. Document device requirements clearly
+4. Add analytics for availability status
+```
+
+### Time Saved
+
+- **Panic path (disable/rollback)**: 2 hours of meetings + lost work
+- **Proper diagnosis**: 45 minutes root cause → fix → deploy
+
+### What We Learned
+
+1. **Always check availability** before creating session
+2. **Test on real devices** across device generations
+3. **Graceful degradation** better than feature removal
+4. **Clear messaging** to users about requirements
+
+---
+
+## Quick Reference Table
+
+| Symptom | Cause | Check | Pattern | Time |
+|---------|-------|-------|---------|------|
+| Won't start | .unavailable | SystemLanguageModel.default.availability | 1a | 5 min |
+| Region issue | Not supported region | Check supported regions | 1b | 5 min |
+| Not opted in | Apple Intelligence disabled | Settings check | 1c | 10 min |
+| Context exceeded | >4096 tokens | Transcript length | 2a | 15 min |
+| Guardrail error | Content policy | User input type | 2b | 10 min |
+| Language error | Unsupported language | supportedLanguages | 2c | 10 min |
+| Hallucinated output | Wrong use case | Task type check | 3a | 20 min |
+| Wrong structure | No @Generable | Manual parsing? | 3b | 10 min |
+| Missing data | No tool | External data needed? | 3c | 30 min |
+| Inconsistent | Random sampling | Need deterministic? | 3d | 5 min |
+| Initial delay | Model loading | First request slow? | 4a | 10 min |
+| Long wait | No streaming | >1s generation? | 4b | 20 min |
+| Schema overhead | Re-inserting schema | Subsequent requests? | 4c | 2 min |
+| Complex prompt | Too much at once | >5s generation? | 4d | 30 min |
+| UI frozen | Main thread | Thread check | 5a | 5 min |
+
+---
+
+## Cross-References
+
+**Related Axiom Skills**:
+- `axiom-foundation-models` — Discipline skill for anti-patterns, proper usage patterns, pressure scenarios
+- `axiom-foundation-models-ref` — Complete API reference with all WWDC 2025 code examples
+
+**Apple Resources**:
+- Foundation Models Framework Documentation
+- WWDC 2025-286: Meet the Foundation Models framework
+- WWDC 2025-301: Deep dive into the Foundation Models framework
+- Instruments Foundation Models Template
+
+---
+
+**Last Updated**: 2025-12-03
+**Version**: 1.0.0
+**Skill Type**: Diagnostic
diff --git a/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml b/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml
new file mode 100644
index 0000000..048ccd8
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Foundation Models Diagnostics"
+ short_description: "Debugging Foundation Models issues"
diff --git a/.claude/skills/axiom-foundation-models-ref/.openskills.json b/.claude/skills/axiom-foundation-models-ref/.openskills.json
new file mode 100644
index 0000000..c5573be
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-foundation-models-ref",
+ "installedAt": "2026-04-12T08:06:18.818Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-foundation-models-ref/SKILL.md b/.claude/skills/axiom-foundation-models-ref/SKILL.md
new file mode 100644
index 0000000..5669b28
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-ref/SKILL.md
@@ -0,0 +1,1089 @@
+---
+name: axiom-foundation-models-ref
+description: Reference — Complete Foundation Models framework guide covering LanguageModelSession, @Generable, @Guide, Tool protocol, streaming, dynamic schemas, built-in use cases, and all WWDC 2025 code examples
+license: MIT
+compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-03"
+---
+
+# Foundation Models Framework — Complete API Reference
+
+## Overview
+
+The Foundation Models framework provides access to Apple's on-device Large Language Model (3 billion parameters, 2-bit quantized) with a Swift API. This reference covers every API, all WWDC 2025 code examples, and comprehensive implementation patterns.
+
+### Model Specifications
+
+3B parameter model, 2-bit quantized, 4096 token context (input + output combined). Optimized for on-device summarization, extraction, classification, and generation. NOT suited for world knowledge, complex reasoning, math, or translation. Runs entirely on-device — no network, no cost, no data leaves device.
+
+---
+
+## When to Use This Reference
+
+Use this reference when:
+- Implementing Foundation Models features
+- Understanding API capabilities
+- Looking up specific code examples
+- Planning architecture with Foundation Models
+- Migrating from prototype to production
+- Debugging implementation issues
+
+**Related Skills**:
+- `axiom-foundation-models` — Discipline skill with anti-patterns, pressure scenarios, decision trees
+- `axiom-foundation-models-diag` — Diagnostic skill for troubleshooting issues
+
+---
+
+## LanguageModelSession
+
+### Overview
+
+`LanguageModelSession` is the core class for interacting with the model. It maintains conversation history (transcript), handles multi-turn interactions, and manages model state.
+
+### Creating a Session
+
+**Basic Creation**:
+```swift
+import FoundationModels
+
+let session = LanguageModelSession()
+```
+
+**With Custom Instructions**:
+```swift
+let session = LanguageModelSession(instructions: """
+ You are a friendly barista in a pixel art coffee shop.
+ Respond to the player's question concisely.
+ """
+)
+```
+
+#### From WWDC 301:1:05
+
+**With Tools**:
+```swift
+let session = LanguageModelSession(
+ tools: [GetWeatherTool()],
+ instructions: "Help user with weather forecasts."
+)
+```
+
+#### From WWDC 286:15:03
+
+**With Specific Model/Use Case**:
+```swift
+let session = LanguageModelSession(
+ model: SystemLanguageModel(useCase: .contentTagging)
+)
+```
+
+#### From WWDC 286:18:39
+
+### Instructions vs Prompts
+
+**Instructions**:
+- Come from **developer**
+- Define model's role, style, constraints
+- Mostly static
+- First entry in transcript
+- Model trained to obey instructions over prompts (security feature)
+
+**Prompts**:
+- Come from **user** (or dynamic app state)
+- Specific requests for generation
+- Dynamic input
+- Each call to `respond(to:)` adds prompt to transcript
+
+**Security Consideration**:
+- **NEVER** interpolate untrusted user input into instructions
+- User input should go in prompts only
+- Prevents prompt injection attacks
+
+### respond(to:) Method
+
+**Basic Text Generation**:
+```swift
+func respond(userInput: String) async throws -> String {
+ let session = LanguageModelSession(instructions: """
+ You are a friendly barista in a world full of pixels.
+ Respond to the player's question.
+ """
+ )
+ let response = try await session.respond(to: userInput)
+ return response.content
+}
+```
+
+#### From WWDC 301:1:05
+
+**Return Type**: `Response` with `.content` property
+
+### respond(to:generating:) Method
+
+**Structured Output with @Generable**:
+```swift
+@Generable
+struct SearchSuggestions {
+ @Guide(description: "A list of suggested search terms", .count(4))
+ var searchTerms: [String]
+}
+
+let prompt = """
+ Generate a list of suggested search terms for an app about visiting famous landmarks.
+ """
+
+let response = try await session.respond(
+ to: prompt,
+ generating: SearchSuggestions.self
+)
+
+print(response.content) // SearchSuggestions instance
+```
+
+#### From WWDC 286:5:51
+
+**Return Type**: `Response` with `.content` property
+
+### Generation Options
+
+See [Sampling & Generation Options](#sampling--generation-options) for `GenerationOptions` including `sampling:`, `temperature:`, and `includeSchemaInPrompt:`.
+
+---
+
+## Multi-Turn Interactions
+
+### Retaining Context
+
+```swift
+let session = LanguageModelSession()
+
+// First turn
+let firstHaiku = try await session.respond(to: "Write a haiku about fishing")
+print(firstHaiku.content)
+// Silent waters gleam,
+// Casting lines in morning mist—
+// Hope in every cast.
+
+// Second turn - model remembers context
+let secondHaiku = try await session.respond(to: "Do another one about golf")
+print(secondHaiku.content)
+// Silent morning dew,
+// Caddies guide with gentle words—
+// Paths of patience tread.
+
+print(session.transcript) // Shows full history
+```
+
+#### From WWDC 286:17:46
+
+**How it works**:
+- Each `respond()` call adds entry to transcript
+- Model uses entire transcript for context
+- Enables conversational interactions
+
+### Transcript Property
+
+```swift
+let transcript = session.transcript
+
+for entry in transcript.entries {
+ print("Entry: \(entry.content)")
+}
+```
+
+**Use cases**:
+- Debugging generation issues
+- Displaying conversation history in UI
+- Exporting chat logs
+- Condensing for context management
+
+---
+
+## isResponding Property
+
+Gate UI on `session.isResponding` to prevent concurrent requests:
+
+```swift
+Button("Go!") {
+ Task { haiku = try await session.respond(to: prompt).content }
+}
+.disabled(session.isResponding)
+```
+
+#### From WWDC 286:18:22
+
+---
+
+## @Generable Macro
+
+### Overview
+
+`@Generable` enables structured output from the model using Swift types. The macro generates a schema at compile-time and uses **constrained decoding** to guarantee structural correctness.
+
+### Basic Usage
+
+**On Structs**:
+```swift
+@Generable
+struct Person {
+ let name: String
+ let age: Int
+}
+
+let response = try await session.respond(
+ to: "Generate a person",
+ generating: Person.self
+)
+
+let person = response.content // Type-safe Person instance
+```
+
+#### From WWDC 301:8:14
+
+**On Enums**:
+```swift
+@Generable
+struct NPC {
+ let name: String
+ let encounter: Encounter
+
+ @Generable
+ enum Encounter {
+ case orderCoffee(String)
+ case wantToTalkToManager(complaint: String)
+ }
+}
+```
+
+#### From WWDC 301:10:49
+
+### Supported Types
+
+**Primitives**:
+- `String`
+- `Int`, `Float`, `Double`, `Decimal`
+- `Bool`
+
+**Collections**:
+- `[ElementType]` (arrays)
+
+**Composed Types**:
+```swift
+@Generable
+struct Itinerary {
+ var destination: String
+ var days: Int
+ var budget: Float
+ var rating: Double
+ var requiresVisa: Bool
+ var activities: [String]
+ var emergencyContact: Person
+ var relatedItineraries: [Itinerary] // Recursive!
+}
+```
+
+#### From WWDC 286:6:18
+
+### @Guide Constraints
+
+`@Guide` constrains generated properties. Supports `description:` (natural language), `.range()` (numeric bounds), `.count()` / `.maximumCount()` (array length), and `Regex` (pattern matching).
+
+```swift
+@Generable
+struct NPC {
+ @Guide(description: "A full name")
+ let name: String
+
+ @Guide(.range(1...10))
+ let level: Int
+
+ @Guide(.count(3))
+ let attributes: [String]
+}
+```
+
+#### From WWDC 301:11:20
+
+### Constrained Decoding
+
+**How it works**:
+1. `@Generable` macro generates schema at compile-time
+2. Schema defines valid token sequences
+3. During generation, model creates probability distribution for next token
+4. Framework **masks out invalid tokens** based on schema
+5. Model can only pick tokens valid according to schema
+6. Guarantees structural correctness - no hallucinated keys, no invalid JSON
+
+**From WWDC 286**: "Constrained decoding prevents structural mistakes. Model is prevented from generating invalid field names or wrong types."
+
+**Benefits**:
+- Zero parsing code needed
+- No runtime parsing errors
+- Type-safe Swift objects
+- Compile-time safety (changes to struct caught by compiler)
+
+### Property Declaration Order
+
+**Properties generated in order declared**:
+```swift
+@Generable
+struct Itinerary {
+ var name: String // Generated FIRST
+ var days: [DayPlan] // Generated SECOND
+ var summary: String // Generated LAST
+}
+```
+
+**Why it matters**:
+- Later properties can reference earlier ones
+- Better model quality: Summaries after content
+- Better streaming UX: Important properties first
+
+#### From WWDC 286:11:00
+
+---
+
+## Streaming
+
+### Overview
+
+Foundation Models uses **snapshot streaming** (not delta streaming). Instead of raw deltas, the framework streams `PartiallyGenerated` types with optional properties that fill in progressively.
+
+### PartiallyGenerated Type
+
+The `@Generable` macro automatically creates a `PartiallyGenerated` nested type:
+
+```swift
+@Generable
+struct Itinerary {
+ var name: String
+ var days: [DayPlan]
+}
+
+// Compiler generates:
+extension Itinerary {
+ struct PartiallyGenerated {
+ var name: String? // All properties optional!
+ var days: [DayPlan]?
+ }
+}
+```
+
+#### From WWDC 286:9:20
+
+### streamResponse Method
+
+```swift
+@Generable
+struct Itinerary {
+ var name: String
+ var days: [Day]
+}
+
+let stream = session.streamResponse(
+ to: "Craft a 3-day itinerary to Mt. Fuji.",
+ generating: Itinerary.self
+)
+
+for try await partial in stream {
+ print(partial) // Incrementally updated Itinerary.PartiallyGenerated
+}
+```
+
+#### From WWDC 286:9:40
+
+**Return Type**: `AsyncSequence`
+
+### SwiftUI Integration
+
+```swift
+struct ItineraryView: View {
+ let session: LanguageModelSession
+ let dayCount: Int
+ let landmarkName: String
+
+ @State
+ private var itinerary: Itinerary.PartiallyGenerated?
+
+ var body: some View {
+ VStack {
+ if let name = itinerary?.name {
+ Text(name).font(.title)
+ }
+
+ if let days = itinerary?.days {
+ ForEach(days, id: \.self) { day in
+ DayView(day: day)
+ }
+ }
+
+ Button("Start") {
+ Task {
+ do {
+ let prompt = """
+ Generate a \(dayCount) itinerary \
+ to \(landmarkName).
+ """
+
+ let stream = session.streamResponse(
+ to: prompt,
+ generating: Itinerary.self
+ )
+
+ for try await partial in stream {
+ self.itinerary = partial
+ }
+ } catch {
+ print(error)
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+#### From WWDC 286:10:05
+
+### Best Practices
+
+**1. Use SwiftUI animations**:
+```swift
+if let name = itinerary?.name {
+ Text(name)
+ .transition(.opacity)
+}
+```
+
+**2. View identity for arrays**:
+```swift
+// ✅ GOOD - Stable identity
+ForEach(days, id: \.id) { day in
+ DayView(day: day)
+}
+
+// ❌ BAD - Identity changes
+ForEach(days.indices, id: \.self) { index in
+ DayView(day: days[index])
+}
+```
+
+**3. Property order optimization**:
+```swift
+// ✅ GOOD - Title first for streaming
+@Generable
+struct Article {
+ var title: String // Shows immediately
+ var summary: String // Shows second
+ var fullText: String // Shows last
+}
+```
+
+#### From WWDC 286:11:00
+
+---
+
+## Tool Protocol
+
+### Overview
+
+Tools let the model autonomously execute your custom code to fetch external data or perform actions. Tools integrate with MapKit, WeatherKit, Contacts, EventKit, or any custom API.
+
+### Protocol Definition
+
+```swift
+protocol Tool {
+ var name: String { get }
+ var description: String { get }
+
+ associatedtype Arguments: Generable
+
+ func call(arguments: Arguments) async throws -> ToolOutput
+}
+```
+
+### Example: GetWeatherTool
+
+```swift
+import FoundationModels
+import WeatherKit
+import CoreLocation
+
+struct GetWeatherTool: Tool {
+ let name = "getWeather"
+ let description = "Retrieve the latest weather information for a city"
+
+ @Generable
+ struct Arguments {
+ @Guide(description: "The city to fetch the weather for")
+ var city: String
+ }
+
+ func call(arguments: Arguments) async throws -> ToolOutput {
+ let places = try await CLGeocoder().geocodeAddressString(arguments.city)
+ let weather = try await WeatherService.shared.weather(for: places.first!.location!)
+ let temperature = weather.currentWeather.temperature.value
+
+ let content = GeneratedContent(properties: ["temperature": temperature])
+ let output = ToolOutput(content)
+
+ // Or if your tool's output is natural language:
+ // let output = ToolOutput("\(arguments.city)'s temperature is \(temperature) degrees.")
+
+ return output
+ }
+}
+```
+
+#### From WWDC 286:13:42
+
+### Attaching Tools to Session
+
+```swift
+let session = LanguageModelSession(
+ tools: [GetWeatherTool()],
+ instructions: "Help the user with weather forecasts."
+)
+
+let response = try await session.respond(
+ to: "What is the temperature in Cupertino?"
+)
+
+print(response.content)
+// It's 71˚F in Cupertino!
+```
+
+#### From WWDC 286:15:03
+
+**How it works**:
+1. Session initialized with tools
+2. User prompt: "What's Tokyo's weather?"
+3. Model analyzes prompt, decides weather data needed
+4. Model generates tool call: `getWeather(city: "Tokyo")`
+5. Framework calls `call()` method
+6. Your code fetches real data from API
+7. Tool output inserted into transcript
+8. Model generates final response using tool output
+
+**From WWDC 301**: "Model autonomously decides when and how often to call tools. Can call multiple tools per request, even in parallel."
+
+### Stateful Tools
+
+Use `class` instead of `struct` to maintain state across tool calls. The tool instance persists for the session lifetime, enabling patterns like tracking previously returned results:
+
+```swift
+class FindContactTool: Tool {
+ let name = "findContact"
+ let description = "Finds a contact from a specified age generation."
+ var pickedContacts = Set()
+
+ @Generable
+ struct Arguments {
+ let generation: Generation
+ @Generable
+ enum Generation { case babyBoomers, genX, millennial, genZ }
+ }
+
+ func call(arguments: Arguments) async throws -> ToolOutput {
+ // Fetch, filter out already-picked, return new contact
+ pickedContacts.insert(pickedContact.givenName)
+ return ToolOutput(pickedContact.givenName)
+ }
+}
+```
+
+#### From WWDC 301:18:47, 301:21:55
+
+### ToolOutput
+
+**Two forms**:
+
+1. **Natural language** (String):
+```swift
+return ToolOutput("Temperature is 71°F")
+```
+
+2. **Structured** (GeneratedContent):
+```swift
+let content = GeneratedContent(properties: ["temperature": 71])
+return ToolOutput(content)
+```
+
+### Tool Naming Best Practices
+
+**DO**:
+- Short, readable names: `getWeather`, `findContact`
+- Use verbs: `get`, `find`, `fetch`, `create`
+- One sentence descriptions
+- Keep descriptions concise (they're in prompt)
+
+**DON'T**:
+- Abbreviations: `gtWthr`
+- Implementation details in description
+- Long descriptions (increases token count)
+
+**From WWDC 301**: "Tool name and description put verbatim in prompt. Longer strings mean more tokens, which increases latency."
+
+### Multiple Tools
+
+```swift
+let session = LanguageModelSession(
+ tools: [
+ GetWeatherTool(),
+ FindRestaurantTool(),
+ FindHotelTool()
+ ],
+ instructions: "Plan travel itineraries."
+)
+
+// Model autonomously decides which tools to call and when
+```
+
+### Tool Calling Behavior
+
+**Key facts**:
+- Tool can be called **multiple times** per request
+- Multiple tools can be called **in parallel**
+- Model decides **when** to call (not guaranteed to call)
+- Arguments guaranteed valid via @Generable
+
+**From WWDC 301**: "When tools called in parallel, your call method may execute concurrently. Keep this in mind when accessing data."
+
+---
+
+## Dynamic Schemas
+
+### Overview
+
+`DynamicGenerationSchema` enables creating schemas at runtime instead of compile-time. Useful for user-defined structures, level creators, or dynamic forms.
+
+### Creating and Using Dynamic Schemas
+
+Build properties with `DynamicGenerationSchema.Property`, compose into schemas, then validate with `GenerationSchema`:
+
+```swift
+// Build schema at runtime
+let questionProp = DynamicGenerationSchema.Property(
+ name: "question", schema: DynamicGenerationSchema(type: String.self)
+)
+let answersProp = DynamicGenerationSchema.Property(
+ name: "answers", schema: DynamicGenerationSchema(
+ arrayOf: DynamicGenerationSchema(referenceTo: "Answer")
+ )
+)
+
+let riddleSchema = DynamicGenerationSchema(name: "Riddle", properties: [questionProp, answersProp])
+let answerSchema = DynamicGenerationSchema(name: "Answer", properties: [/* text, isCorrect */])
+
+// Validate and use
+let schema = try GenerationSchema(root: riddleSchema, dependencies: [answerSchema])
+let response = try await session.respond(to: "Generate a riddle", schema: schema)
+
+let question = try response.content.value(String.self, forProperty: "question")
+```
+
+#### From WWDC 301:14:50, 301:15:10
+
+### Dynamic vs Static @Generable
+
+**Use @Generable when**:
+- Structure known at compile-time
+- Want type safety
+- Want automatic parsing
+
+**Use Dynamic Schemas when**:
+- Structure only known at runtime
+- User-defined schemas
+- Maximum flexibility
+
+**From WWDC 301**: "Compile-time @Generable gives type safety. Dynamic schemas give runtime flexibility. Both use same constrained decoding guarantees."
+
+---
+
+## Sampling & Generation Options
+
+**Greedy (deterministic)** — use for tests and demos. Only deterministic within same model version:
+```swift
+let response = try await session.respond(
+ to: prompt,
+ options: GenerationOptions(sampling: .greedy)
+)
+```
+
+**Temperature** — controls variance. `0.1-0.5` focused, `1.0` default, `1.5-2.0` creative:
+```swift
+let response = try await session.respond(
+ to: prompt,
+ options: GenerationOptions(temperature: 0.5)
+)
+```
+
+#### From WWDC 301:6:14
+
+---
+
+## Built-in Use Cases
+
+### Content Tagging Adapter
+
+**Specialized adapter for**:
+- Tag generation
+- Entity extraction
+- Topic detection
+
+```swift
+@Generable
+struct Result {
+ let topics: [String]
+}
+
+let session = LanguageModelSession(
+ model: SystemLanguageModel(useCase: .contentTagging)
+)
+
+let response = try await session.respond(
+ to: articleText,
+ generating: Result.self
+)
+```
+
+#### From WWDC 286:19:19
+
+### Custom Use Cases
+
+**With custom instructions**:
+```swift
+@Generable
+struct Top3ActionEmotionResult {
+ @Guide(.maximumCount(3))
+ let actions: [String]
+ @Guide(.maximumCount(3))
+ let emotions: [String]
+}
+
+let session = LanguageModelSession(
+ model: SystemLanguageModel(useCase: .contentTagging),
+ instructions: "Tag the 3 most important actions and emotions in the given input text."
+)
+
+let response = try await session.respond(
+ to: text,
+ generating: Top3ActionEmotionResult.self
+)
+```
+
+#### From WWDC 286:19:35
+
+---
+
+## Error Handling
+
+### GenerationError Types
+
+Catch `LanguageModelSession.GenerationError` cases:
+- **`.exceededContextWindowSize`** — Context limit (4096 tokens) exceeded. Condense transcript or create new session.
+- **`.guardrailViolation`** — Content policy triggered. Show graceful message.
+- **`.unsupportedLanguageOrLocale`** — Language not supported. Check `supportedLanguages`.
+
+#### From WWDC 301:3:37, 301:7:06
+
+### Context Window Management
+
+#### Strategy 1: Fresh Session
+```swift
+var session = LanguageModelSession()
+
+do {
+ let response = try await session.respond(to: prompt)
+ print(response.content)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // New session, no history
+ session = LanguageModelSession()
+}
+```
+
+#### From WWDC 301:3:37
+
+#### Strategy 2: Condensed Session
+```swift
+do {
+ let response = try await session.respond(to: prompt)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // New session with some history
+ session = newSession(previousSession: session)
+}
+
+private func newSession(previousSession: LanguageModelSession) -> LanguageModelSession {
+ let allEntries = previousSession.transcript.entries
+ var condensedEntries = [Transcript.Entry]()
+
+ if let firstEntry = allEntries.first {
+ condensedEntries.append(firstEntry) // Instructions
+
+ if allEntries.count > 1, let lastEntry = allEntries.last {
+ condensedEntries.append(lastEntry) // Recent context
+ }
+ }
+
+ let condensedTranscript = Transcript(entries: condensedEntries)
+ // Note: transcript includes instructions
+ return LanguageModelSession(transcript: condensedTranscript)
+}
+```
+
+#### From WWDC 301:3:55
+
+### Fallback Architecture
+
+When Foundation Models is unavailable (older device, user opted out, unsupported region), provide graceful degradation:
+
+```swift
+func summarize(_ text: String) async throws -> String {
+ let model = SystemLanguageModel.default
+ switch model.availability {
+ case .available:
+ let session = LanguageModelSession()
+ let response = try await session.respond(to: "Summarize: \(text)")
+ return response.content
+ case .unavailable:
+ // Fallback: truncate with ellipsis, or call server API
+ return String(text.prefix(200)) + "..."
+ }
+}
+```
+
+**Architecture pattern**: Wrap Foundation Models behind a protocol so you can swap implementations:
+
+```swift
+protocol TextSummarizer {
+ func summarize(_ text: String) async throws -> String
+}
+
+struct OnDeviceSummarizer: TextSummarizer { /* Foundation Models */ }
+struct ServerSummarizer: TextSummarizer { /* Server API fallback */ }
+struct TruncationSummarizer: TextSummarizer { /* Simple truncation */ }
+```
+
+### Nested @Generable Troubleshooting
+
+Nested `@Generable` types must each independently conform to `@Generable`:
+
+```swift
+// ✅ Both types marked @Generable
+@Generable struct Itinerary {
+ var days: [DayPlan]
+}
+
+@Generable struct DayPlan {
+ var activities: [String]
+}
+
+// ❌ Will fail — nested type not @Generable
+@Generable struct Itinerary {
+ var days: [DayPlan] // DayPlan must also be @Generable
+}
+struct DayPlan { var activities: [String] }
+```
+
+**Common issue**: Arrays of non-Generable types compile but fail at runtime. Check all types in the graph.
+
+---
+
+## Availability
+
+### Checking Availability
+
+```swift
+struct AvailabilityExample: View {
+ private let model = SystemLanguageModel.default
+
+ var body: some View {
+ switch model.availability {
+ case .available:
+ Text("Model is available").foregroundStyle(.green)
+ case .unavailable(let reason):
+ Text("Model is unavailable").foregroundStyle(.red)
+ Text("Reason: \(reason)")
+ }
+ }
+}
+```
+
+#### From WWDC 286:19:56
+
+### Supported Languages
+
+```swift
+let supportedLanguages = SystemLanguageModel.default.supportedLanguages
+guard supportedLanguages.contains(Locale.current.language) else {
+ // Show message
+ return
+}
+```
+
+#### From WWDC 301:7:06
+
+### Requirements
+
+**Device Requirements**:
+- Apple Intelligence-enabled device
+- iPhone 15 Pro or later
+- iPad with M1+ chip
+- Mac with Apple silicon
+
+**Region Requirements**:
+- Supported region (check Apple Intelligence availability)
+
+**User Requirements**:
+- User opted in to Apple Intelligence in Settings
+
+---
+
+## Performance & Profiling
+
+### Foundation Models Instrument
+
+**Access**: Instruments app → Foundation Models template
+
+**Metrics**:
+- Initial model load time
+- Token counts (input/output)
+- Generation time per request
+- Latency breakdown
+- Optimization opportunities
+
+**From WWDC 286**: "New Instruments profiling template lets you observe areas of optimization and quantify improvements."
+
+### Optimization: Prewarming
+
+**Problem**: First request takes 1-2s to load model
+
+**Solution**: Create session before user interaction
+
+```swift
+class ViewModel: ObservableObject {
+ private var session: LanguageModelSession?
+
+ init() {
+ // Prewarm on init
+ Task {
+ self.session = LanguageModelSession(instructions: "...")
+ }
+ }
+
+ func generate(prompt: String) async throws -> String {
+ let response = try await session!.respond(to: prompt)
+ return response.content
+ }
+}
+```
+
+**From WWDC 259**: "Prewarming session before user interaction reduces initial latency."
+
+**Time saved**: 1-2 seconds off first generation
+
+### Optimization: includeSchemaInPrompt
+
+**Problem**: Large @Generable schemas increase token count
+
+**Solution**: Skip schema insertion for subsequent requests
+
+```swift
+// First request - schema inserted
+let first = try await session.respond(
+ to: "Generate first person",
+ generating: Person.self
+)
+
+// Subsequent requests - skip schema
+let second = try await session.respond(
+ to: "Generate another person",
+ generating: Person.self,
+ options: GenerationOptions(includeSchemaInPrompt: false)
+)
+```
+
+**From WWDC 259**: "Setting includeSchemaInPrompt to false decreases token count and latency for subsequent requests."
+
+**Time saved**: 10-20% per request
+
+### Optimization: Property Order
+
+Declare important properties first in `@Generable` structs. With streaming, perceived latency drops from 2.5s to 0.2s when title appears before full text. See [Streaming Best Practices](#best-practices) for examples.
+
+---
+
+## Feedback & Analytics
+
+`LanguageModelFeedbackAttachment` lets you report model quality issues to Apple via Feedback Assistant. Create with `input`, `output`, `sentiment` (`.positive`/`.negative`), `issues` (category + explanation), and `desiredOutputExamples`. Encode as JSON and attach to a Feedback Assistant report.
+
+#### From WWDC 286:22:13
+
+---
+
+## Xcode Playgrounds
+
+### Overview
+
+Xcode Playgrounds enable rapid iteration on prompts without rebuilding entire app.
+
+### Basic Usage
+
+```swift
+import FoundationModels
+import Playgrounds
+
+#Playground {
+ let session = LanguageModelSession()
+ let response = try await session.respond(
+ to: "What's a good name for a trip to Japan? Respond only with a title"
+ )
+}
+```
+
+#### From WWDC 286:2:28
+
+Playgrounds can also access types defined in your app (like @Generable structs).
+
+---
+
+## API Quick Reference
+
+- **`LanguageModelSession`** — Main interface: `respond(to:)` → `Response`, `respond(to:generating:)` → `Response`, `streamResponse(to:generating:)` → `AsyncSequence`. Properties: `transcript`, `isResponding`.
+- **`SystemLanguageModel`** — `default.availability` (`.available`/`.unavailable(reason)`), `default.supportedLanguages`, `init(useCase:)`
+- **`GenerationOptions`** — `sampling` (`.greedy`/`.random`), `temperature`, `includeSchemaInPrompt`
+- **`@Generable`** — Macro enabling structured output with constrained decoding
+- **`@Guide`** — Property constraints: `description:`, `.range()`, `.count()`, `.maximumCount()`, `Regex`
+- **`Tool` protocol** — `name`, `description`, `Arguments: Generable`, `call(arguments:) → ToolOutput`
+- **`DynamicGenerationSchema`** — Runtime schema definition with `GeneratedContent` output
+- **`GenerationError`** — `.exceededContextWindowSize`, `.guardrailViolation`, `.unsupportedLanguageOrLocale`
+
+---
+
+## Migration Strategies
+
+### From Server LLMs
+
+- **Migrate when**: Privacy required, offline needed, per-request costs are a concern, and use case fits (summarization/extraction/classification)
+- **Stay on server when**: Need world knowledge, complex reasoning, or >4096 token context
+
+### From Manual JSON Parsing
+
+Use `@Generable` with `respond(to:generating:)` instead of prompting for JSON and parsing manually. See `axiom-foundation-models` Scenario 2 for the complete migration pattern.
+
+---
+
+## Resources
+
+**WWDC**: 286, 259, 301
+
+**Skills**: axiom-foundation-models, axiom-foundation-models-diag
+
+---
+
+**Last Updated**: 2025-12-03
+**Version**: 1.0.0
+**Skill Type**: Reference
+**Content**: All WWDC 2025 code examples included
diff --git a/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml b/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml
new file mode 100644
index 0000000..f88f23c
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Foundation Models Reference"
+ short_description: "Reference — Complete Foundation Models framework guide covering LanguageModelSession, @Generable, @Guide, Tool protoc..."
diff --git a/.claude/skills/axiom-foundation-models/.openskills.json b/.claude/skills/axiom-foundation-models/.openskills.json
new file mode 100644
index 0000000..19987a9
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-foundation-models",
+ "installedAt": "2026-04-12T08:06:17.593Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-foundation-models/SKILL.md b/.claude/skills/axiom-foundation-models/SKILL.md
new file mode 100644
index 0000000..3308ac1
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models/SKILL.md
@@ -0,0 +1,1125 @@
+---
+name: axiom-foundation-models
+description: Use when implementing on-device AI with Apple's Foundation Models framework — prevents context overflow, blocking UI, wrong model use cases, and manual JSON parsing when @Generable should be used. iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+
+license: MIT
+compatibility: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-03"
+---
+
+# Foundation Models — On-Device AI for Apple Platforms
+
+## When to Use This Skill
+
+Use when:
+- Implementing on-device AI features with Foundation Models
+- Adding text summarization, classification, or extraction capabilities
+- Creating structured output from LLM responses
+- Building tool-calling patterns for external data integration
+- Streaming generated content for better UX
+- Debugging Foundation Models issues (context overflow, slow generation, wrong output)
+- Deciding between Foundation Models vs server LLMs (ChatGPT, Claude, etc.)
+
+#### Related Skills
+- Use `axiom-foundation-models-diag` for systematic troubleshooting (context exceeded, guardrail violations, availability problems)
+- Use `axiom-foundation-models-ref` for complete API reference with all WWDC code examples
+
+---
+
+## Red Flags — Anti-Patterns That Will Fail
+
+### ❌ Using for World Knowledge
+**Why it fails**: The on-device model is 3 billion parameters, optimized for summarization, extraction, classification — **NOT** world knowledge or complex reasoning.
+
+**Example of wrong use**:
+```swift
+// ❌ BAD - Asking for world knowledge
+let session = LanguageModelSession()
+let response = try await session.respond(to: "What's the capital of France?")
+```
+
+**Why**: Model will hallucinate or give low-quality answers. It's trained for content generation, not encyclopedic knowledge.
+
+**Correct approach**: Use server LLMs (ChatGPT, Claude) for world knowledge, or provide factual data through Tool calling.
+
+---
+
+### ❌ Blocking Main Thread
+**Why it fails**: `session.respond()` is `async` but if called synchronously on main thread, freezes UI for seconds.
+
+**Example of wrong use**:
+```swift
+// ❌ BAD - Blocking main thread
+Button("Generate") {
+ let response = try await session.respond(to: prompt) // UI frozen!
+}
+```
+
+**Why**: Generation takes 1-5 seconds. User sees frozen app, bad reviews follow.
+
+**Correct approach**:
+```swift
+// ✅ GOOD - Async on background
+Button("Generate") {
+ Task {
+ let response = try await session.respond(to: prompt)
+ // Update UI with response
+ }
+}
+```
+
+---
+
+### ❌ Manual JSON Parsing
+**Why it fails**: Prompting for JSON and parsing with JSONDecoder leads to hallucinated keys, invalid JSON, no type safety.
+
+**Example of wrong use**:
+```swift
+// ❌ BAD - Manual JSON parsing
+let prompt = "Generate a person with name and age as JSON"
+let response = try await session.respond(to: prompt)
+let data = response.content.data(using: .utf8)!
+let person = try JSONDecoder().decode(Person.self, from: data) // CRASHES!
+```
+
+**Why**: Model might output `{firstName: "John"}` when you expect `{name: "John"}`. Or invalid JSON entirely.
+
+**Correct approach**:
+```swift
+// ✅ GOOD - @Generable guarantees structure
+@Generable
+struct Person {
+ let name: String
+ let age: Int
+}
+
+let response = try await session.respond(
+ to: "Generate a person",
+ generating: Person.self
+)
+// response.content is type-safe Person instance
+```
+
+---
+
+### ❌ Ignoring Availability Check
+**Why it fails**: Foundation Models only runs on Apple Intelligence devices in supported regions. App crashes or shows errors without check.
+
+**Example of wrong use**:
+```swift
+// ❌ BAD - No availability check
+let session = LanguageModelSession() // Might fail!
+```
+
+**Correct approach**:
+```swift
+// ✅ GOOD - Check first
+switch SystemLanguageModel.default.availability {
+case .available:
+ let session = LanguageModelSession()
+ // proceed
+case .unavailable(let reason):
+ // Show graceful UI: "AI features require Apple Intelligence"
+}
+```
+
+---
+
+### ❌ Single Huge Prompt
+**Why it fails**: 4096 token context window (input + output). One massive prompt hits limit, gives poor results.
+
+**Example of wrong use**:
+```swift
+// ❌ BAD - Everything in one prompt
+let prompt = """
+ Generate a 7-day itinerary for Tokyo including hotels, restaurants,
+ activities for each day, transportation details, budget breakdown...
+ """
+// Exceeds context, poor quality
+```
+
+**Correct approach**: Break into smaller tasks, use tools for external data, multi-turn conversation.
+
+---
+
+### ❌ Not Handling Generation Errors
+**Why it fails**: Three errors MUST be handled or your app will crash in production.
+
+```swift
+do {
+ let response = try await session.respond(to: prompt)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // Multi-turn transcript grew beyond 4096 tokens
+ // → Condense transcript and create new session (see Pattern 5)
+} catch LanguageModelSession.GenerationError.guardrailViolation {
+ // Content policy triggered
+ // → Show graceful message: "I can't help with that request"
+} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale {
+ // User input in unsupported language
+ // → Show disclaimer, check SystemLanguageModel.default.supportedLanguages
+}
+```
+
+---
+
+## Mandatory First Steps
+
+Before writing any Foundation Models code, complete these steps:
+
+### 1. Check Availability
+
+See "Ignoring Availability Check" in Red Flags above for the required pattern. Foundation Models requires Apple Intelligence-enabled device, supported region, and user opt-in.
+
+---
+
+### 2. Identify Use Case
+**Ask yourself**: What is my primary goal?
+
+| Use Case | Foundation Models? | Alternative |
+|----------|-------------------|-------------|
+| Summarization | ✅ YES | |
+| Extraction (key info from text) | ✅ YES | |
+| Classification (categorize content) | ✅ YES | |
+| Content tagging | ✅ YES (built-in adapter!) | |
+| World knowledge | ❌ NO | ChatGPT, Claude, Gemini |
+| Complex reasoning | ❌ NO | Server LLMs |
+| Mathematical computation | ❌ NO | Calculator, symbolic math |
+
+**Critical**: If your use case requires world knowledge or advanced reasoning, **stop**. Foundation Models is the wrong tool.
+
+---
+
+### 3. Design @Generable Schema
+If you need structured output (not just plain text):
+
+**Bad approach**: Prompt for "JSON" and parse manually
+**Good approach**: Define @Generable type
+
+```swift
+@Generable
+struct SearchSuggestions {
+ @Guide(description: "Suggested search terms", .count(4))
+ var searchTerms: [String]
+}
+```
+
+**Why**: Constrained decoding guarantees structure. No parsing errors, no hallucinated keys.
+
+---
+
+### 4. Consider Tools for External Data
+If your feature needs external information:
+- Weather → WeatherKit tool
+- Locations → MapKit tool
+- Contacts → Contacts API tool
+- Calendar → EventKit tool
+
+**Don't** try to get this information from the model (it will hallucinate).
+**Do** define Tool protocol implementations.
+
+---
+
+### 5. Plan Streaming for Long Generations
+If generation takes >1 second, use streaming:
+
+```swift
+let stream = session.streamResponse(
+ to: prompt,
+ generating: Itinerary.self
+)
+
+for try await partial in stream {
+ // Update UI incrementally
+ self.itinerary = partial
+}
+```
+
+**Why**: Users see progress immediately, perceived latency drops dramatically.
+
+---
+
+## Decision Tree
+
+```
+Need on-device AI?
+│
+├─ World knowledge/reasoning?
+│ └─ ❌ NOT Foundation Models
+│ → Use ChatGPT, Claude, Gemini, etc.
+│ → Reason: 3B parameter model, not trained for encyclopedic knowledge
+│
+├─ Summarization?
+│ └─ ✅ YES → Pattern 1 (Basic Session)
+│ → Example: Summarize article, condense email
+│ → Time: 10-15 minutes
+│
+├─ Structured extraction?
+│ └─ ✅ YES → Pattern 2 (@Generable)
+│ → Example: Extract name, date, amount from invoice
+│ → Time: 15-20 minutes
+│
+├─ Content tagging?
+│ └─ ✅ YES → Pattern 3 (contentTagging use case)
+│ → Example: Tag article topics, extract entities
+│ → Time: 10 minutes
+│
+├─ Need external data?
+│ └─ ✅ YES → Pattern 4 (Tool calling)
+│ → Example: Fetch weather, query contacts, get locations
+│ → Time: 20-30 minutes
+│
+├─ Long generation?
+│ └─ ✅ YES → Pattern 5 (Streaming)
+│ → Example: Generate itinerary, create story
+│ → Time: 15-20 minutes
+│
+└─ Dynamic schemas (runtime-defined structure)?
+ └─ ✅ YES → Pattern 6 (DynamicGenerationSchema)
+ → Example: Level creator, user-defined forms
+ → Time: 30-40 minutes
+```
+
+---
+
+## Pattern 1: Basic Session
+
+**Use when**: Simple text generation, summarization, or content analysis.
+
+### Core Concepts
+
+**LanguageModelSession**:
+- Stateful — retains transcript of all interactions
+- Instructions vs prompts:
+ - **Instructions** (from developer): Define model's role, static guidance
+ - **Prompts** (from user): Dynamic input for generation
+- Model trained to obey instructions over prompts (security feature)
+
+### Implementation
+
+```swift
+import FoundationModels
+
+func respond(userInput: String) async throws -> String {
+ let session = LanguageModelSession(instructions: """
+ You are a friendly barista in a pixel art coffee shop.
+ Respond to the player's question concisely.
+ """
+ )
+ let response = try await session.respond(to: userInput)
+ return response.content
+}
+```
+
+### Key Points
+
+1. **Instructions are optional** — Reasonable defaults if omitted
+2. **Never interpolate user input into instructions** — Security risk (prompt injection)
+3. **Keep instructions concise** — Each token adds latency
+
+### Multi-Turn Interactions
+
+```swift
+let session = LanguageModelSession()
+
+// First turn
+let first = try await session.respond(to: "Write a haiku about fishing")
+print(first.content)
+// "Silent waters gleam,
+// Casting lines in morning mist—
+// Hope in every cast."
+
+// Second turn - model remembers context
+let second = try await session.respond(to: "Do another one about golf")
+print(second.content)
+// "Silent morning dew,
+// Caddies guide with gentle words—
+// Paths of patience tread."
+
+// Inspect full transcript
+print(session.transcript)
+```
+
+**Why this works**: Session retains transcript automatically. Model uses context from previous turns.
+
+### When to Use This Pattern
+
+✅ **Good for**:
+- Simple Q&A
+- Text summarization
+- Content analysis
+- Single-turn generation
+
+❌ **Not good for**:
+- Structured output (use Pattern 2)
+- Long conversations (will hit context limit)
+- External data needs (use Pattern 4)
+
+---
+
+## Pattern 2: @Generable Structured Output
+
+**Use when**: You need structured data from model, not just plain text.
+
+### The Problem
+
+Without @Generable:
+```swift
+// ❌ BAD - Unreliable
+let prompt = "Generate a person with name and age as JSON"
+let response = try await session.respond(to: prompt)
+// Might get: {"firstName": "John"} when you expect {"name": "John"}
+// Might get invalid JSON entirely
+// Must parse manually, prone to crashes
+```
+
+### The Solution: @Generable
+
+```swift
+@Generable
+struct Person {
+ let name: String
+ let age: Int
+}
+
+let session = LanguageModelSession()
+let response = try await session.respond(
+ to: "Generate a person",
+ generating: Person.self
+)
+
+let person = response.content // Type-safe Person instance!
+```
+
+### How It Works (Constrained Decoding)
+
+1. `@Generable` macro generates schema at compile-time
+2. Schema passed to model automatically
+3. Model generates tokens constrained by schema
+4. Framework parses output into Swift type
+5. **Guaranteed structural correctness** — No hallucinated keys, no parsing errors
+
+"Constrained decoding masks out invalid tokens. Model can only pick tokens valid according to schema."
+
+### Supported Types
+
+Supports `String`, `Int`, `Float`, `Double`, `Bool`, arrays, nested `@Generable` types, enums with associated values, and recursive types. See `axiom-foundation-models-ref` for complete list with examples.
+
+### @Guide Constraints
+
+Control generated values with `@Guide`. Supports descriptions, numeric ranges, array counts, and regex patterns:
+
+```swift
+@Generable
+struct NPC {
+ @Guide(description: "A full name")
+ let name: String
+
+ @Guide(.range(1...10))
+ let level: Int
+
+ @Guide(.count(3))
+ let attributes: [String]
+}
+```
+
+**Runtime validation**: `@Guide` constraints are enforced during generation via constrained decoding — the model cannot produce out-of-range values. However, always validate business logic on the result since the model may produce semantically wrong but structurally valid output.
+
+See `axiom-foundation-models-ref` for complete `@Guide` reference (ranges, regex, maximum counts).
+
+### Property Order Matters
+
+Properties generated **in declaration order**:
+```swift
+@Generable
+struct Itinerary {
+ var destination: String // Generated first
+ var days: [DayPlan] // Generated second
+ var summary: String // Generated last
+}
+```
+
+"You may find model produces best summaries when they're last property."
+
+**Why**: Later properties can reference earlier ones. Put most important properties first for streaming.
+
+---
+
+## Pattern 3: Streaming with PartiallyGenerated
+
+**Use when**: Generation takes >1 second and you want progressive UI updates.
+
+### The Problem
+
+Without streaming:
+```swift
+// User waits 3-5 seconds seeing nothing
+let response = try await session.respond(to: prompt, generating: Itinerary.self)
+// Then entire result appears at once
+```
+
+**User experience**: Feels slow, frozen UI.
+
+### The Solution: Streaming
+
+```swift
+@Generable
+struct Itinerary {
+ var name: String
+ var days: [DayPlan]
+}
+
+let stream = session.streamResponse(
+ to: "Generate a 3-day itinerary to Mt. Fuji",
+ generating: Itinerary.self
+)
+
+for try await partial in stream {
+ print(partial) // Incrementally updated
+}
+```
+
+### PartiallyGenerated Type
+
+`@Generable` macro automatically creates a `PartiallyGenerated` type where all properties are optional (they fill in as the model generates them). See `axiom-foundation-models-ref` for details.
+
+### SwiftUI Integration
+
+```swift
+struct ItineraryView: View {
+ let session: LanguageModelSession
+ @State private var itinerary: Itinerary.PartiallyGenerated?
+
+ var body: some View {
+ VStack {
+ if let name = itinerary?.name {
+ Text(name)
+ .font(.title)
+ }
+
+ if let days = itinerary?.days {
+ ForEach(days, id: \.self) { day in
+ DayView(day: day)
+ }
+ }
+
+ Button("Generate") {
+ Task {
+ let stream = session.streamResponse(
+ to: "Generate 3-day itinerary to Tokyo",
+ generating: Itinerary.self
+ )
+
+ for try await partial in stream {
+ self.itinerary = partial
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+### View Identity
+
+**Critical for arrays**:
+```swift
+// ✅ GOOD - Stable identity
+ForEach(days, id: \.id) { day in
+ DayView(day: day)
+}
+
+// ❌ BAD - Identity changes, animations break
+ForEach(days.indices, id: \.self) { index in
+ DayView(day: days[index])
+}
+```
+
+### When to Use Streaming
+
+✅ **Use for**:
+- Itineraries
+- Stories
+- Long descriptions
+- Multi-section content
+
+❌ **Skip for**:
+- Simple Q&A (< 1 sentence)
+- Quick classification
+- Content tagging
+
+### Streaming Error Handling
+
+Handle errors during streaming gracefully — partial results may already be displayed:
+
+```swift
+do {
+ for try await partial in stream {
+ self.itinerary = partial
+ }
+} catch LanguageModelSession.GenerationError.guardrailViolation {
+ // Partial content may be visible — show non-disruptive error
+ self.errorMessage = "Generation stopped by content policy"
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // Too much context — create fresh session and retry
+ session = LanguageModelSession()
+}
+```
+
+---
+
+## Pattern 4: Tool Calling
+
+**Use when**: Model needs external data (weather, locations, contacts) to generate response.
+
+### The Problem
+
+```swift
+// ❌ BAD - Model will hallucinate
+let response = try await session.respond(
+ to: "What's the temperature in Cupertino?"
+)
+// Output: "It's about 72°F" (completely made up!)
+```
+
+**Why**: 3B parameter model doesn't have real-time weather data.
+
+### The Solution: Tool Calling
+
+Let model **autonomously call your code** to fetch external data.
+
+```swift
+import FoundationModels
+import WeatherKit
+import CoreLocation
+
+struct GetWeatherTool: Tool {
+ let name = "getWeather"
+ let description = "Retrieve latest weather for a city"
+
+ @Generable
+ struct Arguments {
+ @Guide(description: "The city to fetch weather for")
+ var city: String
+ }
+
+ func call(arguments: Arguments) async throws -> ToolOutput {
+ let places = try await CLGeocoder().geocodeAddressString(arguments.city)
+ let weather = try await WeatherService.shared.weather(for: places.first!.location!)
+ let temp = weather.currentWeather.temperature.value
+
+ return ToolOutput("\(arguments.city)'s temperature is \(temp) degrees.")
+ }
+}
+```
+
+### Attaching Tool to Session
+
+```swift
+let session = LanguageModelSession(
+ tools: [GetWeatherTool()],
+ instructions: "Help user with weather forecasts."
+)
+
+let response = try await session.respond(
+ to: "What's the temperature in Cupertino?"
+)
+
+print(response.content)
+// "It's 71°F in Cupertino!"
+```
+
+**Model autonomously**:
+1. Recognizes it needs weather data
+2. Calls `GetWeatherTool`
+3. Receives real temperature
+4. Incorporates into natural response
+
+### Key Concepts
+
+- **Tool protocol**: Requires `name`, `description`, `@Generable Arguments`, and `call()` method
+- **ToolOutput**: Return `String` (natural language) or `GeneratedContent` (structured)
+- **Multiple tools**: Session accepts array of tools; model autonomously decides which to call
+- **Stateful tools**: Use `class` (not `struct`) when tools need to maintain state across calls
+
+See `axiom-foundation-models-ref` for `Tool` protocol reference, `ToolOutput` forms, stateful tool patterns, and additional examples.
+
+### Tool Calling Flow
+
+```
+1. Session initialized with tools
+2. User prompt: "What's Tokyo's weather?"
+3. Model analyzes: "Need weather data"
+4. Model generates tool call: getWeather(city: "Tokyo")
+5. Framework calls your tool's call() method
+6. Your tool fetches real data from API
+7. Tool output inserted into transcript
+8. Model generates final response using tool output
+```
+
+"Model decides autonomously when and how often to call tools. Can call multiple tools per request, even in parallel."
+
+### Tool Calling Guarantees
+
+✅ **Guaranteed**:
+- Valid tool names (no hallucinated tools)
+- Valid arguments (via @Generable)
+- Structural correctness
+
+❌ **Not guaranteed**:
+- Tool will be called (model might not need it)
+- Specific argument values (model decides based on context)
+
+### When to Use Tools
+
+✅ **Use for**:
+- Weather data
+- Map/location queries
+- Contact information
+- Calendar events
+- External APIs
+
+❌ **Don't use for**:
+- Data model already has
+- Information in prompt/instructions
+- Simple calculations (model can do these)
+
+---
+
+## Pattern 5: Context Management
+
+**Use when**: Multi-turn conversations that might exceed 4096 token limit.
+
+### The Problem
+
+```swift
+// Long conversation...
+for i in 1...100 {
+ let response = try await session.respond(to: "Question \(i)")
+ // Eventually...
+ // Error: exceededContextWindowSize
+}
+```
+
+**Context window**: 4096 tokens (input + output combined)
+**Average**: ~3 characters per token in English
+
+**Rough calculation**:
+- 4096 tokens ≈ 12,000 characters
+- ≈ 2,000-3,000 words total
+
+**Long conversation** or **verbose prompts/responses** → Exceed limit
+
+### Handling Context Overflow
+
+#### Basic: Start fresh session
+```swift
+var session = LanguageModelSession()
+
+do {
+ let response = try await session.respond(to: prompt)
+ print(response.content)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // New session, no history
+ session = LanguageModelSession()
+}
+```
+
+**Problem**: Loses entire conversation history.
+
+### Better: Condense Transcript
+
+```swift
+var session = LanguageModelSession()
+
+do {
+ let response = try await session.respond(to: prompt)
+} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
+ // New session with condensed history
+ session = condensedSession(from: session)
+}
+
+func condensedSession(from previous: LanguageModelSession) -> LanguageModelSession {
+ let allEntries = previous.transcript.entries
+ var condensedEntries = [Transcript.Entry]()
+
+ // Always include first entry (instructions)
+ if let first = allEntries.first {
+ condensedEntries.append(first)
+
+ // Include last entry (most recent context)
+ if allEntries.count > 1, let last = allEntries.last {
+ condensedEntries.append(last)
+ }
+ }
+
+ let condensedTranscript = Transcript(entries: condensedEntries)
+ return LanguageModelSession(transcript: condensedTranscript)
+}
+```
+
+**Why this works**:
+- Instructions always preserved
+- Recent context retained
+- Total tokens drastically reduced
+
+For advanced strategies (summarizing middle entries with Foundation Models itself), see `axiom-foundation-models-ref`.
+
+### Preventing Context Overflow
+
+**1. Keep prompts concise**:
+```swift
+// ❌ BAD
+let prompt = """
+ I want you to generate a comprehensive detailed analysis of this article
+ with multiple sections including summary, key points, sentiment analysis,
+ main arguments, counter arguments, logical fallacies, and conclusions...
+ """
+
+// ✅ GOOD
+let prompt = "Summarize this article's key points"
+```
+
+**2. Use tools for data**:
+Instead of putting entire dataset in prompt, use tools to fetch on-demand.
+
+**3. Break complex tasks into steps**:
+```swift
+// ❌ BAD - One massive generation
+let response = try await session.respond(
+ to: "Create 7-day itinerary with hotels, restaurants, activities..."
+)
+
+// ✅ GOOD - Multiple smaller generations
+let overview = try await session.respond(to: "Create high-level 7-day plan")
+for day in 1...7 {
+ let details = try await session.respond(to: "Detail activities for day \(day)")
+}
+```
+
+---
+
+## Pattern 6: Sampling & Generation Options
+
+**Use when**: You need control over output randomness/determinism.
+
+### When to Adjust Sampling
+
+| Goal | Setting | Use Cases |
+|------|---------|-----------|
+| Deterministic | `GenerationOptions(sampling: .greedy)` | Unit tests, demos, consistency-critical |
+| Focused | `GenerationOptions(temperature: 0.5)` | Fact extraction, classification |
+| Creative | `GenerationOptions(temperature: 2.0)` | Story generation, brainstorming, varied NPC dialog |
+
+**Default**: Random sampling (temperature 1.0) gives balanced results.
+
+**Caveat**: Greedy determinism only holds for same model version. OS updates may change output.
+
+See `axiom-foundation-models-ref` for complete `GenerationOptions` API reference.
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Just Use ChatGPT API"
+
+**Context**: You're implementing a new AI feature. PM suggests using ChatGPT API for "better results."
+
+**Pressure signals**:
+- 👔 **Authority**: PM outranks you
+- 💸 **Existing integration**: Team already uses OpenAI for other features
+- ⏰ **Speed**: "ChatGPT is proven, Foundation Models is new"
+
+**Rationalization traps**:
+- "PM knows best"
+- "ChatGPT gives better answers"
+- "Faster to implement with existing code"
+
+**Why this fails**:
+
+1. **Privacy violation**: User data sent to external server
+ - Medical notes, financial docs, personal messages
+ - Violates user expectation of on-device privacy
+ - Potential GDPR/privacy law issues
+
+2. **Cost**: Every API call costs money
+ - Foundation Models is **free**
+ - Scale to millions of users = massive costs
+
+3. **Offline unavailable**: Requires internet
+ - Airplane mode, poor signal → feature broken
+ - Foundation Models works offline
+
+4. **Latency**: Network round-trip adds 500-2000ms
+ - Foundation Models: On-device, <100ms startup
+
+**When ChatGPT IS appropriate**:
+- World knowledge required (e.g. "Who is the president of France?")
+- Complex reasoning (multi-step logic, math proofs)
+- Very long context (>4096 tokens)
+
+**Mandatory response**:
+
+```
+"I understand ChatGPT delivers great results for certain tasks. However,
+for this feature, Foundation Models is the right choice for three critical reasons:
+
+1. **Privacy**: This feature processes [medical notes/financial data/personal content].
+ Users expect this data stays on-device. Sending to external API violates that trust
+ and may have compliance issues.
+
+2. **Cost**: At scale, ChatGPT API calls cost $X per 1000 requests. Foundation Models
+ is free. For Y million users, that's $Z annually we can avoid.
+
+3. **Offline capability**: Foundation Models works without internet. Users in airplane
+ mode or with poor signal still get full functionality.
+
+**When to use ChatGPT**: If this feature required world knowledge or complex reasoning,
+ChatGPT would be the right choice. But this is [summarization/extraction/classification],
+which is exactly what Foundation Models is optimized for.
+
+**Time estimate**: Foundation Models implementation: 15-20 minutes.
+Privacy compliance review for ChatGPT: 2-4 weeks."
+```
+
+**Time saved**: Privacy compliance review vs correct implementation: 2-4 weeks vs 20 minutes
+
+---
+
+### Scenario 2: "Parse JSON Manually"
+
+**Context**: Teammate suggests prompting for JSON, parsing with JSONDecoder. Claims it's "simple and familiar."
+
+**Pressure signals**:
+- ⏰ **Deadline**: Ship in 2 days
+- 📚 **Familiarity**: "Everyone knows JSON"
+- 🔧 **Existing code**: Already have JSON parsing utilities
+
+**Rationalization traps**:
+- "JSON is standard"
+- "We parse JSON everywhere already"
+- "Faster than learning new API"
+
+**Why this fails**:
+
+1. **Hallucinated keys**: Model outputs `{firstName: "John"}` when you expect `{name: "John"}`
+ - JSONDecoder crashes: `keyNotFound`
+ - No compile-time safety
+
+2. **Invalid JSON**: Model might output:
+ ```
+ Here's the person: {name: "John", age: 30}
+ ```
+ - Not valid JSON (preamble text)
+ - Parsing fails
+
+3. **No type safety**: Manual string parsing, prone to errors
+
+**Real-world example**:
+```swift
+// ❌ BAD - Will fail
+let prompt = "Generate a person with name and age as JSON"
+let response = try await session.respond(to: prompt)
+
+// Model outputs: {"firstName": "John Smith", "years": 30}
+// Your code expects: {"name": ..., "age": ...}
+// CRASH: keyNotFound(name)
+```
+
+**Debugging time**: 2-4 hours finding edge cases, writing parsing hacks
+
+**Correct approach**:
+```swift
+// ✅ GOOD - 15 minutes, guaranteed to work
+@Generable
+struct Person {
+ let name: String
+ let age: Int
+}
+
+let response = try await session.respond(
+ to: "Generate a person",
+ generating: Person.self
+)
+// response.content is type-safe Person, always valid
+```
+
+**Mandatory response**:
+
+```
+"I understand JSON parsing feels familiar, but for LLM output, @Generable is objectively
+better for three technical reasons:
+
+1. **Constrained decoding guarantees structure**: Model can ONLY generate valid Person
+ instances. Impossible to get wrong keys, invalid JSON, or missing fields.
+
+2. **No parsing code needed**: Framework handles parsing automatically. Zero chance of
+ parsing bugs.
+
+3. **Compile-time safety**: If we change Person struct, compiler catches all issues.
+ Manual JSON parsing = runtime crashes.
+
+**Real cost**: Manual JSON approach will hit edge cases. Debugging 'keyNotFound' crashes
+takes 2-4 hours. @Generable implementation takes 15 minutes and has zero parsing bugs.
+
+**Analogy**: This is like choosing Swift over Objective-C for new code. Both work, but
+Swift's type safety prevents entire categories of bugs."
+```
+
+**Time saved**: 4-8 hours debugging vs 15 minutes correct implementation
+
+---
+
+### Scenario 3: "One Big Prompt"
+
+**Context**: Feature requires extracting name, date, amount, category from invoice. Teammate suggests one prompt: "Extract all information."
+
+**Pressure signals**:
+- 🏗️ **Architecture**: "Simpler with one API call"
+- ⏰ **Speed**: "Why make it complicated?"
+- 📉 **Complexity**: "More prompts = more code"
+
+**Rationalization traps**:
+- "Simpler is better"
+- "One prompt means less code"
+- "Model is smart enough"
+
+**Why this fails**:
+
+1. **Context overflow**: Complex prompt + large invoice → Exceeds 4096 tokens
+2. **Poor results**: Model tries to do too much at once, quality suffers
+3. **Slow generation**: One massive response takes 5-8 seconds
+4. **All-or-nothing**: If one field fails, entire generation fails
+
+**Better approach**: Break into tasks + use tools
+
+```swift
+// ❌ BAD - One massive prompt
+let prompt = """
+ Extract from this invoice:
+ - Vendor name
+ - Invoice date
+ - Total amount
+ - Line items (description, quantity, price each)
+ - Payment terms
+ - Due date
+ - Tax amount
+ ...
+ """
+// 4 seconds, poor quality, might exceed context
+
+// ✅ GOOD - Structured extraction with focused prompts
+@Generable
+struct InvoiceBasics {
+ let vendor: String
+ let date: String
+ let amount: Double
+}
+
+let basics = try await session.respond(
+ to: "Extract vendor, date, and amount",
+ generating: InvoiceBasics.self
+) // 0.5 seconds, axiom-high quality
+
+@Generable
+struct LineItem {
+ let description: String
+ let quantity: Int
+ let price: Double
+}
+
+let items = try await session.respond(
+ to: "Extract line items",
+ generating: [LineItem].self
+) // 1 second, axiom-high quality
+
+// Total: 1.5 seconds, better quality, graceful partial failures
+```
+
+**Mandatory response**:
+
+```
+"I understand the appeal of one simple API call. However, this specific task requires
+a different approach:
+
+1. **Context limits**: Invoice + complex extraction prompt will likely exceed 4096 token
+ limit. Multiple focused prompts stay well under limit.
+
+2. **Better quality**: Model performs better with focused tasks. 'Extract vendor name'
+ gets 95%+ accuracy. 'Extract everything' gets 60-70%.
+
+3. **Faster perceived performance**: Multiple prompts with streaming show progressive
+ results. Users see vendor name in 0.5s, not waiting 5s for everything.
+
+4. **Graceful degradation**: If line items fail, we still have basics. All-or-nothing
+ approach means total failure.
+
+**Implementation**: Breaking into 3-4 focused extractions takes 30 minutes. One big
+prompt takes 2-3 hours debugging why it hits context limit and produces poor results."
+```
+
+**Time saved**: 2-3 hours debugging vs 30 minutes proper design
+
+---
+
+## Performance Optimization
+
+### Key Optimizations
+
+1. **Prewarm session**: Create `LanguageModelSession` at init, not when user taps button. Saves 1-2 seconds off first generation.
+
+2. **`includeSchemaInPrompt: false`**: For subsequent requests with the same `@Generable` type, set this in `GenerationOptions` to reduce token count by 10-20%.
+
+3. **Property order for streaming**: Put most important properties first in `@Generable` structs. User sees title in 0.2s instead of waiting 2.5s for full generation.
+
+4. **Foundation Models Instrument**: Use `Instruments > Foundation Models` template to profile latency, see token counts, and identify optimization opportunities.
+
+See `axiom-foundation-models-ref` for code examples of each optimization.
+
+---
+
+## Checklist
+
+Before shipping Foundation Models features:
+
+### Required Checks
+- [ ] **Availability checked** before creating session
+- [ ] **Using @Generable** for structured output (not manual JSON)
+- [ ] **Handling context overflow** (`exceededContextWindowSize`)
+- [ ] **Handling guardrail violations** (`guardrailViolation`)
+- [ ] **Handling unsupported language** (`unsupportedLanguageOrLocale`)
+- [ ] **Streaming for long generations** (>1 second)
+- [ ] **Not blocking UI** (using `Task {}` for async)
+- [ ] **Tools for external data** (not prompting for weather/locations)
+- [ ] **Prewarmed session** if latency-sensitive
+
+### Best Practices
+- [ ] Instructions are concise (not verbose)
+- [ ] Never interpolating user input into instructions
+- [ ] Property order optimized for streaming UX
+- [ ] Using appropriate temperature/sampling
+- [ ] Tested on real device (not just simulator)
+- [ ] Profiled with Instruments (Foundation Models template)
+- [ ] Error handling shows graceful UI messages
+- [ ] Tested offline (airplane mode)
+- [ ] Tested with long conversations (context handling)
+
+### Model Capability
+- [ ] **Not** using for world knowledge
+- [ ] **Not** using for complex reasoning
+- [ ] Use case is: summarization, extraction, classification, or generation
+- [ ] Have fallback if unavailable (show message, disable feature)
+
+---
+
+## Resources
+
+**WWDC**: 286, 259, 301
+
+**Skills**: axiom-foundation-models-diag, axiom-foundation-models-ref
+
+---
+
+**Last Updated**: 2025-12-03
+**Version**: 1.0.0
+**Target**: iOS 26+, macOS 26+, iPadOS 26+, axiom-visionOS 26+
diff --git a/.claude/skills/axiom-foundation-models/agents/openai.yaml b/.claude/skills/axiom-foundation-models/agents/openai.yaml
new file mode 100644
index 0000000..2fc21f2
--- /dev/null
+++ b/.claude/skills/axiom-foundation-models/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Foundation Models"
+ short_description: "Implementing on-device AI with Apple's Foundation Models framework"
diff --git a/.claude/skills/axiom-getting-started/.openskills.json b/.claude/skills/axiom-getting-started/.openskills.json
new file mode 100644
index 0000000..d368e81
--- /dev/null
+++ b/.claude/skills/axiom-getting-started/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-getting-started",
+ "installedAt": "2026-04-12T08:06:19.539Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-getting-started/SKILL.md b/.claude/skills/axiom-getting-started/SKILL.md
new file mode 100644
index 0000000..066e4c6
--- /dev/null
+++ b/.claude/skills/axiom-getting-started/SKILL.md
@@ -0,0 +1,319 @@
+---
+name: axiom-getting-started
+description: Use when first installing Axiom, unsure which skill to use, want an overview of available skills, or need help finding the right skill for your situation — interactive onboarding that recommends skills based on your project and current focus
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Getting Started with Axiom
+
+Welcome! This skill helps new users discover the most relevant Axiom skills for their situation.
+
+## How This Skill Works
+
+1. Ask the user 2-3 targeted questions about their project
+2. Provide personalized skill recommendations (3-5 skills max)
+3. Show example prompts they can try immediately
+4. Include a complete skill reference for browsing
+
+## Step 1: Ask Questions
+
+Use the AskUserQuestion tool to gather context:
+
+### Question 1: Current Focus
+
+```
+Question: "What brings you to Axiom today?"
+Header: "Focus"
+Options:
+- "Debugging an issue" → Prioritize diagnostic skills
+- "Optimizing performance" → Prioritize profiling skills
+- "Adding new features" → Prioritize reference skills
+- "Code review / quality check" → Prioritize audit commands
+- "Just exploring" → Show overview
+```
+
+### Question 2: Tech Stack
+
+```
+Question: "What's your primary tech stack?"
+Header: "Stack"
+Options:
+- "SwiftUI (iOS 16+)" → SwiftUI-focused skills
+- "UIKit" → UIKit-focused skills
+- "Mixed SwiftUI + UIKit" → Both
+- "Starting new project" → Best practices skills
+```
+
+### Question 3: Pain Points (Optional, Multi-Select)
+
+Only ask if "Debugging an issue" was selected:
+
+```
+Question: "Which areas are you struggling with?"
+Header: "Pain Points"
+Multi-select: true
+Options:
+- "Xcode/build issues"
+- "Memory leaks"
+- "UI/animation problems"
+- "Database/persistence"
+- "Networking"
+- "Concurrency/async"
+- "Accessibility"
+```
+
+## Step 2: Provide Personalized Recommendations
+
+Based on answers, recommend 3-5 skills using this matrix:
+
+### If "Debugging an issue"
+
+**Always recommend**: axiom:xcode-debugging (universal starting point)
+
+**Then add based on pain points**:
+- Xcode/build → xcode-debugging, axiom-build-debugging
+- Memory leaks → memory-debugging, axiom-objc-block-retain-cycles
+- UI/animation (SwiftUI) → swiftui-debugging, axiom-swiftui-performance
+- UI/animation (UIKit) → uikit-animation-debugging, axiom-auto-layout-debugging
+- Database → database-migration, axiom-sqlitedata-migration (decision guide)
+- Networking → networking, axiom-networking-diag
+- Concurrency → swift-concurrency
+- Accessibility → accessibility-diag
+
+### If "Optimizing performance"
+
+**SwiftUI stack**:
+1. performance-profiling (decision trees for tools)
+2. swiftui-performance (SwiftUI Instrument)
+3. swiftui-debugging (view update issues)
+
+**UIKit/Mixed**:
+1. performance-profiling (Instruments guide)
+2. memory-debugging (leak detection)
+3. uikit-animation-debugging (CAAnimation issues)
+
+### If "Adding new features"
+
+**Design decisions**:
+- hig (quick design decisions, checklists)
+- hig-ref (comprehensive HIG reference)
+
+**iOS 26+ features**:
+- liquid-glass (material design system)
+- foundation-models (on-device AI)
+- swiftui-26-ref (complete iOS 26 guide)
+
+**Navigation patterns**:
+- swiftui-nav (iOS 18+ Tab/Sidebar, deep linking)
+- swiftui-nav-ref (comprehensive API reference)
+
+**Integrations**:
+- app-intents-ref (Siri, Shortcuts, Spotlight)
+- networking (Network.framework modern patterns)
+
+**Data persistence**:
+- Ask: "Which persistence framework?" → swiftdata, axiom-sqlitedata, or grdb
+- Migration: axiom-sqlitedata-migration, axiom-realm-migration-ref
+
+### If "Code review / quality check"
+
+**Start with audit commands** (quick wins):
+1. `/axiom:audit-accessibility` — WCAG compliance
+2. `/axiom:audit-concurrency` — Swift 6 violations
+3. `/axiom:audit-memory` — Leak patterns
+4. `/axiom:audit-core-data` — Migration safety
+5. `/axiom:audit-networking` — Deprecated APIs
+
+**Then suggest**:
+- Review skills based on what audits find
+
+### If "Just exploring"
+
+Show the complete skill index (see below) and explain categories.
+
+## Step 3: Output Format
+
+After gathering answers, output:
+
+```markdown
+## Your Recommended Skills
+
+Based on your answers, here are the skills most relevant to you right now:
+
+### [Icon] [Category Name]
+**axiom:[skill-name]** — [One-line description]
+> Try: "[Example prompt they can use immediately]"
+
+[Repeat for 3-5 skills]
+
+### Quick Wins
+Run these audit commands to find issues automatically:
+- `/axiom:audit-[name]` — [What it finds]
+
+## What's Next
+
+1. **Try the example prompts above** — Copy/paste to see how skills work
+2. **Run an audit command** — Get immediate actionable insights
+3. **Describe your problem** — I'll suggest the right skill
+4. **Browse the complete index below** — Explore all 34 skills
+
+---
+
+[Include the Complete Skill Reference below]
+```
+
+## Complete Skill Reference
+
+Include this reference section in every response for browsing:
+
+### Debugging & Troubleshooting
+
+**Environment & Build Issues**
+- **xcode-debugging** — BUILD FAILED, simulator hangs, zombie processes, environment-first diagnostics
+- **build-debugging** — Dependency conflicts, CocoaPods/SPM failures, Multiple commands produce
+
+**Memory & Performance**
+- **memory-debugging** — Memory growth, retain cycles, leak diagnosis with Instruments
+- **performance-profiling** — Decision trees for Instruments (Time Profiler, Allocations, Core Data, Energy)
+- **objc-block-retain-cycles** — Objective-C block memory leaks, weak-strong pattern
+
+**UI Debugging**
+- **swiftui-debugging** — View update issues, struct mutation, binding identity, view recreation
+- **swiftui-performance** — SwiftUI Instrument (iOS 26), long view bodies, Cause & Effect Graph
+- **uikit-animation-debugging** — CAAnimation completion, spring physics, gesture+animation jank
+- **auto-layout-debugging** — Auto Layout conflicts, constraint debugging (not yet in manifest)
+
+### Concurrency & Async
+- **swift-concurrency** — Swift 6 strict concurrency, @concurrent, actor isolation, Sendable, data races
+
+### UI & Design (iOS 26+)
+
+**Liquid Glass (Material Design)**
+- **liquid-glass** — Implementation, Regular vs Clear variants, design review defense
+- **liquid-glass-ref** — Complete app-wide adoption guide (icons, controls, navigation, windows)
+
+**Layout & Navigation**
+- **swiftui-layout** — ViewThatFits vs AnyLayout vs onGeometryChange, decision trees, iOS 26 free-form windows
+- **swiftui-layout-ref** — Complete layout API reference
+- **swiftui-nav** — NavigationStack vs NavigationSplitView, deep links, coordinator patterns, iOS 18+ Tab/Sidebar
+- **swiftui-nav-ref** — Comprehensive navigation API reference
+- **swiftui-nav-diag** — Navigation not responding, unexpected pops, deep link failures, state loss
+
+### Testing
+- **ui-testing** — Recording UI Automation (Xcode 26), condition-based waiting, accessibility-first patterns
+
+### Persistence
+
+**Frameworks**
+- **swiftdata** — @Model, @Query, @Relationship, CloudKit, iOS 26 features, Swift 6 concurrency
+- **sqlitedata** — Point-Free SQLiteData, @Table, FTS5, CTEs, JSON aggregation, CloudKit sync
+- **grdb** — Raw SQL, complex joins, ValueObservation, DatabaseMigrator, performance
+- **database-migration** — Safe schema evolution for SQLite/GRDB, additive migrations, prevents data loss
+
+**Migration Guides**
+- **sqlitedata-migration** — Decision guide, pattern equivalents, performance benchmarks
+- **realm-migration-ref** — Realm → SwiftData migration (Realm Device Sync sunset Sept 2025)
+
+### Networking
+- **networking** — Network.framework (iOS 12-26), NetworkConnection (iOS 26), structured concurrency
+- **networking-diag** — Connection timeouts, TLS failures, data not arriving, performance issues
+- **network-framework-ref** — Complete API reference, TLV framing, Coder protocol, Wi-Fi Aware
+
+### Apple Intelligence (iOS 26+)
+- **foundation-models** — On-device AI, LanguageModelSession, @Generable, streaming, tool calling
+- **foundation-models-diag** — Context exceeded, guardrails, slow generation, availability issues
+- **foundation-models-ref** — Complete API reference, all 26 WWDC examples
+
+### Design & UI Guidelines
+- **hig** — Quick design decisions, color/background/typography choices, HIG compliance checklists
+- **hig-ref** — Comprehensive Human Interface Guidelines reference with code examples
+
+### Integrations
+- **app-intents-ref** — Siri, Apple Intelligence, Shortcuts, Spotlight (iOS 16+)
+- **swiftui-26-ref** — iOS 26 SwiftUI features, @Animatable, 3D layout, WebView, AttributedString
+- **avfoundation-ref** — Audio APIs, bit-perfect DAC, iOS 26 spatial audio, ASAF/APAC
+
+### Diagnostics (Systematic Troubleshooting)
+- **accessibility-diag** — VoiceOver, Dynamic Type, color contrast, WCAG compliance, App Store defense
+- **core-data-diag** — Schema migration crashes, thread-confinement, N+1 queries
+
+### Audit Commands (Quick Scans)
+- `/axiom:audit-accessibility` — VoiceOver labels, Dynamic Type, contrast, touch targets
+- `/axiom:audit-concurrency` — Swift 6 violations, unsafe tasks, missing @MainActor
+- `/axiom:audit-memory` — Timer leaks, observer leaks, closure captures, delegate cycles
+- `/axiom:audit-core-data` — Migration risks, thread violations, N+1 queries
+- `/axiom:audit-networking` — Deprecated APIs (SCNetworkReachability, CFSocket), anti-patterns
+- `/axiom:audit-liquid-glass` — Glass adoption opportunities, toolbar improvements, blur migration
+
+## Skill Categories Explained
+
+- **Discipline skills** (no suffix) — Step-by-step workflows with pressure scenarios, TDD-tested
+- **Diagnostic skills** (-diag suffix) — Systematic troubleshooting with production crisis defense
+- **Reference skills** (-ref suffix) — Comprehensive API guides with WWDC examples
+
+## Quick Decision Trees
+
+**"My build is failing"**
+→ Start: axiom:xcode-debugging
+→ If dependency issue: axiom:build-debugging
+
+**"App is slow"**
+→ Start: axiom:performance-profiling (decision trees)
+→ If SwiftUI: axiom:swiftui-performance
+→ If memory grows: axiom:memory-debugging
+
+**"Memory leak"**
+→ Start: axiom:memory-debugging
+→ If Objective-C blocks: axiom:objc-block-retain-cycles
+
+**"SwiftUI view issues"**
+→ Start: axiom:swiftui-debugging
+→ If performance: axiom:swiftui-performance
+
+**"Navigation problems"**
+→ Start: axiom:swiftui-nav-diag (troubleshooting)
+→ For patterns: axiom:swiftui-nav
+
+**"Which database?"**
+→ Decision guide: axiom:sqlitedata-migration
+→ Then: axiom:swiftdata, axiom:sqlitedata, or axiom:grdb
+
+**"iOS 26 design"**
+→ Start: axiom:liquid-glass
+→ Complete guide: axiom:liquid-glass-ref
+
+**"Code quality check"**
+→ Run: `/axiom:audit-accessibility`, `/axiom:audit-concurrency`, `/axiom:audit-memory`
+→ Fix issues with relevant skills
+
+## How Skills Work
+
+Axiom skills load automatically — you don't need to memorize names or commands.
+
+**Automatic triggering** (most common): Just describe your problem naturally. Claude detects which skill applies and loads it.
+- "My SwiftData CloudKit sync isn't working" → loads `cloud-sync-diag`
+- "I'm getting Sendable errors in Swift 6" → loads `swift-concurrency`
+
+**Explicit invocation**: If you know the skill name, invoke it directly:
+- `/skill axiom-swift-concurrency`
+- `/skill axiom-liquid-glass`
+
+**Audit commands**: Run automated scans with slash commands:
+- `/axiom:audit-memory` — scans for memory leak patterns
+- `/axiom:audit-concurrency` — scans for Swift 6 violations
+
+**Key insight**: You don't need to know skill names. Describe what you're working on and Axiom routes to the right skill automatically.
+
+## Tips
+
+- **Describe your problem** — Claude will suggest the right skill
+- **Run audits first** — Quick wins with automated scans
+- **Start with diagnostic skills** — When troubleshooting specific issues
+- **Use reference skills** — When implementing new features
+- **All skills are searchable** — Just describe what you need
+
+---
+
+**Total**: 50 skills, 12 audit commands, covering the complete iOS development lifecycle from design to deployment
diff --git a/.claude/skills/axiom-getting-started/agents/openai.yaml b/.claude/skills/axiom-getting-started/agents/openai.yaml
new file mode 100644
index 0000000..ed3c45a
--- /dev/null
+++ b/.claude/skills/axiom-getting-started/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Getting Started"
+ short_description: "First installing Axiom, unsure which skill to use, want an overview of available skills, or need help finding the rig..."
diff --git a/.claude/skills/axiom-grdb/.openskills.json b/.claude/skills/axiom-grdb/.openskills.json
new file mode 100644
index 0000000..b5315f2
--- /dev/null
+++ b/.claude/skills/axiom-grdb/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-grdb",
+ "installedAt": "2026-04-12T08:06:20.544Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-grdb/SKILL.md b/.claude/skills/axiom-grdb/SKILL.md
new file mode 100644
index 0000000..edccbf7
--- /dev/null
+++ b/.claude/skills/axiom-grdb/SKILL.md
@@ -0,0 +1,677 @@
+---
+name: axiom-grdb
+description: Use when writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, query profiling under performance pressure, or dropping down from SQLiteData for performance - direct SQLite access for iOS/macOS
+license: MIT
+metadata:
+ version: "1.1.0"
+ last-updated: "TDD-tested with complex query performance scenarios"
+---
+
+# GRDB
+
+## Overview
+
+Direct SQLite access using [GRDB.swift](https://github.com/groue/GRDB.swift) — a toolkit for SQLite databases with type-safe queries, migrations, and reactive observation.
+
+**Core principle** Type-safe Swift wrapper around raw SQL with full SQLite power when you need it.
+
+**Requires** iOS 13+, Swift 5.7+
+**License** MIT (free and open source)
+
+## When to Use GRDB
+
+#### Use raw GRDB when you need
+- ✅ Complex SQL joins across 4+ tables
+- ✅ Window functions (ROW_NUMBER, RANK, LAG/LEAD)
+- ✅ Reactive queries with ValueObservation
+- ✅ Full control over SQL for performance
+- ✅ Advanced migration logic beyond schema changes
+
+**Note:** SQLiteData now supports GROUP BY (`.group(by:)`) and HAVING (`.having()`) via the query builder — see the `axiom-sqlitedata-ref` skill.
+
+#### Use SQLiteData instead when
+- Type-safe `@Table` models are sufficient
+- CloudKit sync needed
+- Prefer declarative queries over SQL
+
+#### Use SwiftData when
+- Simple CRUD with native Apple integration
+- Don't need raw SQL control
+
+**For migrations** See the `axiom-database-migration` skill for safe schema evolution patterns.
+
+## Example Prompts
+
+These are real questions developers ask that this skill is designed to answer:
+
+#### 1. "I need to query messages with their authors and count of reactions in one query. How do I write the JOIN?"
+→ The skill shows complex JOIN queries with multiple tables and aggregations
+
+#### 2. "I want to observe a filtered list and update the UI whenever notes with a specific tag change."
+→ The skill covers ValueObservation patterns for reactive query updates
+
+#### 3. "I'm importing thousands of chat records and need custom migration logic. How do I use DatabaseMigrator?"
+→ The skill explains migration registration, data transforms, and safe rollback patterns
+
+#### 4. "My query is slow (takes 10+ seconds). How do I profile and optimize it?"
+→ The skill covers EXPLAIN QUERY PLAN, database.trace for profiling, and index creation
+
+#### 5. "I need to fetch tasks grouped by due date with completion counts, ordered by priority. Raw SQL seems easier than type-safe queries."
+→ The skill demonstrates when GRDB's raw SQL is clearer than type-safe wrappers
+
+---
+
+## Database Setup
+
+### DatabaseQueue (Single Connection)
+
+```swift
+import GRDB
+
+// File-based database
+let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
+let dbQueue = try DatabaseQueue(path: "\(dbPath)/db.sqlite")
+
+// In-memory database (tests)
+let dbQueue = try DatabaseQueue()
+```
+
+### DatabasePool (Connection Pool)
+
+```swift
+// For apps with heavy concurrent access
+let dbPool = try DatabasePool(path: dbPath)
+```
+
+**Use Queue for** Most apps (simpler, sufficient)
+**Use Pool for** Heavy concurrent writes from multiple threads
+
+## Record Types
+
+### Using Codable
+
+```swift
+struct Track: Codable {
+ var id: String
+ var title: String
+ var artist: String
+ var duration: TimeInterval
+}
+
+// Fetch
+let tracks = try dbQueue.read { db in
+ try Track.fetchAll(db, sql: "SELECT * FROM tracks")
+}
+
+// Insert
+try dbQueue.write { db in
+ try track.insert(db) // Codable conformance provides insert
+}
+```
+
+### FetchableRecord (Read-Only)
+
+```swift
+struct TrackInfo: FetchableRecord {
+ var title: String
+ var artist: String
+ var albumTitle: String
+
+ init(row: Row) {
+ title = row["title"]
+ artist = row["artist"]
+ albumTitle = row["album_title"]
+ }
+}
+
+let results = try dbQueue.read { db in
+ try TrackInfo.fetchAll(db, sql: """
+ SELECT tracks.title, tracks.artist, albums.title as album_title
+ FROM tracks
+ JOIN albums ON tracks.albumId = albums.id
+ """)
+}
+```
+
+### PersistableRecord (Write)
+
+```swift
+struct Track: Codable, PersistableRecord {
+ var id: String
+ var title: String
+
+ // Customize table name
+ static let databaseTableName = "tracks"
+}
+
+try dbQueue.write { db in
+ var track = Track(id: "1", title: "Song")
+ try track.insert(db)
+
+ track.title = "Updated"
+ try track.update(db)
+
+ try track.delete(db)
+}
+```
+
+## Raw SQL Queries
+
+### Reading Data
+
+```swift
+// Fetch all rows
+let rows = try dbQueue.read { db in
+ try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])
+}
+
+// Fetch single value
+let count = try dbQueue.read { db in
+ try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tracks")
+}
+
+// Fetch into Codable
+let tracks = try dbQueue.read { db in
+ try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY title")
+}
+```
+
+### Writing Data
+
+```swift
+try dbQueue.write { db in
+ try db.execute(sql: """
+ INSERT INTO tracks (id, title, artist, duration)
+ VALUES (?, ?, ?, ?)
+ """, arguments: ["1", "Song", "Artist", 240])
+}
+```
+
+### Transactions
+
+```swift
+try dbQueue.write { db in
+ // Automatic transaction - all or nothing
+ for track in tracks {
+ try track.insert(db)
+ }
+ // Commits automatically on success, rolls back on error
+}
+```
+
+## Type-Safe Query Interface
+
+### Filtering
+
+```swift
+let request = Track
+ .filter(Column("genre") == "Rock")
+ .filter(Column("duration") > 180)
+
+let tracks = try dbQueue.read { db in
+ try request.fetchAll(db)
+}
+```
+
+### Sorting
+
+```swift
+let request = Track
+ .order(Column("title").asc)
+ .limit(10)
+```
+
+### Joins
+
+```swift
+struct TrackWithAlbum: FetchableRecord {
+ var trackTitle: String
+ var albumTitle: String
+}
+
+let request = Track
+ .joining(required: Track.belongsTo(Album.self))
+ .select(Column("title").forKey("trackTitle"), Column("album_title").forKey("albumTitle"))
+
+let results = try dbQueue.read { db in
+ try TrackWithAlbum.fetchAll(db, request)
+}
+```
+
+## Complex Joins
+
+```swift
+let sql = """
+ SELECT
+ tracks.title as track_title,
+ albums.title as album_title,
+ artists.name as artist_name,
+ COUNT(plays.id) as play_count
+ FROM tracks
+ JOIN albums ON tracks.albumId = albums.id
+ JOIN artists ON albums.artistId = artists.id
+ LEFT JOIN plays ON plays.trackId = tracks.id
+ WHERE artists.genre = ?
+ GROUP BY tracks.id
+ HAVING play_count > 10
+ ORDER BY play_count DESC
+ LIMIT 50
+ """
+
+struct TrackStats: FetchableRecord {
+ var trackTitle: String
+ var albumTitle: String
+ var artistName: String
+ var playCount: Int
+
+ init(row: Row) {
+ trackTitle = row["track_title"]
+ albumTitle = row["album_title"]
+ artistName = row["artist_name"]
+ playCount = row["play_count"]
+ }
+}
+
+let stats = try dbQueue.read { db in
+ try TrackStats.fetchAll(db, sql: sql, arguments: ["Rock"])
+}
+```
+
+## ValueObservation (Reactive Queries)
+
+### Basic Observation
+
+```swift
+import GRDB
+import Combine
+
+let observation = ValueObservation.tracking { db in
+ try Track.fetchAll(db)
+}
+
+// Start observing with Combine
+let cancellable = observation.publisher(in: dbQueue)
+ .sink(
+ receiveCompletion: { _ in },
+ receiveValue: { tracks in
+ print("Tracks updated: \(tracks.count)")
+ }
+ )
+```
+
+### SwiftUI Integration
+
+```swift
+import GRDB
+import GRDBQuery // https://github.com/groue/GRDBQuery
+
+@Query(Tracks())
+var tracks: [Track]
+
+struct Tracks: Queryable {
+ static var defaultValue: [Track] { [] }
+
+ func publisher(in dbQueue: DatabaseQueue) -> AnyPublisher<[Track], Error> {
+ ValueObservation
+ .tracking { db in try Track.fetchAll(db) }
+ .publisher(in: dbQueue)
+ .eraseToAnyPublisher()
+ }
+}
+```
+
+**See** [GRDBQuery documentation](https://github.com/groue/GRDBQuery) for SwiftUI reactive bindings.
+
+### Filtered Observation
+
+```swift
+func observeGenre(_ genre: String) -> ValueObservation<[Track]> {
+ ValueObservation.tracking { db in
+ try Track
+ .filter(Column("genre") == genre)
+ .fetchAll(db)
+ }
+}
+
+let cancellable = observeGenre("Rock")
+ .publisher(in: dbQueue)
+ .sink { tracks in
+ print("Rock tracks: \(tracks.count)")
+ }
+```
+
+## Migrations
+
+### DatabaseMigrator
+
+```swift
+var migrator = DatabaseMigrator()
+
+// Migration 1: Create tables
+migrator.registerMigration("v1") { db in
+ try db.create(table: "tracks") { t in
+ t.column("id", .text).primaryKey()
+ t.column("title", .text).notNull()
+ t.column("artist", .text).notNull()
+ t.column("duration", .real).notNull()
+ }
+}
+
+// Migration 2: Add column
+migrator.registerMigration("v2_add_genre") { db in
+ try db.alter(table: "tracks") { t in
+ t.add(column: "genre", .text)
+ }
+}
+
+// Migration 3: Add index
+migrator.registerMigration("v3_add_indexes") { db in
+ try db.create(index: "idx_genre", on: "tracks", columns: ["genre"])
+}
+
+// Run migrations
+try migrator.migrate(dbQueue)
+```
+
+**For migration safety patterns** See the `axiom-database-migration` skill.
+
+### Migration with Data Transform
+
+```swift
+migrator.registerMigration("v4_normalize_artists") { db in
+ // 1. Create new table
+ try db.create(table: "artists") { t in
+ t.column("id", .text).primaryKey()
+ t.column("name", .text).notNull()
+ }
+
+ // 2. Extract unique artists
+ try db.execute(sql: """
+ INSERT INTO artists (id, name)
+ SELECT DISTINCT
+ lower(replace(artist, ' ', '_')) as id,
+ artist as name
+ FROM tracks
+ """)
+
+ // 3. Add foreign key to tracks
+ try db.alter(table: "tracks") { t in
+ t.add(column: "artistId", .text)
+ .references("artists", onDelete: .cascade)
+ }
+
+ // 4. Populate foreign keys
+ try db.execute(sql: """
+ UPDATE tracks
+ SET artistId = (
+ SELECT id FROM artists
+ WHERE artists.name = tracks.artist
+ )
+ """)
+}
+```
+
+## Performance Patterns
+
+### Batch Writes
+
+```swift
+try dbQueue.write { db in
+ for batch in tracks.chunked(into: 500) {
+ for track in batch {
+ try track.insert(db)
+ }
+ }
+}
+```
+
+### Prepared Statements
+
+```swift
+try dbQueue.write { db in
+ let statement = try db.makeStatement(sql: """
+ INSERT INTO tracks (id, title, artist, duration)
+ VALUES (?, ?, ?, ?)
+ """)
+
+ for track in tracks {
+ try statement.execute(arguments: [track.id, track.title, track.artist, track.duration])
+ }
+}
+```
+
+### Indexes
+
+```swift
+try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"])
+try db.create(index: "idx_tracks_genre_duration", on: "tracks", columns: ["genre", "duration"])
+
+// Unique index
+try db.create(index: "idx_tracks_unique_title", on: "tracks", columns: ["title"], unique: true)
+```
+
+### Query Planning
+
+```swift
+// Analyze query performance
+let explanation = try dbQueue.read { db in
+ try String.fetchOne(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM tracks WHERE artist = ?", arguments: ["Artist"])
+}
+print(explanation)
+```
+
+## Dropping Down from SQLiteData
+
+When using SQLiteData but need GRDB for specific operations:
+
+```swift
+import SQLiteData
+import GRDB
+
+@Dependency(\.database) var database // SQLiteData Database
+
+// Access underlying GRDB DatabaseQueue
+try await database.database.write { db in
+ // Full GRDB power here
+ try db.execute(sql: "CREATE INDEX idx_genre ON tracks(genre)")
+}
+```
+
+#### Common scenarios
+- Complex JOIN queries
+- Custom migrations
+- Bulk SQL operations
+- ValueObservation setup
+
+## Quick Reference
+
+### Common Operations
+
+```swift
+// Read single value
+let count = try db.fetchOne(Int.self, sql: "SELECT COUNT(*) FROM tracks")
+
+// Read all rows
+let rows = try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])
+
+// Write
+try db.execute(sql: "INSERT INTO tracks VALUES (?, ?, ?)", arguments: [id, title, artist])
+
+// Transaction
+try dbQueue.write { db in
+ // All or nothing
+}
+
+// Observe changes
+ValueObservation.tracking { db in
+ try Track.fetchAll(db)
+}.publisher(in: dbQueue)
+```
+
+## Resources
+
+**GitHub**: groue/GRDB.swift, groue/GRDBQuery
+
+**Docs**: sqlite.org/docs.html
+
+**Skills**: axiom-database-migration, axiom-sqlitedata, axiom-swiftdata
+
+## Production Performance: Query Optimization Under Pressure
+
+### Red Flags — When GRDB Queries Slow Down
+
+If you see ANY of these symptoms:
+- ❌ Complex JOIN query takes 10+ seconds
+- ❌ ValueObservation runs on every single change (battery drain)
+- ❌ Can't explain why migration ran twice on old version
+
+#### DO NOT
+1. Blindly add indexes (don't know which columns help)
+2. Move logic to Swift (premature escape from database)
+3. Over-engineer migrations (distrust the system)
+
+#### DO
+1. Profile with `database.trace`
+2. Use `EXPLAIN QUERY PLAN` to understand execution
+3. Trust GRDB's migration versioning system
+
+### Profiling Complex Queries
+
+#### When query is slow (10+ seconds)
+
+```swift
+var database = try DatabaseQueue(path: dbPath)
+
+// Enable tracing to see SQL execution
+database.trace { print($0) }
+
+// Run the slow query
+try database.read { db in
+ let results = try Track.fetchAll(db) // Watch output for execution time
+}
+
+// Use EXPLAIN QUERY PLAN to understand execution:
+try database.read { db in
+ let plan = try String(fetching: db, sql: "EXPLAIN QUERY PLAN SELECT ...")
+ print(plan)
+ // Look for SCAN (slow, full table) vs SEARCH (fast, indexed)
+}
+```
+
+#### Add indexes strategically
+
+```swift
+// Add index on frequently queried column
+try database.write { db in
+ try db.execute(sql: "CREATE INDEX idx_plays_track_id ON plays(track_id)")
+}
+```
+
+#### Time cost
+- Profile: 10 min (enable trace, run query, read output)
+- Understand: 5 min (interpret EXPLAIN QUERY PLAN)
+- Fix: 5 min (add index)
+- **Total: 20 minutes** (vs 30+ min blindly trying solutions)
+
+### ValueObservation Performance
+
+#### When using reactive queries, know the costs
+
+```swift
+// Re-evaluates query on ANY write to database
+ValueObservation.tracking { db in
+ try Track.fetchAll(db)
+}.start(in: database, onError: { }, onChange: { tracks in
+ // Called for every change — CPU spike!
+})
+```
+
+#### Optimization patterns
+
+```swift
+// Coalesce rapid updates (recommended)
+ValueObservation.tracking { db in
+ try Track.fetchAll(db)
+}.removeDuplicates() // Skip duplicate results
+ .debounce(for: 0.5, scheduler: DispatchQueue.main) // Batch updates
+ .start(in: database, ...)
+```
+
+#### Decision framework
+- Small datasets (<1000 records): Use plain `.tracking`
+- Medium datasets (1-10k records): Add `.removeDuplicates()` + `.debounce()`
+- Large datasets (10k+ records): Use explicit table dependencies or predicates
+
+### Migration Versioning Guarantees
+
+#### Trust GRDB's DatabaseMigrator - it prevents re-running migrations
+
+```swift
+var migrator = DatabaseMigrator()
+
+migrator.registerMigration("v1_initial") { db in
+ try db.execute(sql: "CREATE TABLE tracks (...)")
+}
+
+migrator.registerMigration("v2_add_plays") { db in
+ try db.execute(sql: "CREATE TABLE plays (...)")
+}
+
+// GRDB guarantees:
+// - Each migration runs exactly ONCE
+// - In order (v1, then v2)
+// - Safe to call migrate() multiple times
+try migrator.migrate(dbQueue)
+```
+
+#### You don't need defensive SQL (IF NOT EXISTS)
+- GRDB tracks which migrations have run
+- Running `migrate()` twice only executes new ones
+- Over-engineering adds complexity without benefit
+
+#### Trust it.
+
+---
+
+## Common Mistakes
+
+### ❌ Not using transactions for batch writes
+```swift
+for track in 50000Tracks {
+ try dbQueue.write { db in try track.insert(db) } // 50k transactions!
+}
+```
+**Fix** Single transaction with batches
+
+### ❌ Synchronous database access on main thread
+```swift
+let tracks = try dbQueue.read { db in try Track.fetchAll(db) } // Blocks UI
+```
+**Fix** Use async/await or dispatch to background queue
+
+### ❌ Forgetting to add indexes
+```swift
+// Slow query without index
+try Track.filter(Column("genre") == "Rock").fetchAll(db)
+```
+**Fix** Create indexes on frequently queried columns
+
+### ❌ N+1 queries
+```swift
+for track in tracks {
+ let album = try Album.fetchOne(db, key: track.albumId) // N queries!
+}
+```
+**Fix** Use JOIN or batch fetch
+
+## tvOS
+
+**Local GRDB databases are not persistent on tvOS.** The system deletes Caches (including Application Support) under storage pressure. A local-only GRDB database will lose all data between app launches.
+
+**If targeting tvOS**, pair GRDB with CloudKit sync (via CKSyncEngine or SQLiteData's SyncEngine) so iCloud is the persistent store and the local database is a rebuildable cache. See `axiom-tvos` for full tvOS storage constraints.
+
+---
+
+**Targets:** iOS 13+, Swift 5.7+
+**Framework:** GRDB.swift 6.0+
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-grdb/agents/openai.yaml b/.claude/skills/axiom-grdb/agents/openai.yaml
new file mode 100644
index 0000000..a5e6a9f
--- /dev/null
+++ b/.claude/skills/axiom-grdb/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "GRDB"
+ short_description: "Writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, q..."
diff --git a/.claude/skills/axiom-hang-diagnostics/.openskills.json b/.claude/skills/axiom-hang-diagnostics/.openskills.json
new file mode 100644
index 0000000..18330f1
--- /dev/null
+++ b/.claude/skills/axiom-hang-diagnostics/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-hang-diagnostics",
+ "installedAt": "2026-04-12T08:06:21.200Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-hang-diagnostics/SKILL.md b/.claude/skills/axiom-hang-diagnostics/SKILL.md
new file mode 100644
index 0000000..90fa781
--- /dev/null
+++ b/.claude/skills/axiom-hang-diagnostics/SKILL.md
@@ -0,0 +1,478 @@
+---
+name: axiom-hang-diagnostics
+description: Use when app freezes, UI unresponsive, main thread blocked, watchdog termination, or diagnosing hang reports from Xcode Organizer or MetricKit
+license: MIT
+---
+
+# Hang Diagnostics
+
+Systematic diagnosis and resolution of app hangs. A hang occurs when the main thread is blocked for more than 1 second, making the app unresponsive to user input.
+
+## Red Flags — Check This Skill When
+
+| Symptom | This Skill Applies |
+|---------|-------------------|
+| App freezes briefly during use | Yes — likely hang |
+| UI doesn't respond to touches | Yes — main thread blocked |
+| "App not responding" system dialog | Yes — severe hang |
+| Xcode Organizer shows hang diagnostics | Yes — field hang reports |
+| MetricKit MXHangDiagnostic received | Yes — aggregated hang data |
+| Animations stutter or skip | Maybe — could be hitch, not hang |
+| App feels slow but responsive | No — performance issue, not hang |
+
+## What Is a Hang
+
+A **hang** is when the main runloop cannot process events for more than 1 second. The user taps, but nothing happens.
+
+```
+User taps → Main thread busy/blocked → Event queued → 1+ second delay → HANG
+```
+
+**Key distinction**: The main thread handles ALL user input. If it's busy or blocked, the entire UI freezes.
+
+### Hang vs Hitch vs Lag
+
+| Issue | Duration | User Experience | Tool |
+|-------|----------|-----------------|------|
+| **Hang** | >1 second | App frozen, unresponsive | Time Profiler, System Trace |
+| **Hitch** | 1-3 frames (16-50ms) | Animation stutters | Animation Hitches instrument |
+| **Lag** | 100-500ms | Feels slow but responsive | Time Profiler |
+
+**This skill covers hangs.** For hitches, see `axiom-swiftui-performance`. For general lag, see `axiom-performance-profiling`.
+
+## The Two Causes of Hangs
+
+Every hang has one of two root causes:
+
+### 1. Main Thread Busy
+
+The main thread is doing work instead of processing events.
+
+**Subcategories**:
+
+| Type | Example | Fix |
+|------|---------|-----|
+| **Proactive work** | Pre-computing data user hasn't requested | Lazy initialization, compute on demand |
+| **Irrelevant work** | Processing all notifications, not just relevant ones | Filter notifications, targeted observers |
+| **Suboptimal API** | Using blocking API when async exists | Switch to async API |
+
+### 2. Main Thread Blocked
+
+The main thread is waiting for something else.
+
+**Subcategories**:
+
+| Type | Example | Fix |
+|------|---------|-----|
+| **Synchronous IPC** | Calling system service synchronously | Use async API variant |
+| **File I/O** | `Data(contentsOf:)` on main thread | Move to background queue |
+| **Network** | Synchronous URL request | Use URLSession async |
+| **Lock contention** | Waiting for lock held by background thread | Reduce critical section, use actors |
+| **Semaphore/dispatch_sync** | Blocking on background work | Restructure to async completion |
+
+## Decision Tree — Diagnosing Hangs
+
+```
+START: App hangs reported
+ │
+ ├─→ Do you have hang diagnostics from Organizer or MetricKit?
+ │ │
+ │ ├─→ YES: Examine stack trace
+ │ │ │
+ │ │ ├─→ Stack shows your code running
+ │ │ │ → BUSY: Main thread doing work
+ │ │ │ → Profile with Time Profiler
+ │ │ │
+ │ │ └─→ Stack shows waiting (semaphore, lock, dispatch_sync)
+ │ │ → BLOCKED: Main thread waiting
+ │ │ → Profile with System Trace
+ │ │
+ │ └─→ NO: Can you reproduce?
+ │ │
+ │ ├─→ YES: Profile with Time Profiler first
+ │ │ │
+ │ │ ├─→ High CPU on main thread
+ │ │ │ → BUSY: Optimize the work
+ │ │ │
+ │ │ └─→ Low CPU, thread blocked
+ │ │ → Use System Trace to find what's blocking
+ │ │
+ │ └─→ NO: Enable MetricKit in app
+ │ → Wait for field reports
+ │ → Check Organizer > Hangs
+```
+
+## Tool Selection
+
+| Scenario | Primary Tool | Why |
+|----------|-------------|-----|
+| **Reproduces locally** | Time Profiler | See exactly what main thread is doing |
+| **Blocked thread suspected** | System Trace | Shows thread state, lock contention |
+| **Field reports only** | Xcode Organizer | Aggregated hang diagnostics |
+| **Want in-app data** | MetricKit | MXHangDiagnostic with call stacks |
+| **Need precise timing** | System Trace | Nanosecond-level thread analysis |
+
+## Time Profiler Workflow for Hangs
+
+1. **Launch Instruments** → Select Time Profiler template
+2. **Record during hang** → Reproduce the freeze
+3. **Stop recording** → Find the hang period in timeline
+4. **Select hang region** → Drag to select frozen timespan
+5. **Examine call tree** → Look for main thread work
+
+**What to look for**:
+- Functions with high "Self Time" on main thread
+- Unexpectedly deep call stacks
+- System calls that shouldn't be on main thread
+
+## System Trace Workflow for Blocked Hangs
+
+1. **Launch Instruments** → Select System Trace template
+2. **Record during hang** → Capture thread states
+3. **Find main thread** → Filter to main thread
+4. **Look for red/orange** → Blocked states
+5. **Examine blocking reason** → Lock, semaphore, IPC
+
+**Thread states**:
+- **Running (blue)**: Executing code
+- **Preempted (orange)**: Runnable but not scheduled
+- **Blocked (red)**: Waiting for resource
+
+## Common Hang Patterns and Fixes
+
+### Pattern 1: Synchronous File I/O
+
+**Before (hangs)**:
+```swift
+// Main thread blocks on file read
+func loadUserData() {
+ let data = try! Data(contentsOf: largeFileURL) // BLOCKS
+ processData(data)
+}
+```
+
+**After (async)**:
+```swift
+func loadUserData() {
+ Task.detached {
+ let data = try Data(contentsOf: largeFileURL)
+ await MainActor.run {
+ self.processData(data)
+ }
+ }
+}
+```
+
+### Pattern 2: Unfiltered Notification Observer
+
+**Before (processes all)**:
+```swift
+NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handleChange),
+ name: .NSManagedObjectContextObjectsDidChange,
+ object: nil // Receives ALL contexts
+)
+```
+
+**After (filtered)**:
+```swift
+NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(handleChange),
+ name: .NSManagedObjectContextObjectsDidChange,
+ object: relevantContext // Only this context
+)
+```
+
+### Pattern 3: Expensive Formatter Creation
+
+**Before (creates each time)**:
+```swift
+func formatDate(_ date: Date) -> String {
+ let formatter = DateFormatter() // EXPENSIVE
+ formatter.dateStyle = .medium
+ return formatter.string(from: date)
+}
+```
+
+**After (cached)**:
+```swift
+private static let dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter
+}()
+
+func formatDate(_ date: Date) -> String {
+ Self.dateFormatter.string(from: date)
+}
+```
+
+### Pattern 4: dispatch_sync to Main Thread
+
+**Before (deadlock risk)**:
+```swift
+// From background thread
+DispatchQueue.main.sync { // BLOCKS if main is blocked
+ updateUI()
+}
+```
+
+**After (async)**:
+```swift
+DispatchQueue.main.async {
+ self.updateUI()
+}
+```
+
+### Pattern 5: Semaphore for Async Result
+
+**Before (blocks main thread)**:
+```swift
+func fetchDataSync() -> Data {
+ let semaphore = DispatchSemaphore(value: 0)
+ var result: Data?
+
+ URLSession.shared.dataTask(with: url) { data, _, _ in
+ result = data
+ semaphore.signal()
+ }.resume()
+
+ semaphore.wait() // BLOCKS MAIN THREAD
+ return result!
+}
+```
+
+**After (async/await)**:
+```swift
+func fetchData() async throws -> Data {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return data
+}
+```
+
+### Pattern 6: Lock Contention
+
+**Before (shared lock)**:
+```swift
+class DataManager {
+ private let lock = NSLock()
+ private var cache: [String: Data] = [:]
+
+ func getData(for key: String) -> Data? {
+ lock.lock() // Main thread waits for background
+ defer { lock.unlock() }
+ return cache[key]
+ }
+}
+```
+
+**After (actor)**:
+```swift
+actor DataManager {
+ private var cache: [String: Data] = [:]
+
+ func getData(for key: String) -> Data? {
+ cache[key] // Actor serializes access safely
+ }
+}
+```
+
+### Pattern 7: App Launch Hang (Watchdog)
+
+**Before (too much work)**:
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ loadAllUserData() // Expensive
+ setupAnalytics() // Network calls
+ precomputeLayouts() // CPU intensive
+ return true
+}
+```
+
+**After (deferred)**:
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ // Only essential setup
+ setupMinimalUI()
+ return true
+}
+
+func applicationDidBecomeActive(_ application: UIApplication) {
+ // Defer non-essential work
+ Task {
+ await loadUserDataInBackground()
+ }
+}
+```
+
+### Pattern 8: Image Processing on Main Thread
+
+**Before (blocks UI)**:
+```swift
+func processImage(_ image: UIImage) {
+ let filtered = applyExpensiveFilter(image) // BLOCKS
+ imageView.image = filtered
+}
+```
+
+**After (background processing)**:
+```swift
+func processImage(_ image: UIImage) {
+ imageView.image = placeholder
+
+ Task.detached(priority: .userInitiated) {
+ let filtered = applyExpensiveFilter(image)
+ await MainActor.run {
+ self.imageView.image = filtered
+ }
+ }
+}
+```
+
+## Xcode Organizer Hang Diagnostics
+
+**Window > Organizer > Select App > Hangs**
+
+The Organizer shows aggregated hang data from users who opted into sharing diagnostics.
+
+**Reading the report**:
+1. **Hang Rate**: Hangs per day per device
+2. **Call Stack**: Where the hang occurred
+3. **Device/OS breakdown**: Which configurations affected
+
+**Interpreting call stacks**:
+- **Your code at top**: Main thread busy with your work
+- **System API at top**: You called blocking API on main thread
+- **pthread_mutex/semaphore**: Lock contention or explicit waiting
+
+## MetricKit Hang Diagnostics
+
+Adopt MetricKit to receive hang diagnostics in your app:
+
+```swift
+import MetricKit
+
+class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
+ for payload in payloads {
+ if let hangDiagnostics = payload.hangDiagnostics {
+ for diagnostic in hangDiagnostics {
+ analyzeHang(diagnostic)
+ }
+ }
+ }
+ }
+
+ private func analyzeHang(_ diagnostic: MXHangDiagnostic) {
+ // Duration of the hang
+ let duration = diagnostic.hangDuration
+
+ // Call stack tree (needs symbolication)
+ let callStack = diagnostic.callStackTree
+
+ // Send to your analytics
+ uploadHangDiagnostic(duration: duration, callStack: callStack)
+ }
+}
+```
+
+**Key MXHangDiagnostic properties**:
+- `hangDuration`: How long the hang lasted
+- `callStackTree`: MXCallStackTree with frames
+- `signatureIdentifier`: For grouping similar hangs
+
+## Watchdog Terminations
+
+The watchdog kills apps that hang during key transitions:
+
+| Transition | Time Limit | Consequence |
+|------------|-----------|-------------|
+| **App launch** | ~20 seconds | App killed, crash logged |
+| **Background transition** | ~5 seconds | App killed |
+| **Foreground transition** | ~10 seconds | App killed |
+
+**Watchdog disabled in**:
+- Simulator
+- Debugger attached
+- Development builds (sometimes)
+
+**Watchdog kills are logged as crashes** with exception type `EXC_CRASH (SIGKILL)` and termination reason `Namespace RUNNINGBOARD, Code 3735883980` (hex `0xDEAD10CC` — indicates app held a file lock or SQLite database lock while being suspended).
+
+## Pressure Scenarios
+
+### Scenario 1: Manager Says "Just Add a Loading Spinner"
+
+**Situation**: App hangs during data load. Manager suggests adding spinner to "fix" it.
+
+**Why this fails**: Adding a spinner doesn't prevent the hang—the UI still freezes, the spinner won't animate, and the app remains unresponsive.
+
+**Correct response**: "A spinner won't animate during a hang because the main thread is blocked. We need to move this work off the main thread so the spinner can actually spin and the app stays responsive."
+
+### Scenario 2: "It Works Fine in Testing"
+
+**Situation**: QA can't reproduce the hang. Logs show it happens in production.
+
+**Analysis**:
+1. Field devices have different data sizes
+2. Network conditions vary (slow connection = longer sync)
+3. Background apps consume memory/CPU
+4. Watchdog is disabled in debug builds
+
+**Action**:
+- Add MetricKit to capture field diagnostics
+- Test with production-sized datasets
+- Test without debugger attached
+- Check Organizer for hang reports
+
+### Scenario 3: "We've Always Done It This Way"
+
+**Situation**: Legacy code calls synchronous API on main thread. Refactoring is "too risky."
+
+**Why it matters**: Even if it worked before:
+- Data may have grown larger
+- OS updates may have changed timing
+- New devices have different characteristics
+- Users notice more as apps get faster
+
+**Approach**:
+1. Add metrics to measure current hang rate
+2. Refactor incrementally with feature flags
+3. A/B test to show improvement
+4. Document risk of not fixing
+
+## Anti-Patterns to Avoid
+
+| Anti-Pattern | Why It's Wrong | Instead |
+|--------------|----------------|---------|
+| `DispatchQueue.main.sync` from background | Can deadlock, always blocks | Use `.async` |
+| Semaphore to convert async to sync | Blocks calling thread | Stay async with completion/await |
+| File I/O on main thread | Unpredictable latency | Background queue |
+| Unfiltered notification observer | Processes irrelevant events | Filter by object/name |
+| Creating formatters in loops | Expensive initialization | Cache and reuse |
+| Synchronous network request | Blocks on network latency | URLSession async |
+
+## Hang Prevention Checklist
+
+Before shipping, verify:
+
+- [ ] No `Data(contentsOf:)` or file reads on main thread
+- [ ] No `DispatchQueue.main.sync` from background threads
+- [ ] No semaphore.wait() on main thread
+- [ ] Formatters (DateFormatter, NumberFormatter) are cached
+- [ ] Notification observers filter appropriately
+- [ ] Launch work is minimized (defer non-essential)
+- [ ] Image processing happens off main thread
+- [ ] Database queries don't run on main thread
+- [ ] MetricKit adopted for field diagnostics
+
+## Resources
+
+**WWDC**: 2021-10258, 2022-10082
+
+**Docs**: /xcode/analyzing-responsiveness-issues-in-your-shipping-app, /metrickit/mxhangdiagnostic
+
+**Skills**: axiom-metrickit-ref, axiom-performance-profiling, axiom-swift-concurrency, axiom-lldb (interactive thread inspection at freeze point)
diff --git a/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml b/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml
new file mode 100644
index 0000000..7806fde
--- /dev/null
+++ b/.claude/skills/axiom-hang-diagnostics/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Hang Diagnostics"
+ short_description: "App freezes, UI unresponsive, main thread blocked, watchdog termination, or diagnosing hang reports from Xcode Organi..."
diff --git a/.claude/skills/axiom-haptics/.openskills.json b/.claude/skills/axiom-haptics/.openskills.json
new file mode 100644
index 0000000..2032c8e
--- /dev/null
+++ b/.claude/skills/axiom-haptics/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-haptics",
+ "installedAt": "2026-04-12T08:06:21.837Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-haptics/SKILL.md b/.claude/skills/axiom-haptics/SKILL.md
new file mode 100644
index 0000000..fe9858a
--- /dev/null
+++ b/.claude/skills/axiom-haptics/SKILL.md
@@ -0,0 +1,814 @@
+---
+name: axiom-haptics
+description: Use when implementing haptic feedback, Core Haptics patterns, audio-haptic synchronization, or debugging haptic issues - covers UIFeedbackGenerator, CHHapticEngine, AHAP patterns, and Apple's Causality-Harmony-Utility design principles from WWDC 2021
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Haptics & Audio Feedback
+
+Comprehensive guide to implementing haptic feedback on iOS. Every Apple Design Award winner uses excellent haptic feedback - Camera, Maps, Weather all use haptics masterfully to create delightful, responsive experiences.
+
+## Overview
+
+Haptic feedback provides tactile confirmation of user actions and system events. When designed thoughtfully using the Causality-Harmony-Utility framework, axiom-haptics transform interfaces from functional to delightful.
+
+This skill covers both simple haptics (`UIFeedbackGenerator`) and advanced custom patterns (`Core Haptics`), with real-world examples and audio-haptic synchronization techniques.
+
+## When to Use This Skill
+
+- Adding haptic feedback to user interactions
+- Choosing between UIFeedbackGenerator and Core Haptics
+- Designing audio-haptic experiences that feel unified
+- Creating custom haptic patterns with AHAP files
+- Synchronizing haptics with animations and audio
+- Debugging haptic issues (simulator vs device)
+- Optimizing haptic performance and battery impact
+
+## System Requirements
+
+- **iOS 10+** for UIFeedbackGenerator
+- **iOS 13+** for Core Haptics (CHHapticEngine)
+- **iPhone 8+** for Core Haptics hardware support
+- **Physical device required** - haptics cannot be felt in Simulator
+
+---
+
+## Part 1: Design Principles (WWDC 2021/10278)
+
+Apple's audio and haptic design teams established three core principles for multimodal feedback:
+
+### Causality - Make it obvious what caused the feedback
+
+**Problem**: User can't tell what triggered the haptic
+**Solution**: Haptic timing must match the visual/interaction moment
+
+**Example from WWDC**:
+- ✅ Ball hits wall → haptic fires at collision moment
+- ❌ Ball hits wall → haptic fires 100ms later (confusing)
+
+**Code pattern**:
+```swift
+// ✅ Immediate feedback on touch
+@objc func buttonTapped() {
+ let generator = UIImpactFeedbackGenerator(style: .medium)
+ generator.impactOccurred() // Fire immediately
+ performAction()
+}
+
+// ❌ Delayed feedback loses causality
+@objc func buttonTapped() {
+ performAction()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ let generator = UIImpactFeedbackGenerator(style: .medium)
+ generator.impactOccurred() // Too late!
+ }
+}
+```
+
+### Harmony - Senses work best when coherent
+
+**Problem**: Visual, audio, and haptic don't match
+**Solution**: All three senses should feel like a unified experience
+
+**Example from WWDC**:
+- Small ball → light haptic + high-pitched sound
+- Large ball → heavy haptic + low-pitched sound
+- Shield transformation → continuous haptic + progressive audio
+
+**Key insight**: A large object should **feel** heavy, **sound** low and resonant, and **look** substantial. All three senses reinforce the same experience.
+
+### Utility - Provide clear value
+
+**Problem**: Haptics used everywhere "just because we can"
+**Solution**: Reserve haptics for significant moments that benefit the user
+
+**When to use haptics**:
+- ✅ Confirming an important action (payment completed)
+- ✅ Alerting to critical events (low battery)
+- ✅ Providing continuous feedback (scrubbing slider)
+- ✅ Enhancing delight (app launch flourish)
+
+**When NOT to use haptics**:
+- ❌ Every single tap (overwhelming)
+- ❌ Scrolling through long lists (battery drain)
+- ❌ Background events user can't see (confusing)
+- ❌ Decorative animations (no value)
+
+---
+
+## Part 2: UIFeedbackGenerator (Simple Haptics)
+
+For most apps, `UIFeedbackGenerator` provides 3 simple haptic types without custom patterns.
+
+### UIImpactFeedbackGenerator
+
+Physical collision or impact sensation.
+
+**Styles** (ordered light → heavy):
+- `.light` - Small, delicate tap
+- `.medium` - Standard tap (most common)
+- `.heavy` - Strong, solid impact
+- `.rigid` - Firm, precise tap
+- `.soft` - Gentle, cushioned tap
+
+**Usage pattern**:
+```swift
+class MyViewController: UIViewController {
+ let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ // Prepare reduces latency for next impact
+ impactGenerator.prepare()
+ }
+
+ @objc func userDidTap() {
+ impactGenerator.impactOccurred()
+ }
+}
+```
+
+**Intensity variation** (iOS 13+):
+```swift
+// intensity: 0.0 (lightest) to 1.0 (strongest)
+impactGenerator.impactOccurred(intensity: 0.5)
+```
+
+**Common use cases**:
+- Button taps (`.medium`)
+- Toggle switches (`.light`)
+- Deleting items (`.heavy`)
+- Confirming selections (`.rigid`)
+
+### UISelectionFeedbackGenerator
+
+Discrete selection changes (picker wheels, segmented controls).
+
+**Usage**:
+```swift
+class PickerViewController: UIViewController {
+ let selectionGenerator = UISelectionFeedbackGenerator()
+
+ func pickerView(_ picker: UIPickerView, didSelectRow row: Int,
+ inComponent component: Int) {
+ selectionGenerator.selectionChanged()
+ }
+}
+```
+
+**Feels like**: Clicking a physical wheel with detents
+
+**Common use cases**:
+- Picker wheels
+- Segmented controls
+- Page indicators
+- Step-through interfaces
+
+### UINotificationFeedbackGenerator
+
+System-level success/warning/error feedback.
+
+**Types**:
+- `.success` - Task completed successfully
+- `.warning` - Attention needed, but not critical
+- `.error` - Critical error occurred
+
+**Usage**:
+```swift
+let notificationGenerator = UINotificationFeedbackGenerator()
+
+func submitForm() {
+ // Validate form
+ if isValid {
+ notificationGenerator.notificationOccurred(.success)
+ saveData()
+ } else {
+ notificationGenerator.notificationOccurred(.error)
+ showValidationErrors()
+ }
+}
+```
+
+**Best practice**: Match haptic type to user outcome
+- ✅ Payment succeeds → `.success`
+- ✅ Form validation fails → `.error`
+- ✅ Approaching storage limit → `.warning`
+
+### Performance: prepare()
+
+Call `prepare()` before the haptic to reduce latency:
+
+```swift
+// ✅ Good - prepare before user action
+@IBAction func buttonTouchDown(_ sender: UIButton) {
+ impactGenerator.prepare() // User's finger is down
+}
+
+@IBAction func buttonTouchUpInside(_ sender: UIButton) {
+ impactGenerator.impactOccurred() // Immediate haptic
+}
+
+// ❌ Bad - unprepared haptic may lag
+@IBAction func buttonTapped(_ sender: UIButton) {
+ let generator = UIImpactFeedbackGenerator()
+ generator.impactOccurred() // May have 10-20ms delay
+}
+```
+
+**Prepare timing**: System keeps engine ready for ~1 second after `prepare()`.
+
+---
+
+## Part 3: Core Haptics (Custom Haptics)
+
+For apps needing custom patterns, `Core Haptics` provides full control over haptic waveforms.
+
+### Four Fundamental Elements
+
+1. **Engine** (`CHHapticEngine`) - Link to the phone's actuator
+2. **Player** (`CHHapticPatternPlayer`) - Playback control
+3. **Pattern** (`CHHapticPattern`) - Collection of events over time
+4. **Events** (`CHHapticEvent`) - Building blocks specifying the experience
+
+### CHHapticEngine Lifecycle
+
+```swift
+import CoreHaptics
+
+class HapticManager {
+ var engine: CHHapticEngine?
+
+ func initializeHaptics() {
+ // Check device support
+ guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
+ print("Device doesn't support haptics")
+ return
+ }
+
+ do {
+ // Create engine
+ engine = try CHHapticEngine()
+
+ // Handle interruptions (calls, Siri, etc.)
+ engine?.stoppedHandler = { reason in
+ print("Engine stopped: \(reason)")
+ self.restartEngine()
+ }
+
+ // Handle reset (audio session changes)
+ engine?.resetHandler = {
+ print("Engine reset")
+ self.restartEngine()
+ }
+
+ // Start engine
+ try engine?.start()
+
+ } catch {
+ print("Failed to create haptic engine: \(error)")
+ }
+ }
+
+ func restartEngine() {
+ do {
+ try engine?.start()
+ } catch {
+ print("Failed to restart engine: \(error)")
+ }
+ }
+}
+```
+
+**Critical**: Always set `stoppedHandler` and `resetHandler` to handle system interruptions.
+
+### CHHapticEvent Types
+
+#### Transient Events
+
+Short, discrete feedback (like a tap).
+
+```swift
+let intensity = CHHapticEventParameter(
+ parameterID: .hapticIntensity,
+ value: 1.0 // 0.0 to 1.0
+)
+
+let sharpness = CHHapticEventParameter(
+ parameterID: .hapticSharpness,
+ value: 0.5 // 0.0 (dull) to 1.0 (sharp)
+)
+
+let event = CHHapticEvent(
+ eventType: .hapticTransient,
+ parameters: [intensity, sharpness],
+ relativeTime: 0.0 // Seconds from pattern start
+)
+```
+
+**Parameters**:
+- `hapticIntensity`: Strength (0.0 = barely felt, 1.0 = maximum)
+- `hapticSharpness`: Character (0.0 = dull thud, 1.0 = crisp snap)
+
+#### Continuous Events
+
+Sustained feedback over time (like a vibration motor).
+
+```swift
+let intensity = CHHapticEventParameter(
+ parameterID: .hapticIntensity,
+ value: 0.8
+)
+
+let sharpness = CHHapticEventParameter(
+ parameterID: .hapticSharpness,
+ value: 0.3
+)
+
+let event = CHHapticEvent(
+ eventType: .hapticContinuous,
+ parameters: [intensity, sharpness],
+ relativeTime: 0.0,
+ duration: 2.0 // Seconds
+)
+```
+
+**Use cases**:
+- Rolling texture as object moves
+- Motor running
+- Charging progress
+- Long press feedback
+
+### Creating and Playing Patterns
+
+```swift
+func playCustomPattern() {
+ // Create events
+ let tap1 = CHHapticEvent(
+ eventType: .hapticTransient,
+ parameters: [
+ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
+ CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
+ ],
+ relativeTime: 0.0
+ )
+
+ let tap2 = CHHapticEvent(
+ eventType: .hapticTransient,
+ parameters: [
+ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
+ CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7)
+ ],
+ relativeTime: 0.3
+ )
+
+ let tap3 = CHHapticEvent(
+ eventType: .hapticTransient,
+ parameters: [
+ CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
+ CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
+ ],
+ relativeTime: 0.6
+ )
+
+ do {
+ // Create pattern from events
+ let pattern = try CHHapticPattern(
+ events: [tap1, tap2, tap3],
+ parameters: []
+ )
+
+ // Create player
+ let player = try engine?.makePlayer(with: pattern)
+
+ // Play
+ try player?.start(atTime: CHHapticTimeImmediate)
+
+ } catch {
+ print("Failed to play pattern: \(error)")
+ }
+}
+```
+
+### CHHapticAdvancedPatternPlayer - Looping
+
+For continuous feedback (rolling textures, motors), use advanced player:
+
+```swift
+func startRollingTexture() {
+ let event = CHHapticEvent(
+ eventType: .hapticContinuous,
+ parameters: [
+ CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4),
+ CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2)
+ ],
+ relativeTime: 0.0,
+ duration: 0.5
+ )
+
+ do {
+ let pattern = try CHHapticPattern(events: [event], parameters: [])
+
+ // Use advanced player for looping
+ let player = try engine?.makeAdvancedPlayer(with: pattern)
+
+ // Enable looping
+ try player?.loopEnabled = true
+
+ // Start
+ try player?.start(atTime: CHHapticTimeImmediate)
+
+ // Update intensity dynamically based on ball speed
+ updateTextureIntensity(player: player)
+
+ } catch {
+ print("Failed to start texture: \(error)")
+ }
+}
+
+func updateTextureIntensity(player: CHHapticAdvancedPatternPlayer?) {
+ let newIntensity = calculateIntensityFromBallSpeed()
+
+ let intensityParam = CHHapticDynamicParameter(
+ parameterID: .hapticIntensityControl,
+ value: newIntensity,
+ relativeTime: 0
+ )
+
+ try? player?.sendParameters([intensityParam], atTime: CHHapticTimeImmediate)
+}
+```
+
+**Key difference**: `CHHapticPatternPlayer` plays once, `CHHapticAdvancedPatternPlayer` supports looping and dynamic parameter updates.
+
+---
+
+## Part 4: AHAP Files (Apple Haptic Audio Pattern)
+
+AHAP (Apple Haptic Audio Pattern) files are JSON files combining haptic events and audio.
+
+### Basic AHAP Structure
+
+```json
+{
+ "Version": 1.0,
+ "Metadata": {
+ "Project": "My App",
+ "Created": "2024-01-15"
+ },
+ "Pattern": [
+ {
+ "Event": {
+ "Time": 0.0,
+ "EventType": "HapticTransient",
+ "EventParameters": [
+ {
+ "ParameterID": "HapticIntensity",
+ "ParameterValue": 1.0
+ },
+ {
+ "ParameterID": "HapticSharpness",
+ "ParameterValue": 0.5
+ }
+ ]
+ }
+ }
+ ]
+}
+```
+
+### Adding Audio to AHAP
+
+```json
+{
+ "Version": 1.0,
+ "Pattern": [
+ {
+ "Event": {
+ "Time": 0.0,
+ "EventType": "AudioCustom",
+ "EventParameters": [
+ {
+ "ParameterID": "AudioVolume",
+ "ParameterValue": 0.8
+ }
+ ],
+ "EventWaveformPath": "ShieldA.wav"
+ }
+ },
+ {
+ "Event": {
+ "Time": 0.0,
+ "EventType": "HapticContinuous",
+ "EventDuration": 0.5,
+ "EventParameters": [
+ {
+ "ParameterID": "HapticIntensity",
+ "ParameterValue": 0.6
+ }
+ ]
+ }
+ }
+ ]
+}
+```
+
+### Loading AHAP Files
+
+```swift
+func loadAHAPPattern(named name: String) -> CHHapticPattern? {
+ guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
+ print("AHAP file not found")
+ return nil
+ }
+
+ do {
+ return try CHHapticPattern(contentsOf: url)
+ } catch {
+ print("Failed to load AHAP: \(error)")
+ return nil
+ }
+}
+
+// Usage
+if let pattern = loadAHAPPattern(named: "ShieldTransient") {
+ let player = try? engine?.makePlayer(with: pattern)
+ try? player?.start(atTime: CHHapticTimeImmediate)
+}
+```
+
+### Design Workflow (WWDC Example)
+
+1. **Create visual animation** (e.g., shield transformation, 500ms)
+2. **Design audio** (convey energy gain and robustness)
+3. **Design haptic** (feel the transformation)
+4. **Test harmony** - Do all three senses work together?
+5. **Iterate** - Swap AHAP assets until coherent
+6. **Implement** - Update code to use final assets
+
+**Example iteration**: Shield initially used 3 transient pulses (haptic) + progressive continuous sound (audio) → no harmony. Solution: Switch to continuous haptic + ShieldA.wav audio → unified experience.
+
+---
+
+## Part 5: Audio-Haptic Synchronization
+
+### Matching Animation Timing
+
+```swift
+class ViewController: UIViewController {
+ let animationDuration: TimeInterval = 0.5
+
+ func performShieldTransformation() {
+ // Start haptic/audio simultaneously with animation
+ playShieldPattern()
+
+ UIView.animate(withDuration: animationDuration) {
+ self.shieldView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
+ self.shieldView.alpha = 0.8
+ }
+ }
+
+ func playShieldPattern() {
+ if let pattern = loadAHAPPattern(named: "ShieldContinuous") {
+ let player = try? engine?.makePlayer(with: pattern)
+ try? player?.start(atTime: CHHapticTimeImmediate)
+ }
+ }
+}
+```
+
+**Critical**: Fire haptic at the exact moment the visual change occurs, not before or after.
+
+### Coordinating with Audio
+
+```swift
+import AVFoundation
+
+class AudioHapticCoordinator {
+ let audioPlayer: AVAudioPlayer
+ let hapticEngine: CHHapticEngine
+
+ func playCoordinatedExperience() {
+ // Prepare both systems
+ hapticEngine.notifyWhenPlayersFinished { _ in
+ return .stopEngine
+ }
+
+ // Start at exact same moment
+ let startTime = CACurrentMediaTime() + 0.05 // Small delay for sync
+
+ // Start audio
+ audioPlayer.play(atTime: startTime)
+
+ // Start haptic
+ if let pattern = loadAHAPPattern(named: "CoordinatedPattern") {
+ let player = try? hapticEngine.makePlayer(with: pattern)
+ try? player?.start(atTime: CHHapticTimeImmediate)
+ }
+ }
+}
+```
+
+---
+
+## Part 6: Common Patterns
+
+### Button Tap
+
+```swift
+class HapticButton: UIButton {
+ let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
+
+ override func touchesBegan(_ touches: Set, with event: UIEvent?) {
+ super.touchesBegan(touches, with: event)
+ impactGenerator.prepare()
+ }
+
+ override func touchesEnded(_ touches: Set, with event: UIEvent?) {
+ super.touchesEnded(touches, with: event)
+ impactGenerator.impactOccurred()
+ }
+}
+```
+
+### Slider Scrubbing
+
+```swift
+class HapticSlider: UISlider {
+ let selectionGenerator = UISelectionFeedbackGenerator()
+ var lastValue: Float = 0
+
+ @objc func valueChanged() {
+ let threshold: Float = 0.1
+
+ if abs(value - lastValue) >= threshold {
+ selectionGenerator.selectionChanged()
+ lastValue = value
+ }
+ }
+}
+```
+
+### Pull-to-Refresh
+
+```swift
+class PullToRefreshController: UIViewController {
+ let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
+ var isRefreshing = false
+
+ func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ let threshold: CGFloat = -100
+ let offset = scrollView.contentOffset.y
+
+ if offset <= threshold && !isRefreshing {
+ impactGenerator.impactOccurred()
+ isRefreshing = true
+ beginRefresh()
+ }
+ }
+}
+```
+
+### Success/Error Feedback
+
+```swift
+func handleServerResponse(_ result: Result) {
+ let notificationGenerator = UINotificationFeedbackGenerator()
+
+ switch result {
+ case .success:
+ notificationGenerator.notificationOccurred(.success)
+ showSuccessMessage()
+ case .failure:
+ notificationGenerator.notificationOccurred(.error)
+ showErrorAlert()
+ }
+}
+```
+
+---
+
+## Part 7: Testing & Debugging
+
+### Simulator Limitations
+
+**Haptics DO NOT work in Simulator**. You will see:
+- No haptic feedback
+- No warnings or errors
+- Code runs normally
+
+**Solution**: Always test on physical device (iPhone 8 or newer).
+
+### Device Testing Checklist
+
+- [ ] Test with Haptics disabled in Settings → Sounds & Haptics
+- [ ] Test with Low Power Mode enabled
+- [ ] Test during incoming call (engine may stop)
+- [ ] Test with audio playing in background
+- [ ] Test with different intensity/sharpness values
+- [ ] Verify battery impact (Instruments Energy Log)
+
+### Debug Logging
+
+```swift
+func playHaptic() {
+ #if DEBUG
+ print("🔔 Playing haptic - Engine running: \(engine?.currentTime ?? -1)")
+ #endif
+
+ do {
+ let player = try engine?.makePlayer(with: pattern)
+ try player?.start(atTime: CHHapticTimeImmediate)
+
+ #if DEBUG
+ print("✅ Haptic started successfully")
+ #endif
+ } catch {
+ #if DEBUG
+ print("❌ Haptic failed: \(error.localizedDescription)")
+ #endif
+ }
+}
+```
+
+---
+
+## Troubleshooting
+
+### Engine fails to start
+
+**Symptom**: `CHHapticEngine.start()` throws error
+
+**Causes**:
+1. Device doesn't support Core Haptics (< iPhone 8)
+2. Haptics disabled in Settings
+3. Low Power Mode enabled
+
+**Solution**:
+```swift
+func safelyStartEngine() {
+ guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
+ print("Device doesn't support haptics")
+ return
+ }
+
+ do {
+ try engine?.start()
+ } catch {
+ print("Engine start failed: \(error)")
+ // Fall back to UIFeedbackGenerator
+ useFallbackHaptics()
+ }
+}
+```
+
+### Haptics not felt
+
+**Symptom**: Code runs but no haptic felt on device
+
+**Debug steps**:
+1. Check Settings → Sounds & Haptics → System Haptics is ON
+2. Check Low Power Mode is OFF
+3. Verify device is iPhone 8 or newer
+4. Check intensity > 0.3 (values below may be too subtle)
+5. Test with UIFeedbackGenerator to isolate Core Haptics vs system issue
+
+### Audio out of sync with haptics
+
+**Symptom**: Audio plays but haptic delayed or vice versa
+
+**Causes**:
+1. Not calling `prepare()` before haptic
+2. Audio/haptic started at different times
+3. Heavy main thread work blocking playback
+
+**Solution**:
+```swift
+// ✅ Synchronized start
+func playCoordinated() {
+ impactGenerator.prepare() // Reduce latency
+
+ // Start both simultaneously
+ audioPlayer.play()
+ impactGenerator.impactOccurred()
+}
+```
+
+### Audio file errors with AHAP
+
+**Symptom**: AHAP pattern fails to load or play
+
+**Cause**: Audio file > 4.2 MB or > 23 seconds
+
+**Solution**: Keep audio files small and short. Use compressed formats (AAC) and trim to essential duration.
+
+---
+
+## Resources
+
+**WWDC**: 2021-10278, 2019-520, 2019-223
+
+**Docs**: /corehaptics, /corehaptics/chhapticengine
+
+**Skills**: axiom-swiftui-animation-ref, axiom-ui-testing, axiom-accessibility-diag
diff --git a/.claude/skills/axiom-haptics/agents/openai.yaml b/.claude/skills/axiom-haptics/agents/openai.yaml
new file mode 100644
index 0000000..9636b51
--- /dev/null
+++ b/.claude/skills/axiom-haptics/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Haptics"
+ short_description: "Implementing haptic feedback, Core Haptics patterns, audio-haptic synchronization, or debugging haptic issues"
diff --git a/.claude/skills/axiom-health-check/.openskills.json b/.claude/skills/axiom-health-check/.openskills.json
new file mode 100644
index 0000000..e2e6abd
--- /dev/null
+++ b/.claude/skills/axiom-health-check/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-health-check",
+ "installedAt": "2026-04-12T08:06:21.838Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-health-check/SKILL.md b/.claude/skills/axiom-health-check/SKILL.md
new file mode 100644
index 0000000..04df4ac
--- /dev/null
+++ b/.claude/skills/axiom-health-check/SKILL.md
@@ -0,0 +1,133 @@
+---
+name: axiom-health-check
+description: Use when the user wants a comprehensive project-wide audit, full health check, or scan across all domains.
+license: MIT
+disable-model-invocation: true
+---
+# Health Check Meta-Audit Agent
+
+You are an orchestrator that launches specialized Axiom auditors in parallel, collects their findings, deduplicates by file:line, and produces a unified health report.
+
+## Files to Exclude
+
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Phase 1: Detect Which Auditors to Run
+
+First, find all Swift source files with Glob (`**/*.swift`), then use Grep to detect framework signals.
+
+### Always Run
+
+These auditors apply to every iOS project:
+
+| Auditor | Reason |
+|---------|--------|
+| memory-auditor | Memory leaks affect all apps |
+| security-privacy-scanner | Privacy compliance is mandatory |
+| accessibility-auditor | Accessibility is required for App Store |
+| swift-performance-analyzer | Performance affects all apps |
+| modernization-helper | Deprecated API detection |
+| codable-auditor | Serialization issues are universal |
+
+### Conditional (grep for signals)
+
+Run these only when their framework signals are present in the codebase:
+
+| Signal (grep pattern) | Auditor |
+|----------------------|---------|
+| `import SwiftUI` | swiftui-performance-analyzer, swiftui-architecture-auditor, swiftui-layout-auditor, swiftui-nav-auditor |
+| `import SwiftData` or `@Model` | swiftdata-auditor |
+| `import CoreData` or `.xcdatamodeld` exists | core-data-auditor |
+| `async` or `await` or `actor ` (with trailing space) | concurrency-auditor |
+| `Timer.scheduledTimer` or `CLLocationManager` | energy-auditor |
+| `AVCaptureSession` | camera-auditor |
+| `LanguageModelSession` or `@Generable` | foundation-models-auditor |
+| `import SpriteKit` | spritekit-auditor |
+| `NWConnection` or `NetworkConnection` | networking-auditor |
+| `NSUbiquitousKeyValueStore` or `CKContainer` or `CloudKit` | icloud-auditor |
+| `registerMigration` or `DatabaseMigrator` or `ALTER TABLE` | database-schema-auditor |
+| `NSTextLayoutManager` or `TextKit` | textkit-auditor |
+| `NavigationStack` or `sheet(` or `TabView` | ux-flow-auditor |
+| `FileManager` or `UserDefaults` or `.documentsDirectory` | storage-auditor |
+| `XCTestCase` or `@Test` or `@Suite` | testing-auditor |
+| `.glassBackgroundEffect` or `GlassEffectContainer` | liquid-glass-auditor |
+| Screenshots folder exists (`Screenshots/` or `marketing/`) | screenshot-validator |
+
+### User Exclusions
+
+If the user says "skip X" or "exclude X", remove that auditor from the run list. Acknowledge which auditors were excluded and why.
+
+## Phase 2: Launch Auditors in Parallel
+
+Use the Agent tool with `run_in_background: true` for each selected auditor. Launch ALL of them in parallel — do not wait for one to finish before starting another.
+
+Today's date tag for filenames: use ISO format `YYYY-MM-DD`.
+
+Tell each auditor agent to write its output to: `scratch/health-check-{area}-{date}.md`
+where `{area}` is the auditor name (e.g., `memory`, `accessibility`, `concurrency`).
+
+While auditors run, inform the user:
+- How many auditors were launched
+- Which are "always run" vs "conditional" (and what signals triggered them)
+- Which were skipped (no signal detected) or excluded (user request)
+
+## Phase 3: Collect and Deduplicate
+
+After all auditors complete:
+
+1. Use TaskOutput to collect the summary from each background agent launched in Phase 2. Wait for all agents to return before proceeding.
+2. Read each `scratch/health-check-*-{date}.md` file
+3. Parse findings — look for file:line references and severity levels
+4. Identify duplicate file:line references across multiple auditor reports
+5. Merge duplicates: keep all domain tags (e.g., "memory + concurrency") and the highest severity
+
+## Phase 4: Generate Unified Report
+
+Write to `scratch/health-check-{date}.md` with:
+
+### Executive Summary
+
+Top 5 most critical findings across all domains. Each with:
+- Severity (CRITICAL/HIGH/MEDIUM/LOW)
+- Domain(s)
+- File:line
+- One-line description
+
+### Findings by Domain
+
+Group findings by domain (memory, accessibility, concurrency, etc.). Within each domain, sort by severity (CRITICAL first).
+
+### Passed Audits
+
+List auditors that found zero issues — this is valuable signal.
+
+### Summary Table
+
+| Auditor | Trigger Reason | Findings | Severity Breakdown | Report File |
+|---------|---------------|----------|-------------------|-------------|
+| memory-auditor | always | 3 | 1 HIGH, 2 MEDIUM | scratch/health-check-memory-{date}.md |
+| ... | ... | ... | ... | ... |
+
+## Output Limits
+
+If >100 total findings across all auditors:
+- Show only CRITICAL and HIGH findings in the conversation response
+- Reference the scratch files for MEDIUM and LOW findings
+- Provide the summary table in full regardless
+
+If <=100 total findings:
+- Show all findings grouped by domain in the conversation response
+
+## Guidelines
+
+1. Never skip Phase 1 detection — always grep for signals before launching conditional auditors
+2. Launch all auditors in parallel — sequential launching wastes time
+3. Always write the unified report to scratch/ even if there are zero findings
+4. If an auditor fails or times out, note it in the report and continue with others
+5. Deduplicate aggressively — the same file:line appearing in 3 auditors should be one finding with 3 domain tags
+
+## Related
+
+For individual audits: Use the specific auditor agent directly (e.g., `memory-auditor`, `accessibility-auditor`)
+For build-specific issues: `build-fixer` agent
+For test-specific issues: `test-failure-analyzer` agent
diff --git a/.claude/skills/axiom-health-check/agents/openai.yaml b/.claude/skills/axiom-health-check/agents/openai.yaml
new file mode 100644
index 0000000..87db969
--- /dev/null
+++ b/.claude/skills/axiom-health-check/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Health Check"
+ short_description: "The user wants a comprehensive project-wide audit, full health check, or scan across all domains."
diff --git a/.claude/skills/axiom-hig-ref/.openskills.json b/.claude/skills/axiom-hig-ref/.openskills.json
new file mode 100644
index 0000000..684dae7
--- /dev/null
+++ b/.claude/skills/axiom-hig-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-hig-ref",
+ "installedAt": "2026-04-12T08:06:23.157Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-hig-ref/SKILL.md b/.claude/skills/axiom-hig-ref/SKILL.md
new file mode 100644
index 0000000..973f809
--- /dev/null
+++ b/.claude/skills/axiom-hig-ref/SKILL.md
@@ -0,0 +1,1236 @@
+---
+name: axiom-hig-ref
+description: Reference — Comprehensive Apple Human Interface Guidelines covering colors (semantic, custom, patterns), backgrounds (material hierarchy, dynamic), typography (built-in styles, custom fonts, Dynamic Type), SF Symbols (rendering modes, color, axiom-localization), Dark Mode, accessibility, and platform-specific considerations
+license: MIT
+compatibility: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS
+metadata:
+ version: "1.0.0"
+---
+
+# Apple Human Interface Guidelines — Comprehensive Reference
+
+## Overview
+
+The Human Interface Guidelines (HIG) define Apple's design philosophy and provide concrete guidance for creating intuitive, accessible, platform-appropriate experiences across all Apple devices.
+
+### Three Core Principles
+
+Every design decision should support these principles:
+
+**1. Clarity**
+Content is paramount. Interface elements should defer to content, not compete with it. Every element has a purpose, unnecessary complexity is eliminated, and users should immediately know what they can do without extensive instructions.
+
+**2. Consistency**
+Apps use standard UI elements and familiar patterns. Navigation follows platform conventions, gestures work as expected, and components appear in expected locations. This familiarity reduces cognitive load.
+
+**3. Deference**
+The UI should not distract from essential content. Use subtle backgrounds, receding navigation when not needed, restrained branding, and let content be the hero.
+
+**From Apple HIG:** "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it."
+
+### Design System Philosophy
+
+From WWDC25: "A systematic approach means designing with intention at every level, ensuring that all elements, from the tiniest control to the largest surface, are considered in relation to the whole."
+
+#### Related Skills
+- Use `axiom-hig` for quick decisions and checklists
+- Use `axiom-liquid-glass` for iOS 26 material implementation
+- Use `axiom-liquid-glass-ref` for iOS 26 app-wide adoption
+- Use `axiom-accessibility-diag` for accessibility troubleshooting
+
+---
+
+## Color System
+
+### Semantic Colors Explained
+
+Instead of hardcoded color values, use **semantic colors** that describe the *purpose* of a color rather than its appearance. Semantic colors automatically adapt to light/dark mode and accessibility settings.
+
+**Key insight from WWDC19:** "Think of Dark Mode as having the lights dimmed rather than everything being flipped inside out." Colors are NOT simply inverted—table row backgrounds are lighter in both modes.
+
+### Label Colors (Foreground Content)
+
+Four semantic label levels for text and symbols, each progressively less prominent:
+
+| Style | Semantic Color | Usage |
+|---|---|---|
+| `.primary` | `label` | Titles, most prominent text |
+| `.secondary` | `secondaryLabel` | Subtitles, less prominent |
+| `.tertiary` | `tertiaryLabel` | Placeholder text |
+| `.quaternary` | `quaternaryLabel` | Disabled text |
+
+```swift
+Text("Title").foregroundStyle(.primary) // Black in Light, white in Dark
+Text("Subtitle").foregroundStyle(.secondary)
+```
+
+### Background Colors (Primary → Tertiary)
+
+Background colors come in two sets — **ungrouped** (standard lists) and **grouped** (iOS Settings style):
+
+| Level | Ungrouped | Grouped |
+|---|---|---|
+| Primary | `.systemBackground` | `.systemGroupedBackground` |
+| Secondary | `.secondarySystemBackground` | `.secondarySystemGroupedBackground` |
+| Tertiary | `.tertiarySystemBackground` | `.tertiarySystemGroupedBackground` |
+
+Ungrouped: pure white/black in Light/Dark. Grouped: light gray/dark in Light/Dark.
+
+```swift
+// Standard list → ungrouped backgrounds
+List { Text("Item") }
+ .background(Color(.systemBackground))
+
+// Settings-style list → grouped backgrounds
+List { Section("Section") { Text("Item") } }
+ .listStyle(.grouped)
+```
+
+### Base vs Elevated Backgrounds
+
+There are actually **two sets** of background colors for layering interfaces:
+
+- **Base set:** Used for background apps/interfaces
+- **Elevated set:** Used for foreground apps/interfaces
+
+**Why this matters:**
+
+In Light Mode, simple drop shadows create visual separation. In Dark Mode, drop shadows are less effective, so the system uses **lighter colors for elevated content**.
+
+**Example:** iPad multitasking:
+- Mail app alone → base color set
+- Contacts in slide-over → elevated colors (lighter, stands out)
+- Both side-by-side → both use elevated colors for contrast around splitter
+- Email compose sheet → elevated colors with overlay dimming
+
+**Critical:** Some darker colors may not contrast well when elevated. Always test designs in elevated state. Semi-opaque fill and separator colors adapt gracefully.
+
+### Tint Colors (Dynamic Adaptation)
+
+Tint colors are **dynamic** - they have variants for Light and Dark modes:
+
+```swift
+// Tint color automatically adapts
+Button("Primary Action") {
+ // action
+}
+.tint(.blue)
+// Gets lighter in Dark Mode, darker in Light Mode
+```
+
+**Custom tint colors:**
+When creating custom tint colors, select colors that work well in both modes. Use a contrast calculator to aim for **4.5:1 or higher** contrast ratio. Colors that work in Light Mode may have insufficient contrast in Dark Mode.
+
+### Fill Colors (Semi-Transparent)
+
+Fill colors are **semi-transparent** to contrast well against variable backgrounds:
+
+```swift
+// System fill colors
+Color(.systemFill)
+Color(.secondarySystemFill)
+Color(.tertiarySystemFill)
+Color(.quaternarySystemFill)
+```
+
+**When to use:** Controls, buttons, and interactive elements that need to appear above dynamic backgrounds.
+
+### Separator Colors
+
+```swift
+// Standard separator (semi-transparent)
+Color(.separator)
+
+// Opaque separator
+Color(.opaqueSeparator)
+```
+
+**Opaque separators** are used when transparency would create undesirable results (e.g., intersecting grid lines where overlapping semi-transparent colors create optical illusions).
+
+### When to Use Permanent Dark Backgrounds
+
+**Apple's explicit guidance:**
+> "In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables **immersive media viewing** to use a permanently dark appearance that lets the UI recede and helps people focus on the media."
+
+**Examples from Apple's apps:**
+
+| App | Background | Rationale |
+|-----|------------|-----------|
+| Music | Dark | Album art should be visual focus |
+| Photos | Dark | Images are hero content |
+| Clock | Dark | Nighttime use, instrument feel |
+| Stocks | Dark | Data visualization, charts |
+| Camera | Dark | Reduces distraction during capture |
+
+**For all other apps:** Support both Light and Dark modes via system backgrounds.
+
+### Creating Custom Colors
+
+When you need custom colors:
+
+1. **Open Assets.xcassets**
+2. **Add Color Set**
+3. **Configure variants:**
+ - Light mode color
+ - Dark mode color
+ - High Contrast Light (optional but recommended)
+ - High Contrast Dark (optional but recommended)
+
+```swift
+// Use custom color from asset catalog
+Color("BrandAccent")
+// Automatically uses correct variant
+```
+
+---
+
+## Typography
+
+### System Fonts
+
+**San Francisco (SF):** The system sans-serif font family.
+- SF Pro: General use
+- SF Compact: watchOS and space-constrained layouts
+- SF Mono: Code and monospaced text
+- SF Rounded: Softer, friendlier feel
+- Weights: Ultralight, Thin, Light, Regular, Medium, Semibold, Bold, Heavy, Black
+
+**New York (NY):** System serif font family for editorial content.
+
+**Both available as variable fonts** with seamless weight transitions.
+
+### Font Weight Recommendations
+
+**From Apple HIG:** "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light."
+
+**Why:** Light weights have legibility issues, especially at small sizes, in bright lighting, or for users with visual impairments.
+
+**Hierarchy:**
+```swift
+// Headers - Bold weight for prominence
+Text("Header")
+ .font(.title.weight(.bold))
+
+// Subheaders - Semibold
+Text("Subheader")
+ .font(.title2.weight(.semibold))
+
+// Body - Regular or Medium
+Text("Body text")
+ .font(.body)
+
+// Captions - Regular (never Light)
+Text("Caption")
+ .font(.caption)
+```
+
+### Text Styles for Hierarchy
+
+Use built-in text styles for automatic hierarchy and Dynamic Type support:
+
+```swift
+.font(.largeTitle) .font(.title) .font(.title2)
+.font(.title3) .font(.headline) .font(.body)
+.font(.callout) .font(.subheadline) .font(.footnote)
+.font(.caption) .font(.caption2)
+```
+
+All text styles scale automatically with Dynamic Type.
+
+### Dynamic Type Support
+
+**Requirement:** Apps must support text scaling of at least **200%** (iOS, iPadOS) or **140%** (watchOS).
+
+**Implementation:**
+```swift
+// ✅ CORRECT - Scales automatically
+Text("Hello")
+ .font(.body)
+
+// ❌ WRONG - Fixed size, doesn't scale
+Text("Hello")
+ .font(.system(size: 17))
+```
+
+**Layout considerations:**
+- Reduce multicolumn layouts at larger sizes
+- Minimize text truncation
+- Use stacked layouts instead of inline at large sizes
+- Maintain consistent information hierarchy regardless of size
+
+**Not all content scales equally:** Prioritize what users actually care about. Secondary elements like tab titles shouldn't grow as much as primary content.
+
+### Custom Fonts
+
+When using custom fonts:
+- Ensure legibility at various distances and conditions
+- Implement Dynamic Type support
+- Respond to Bold Text accessibility setting
+- Test at all text sizes
+- Match system font behaviors for accessibility
+
+**If your custom font is thin:** Increase size by ~2 points when pairing with uppercase Latin text.
+
+### Leading (Line Spacing)
+
+**Loose leading:** Wide columns (easier to track to next line)
+**Tight leading:** Constrained height (avoid for 3+ lines)
+
+```swift
+// Adjust leading for specific layouts
+Text("Long content...")
+ .lineSpacing(8) // Add space between lines
+```
+
+---
+
+## Shapes & Geometry
+
+### Three Shape Types (iOS 26)
+
+From WWDC25: "There's a quiet geometry to how our shapes fit together, driven by **concentricity**. By aligning radii and margins around a shared center, shapes can comfortably nest within each other."
+
+#### 1. Fixed Shapes
+
+Constant corner radius regardless of size:
+
+```swift
+RoundedRectangle(cornerRadius: 12)
+```
+
+**Use when:** You need a specific, unchanging corner radius.
+
+#### 2. Capsules
+
+Radius is half the container's height:
+
+```swift
+Capsule()
+```
+
+**Use when:** You want shapes that adapt to content while maintaining rounded ends. Perfect for buttons, pills, and controls.
+
+**Found throughout iOS 26:** Sliders, switches, grouped table views, tab bars, navigation bars.
+
+#### 3. Concentric Shapes
+
+Calculate radius by subtracting padding from parent's radius:
+
+```swift
+.containerRelativeShape(.roundedRectangle)
+```
+
+**Use when:** Nesting shapes within containers to maintain visual harmony.
+
+### Concentricity Principle
+
+**Hardware ↔ Software harmony:** Apple's hardware features consistent bezel curvature. The same precision now guides UI, with curvature, size, and proportion aligning to create unified rhythm between what you hold and what you see.
+
+**Example of concentricity:**
+```
+Window (rounded corners)
+ ├─ Sheet (concentric to window)
+ │ ├─ Card (concentric to sheet)
+ │ │ └─ Button (concentric to card)
+```
+
+### Platform-Specific Guidance
+
+**iOS:**
+- **Capsules** for buttons, switches, grouped lists
+- Creates hierarchy and focus in touch-friendly layouts
+
+**macOS:**
+- **Mini, Small, Medium controls** → Rounded rectangles (dense layouts, inspector panels)
+- **Large, X-Large controls** → Capsules (spacious areas, emphasis via Liquid Glass)
+
+### Optical Centering
+
+To preserve optical balance, views are:
+- Mathematically centered when it makes sense
+- Subtly offset when optical weight requires it
+
+**Example:** Asymmetric icons may need padding adjustments for optical centering rather than geometric centering.
+
+---
+
+## Materials & Depth
+
+### Standard Materials
+
+Materials allow background content to show through, creating visual depth and hierarchy.
+
+#### Four Thickness Options
+
+1. **Ultra-thin** — Minimal separation, content clearly visible
+2. **Thin** — Lighter-weight interactions
+3. **Regular** — Default, works well in most circumstances
+4. **Thick** — Most separation from background
+
+**Choosing thickness:**
+- Content needs more contrast → thicker material
+- Simpler content → thin/ultra-thin material
+
+```swift
+// Apply material
+.background(.ultraThinMaterial)
+.background(.thinMaterial)
+.background(.regularMaterial)
+.background(.thickMaterial)
+```
+
+### Vibrancy with Materials
+
+**Key principle:** Use vibrant colors on top of materials for legibility. Solid colors can get muddy depending on background context. Vibrancy maintains contrast regardless of background.
+
+```swift
+// Vibrant text on material
+VStack {
+ Text("Primary")
+ .foregroundStyle(.primary) // Vibrant
+ Text("Secondary")
+ .foregroundStyle(.secondary) // Vibrant
+}
+.background(.regularMaterial)
+```
+
+### Liquid Glass (iOS 26+)
+
+**Purpose:** Creates a distinct functional layer for controls and navigation, floating above content.
+
+**Two variants:**
+
+1. **Regular Liquid Glass**
+ - Default, use in 95% of cases
+ - Full visual and adaptive effects
+ - Provides legibility regardless of context
+ - Works over any background
+
+2. **Clear Liquid Glass**
+ - Highly translucent
+ - No adaptive behaviors
+ - **Only use for components over visually rich backgrounds** (photos, videos)
+ - Requires dimming layer for legibility
+
+**Modals & Sheets (iOS 26+):** Sheets, alerts, and popovers automatically adopt Liquid Glass with Xcode 26 — remove custom `.presentationBackground()` or `UIBlurEffect` backgrounds. System handles material, concentric corner radius, and morphing transitions. Use elevated semantic colors for modal content backgrounds, not Liquid Glass on the sheet body.
+
+**Cross-reference:** For full Liquid Glass implementation patterns (sheets, alerts, popovers, morphing transitions), see `axiom-liquid-glass-ref`. For decision trees, see `axiom-liquid-glass`.
+
+---
+
+## Layout Principles
+
+### Visual Hierarchy
+
+**Place items to convey their relative importance:**
+- Important content → top and leading side
+- Secondary content → below or trailing
+- Tertiary content → separate views or progressive disclosure
+
+**From Apple HIG:** "Make essential information easy to find by giving it sufficient space and avoid obscuring it with nonessential details."
+
+### Grouping & Organization
+
+Group related items using:
+- Negative space (whitespace)
+- Colors and materials
+- Separator lines
+
+**Ensure content and controls remain clearly distinct** through Liquid Glass material and scroll edge effects.
+
+### Content Extension to Edges
+
+"Extend content to fill the screen or window" with backgrounds and artwork reaching display edges.
+
+**Background extension views:** Use when content doesn't naturally span the full window.
+
+```swift
+// Content extends to edges
+VStack {
+ FullWidthImage()
+ .ignoresSafeArea() // Extends to screen edges
+}
+```
+
+### Safe Areas & Layout Guides
+
+**Safe Areas:** Rectangular regions unobstructed by:
+- Status bar
+- Navigation bar
+- Tab bar
+- Toolbar
+- Device features (Dynamic Island, notch, home indicator)
+
+**Layout Guides:** Define rectangular regions for positioning and spacing content with:
+- Predefined margins
+- Text width optimization
+- Reading width constraints
+
+**Key principle:** "Respect key display and system features in each platform."
+
+```swift
+// Respect safe areas
+VStack {
+ Text("Content")
+}
+.safeAreaInset(edge: .bottom) {
+ BottomBar()
+}
+```
+
+### Align Components
+
+"Align components with one another to make them easier to scan."
+
+**Grid alignment:**
+- Text baselines align
+- Controls align on common grid
+- Spacing is consistent and rhythmic
+
+### Adaptability Requirements
+
+Design layouts that:
+- "Adapt gracefully to context changes while remaining recognizably consistent"
+- Support Dynamic Type text-size changes
+- Work across multiple devices, orientations, and localizations
+- Account for different screen sizes, resolutions, and system features
+
+---
+
+## Accessibility
+
+### Vision Accessibility
+
+#### Text & Legibility
+
+**Requirements:**
+- Support text enlargement of at least **200%** (140% for watchOS)
+- Implement Dynamic Type for systemwide text adjustment
+- Use font weights that enhance readability (avoid Light weights with custom fonts)
+
+#### Color Contrast
+
+**WCAG Level AA standards:**
+- Normal text (14pt+): **4.5:1 minimum**
+- Small text (<14pt): **7:1 recommended**
+- Large text (18pt+ regular, 14pt+ bold): 3:1 acceptable
+
+**Implementation:**
+```swift
+// ✅ Use semantic colors (automatic contrast)
+Text("Label").foregroundStyle(.primary)
+
+// ❌ Custom colors may fail contrast
+Text("Label").foregroundStyle(.gray) // Check with calculator
+```
+
+**High contrast mode:**
+Provide higher contrast color schemes when "Increase Contrast" accessibility setting is enabled.
+
+**Test in both Light and Dark modes.**
+
+#### Color Considerations
+
+**Critical:** "Convey information with more than color alone" to support colorblind users.
+
+**Solutions:**
+- Use distinct shapes or icons alongside color
+- Add text labels
+- Employ system-defined colors with accessible variants
+- Test with Color Blindness simulators
+
+**Example:**
+```swift
+// ❌ Only color indicates status
+Circle().fill(isActive ? .green : .red)
+
+// ✅ Shape + color
+HStack {
+ Image(systemName: isActive ? "checkmark.circle.fill" : "xmark.circle.fill")
+ Text(isActive ? "Active" : "Inactive")
+}
+.foregroundStyle(isActive ? .green : .red)
+```
+
+#### Screen Readers
+
+Describe interface and content for VoiceOver accessibility:
+
+```swift
+Button {
+ share()
+} label: {
+ Image(systemName: "square.and.arrow.up")
+}
+.accessibilityLabel("Share")
+```
+
+### Hearing Accessibility
+
+#### Media Alternatives
+
+For video/audio content, provide:
+- Captions for dialogue
+- Subtitles
+- Audio descriptions for visual-only information
+- Transcripts for longer-form media
+
+#### Audio Cues
+
+Pair audio signals with:
+- Haptic feedback
+- Visual indicators
+
+### Mobility Accessibility
+
+**Touch targets:**
+- Minimum: **44x44 points**
+- Spacing: 12-24 points padding around controls
+
+**Gestures:**
+- Use simple gestures
+- Offer alternatives (buttons alongside gestures)
+- Support Voice Control
+- Enable keyboard navigation
+
+**Assistive technologies:**
+- VoiceOver
+- Switch Control
+- Full Keyboard Access
+
+### Cognitive Accessibility
+
+#### Interaction Design
+
+- "Keep actions simple and intuitive"
+- Avoid time-based auto-dismissing views
+- Prevent autoplay of audio/video without controls
+
+#### Motion & Visual Effects
+
+**Respect "Reduce Motion":**
+- Minimize animations
+- Avoid excessive flashing lights
+- Support "Dim Flashing Lights"
+- Reduce bounce effects
+- Minimize z-axis depth changes
+
+```swift
+// Check Reduce Motion setting
+@Environment(\.accessibilityReduceMotion) var reduceMotion
+
+var body: some View {
+ content
+ .animation(reduceMotion ? nil : .spring(), value: isExpanded)
+}
+```
+
+#### Game Accommodations
+
+Offer adjustable difficulty levels.
+
+### visionOS Specific
+
+Prioritize comfort:
+- Maintain horizontal layouts
+- Reduce animation speed
+- Avoid head-anchored content (prevents assistive technology use)
+
+---
+
+## Motion & Animation
+
+### Core Principles
+
+**Purposeful Animation:** "Add motion purposefully, supporting the experience without overshadowing it."
+
+**Avoid gratuitous animations** that distract or cause discomfort. Motion should enhance rather than dominate the interface.
+
+### Accessibility First
+
+**Make motion optional.** Supplement visual feedback with **haptics** and **audio** to communicate important information, ensuring all users can understand your interface regardless of motion preferences.
+
+### Best Practices for Feedback
+
+#### Realistic Motion
+
+Design animations aligned with user expectations and gestures. Feedback should be:
+- "Brief and precise"
+- Lightweight
+- Effectively conveying information without distraction
+
+#### Frequency Considerations
+
+**Avoid animating frequent UI interactions.** Standard system elements already include subtle animations, so custom elements shouldn't add unnecessary motion to common actions.
+
+#### User Control
+
+"Let people cancel motion" by not forcing them to wait for animations to complete before proceeding, especially for repeated interactions.
+
+```swift
+// ✅ Allow immediate tap, don't block on animation
+Button("Next") {
+ withAnimation(.easeOut(duration: 0.2)) {
+ showNext = true
+ }
+}
+// User can tap again immediately, not forced to wait
+```
+
+### Platform-Specific Guidance
+
+#### visionOS
+
+- **Avoid motion at peripheral vision edges** — causes discomfort
+- Use fades when relocating objects rather than visible movement
+- Maintain stationary frames of reference
+- Avoid sustained oscillations (especially at 0.2 Hz frequency)
+- Prevent virtual world rotation (disrupts stability)
+
+#### watchOS
+
+SwiftUI provides animation capabilities; WatchKit offers `WKInterfaceImage` for layout animations and sequences.
+
+---
+
+## Icons & Symbols
+
+### SF Symbols
+
+6,900+ vector symbols that match San Francisco font, scale with Dynamic Type, and adapt to Bold Text and Dark Mode automatically. Nine weights, three scales, four rendering modes, and 12+ animation effects.
+
+> **For comprehensive coverage** of rendering modes (Monochrome, Hierarchical, Palette, Multicolor), symbol effects (Bounce, Pulse, Wiggle, Draw On/Off), and custom symbol authoring, see `axiom-sf-symbols` (decision trees) and `axiom-sf-symbols-ref` (complete API).
+
+### Custom Interface Icons
+
+**Design principles:** Recognizable, simplified designs with familiar visual metaphors. Maintain uniform size, detail level, stroke thickness, and perspective. Match icon weight with adjacent text. Adjust padding for optical centering when visual weight is asymmetric.
+
+**Format:** Use **PDF or SVG** for automatic scaling. System components handle selected states automatically.
+
+### When to Use Icons vs Text
+
+From WWDC25: "A pencil might suggest annotate, and a checkmark can look like confirm—making actions like Select or Edit easy to misread. **When there's no clear shorthand, a text label is always the better choice.**"
+
+**Use icons when:**
+- Symbol has clear, universal meaning (share, trash, settings)
+- Space is constrained
+- Icon aids quick scanning
+
+**Use text when:**
+- Action has no clear symbol
+- Multiple similar actions exist
+- Clarity is more important than space
+
+### Accessibility
+
+**Always provide alternative text labels** enabling VoiceOver descriptions:
+
+```swift
+Image(systemName: "star.fill")
+ .accessibilityLabel("Favorite")
+```
+
+---
+
+## Gestures & Input
+
+### Core Gesture Design Principles
+
+**Consistency and Familiarity:** "People expect most gestures to work the same regardless of their current context." Standard gestures like tap, swipe, and drag should perform their expected functions across platforms.
+
+**Responsive Feedback:** "Handle gestures as responsively as possible" and provide immediate feedback during gesture performance so users can predict outcomes.
+
+### Standard Gestures
+
+Basic gestures supported across all platforms (though precise movements vary by device):
+- Tap
+- Swipe
+- Drag
+- Pinch
+- Rotate (iOS/iPadOS)
+- Long press
+
+### Touch Target Requirements
+
+**Minimum touch target sizes:**
+
+| Platform | Minimum Size | Spacing |
+|----------|-------------|---------|
+| iOS/iPadOS | 44x44 points | 12-24pt padding |
+| macOS | Varies by control | System spacing |
+| watchOS | System controls | Optimized for small screen |
+| tvOS | Large (focus model) | 60pt+ spacing |
+
+```swift
+// ✅ Adequate touch target
+Button("Tap") { }
+ .frame(minWidth: 44, minHeight: 44)
+
+// ❌ Too small
+Button("Tap") { }
+ .frame(width: 20, height: 20) // Fails accessibility
+```
+
+### Custom Gesture Guidelines
+
+Custom gestures should only be implemented when necessary and must be:
+- **Discoverable** — Users can find them
+- **Straightforward to perform** — Easy to execute
+- **Distinct from other gestures** — No conflicts
+- **Never the only method** — Provide alternatives for important actions
+
+**Warning:** Don't replace standard gestures with custom ones. Shortcuts should supplement, not replace, familiar interactions.
+
+### Accessibility
+
+**Critical:** "Give people more than one way to interact with your app." Never assume users can perform specific gestures.
+
+**Provide alternatives:**
+- Voice control
+- Keyboard navigation
+- Button alternatives to gestures
+
+```swift
+// ✅ Swipe action + button alternative
+.swipeActions {
+ Button("Delete", role: .destructive) {
+ delete()
+ }
+}
+.contextMenu {
+ Button("Delete", role: .destructive) {
+ delete()
+ }
+}
+```
+
+---
+
+## Launch & Onboarding
+
+### Launch Screens
+
+**Mandatory for:** iOS, iPadOS, tvOS
+**Not required for:** macOS, axiom-visionOS, watchOS
+
+**Design principle:** "Design a launch screen that's nearly identical to the first screen of your app or game" to avoid jarring visual transitions.
+
+#### Best Practices
+
+**Minimize branding:**
+- Avoid logos
+- No splash screens
+- No artistic flourishes
+- Purpose: Enhance perception of quick startup, not showcase brand
+
+**No text:**
+- Launch screen content cannot be localized
+- Avoid text entirely
+
+**Match appearance:**
+- Respect device orientation
+- Adapt to light/dark mode
+
+```swift
+// Launch screen matches first screen
+// Transitions smoothly without flash
+```
+
+### Onboarding
+
+Onboarding is a **separate experience** that follows the launch phase. Provides "a high-level view of your app or game" and can include a splash screen if needed.
+
+**When to use:** Only when you have meaningful context to communicate to new users.
+
+**What onboarding can include:**
+- Branding and splash screens
+- Educational content
+- Permission requests
+- Account setup
+
+**Timeline:**
+1. Launch — System displays launch screen, transitions to first screen
+2. Onboarding (optional) — Can include branding and education
+3. Continued use — "Restore the previous state when your app restarts so people can continue where they left off"
+
+---
+
+## Platform-Specific Guidance
+
+### iOS
+
+**Tab Bar Guidelines:**
+- Maximum 5 tabs on iPhone (6th+ go in "More" automatically)
+- Every tab must have icon AND text label — icon-only violates HIG
+- Always visible — don't hide during navigation within a tab
+- Tab order reflects usage frequency (most-used on left)
+- Maintain tab state: preserve scroll position and navigation state when switching
+- iOS 26: Liquid Glass automatic — don't add custom blur/material backgrounds
+- iPad: tab bar → sidebar in landscape; use `TabView` with `Tab` for adaptation
+
+**Navigation Bar Guidelines:**
+- Always use system back button (chevron) — don't replace with custom "X"
+- Title describes current view content, not app name
+- Large titles (`prefersLargeTitles`) for top-level views only; inline for pushed views
+- 1-3 toolbar actions max; use `...` menu for additional
+- iOS 26: Liquid Glass with toolbar morphing between views
+
+**System integration:** Widgets, Home Screen quick actions, Spotlight, Shortcuts, Activity views
+
+### iPadOS
+
+Extends iOS with larger display, sidebar navigation, split view, pointer/trackpad, arbitrary windows (iOS 26+). Don't just scale iOS layouts — leverage sidebars and split views.
+
+### macOS
+
+Pointer-first, keyboard-centric. Dense layouts, smaller controls than iOS. Multiple windows, menu bar, contextual menus, keyboard shortcuts essential. Controls: Mini/Small/Medium → rounded rectangles, Large/X-Large → capsules.
+
+### watchOS
+
+Very small display — glanceable, minimal interaction. Full-bleed content, minimal padding, Digital Crown interactions, complications for watch faces. Always-on display consideration.
+
+**Adapting from iPad/iOS:** Replace sidebars with page-based flow. Convert swipe/pinch to Digital Crown rotation. Use opacity/spacing for hierarchy (no materials/Liquid Glass). Complications replace dashboards. `@State`/`@Environment` reuse well; view hierarchy must be rewritten.
+
+### tvOS
+
+10-foot viewing distance, focus-based navigation, gestural remote. Large touch targets, prominent focus states, limited text input, directional navigation.
+
+**Focus Engine**: tvOS uses a UIKit Focus Engine for hardware navigation that coexists with SwiftUI's @FocusState. The Focus Engine is the ultimate authority — @FocusState assignments are ignored if the Focus Engine considers a view unfocusable. Use UIFocusGuide to bridge navigation gaps between isolated views.
+
+**TVUIKit**: tvOS-exclusive components — TVPosterView (parallax focus effects), TVDigitEntryViewController (PIN entry). No SwiftUI equivalents exist; bridge via UIViewRepresentable.
+
+**Text input**: Standard text fields trigger a fullscreen system keyboard. For better UX, use the shadow input pattern (Button UI + hidden CocoaTextField). See `axiom-tvos` for implementation details.
+
+**Storage**: No persistent local storage. All local files are cache that the system deletes. See `axiom-tvos` for data strategy.
+
+### visionOS
+
+Spatial computing with glass materials, 3D layouts, depth. Comfortable viewing depth, avoid head-anchored content, center content in field of view.
+
+---
+
+## Inclusive Design
+
+### Language & Communication
+
+**Welcoming language requirements:**
+- Use plain, direct, and respectful tone
+- Don't suggest exclusivity based on education level
+- Address people directly with "you/your" rather than "the user"
+- Define specialized or technical terms when necessary
+- Replace culture-specific expressions with plain alternatives
+
+**Avoid phrases with oppressive origins** (e.g., "peanut gallery").
+
+**Exercise caution with humor** — it's subjective and difficult to translate across cultures.
+
+### Visual Representation
+
+**Portraying human diversity:**
+- Feature people demonstrating range of racial backgrounds, body types, ages, physical capabilities
+- Avoid stereotypical representations in occupations and behaviors
+
+**Avoiding assumptions:**
+- Don't assume narrow definitions of family structures
+- Don't assume universal experiences
+- Replace culture-specific security questions with more universal experiences
+
+### Gender Identity & Pronouns
+
+**Best practices:**
+- Avoid unnecessary gender references in copy
+- Provide inclusive options: "nonbinary," "self-identify," "decline to state"
+- Use nongendered imagery
+- Allow customization of avatars and characters
+
+### Accessibility & Disability
+
+**Recognize:**
+- Disabilities exist on spectrums
+- Temporary/situational disabilities affect everyone
+
+**Include:**
+- People with disabilities in diversity representations
+- Adopt people-first approach in writing ("person with disability" vs "disabled person")
+
+### Localization & Global Considerations
+
+**Prepare software for:**
+- Internationalization
+- Translation into multiple languages
+
+**Cultural color awareness:**
+- Colors carry culture-specific meanings
+- White represents death in some cultures, purity in others
+- Red signifies danger in some cultures, positive meanings elsewhere
+
+**Use plain language** and avoid stereotypes to facilitate smoother localization.
+
+---
+
+## Branding
+
+### Core Principles
+
+**Voice & Tone:** Maintain consistent brand personality through written communication.
+
+**Visual Elements:**
+- Consider accent color for UI components
+- Custom font if strongly associated with brand (but system fonts work better for body copy due to legibility)
+
+### Key Restraint Guidelines
+
+**Most critical guidance — restraint:**
+
+**Defer to content:** "Using screen space for an element that does nothing but display a brand asset can mean there's less room for the content people care about."
+
+**Logo minimalism:** "Resist the temptation to display your logo throughout your app or game unless it's essential for providing context."
+
+**Familiar patterns:** Maintain standard UI behaviors and component placement even with stylized designs to keep interfaces approachable.
+
+**Launch screen caution:** Avoid using launch screens for branding since they disappear too quickly; consider onboarding screens instead for brand integration.
+
+### Appropriate Branding
+
+**Do:**
+- Use your brand's accent color as app tint color
+- Include branding in onboarding (not launch screen)
+- Use brand voice in copy
+- Feature brand in content, not chrome
+
+**Don't:**
+- Display logo in navigation bar
+- Override system backgrounds with brand colors
+- Add splash screens
+- Make branding compete with content
+
+### Legal Consideration
+
+Apple trademarks cannot appear in your app name or images—consult Apple's official trademark guidelines.
+
+---
+
+## Troubleshooting Common HIG Issues
+
+### Color Contrast Failures
+
+**Symptom:** App Store rejection for accessibility violations, or colors don't meet WCAG standards.
+
+**Diagnosis:** Test with Accessibility Inspector, contrast calculators, both Light/Dark modes, and Increase Contrast enabled. See Accessibility > Vision section above for contrast ratio requirements.
+
+**Solution:**
+```swift
+// ❌ Custom gray may fail contrast
+Text("Label").foregroundStyle(.gray)
+
+// ✅ Semantic colors (automatic compliance)
+Text("Label").foregroundStyle(.secondary)
+
+// ✅ Verified custom color (~8:1 on white, WCAG AAA)
+Text("Label").foregroundStyle(Color(red: 0.25, green: 0.25, blue: 0.25))
+```
+
+### Touch Targets Too Small
+
+**Symptom:** Users report difficult tapping, App Store accessibility rejection.
+
+**Diagnosis:**
+```swift
+// Check button size
+Button("Tap") { }
+ .frame(width: 30, height: 30) // ❌ Too small
+```
+
+**Solution:**
+```swift
+// ✅ Expand touch target to minimum 44x44
+Button("Tap") { }
+ .frame(minWidth: 44, minHeight: 44)
+
+// ✅ Alternative: Add padding
+Button("Tap") { }
+ .padding() // System adds appropriate padding
+```
+
+### Dark Mode Issues
+
+**Symptom:** Colors look wrong in Dark Mode, insufficient contrast.
+
+**Diagnosis:**
+- Hardcoded colors that don't adapt
+- Custom colors without dark variants
+- Not testing in both appearance modes
+
+**Solution:**
+```swift
+// ❌ PROBLEM: Hardcoded white text
+Text("Label").foregroundStyle(.white)
+// Invisible in Light Mode
+
+// ✅ SOLUTION: Semantic color
+Text("Label").foregroundStyle(.primary)
+// Black in Light, white in Dark
+
+// ✅ ALTERNATIVE: Asset catalog color with variants
+Text("Label").foregroundStyle(Color("BrandText"))
+// Define in Assets.xcassets with Light/Dark variants
+```
+
+### Light Font Weight Legibility
+
+**Symptom:** Text hard to read, especially at small sizes or in bright lighting.
+
+**Diagnosis:**
+```swift
+Text("Headline")
+ .font(.system(size: 17, weight: .ultralight)) // ❌ Too light
+```
+
+**Solution:**
+```swift
+// ✅ Use Regular minimum
+Text("Headline")
+ .font(.system(size: 17, weight: .regular))
+
+// ✅ Better: Use system text styles
+Text("Headline")
+ .font(.headline) // Automatically uses appropriate weight
+```
+
+### Dynamic Type Not Working
+
+**Symptom:** Text doesn't scale when user changes text size in Settings.
+
+```swift
+// ❌ Fixed size doesn't scale
+Text("Label").font(.system(size: 17))
+
+// ✅ Text styles scale automatically
+Text("Label").font(.body)
+
+// ✅ Custom font with scaling
+Text("Label").font(.custom("CustomFont", size: 17, relativeTo: .body))
+```
+
+### Reduce Motion Not Respected
+
+**Symptom:** Users with motion sensitivity experience discomfort.
+
+**Diagnosis:**
+- Animations always play regardless of setting
+- No alternative for motion-sensitive users
+
+**Solution:**
+```swift
+// ✅ Check Reduce Motion setting
+@Environment(\.accessibilityReduceMotion) var reduceMotion
+
+var body: some View {
+ content
+ .animation(reduceMotion ? nil : .spring(), value: isExpanded)
+}
+
+// ✅ Alternative: Simpler animation
+.animation(reduceMotion ? .linear(duration: 0.1) : .spring(), value: isExpanded)
+```
+
+### VoiceOver Labels Missing
+
+**Symptom:** VoiceOver announces unhelpful information like "Button" instead of action.
+
+**Diagnosis:**
+```swift
+// ❌ Image button without label
+Button {
+ share()
+} label: {
+ Image(systemName: "square.and.arrow.up")
+}
+// VoiceOver says: "Button"
+```
+
+**Solution:**
+```swift
+// ✅ Add accessibility label
+Button {
+ share()
+} label: {
+ Image(systemName: "square.and.arrow.up")
+}
+.accessibilityLabel("Share")
+// VoiceOver says: "Share, Button"
+```
+
+### Information Only Conveyed by Color
+
+**Symptom:** Colorblind users can't distinguish status.
+
+**Diagnosis:**
+```swift
+// ❌ Only color indicates state
+Circle()
+ .fill(isComplete ? .green : .red)
+```
+
+**Solution:**
+```swift
+// ✅ Use shape + color + text
+HStack {
+ Image(systemName: isComplete ? "checkmark.circle.fill" : "xmark.circle.fill")
+ Text(isComplete ? "Complete" : "Incomplete")
+}
+.foregroundStyle(isComplete ? .green : .red)
+```
+
+### Launch Screen Branding Rejection
+
+**Symptom:** App Store rejects launch screen with logo or text.
+
+**Diagnosis:**
+- Launch screen contains branding elements
+- Launch screen has text that can't be localized
+
+**Solution:**
+```swift
+// ❌ Launch screen with logo (rejected)
+// Launch.storyboard contains app logo
+
+// ✅ Launch screen matches first screen (approved)
+// Launch.storyboard shows same background/layout as first screen
+// No text, no logos, minimal branding
+
+// Move branding to onboarding screen instead
+```
+
+### Custom Appearance Toggle Issues
+
+**Symptom:** Users confused by app-specific dark mode setting, double settings.
+
+**Diagnosis:**
+- App has its own Light/Dark toggle
+- Conflicts with system Settings → Display & Brightness
+
+**Solution:**
+```swift
+// ❌ App-specific appearance toggle
+.preferredColorScheme(userPreference == .dark ? .dark : .light)
+
+// ✅ Respect system preference
+// Remove custom toggle, use system preference
+// Let iOS Settings control appearance
+```
+
+---
+
+## Resources
+
+**WWDC**: 356, 2019-808
+
+**Docs**: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/materials, /design/human-interface-guidelines/typography, /design/human-interface-guidelines/layout, /design/human-interface-guidelines/accessibility, /design/human-interface-guidelines/icons
+
+**Skills**: axiom-hig, axiom-liquid-glass, axiom-liquid-glass-ref, axiom-swiftui-layout-ref, axiom-accessibility-diag, axiom-tvos
+
+---
+
+**Last Updated**: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808
+**Skill Type**: Reference (Comprehensive guide with code examples)
diff --git a/.claude/skills/axiom-hig-ref/agents/openai.yaml b/.claude/skills/axiom-hig-ref/agents/openai.yaml
new file mode 100644
index 0000000..6a2858e
--- /dev/null
+++ b/.claude/skills/axiom-hig-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "HIG Reference"
+ short_description: "Reference — Comprehensive Apple Human Interface Guidelines covering colors (semantic, custom, patterns), backgrounds ..."
diff --git a/.claude/skills/axiom-hig/.openskills.json b/.claude/skills/axiom-hig/.openskills.json
new file mode 100644
index 0000000..8fbc8ba
--- /dev/null
+++ b/.claude/skills/axiom-hig/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-hig",
+ "installedAt": "2026-04-12T08:06:22.503Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-hig/SKILL.md b/.claude/skills/axiom-hig/SKILL.md
new file mode 100644
index 0000000..3d28dc0
--- /dev/null
+++ b/.claude/skills/axiom-hig/SKILL.md
@@ -0,0 +1,447 @@
+---
+name: axiom-hig
+description: Use when making design decisions, reviewing UI for HIG compliance, choosing colors/backgrounds/typography, or defending design choices - quick decision frameworks and checklists for Apple Human Interface Guidelines
+license: MIT
+compatibility: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS. iOS 13+ (Dark Mode), iOS 17+ (latest semantic colors), iOS 26+ (Liquid Glass)
+metadata:
+ version: "1.0.0"
+---
+
+# Apple Human Interface Guidelines — Quick Reference
+
+## When to Use This Skill
+
+Use when:
+- Making visual design decisions (colors, backgrounds, typography)
+- Reviewing UI for HIG compliance
+- Answering "Should I use a dark background?"
+- Choosing between design options
+- Defending design decisions to stakeholders
+- Quick lookups for common design questions
+
+#### Related Skills
+- Use `axiom-hig-ref` for comprehensive details and code examples
+- Use `axiom-liquid-glass` for iOS 26 material design implementation and version-conditional design (supporting both pre-Liquid Glass and Liquid Glass in the same app)
+- Use `axiom-liquid-glass-ref` for iOS 26 app-wide adoption guide with backward compatibility strategy
+- Use `axiom-accessibility-diag` for accessibility troubleshooting
+
+#### Version-Conditional Design
+When supporting both iOS 25 (pre-Liquid Glass) and iOS 26+, see `axiom-liquid-glass` for the adoption strategy — it covers when to use `#available(iOS 26, *)`, how to degrade gracefully, and which system components adopt Liquid Glass automatically vs which need explicit opt-in.
+
+---
+
+## Quick Decision Trees
+
+### Background Color Decision
+
+```
+Is your app media-focused (photos, videos, music)?
+├─ Yes → Consider permanent dark appearance
+│ WHY: "Lets UI recede, helps people focus on media" (Apple HIG)
+│ EXAMPLES: Apple Music, Photos, Clock apps use dark
+│ CODE: .preferredColorScheme(.dark) on root view
+│
+└─ No → Use system backgrounds (respect user preference)
+ CODE: systemBackground (adapts to light/dark automatically)
+ GROUPED: systemGroupedBackground for iOS Settings-style lists
+```
+
+**Apple's guidance:** "In rare cases, consider using only a dark appearance in the interface. For example, it can make sense for an app that enables immersive media viewing to use a permanently dark appearance."
+
+### Color Selection Decision
+
+```
+Do you need a specific color value?
+├─ No → Use semantic colors
+│ label, secondaryLabel, tertiaryLabel, quaternaryLabel
+│ systemBackground, secondarySystemBackground, tertiarySystemBackground
+│ WHY: Automatically adapts to light/dark/high contrast
+│
+└─ Yes → Create Color Set in asset catalog
+ 1. Open Assets.xcassets
+ 2. Add Color Set
+ 3. Configure variants:
+ ├─ Light mode color
+ ├─ Dark mode color
+ └─ High contrast (optional but recommended)
+```
+
+**Key principle:** "Use semantic color names like labelColor that automatically adjust to the current interface style."
+
+### Font Weight Decision
+
+```
+Which font weight should I use?
+├─ ❌ AVOID: Ultralight, Thin, Light
+│ WHY: Legibility issues, especially at small sizes
+│
+├─ ✅ PREFER: Regular, Medium, Semibold, Bold
+│ WHY: Maintains legibility across sizes and conditions
+│
+└─ Headers: Semibold or Bold for hierarchy
+ Body: Regular or Medium
+```
+
+**Apple's guidance:** "Avoid light font weights. Prefer Regular, Medium, Semibold, or Bold weights instead of Ultralight, Thin, or Light."
+
+---
+
+## Core Principles Checklist
+
+### Before Shipping Any UI
+
+**Verify every screen passes these checks:**
+
+#### Appearance
+- [ ] Works in Light Mode
+- [ ] Works in Dark Mode
+- [ ] Passes with Increased Contrast enabled
+- [ ] Passes with Reduce Transparency enabled
+
+#### Typography
+- [ ] Supports Dynamic Type (text scales to 200%)
+- [ ] No light font weights (Regular minimum)
+- [ ] Hierarchy clear at all text sizes
+- [ ] No truncation at large text sizes
+
+#### Accessibility
+- [ ] Contrast ratio ≥ 4.5:1 minimum
+- [ ] Contrast ratio ≥ 7:1 for small text (recommended)
+- [ ] Touch targets ≥ 44x44 points
+- [ ] Information conveyed by more than color alone
+- [ ] VoiceOver labels for all interactive elements
+
+#### Motion
+- [ ] Respects Reduce Motion setting
+- [ ] Animations can be canceled/skipped
+- [ ] No auto-playing video without controls
+
+#### Localization
+- [ ] No hardcoded strings in images
+- [ ] Right-to-left language support
+- [ ] Proper text directionality
+
+---
+
+## Common Design Questions
+
+### Q: Should my app have a dark background?
+
+**A:** Only for media-focused apps (photos, videos, music) where content should be the hero. Use system backgrounds for everything else.
+
+**Apple's own apps:**
+| App | Background | Reason |
+|-----|------------|--------|
+| Music | Dark | Album art is focus |
+| Photos | Dark | Images are hero |
+| Clock | Dark | Nighttime use |
+| Notes | System | Document editing |
+| Settings | System | Utilitarian |
+
+**Code:**
+```swift
+// ❌ WRONG - Don't override unless media-focused
+.background(Color.black)
+
+// ✅ CORRECT - Let system decide
+.background(Color(.systemBackground))
+```
+
+### Q: What's the right background color?
+
+**A:** Use `systemBackground` which adapts to light/dark automatically. For grouped content (like iOS Settings), use `systemGroupedBackground`.
+
+**Color hierarchy:**
+- Primary: `systemBackground` - Main background
+- Secondary: `secondarySystemBackground` - Grouping elements
+- Tertiary: `tertiarySystemBackground` - Grouping within secondary
+
+```swift
+// ✅ Standard list
+List { }
+ .background(Color(.systemBackground))
+
+// ✅ Grouped list (Settings style)
+List { }
+ .listStyle(.grouped)
+ .background(Color(.systemGroupedBackground))
+```
+
+### Q: How do I ensure legibility?
+
+**A:** Use semantic label colors, maintain 4.5:1 contrast, avoid light font weights.
+
+**Label hierarchy:**
+```swift
+// Most prominent
+Text("Title").foregroundStyle(.primary)
+
+// Subtitles
+Text("Subtitle").foregroundStyle(.secondary)
+
+// Tertiary information
+Text("Detail").foregroundStyle(.tertiary)
+
+// Disabled text
+Text("Disabled").foregroundStyle(.quaternary)
+```
+
+### Q: Should I use SF Symbols or custom icons?
+
+**A:** SF Symbols unless you need brand-specific imagery. They scale with Dynamic Type and adapt to appearance automatically.
+
+**Benefits of SF Symbols:**
+- 5,000+ symbols included (SF Symbols 5)
+- Automatic light/dark adaptation
+- Scale with Dynamic Type
+- Become bolder with Bold Text accessibility
+- Nine weights matching San Francisco font
+
+**When to use custom:**
+- Brand-specific imagery
+- App-specific concepts not in SF Symbols
+- Unique visual style requirement
+
+### Q: Light/Dark Mode or user choice?
+
+**A:** Always support both. Never create app-specific appearance settings.
+
+**Apple's guidance:** "Avoid creating app-specific appearance settings. Users expect apps to honor their systemwide Dark Mode choice. An app-specific appearance mode option creates more work for people because they have to adjust more than one setting to get the appearance they want."
+
+### Q: What contrast ratio do I need?
+
+**A:** 4.5:1 minimum for normal text, 7:1 recommended for small text.
+
+**WCAG Contrast Standards:**
+- **AA (required):** 4.5:1 for normal text, 3:1 for large text (18pt+/14pt+ bold)
+- **AAA (enhanced):** 7:1 for normal text, 4.5:1 for large text
+- **Apple guidance:** Use semantic colors which automatically meet AA requirements
+
+**Testing:** Use online contrast calculators or Xcode's Accessibility Inspector.
+
+### Q: What's the minimum touch target size?
+
+**A:** 44x44 points on iOS/iPadOS, with spacing between targets.
+
+**Platform-specific:**
+- iOS/iPadOS: 44x44 points minimum
+- macOS: 20x20 points minimum; larger for primary actions
+- watchOS: Use system controls (optimized for small screen)
+- tvOS: 60+ point spacing for focus clarity
+
+---
+
+## Design Review Checklist
+
+### When Reviewing Any Design
+
+Use this checklist for design reviews, App Store submissions, or stakeholder presentations:
+
+#### Content-First Design
+- [ ] Does UI defer to content? (Not competing for attention)
+- [ ] Is branding restrained? (No logo on every screen)
+- [ ] Are backgrounds content-appropriate? (Media apps dark, others system)
+
+#### Platform Consistency
+- [ ] Does it feel native to iOS/iPad/Mac?
+- [ ] Uses system colors and fonts?
+- [ ] Standard gestures work as expected?
+- [ ] Navigation patterns familiar?
+
+#### Accessibility Compliance
+- [ ] All contrast ratios meet requirements?
+- [ ] All touch targets ≥ 44x44 points?
+- [ ] Information conveyed beyond color?
+- [ ] VoiceOver labels complete?
+- [ ] Dynamic Type supported?
+
+#### Light & Dark Modes
+- [ ] Works in both appearance modes?
+- [ ] Colors adapt automatically?
+- [ ] No hardcoded color values?
+- [ ] Increased Contrast tested?
+
+#### Localization-Ready
+- [ ] No hardcoded strings in images?
+- [ ] RTL language support?
+- [ ] Text doesn't truncate?
+- [ ] Layouts adapt to text size?
+
+---
+
+## Design Review Pressure: Defending HIG Decisions
+
+### The Problem
+
+In design reviews, you'll hear:
+- "Let's add our logo to every screen for brand consistency"
+- "Use light font weights—they look more elegant"
+- "Make a custom appearance toggle—some users prefer dark"
+- "This screen needs a splash screen for our brand"
+
+These violate HIG. Here's how to push back professionally.
+
+### Red Flags — Requests That Violate HIG
+
+If you hear ANY of these, **reference this skill**:
+
+- ❌ **"Add logo to navigation bar"** — Wastes space, distracts from content
+- ❌ **"Use Ultralight font"** — Legibility issues, fails accessibility
+- ❌ **"Custom dark mode toggle"** — Creates more work for users, ignores system preference
+- ❌ **"Splash screen for branding"** — Launch screens can't include branding
+- ❌ **"Custom brand color for all text"** — May fail contrast requirements
+
+### How to Push Back Professionally
+
+#### Step 1: Show the HIG Guidance
+
+```
+"I want to make this change, but let me show you Apple's guidance:
+
+[Show the relevant HIG section from this skill or hig-ref]
+
+Apple explicitly recommends against this because..."
+```
+
+#### Step 2: Demonstrate the Risk
+
+**For contrast issues:**
+- Show the design at 4.5:1 contrast (passing)
+- Show their proposal (failing)
+- Explain App Store rejection risk
+
+**For appearance toggles:**
+- Show iOS Settings → Display & Brightness
+- Explain users already have this control
+- Demonstrate confusion of two separate settings
+
+#### Step 3: Offer Compromise
+
+```
+"I understand the brand concern. Here are HIG-compliant alternatives:
+
+1. Use your brand color as the app's tint color
+2. Feature branding in onboarding (not launch screen)
+3. Use your accent color for primary actions
+4. Include subtle branding in content, not chrome"
+```
+
+#### Step 4: Document the Decision
+
+If overruled:
+
+```
+Slack message to PM + designer:
+
+"Design review decided to [violate HIG guidance].
+
+Important risks to monitor:
+- App Store rejection (HIG violations)
+- Accessibility issues (users with visual impairments)
+- User complaints (departure from platform norms)
+
+I'm flagging this proactively. If we see issues after launch,
+we'll need an expedited follow-up."
+```
+
+### When to Accept the Design Decision
+
+Sometimes designers have valid reasons to override HIG. Accept if:
+
+- [ ] They understand the HIG guidance
+- [ ] They're willing to accept rejection/accessibility risks
+- [ ] You document the decision in writing
+- [ ] They commit to monitoring post-launch feedback
+
+---
+
+## Three Core HIG Principles
+
+Every design decision should support these principles:
+
+### 1. Clarity
+
+**Definition:** Content should be paramount, interface elements should defer to content.
+
+**In practice:**
+- White space is your friend
+- Every element has a purpose
+- Remove anything that doesn't serve the user
+- Users should know what they can do without instructions
+
+### 2. Consistency
+
+**Definition:** Use standard UI elements and familiar patterns.
+
+**In practice:**
+- Standard gestures work as expected
+- Navigation follows platform conventions
+- Colors and fonts use system values
+- Familiar components in familiar locations
+
+### 3. Deference
+
+**Definition:** UI shouldn't compete with content for attention.
+
+**In practice:**
+- Subtle backgrounds, not bold
+- Navigation recedes when not needed
+- Content is the hero
+- Branding is restrained
+
+**From HIG:** "Deference makes an app beautiful by ensuring the content stands out while the surrounding visual elements do not compete with it."
+
+---
+
+## Platform-Specific Quick Tips
+
+### iOS
+- Portrait-first design
+- One-handed reachability
+- Bottom tab bar for primary navigation
+- Swipe back gesture
+
+### iPadOS
+- Sidebar-adaptable layouts
+- Split view support
+- Pointer interactions
+- Arbitrary window sizing (iOS 26+)
+
+### macOS
+- Menu bar for commands
+- Dense layouts acceptable
+- Pointer-first interactions
+- Window chrome and controls
+
+### watchOS
+- Glanceable interfaces
+- Full-bleed content
+- Minimal padding
+- Digital Crown interactions
+
+### tvOS
+- Focus-based navigation
+- 10-foot viewing distance
+- Large touch targets
+- Gestural remote
+
+### visionOS
+- Spatial layout
+- Glass materials
+- Comfortable viewing depth
+- Avoid head-anchored content
+
+---
+
+## Resources
+
+**WWDC**: 356, 2019-808
+
+**Docs**: /design/human-interface-guidelines, /design/human-interface-guidelines/color, /design/human-interface-guidelines/dark-mode, /design/human-interface-guidelines/typography
+
+**Skills**: axiom-hig-ref, axiom-liquid-glass, axiom-liquid-glass-ref, axiom-accessibility-diag
+
+---
+
+**Last Updated**: Based on Apple HIG (2024-2025), WWDC25-356, WWDC19-808
+**Skill Type**: Discipline (Quick decisions, checklists, pressure scenarios)
diff --git a/.claude/skills/axiom-hig/agents/openai.yaml b/.claude/skills/axiom-hig/agents/openai.yaml
new file mode 100644
index 0000000..b13eeb8
--- /dev/null
+++ b/.claude/skills/axiom-hig/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "HIG"
+ short_description: "Making design decisions, reviewing UI for HIG compliance, choosing colors/backgrounds/typography, or defending design..."
diff --git a/.claude/skills/axiom-icloud-drive-ref/.openskills.json b/.claude/skills/axiom-icloud-drive-ref/.openskills.json
new file mode 100644
index 0000000..6da4c2f
--- /dev/null
+++ b/.claude/skills/axiom-icloud-drive-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-icloud-drive-ref",
+ "installedAt": "2026-04-12T08:06:23.761Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-icloud-drive-ref/SKILL.md b/.claude/skills/axiom-icloud-drive-ref/SKILL.md
new file mode 100644
index 0000000..4d609e0
--- /dev/null
+++ b/.claude/skills/axiom-icloud-drive-ref/SKILL.md
@@ -0,0 +1,496 @@
+---
+name: axiom-icloud-drive-ref
+description: Use when implementing 'iCloud Drive', 'ubiquitous container', 'file sync', 'NSFileCoordinator', 'NSFilePresenter', 'isUbiquitousItem', 'NSUbiquitousKeyValueStore', 'ubiquitous file sync' - comprehensive file-based iCloud sync reference
+license: MIT
+compatibility: iOS 5.0+, iPadOS 13.0+, macOS 10.7+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-12"
+---
+
+# iCloud Drive Reference
+
+**Purpose**: Comprehensive reference for file-based iCloud sync using ubiquitous containers
+**Availability**: iOS 5.0+ (basic), iOS 8.0+ (iCloud Drive), iOS 11.0+ (modern APIs)
+**Context**: File-based cloud storage, not database (use CloudKit for structured data)
+
+## When to Use This Skill
+
+Use this skill when:
+- Implementing document-based iCloud sync
+- Syncing user files across devices
+- Building document-based apps (like Pages, Numbers)
+- Coordinating file access across processes
+- Handling iCloud file conflicts
+- Using NSUbiquitousKeyValueStore for preferences
+
+**NOT for**: Structured data with relationships (use `axiom-cloudkit-ref` instead)
+
+---
+
+## Overview
+
+**iCloud Drive is for FILE-BASED sync**, not structured data.
+
+**Use when**:
+- User creates/edits documents
+- Files need to sync like Dropbox
+- Document picker integration
+
+**Don't use when**:
+- Need queryable structured data (use CloudKit)
+- Need relationships between records (use CloudKit)
+- Small key-value preferences (use NSUbiquitousKeyValueStore)
+
+---
+
+## Ubiquitous Containers
+
+### Getting Ubiquitous Container URL
+
+```swift
+// ✅ CORRECT: Get iCloud container
+func getICloudContainerURL() -> URL? {
+ // nil = use first container in entitlements
+ return FileManager.default.url(
+ forUbiquityContainerIdentifier: nil
+ )
+}
+
+// ✅ Check if iCloud is available
+if let iCloudURL = getICloudContainerURL() {
+ print("iCloud available: \(iCloudURL)")
+} else {
+ print("iCloud not available (not signed in or no entitlement)")
+}
+```
+
+### Container Structure
+
+```
+iCloud Container/
+├── Documents/ # User-visible files (Files app)
+│ └── MyApp/ # Your app's documents
+├── Library/ # Hidden from user
+│ ├── Application Support/
+│ └── Caches/
+```
+
+### Saving to iCloud Drive
+
+```swift
+// ✅ CORRECT: Save document to iCloud
+func saveToICloud(data: Data, filename: String) throws {
+ guard let iCloudURL = FileManager.default.url(
+ forUbiquityContainerIdentifier: nil
+ ) else {
+ throw iCloudError.notAvailable
+ }
+
+ let documentsURL = iCloudURL.appendingPathComponent("Documents")
+
+ // Create directory if needed
+ try FileManager.default.createDirectory(
+ at: documentsURL,
+ withIntermediateDirectories: true
+ )
+
+ let fileURL = documentsURL.appendingPathComponent(filename)
+
+ // Use file coordination for safe access
+ let coordinator = NSFileCoordinator()
+ var error: NSError?
+
+ coordinator.coordinate(
+ writingItemAt: fileURL,
+ options: .forReplacing,
+ error: &error
+ ) { newURL in
+ try? data.write(to: newURL)
+ }
+
+ if let error = error {
+ throw error
+ }
+}
+```
+
+---
+
+## File Coordination (Critical for Safety)
+
+**Always use NSFileCoordinator** when accessing iCloud files. This prevents:
+- Race conditions with sync
+- Data corruption
+- Lost updates
+
+### Reading Files
+
+```swift
+// ✅ CORRECT: Coordinated read
+func readICloudFile(url: URL) throws -> Data {
+ let coordinator = NSFileCoordinator()
+ var data: Data?
+ var coordinationError: NSError?
+
+ coordinator.coordinate(
+ readingItemAt: url,
+ options: [],
+ error: &coordinationError
+ ) { newURL in
+ data = try? Data(contentsOf: newURL)
+ }
+
+ if let error = coordinationError {
+ throw error
+ }
+
+ guard let data = data else {
+ throw fileError.readFailed
+ }
+
+ return data
+}
+```
+
+### Writing Files
+
+```swift
+// ✅ CORRECT: Coordinated write
+func writeICloudFile(data: Data, to url: URL) throws {
+ let coordinator = NSFileCoordinator()
+ var coordinationError: NSError?
+
+ coordinator.coordinate(
+ writingItemAt: url,
+ options: .forReplacing,
+ error: &coordinationError
+ ) { newURL in
+ try? data.write(to: newURL)
+ }
+
+ if let error = coordinationError {
+ throw error
+ }
+}
+```
+
+### Moving Files
+
+```swift
+// ✅ CORRECT: Coordinated move
+func moveFile(from sourceURL: URL, to destURL: URL) throws {
+ let coordinator = NSFileCoordinator()
+ var coordinationError: NSError?
+
+ coordinator.coordinate(
+ writingItemAt: sourceURL,
+ options: .forMoving,
+ writingItemAt: destURL,
+ options: .forReplacing,
+ error: &coordinationError
+ ) { newSource, newDest in
+ try? FileManager.default.moveItem(at: newSource, to: newDest)
+ }
+
+ if let error = coordinationError {
+ throw error
+ }
+}
+```
+
+---
+
+## URL Resource Values for iCloud
+
+### Checking iCloud Status
+
+```swift
+// ✅ Check if file is in iCloud
+func isInICloud(url: URL) -> Bool {
+ let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey])
+ return values?.isUbiquitousItem ?? false
+}
+
+// ✅ Check download status
+func getDownloadStatus(url: URL) -> String {
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemDownloadingStatusKey,
+ .ubiquitousItemIsDownloadingKey,
+ .ubiquitousItemDownloadingErrorKey
+ ])
+
+ if let downloading = values?.ubiquitousItemIsDownloading, downloading {
+ return "Downloading..."
+ }
+
+ if let status = values?.ubiquitousItemDownloadingStatus {
+ switch status {
+ case .current:
+ return "Downloaded"
+ case .notDownloaded:
+ return "Not downloaded (iCloud only)"
+ case .downloaded:
+ return "Downloaded"
+ @unknown default:
+ return "Unknown"
+ }
+ }
+
+ return "Unknown"
+}
+
+// ✅ Check upload status
+func isUploading(url: URL) -> Bool {
+ let values = try? url.resourceValues(forKeys: [.ubiquitousItemIsUploadingKey])
+ return values?.ubiquitousItemIsUploading ?? false
+}
+
+// ✅ Check for conflicts
+func hasConflicts(url: URL) -> Bool {
+ let values = try? url.resourceValues(forKeys: [
+ .ubiquitousItemHasUnresolvedConflictsKey
+ ])
+ return values?.ubiquitousItemHasUnresolvedConflicts ?? false
+}
+```
+
+### Downloading Files
+
+```swift
+// ✅ CORRECT: Request download
+func downloadFromICloud(url: URL) throws {
+ try FileManager.default.startDownloadingUbiquitousItem(at: url)
+}
+
+// ✅ Monitor download progress
+let query = NSMetadataQuery()
+query.predicate = NSPredicate(format: "%K == %@",
+ NSMetadataItemURLKey, url as NSURL)
+query.searchScopes = [NSMetadataQueryUbiquitousDataScope]
+
+NotificationCenter.default.addObserver(
+ forName: .NSMetadataQueryDidUpdate,
+ object: query,
+ queue: .main
+) { notification in
+ // Check progress
+ if let item = query.results.first as? NSMetadataItem {
+ if let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double {
+ print("Downloaded: \(percent)%")
+ }
+ }
+}
+
+query.start()
+```
+
+---
+
+## Conflict Resolution
+
+### Detecting Conflicts
+
+```swift
+// ✅ Get conflict versions
+func getConflictVersions(for url: URL) -> [NSFileVersion]? {
+ return NSFileVersion.unresolvedConflictVersionsOfItem(at: url)
+}
+```
+
+### Resolving Conflicts
+
+```swift
+// ✅ CORRECT: Resolve conflicts
+func resolveConflicts(at url: URL, keepingVersion: ConflictResolution) throws {
+ guard let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url),
+ !conflicts.isEmpty else {
+ return // No conflicts
+ }
+
+ let current = try NSFileVersion.currentVersionOfItem(at: url)
+
+ switch keepingVersion {
+ case .current:
+ // Keep current version, discard others
+ for conflict in conflicts {
+ conflict.isResolved = true
+ }
+
+ case .other(let chosenVersion):
+ // Replace current with chosen conflict version
+ try chosenVersion.replaceItem(at: url, options: [])
+ chosenVersion.isResolved = true
+
+ // Mark other conflicts as resolved
+ for conflict in conflicts where conflict != chosenVersion {
+ conflict.isResolved = true
+ }
+
+ case .manual:
+ // App merges manually, then marks resolved
+ let mergedData = mergeConflicts(current: current, conflicts: conflicts)
+ try mergedData.write(to: url)
+
+ for conflict in conflicts {
+ conflict.isResolved = true
+ }
+ }
+
+ // Remove resolved versions
+ try NSFileVersion.removeOtherVersionsOfItem(at: url)
+}
+
+enum ConflictResolution {
+ case current
+ case other(NSFileVersion)
+ case manual
+}
+```
+
+---
+
+## NSUbiquitousKeyValueStore (Preferences Sync)
+
+**For small preferences only** (<1 MB total, <1024 keys)
+
+```swift
+// ✅ CORRECT: Sync small preferences
+let store = NSUbiquitousKeyValueStore.default
+
+// Set values
+store.set(true, forKey: "darkModeEnabled")
+store.set(2.0, forKey: "textSizeMultiplier")
+store.set(["en", "es"], forKey: "selectedLanguages")
+
+// Synchronize
+store.synchronize()
+
+// Read values
+let darkMode = store.bool(forKey: "darkModeEnabled")
+let textSize = store.double(forKey: "textSizeMultiplier")
+
+// Listen for changes from other devices
+NotificationCenter.default.addObserver(
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
+ object: store,
+ queue: .main
+) { notification in
+ // Update UI with new values
+ updatePreferences()
+}
+```
+
+**Limitations**:
+- Total storage: 1 MB
+- Max keys: 1024
+- Max value size: 1 MB
+- Use only for preferences, not data
+
+---
+
+## Entitlements
+
+```xml
+
+com.apple.developer.icloud-services
+
+ CloudDocuments
+
+
+
+com.apple.developer.ubiquity-container-identifiers
+
+ iCloud.com.example.app
+
+
+
+com.apple.developer.ubiquity-kvstore-identifier
+$(TeamIdentifierPrefix)com.example.app
+```
+
+---
+
+## Common Patterns
+
+### Pattern 1: Document Picker Integration
+
+```swift
+// ✅ Present iCloud document picker
+import UniformTypeIdentifiers
+
+let picker = UIDocumentPickerViewController(
+ forOpeningContentTypes: [.pdf, .plainText]
+)
+picker.delegate = self
+picker.allowsMultipleSelection = false
+
+// Enable iCloud
+picker.directoryURL = getICloudContainerURL()
+
+present(picker, animated: true)
+```
+
+### Pattern 2: Monitor Directory for Changes
+
+```swift
+// ✅ Monitor iCloud directory
+class ICloudMonitor {
+ let query = NSMetadataQuery()
+
+ func startMonitoring(directory: URL) {
+ query.predicate = NSPredicate(format: "%K BEGINSWITH %@",
+ NSMetadataItemPathKey, directory.path)
+
+ query.searchScopes = [NSMetadataQueryUbiquitousDataScope]
+
+ NotificationCenter.default.addObserver(
+ forName: .NSMetadataQueryDidUpdate,
+ object: query,
+ queue: .main
+ ) { [weak self] _ in
+ self?.processResults()
+ }
+
+ query.start()
+ }
+
+ func processResults() {
+ for item in query.results {
+ if let metadataItem = item as? NSMetadataItem,
+ let url = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL {
+ print("File: \(url.lastPathComponent)")
+ }
+ }
+ }
+}
+```
+
+---
+
+## Quick Reference
+
+| Task | API | Notes |
+|------|-----|-------|
+| Get iCloud URL | `FileManager.default.url(forUbiquityContainerIdentifier:)` | Returns nil if unavailable |
+| Check if in iCloud | `.isUbiquitousItemKey` resource value | Bool |
+| Download file | `startDownloadingUbiquitousItem(at:)` | Async, monitor with NSMetadataQuery |
+| Check download status | `.ubiquitousItemDownloadingStatusKey` | current/notDownloaded/downloaded |
+| Check for conflicts | `.ubiquitousItemHasUnresolvedConflictsKey` | Bool |
+| Resolve conflicts | `NSFileVersion.unresolvedConflictVersionsOfItem(at:)` | Manual merge or choose version |
+| Sync preferences | `NSUbiquitousKeyValueStore.default` | <1 MB total |
+| File coordination | `NSFileCoordinator` | **Always** use for iCloud files |
+
+---
+
+## Related Skills
+
+- `axiom-storage` — Choose iCloud Drive vs CloudKit
+- `axiom-cloudkit-ref` — For structured data sync
+- `axiom-cloud-sync-diag` — Debug iCloud sync issues
+
+---
+
+**Last Updated**: 2025-12-12
+**Skill Type**: Reference
+**Minimum iOS**: 5.0 (basic), 8.0 (iCloud Drive), 11.0 (modern APIs)
diff --git a/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml b/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml
new file mode 100644
index 0000000..f648689
--- /dev/null
+++ b/.claude/skills/axiom-icloud-drive-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "iCloud Drive Reference"
+ short_description: "Implementing 'iCloud Drive', 'ubiquitous container', 'file sync', 'NSFileCoordinator', 'NSFilePresenter', 'isUbiquito..."
diff --git a/.claude/skills/axiom-implement-iap/.openskills.json b/.claude/skills/axiom-implement-iap/.openskills.json
new file mode 100644
index 0000000..6edf14a
--- /dev/null
+++ b/.claude/skills/axiom-implement-iap/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-implement-iap",
+ "installedAt": "2026-04-12T08:06:23.762Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-implement-iap/SKILL.md b/.claude/skills/axiom-implement-iap/SKILL.md
new file mode 100644
index 0000000..9933b85
--- /dev/null
+++ b/.claude/skills/axiom-implement-iap/SKILL.md
@@ -0,0 +1,172 @@
+---
+name: axiom-implement-iap
+description: Use when the user wants to add in-app purchases, implement StoreKit 2, or set up subscriptions.
+license: MIT
+disable-model-invocation: true
+---
+# In-App Purchase Implementation Agent
+
+You are an expert at implementing production-ready in-app purchases using StoreKit 2.
+
+## Your Mission
+
+Implement complete IAP following testing-first workflow:
+1. Create StoreKit configuration FIRST
+2. Implement centralized StoreManager
+3. Add transaction listener and verification
+4. Implement purchase flows
+5. Add subscription management (if applicable)
+6. Implement restore purchases
+7. Provide testing instructions
+
+## Phase 1: Gather Requirements
+
+Ask the user:
+1. **Product types**: Consumables, non-consumables, subscriptions?
+2. **Product IDs**: Format `com.company.app.product_name`
+3. **Server backend**: For appAccountToken integration?
+4. **Subscription details**: Group ID, tiers, trial duration?
+
+## Phase 2: Create StoreKit Configuration (FIRST!)
+
+**CRITICAL**: Create `.storekit` file BEFORE any Swift code!
+
+1. Create via Xcode: File → New → File → StoreKit Configuration File
+2. Add products with ID, name, price
+3. Configure scheme: Edit Scheme → Run → Options → StoreKit Configuration
+4. Test products load before proceeding
+
+## Phase 3: Implement StoreManager
+
+Create `StoreManager.swift` with these essential components:
+
+```swift
+@MainActor
+final class StoreManager: ObservableObject {
+ @Published private(set) var products: [Product] = []
+ @Published private(set) var purchasedProductIDs: Set = []
+ private var transactionListener: Task?
+
+ init(productIDs: [String]) {
+ // Start transaction listener IMMEDIATELY
+ transactionListener = listenForTransactions()
+ Task { await loadProducts(); await updatePurchasedProducts() }
+ }
+
+ // CRITICAL: Transaction listener handles ALL purchase sources
+ func listenForTransactions() -> Task {
+ Task.detached { [weak self] in
+ for await result in Transaction.updates {
+ await self?.handleTransaction(result)
+ }
+ }
+ }
+
+ private func handleTransaction(_ result: VerificationResult) async {
+ guard let transaction = try? result.payloadValue else { return }
+ if transaction.revocationDate != nil {
+ // Handle refund
+ await transaction.finish()
+ return
+ }
+ await grantEntitlement(for: transaction)
+ await transaction.finish() // CRITICAL: Always finish
+ await updatePurchasedProducts()
+ }
+
+ func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
+ let result = try await product.purchase(confirmIn: scene)
+ switch result {
+ case .success(let verification):
+ guard let tx = try? verification.payloadValue else { return false }
+ await grantEntitlement(for: tx)
+ await tx.finish()
+ return true
+ case .userCancelled, .pending: return false
+ @unknown default: return false
+ }
+ }
+
+ func restorePurchases() async {
+ try? await AppStore.sync()
+ await updatePurchasedProducts()
+ }
+}
+```
+
+**Key Requirements**:
+- ✅ Transaction listener (handles ALL purchase sources)
+- ✅ Transaction verification
+- ✅ Always calls finish()
+- ✅ Handles refunds
+- ✅ @MainActor for UI state
+
+## Phase 4: Purchase UI
+
+**Custom View** or **StoreKit Views** (iOS 17+):
+```swift
+// Custom
+Button(product.displayPrice) {
+ Task { _ = try await store.purchase(product, confirmIn: scene) }
+}
+
+// StoreKit Views (simpler)
+StoreKit.StoreView(ids: productIDs)
+SubscriptionStoreView(groupID: "pro_tier")
+```
+
+## Phase 5: Subscription Management (If Applicable)
+
+Check subscription status via:
+```swift
+let statuses = try? await Product.SubscriptionInfo.status(for: groupID)
+// Handle: .subscribed, .expired, .inGracePeriod, .inBillingRetryPeriod
+```
+
+## Phase 6: Restore Purchases (REQUIRED)
+
+**App Store Requirement**: Non-consumables/subscriptions MUST have restore:
+```swift
+Button("Restore Purchases") {
+ Task { await store.restorePurchases() }
+}
+```
+
+## Deliverables
+
+1. `Products.storekit` - Configuration file
+2. `StoreManager.swift` - Centralized IAP manager
+3. Purchase UI (custom or StoreKit views)
+4. Settings with restore button
+5. Testing instructions
+
+## Implementation Checklist
+
+- [ ] StoreKit config created and tested
+- [ ] StoreManager with transaction listener
+- [ ] Purchase flow with verification
+- [ ] transaction.finish() always called
+- [ ] Entitlements tracked
+- [ ] Restore purchases implemented
+- [ ] Subscription states handled (if applicable)
+
+## Critical Pitfalls to Avoid
+
+1. ❌ Writing code before .storekit file
+2. ❌ No Transaction.updates listener
+3. ❌ Forgetting transaction.finish()
+4. ❌ No restore button (App Store rejection)
+5. ❌ Ignoring refunds (revocationDate)
+
+## Testing Instructions
+
+1. **Local**: Run with Products.storekit in scheme
+2. **Sandbox**: Create sandbox account in App Store Connect
+3. **TestFlight**: Upload build, test real flows
+4. **Production**: Use promo codes
+
+## Related
+
+For detailed patterns: `axiom-in-app-purchases` skill
+For API reference: `axiom-storekit-ref` skill
+For auditing: `iap-auditor` agent
diff --git a/.claude/skills/axiom-implement-iap/agents/openai.yaml b/.claude/skills/axiom-implement-iap/agents/openai.yaml
new file mode 100644
index 0000000..d1beae9
--- /dev/null
+++ b/.claude/skills/axiom-implement-iap/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Implement IAP"
+ short_description: "The user wants to add in-app purchases, implement StoreKit 2, or set up subscriptions."
diff --git a/.claude/skills/axiom-in-app-purchases/.openskills.json b/.claude/skills/axiom-in-app-purchases/.openskills.json
new file mode 100644
index 0000000..3377317
--- /dev/null
+++ b/.claude/skills/axiom-in-app-purchases/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-in-app-purchases",
+ "installedAt": "2026-04-12T08:06:24.266Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-in-app-purchases/SKILL.md b/.claude/skills/axiom-in-app-purchases/SKILL.md
new file mode 100644
index 0000000..83ab07c
--- /dev/null
+++ b/.claude/skills/axiom-in-app-purchases/SKILL.md
@@ -0,0 +1,887 @@
+---
+name: axiom-in-app-purchases
+description: Use when implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling - testing-first workflow with .storekit configuration, StoreManager architecture, transaction verification, subscription management, and restore purchases for consumables, non-consumables, and auto-renewable subscriptions
+license: MIT
+metadata:
+ version: "1.0"
+---
+
+# StoreKit 2 In-App Purchase Implementation
+
+**Purpose**: Guide robust, testable in-app purchase implementation
+**StoreKit Version**: StoreKit 2
+**iOS Version**: iOS 15+ (iOS 18.4+ for latest features)
+**Xcode**: Xcode 13+ (Xcode 16+ recommended)
+**Context**: WWDC 2025-241, 2025-249, 2023-10013, 2021-10114
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Implementing any in-app purchase functionality (new or existing)
+- Adding consumable products (coins, hints, boosts)
+- Adding non-consumable products (premium features, level packs)
+- Adding auto-renewable subscriptions (monthly/annual plans)
+- Debugging purchase failures, missing transactions, or restore issues
+- Setting up StoreKit testing configuration
+- Implementing subscription status tracking
+- Adding promotional offers or introductory offers
+- Server-side receipt validation
+- Family Sharing support
+
+❌ **Do NOT use this skill for**:
+- StoreKit 1 (legacy API) - this skill focuses on StoreKit 2
+- App Store Connect product configuration (separate documentation)
+- Pricing strategy or business model decisions
+
+---
+
+## ⚠️ Already Wrote Code Before Creating .storekit Config?
+
+If you wrote purchase code before creating `.storekit` configuration, you have three options:
+
+### Option A: Delete and Start Over (Strongly Recommended)
+
+Delete all IAP code and follow the testing-first workflow below. This reinforces correct habits and ensures you experience the full benefit of .storekit-first development.
+
+**Why this is best**:
+- Validates that you understand the workflow
+- Catches product ID issues you might have missed
+- Builds muscle memory for future IAP implementations
+- Takes only 15-30 minutes for experienced developers
+
+### Option B: Create .storekit Config Now (Acceptable with Caution)
+
+Create the `.storekit` file now with your existing product IDs. Test everything works locally. Document in your PR that you tested in sandbox first.
+
+**Trade-offs**:
+- ✅ Keeps working code
+- ✅ Adds local testing capability
+- ❌ Misses product ID validation benefit
+- ❌ Reinforces testing-after pattern
+- ❌ Requires extra vigilance in code review
+
+**If choosing this path**: Create .storekit immediately, verify locally, and commit a note explaining the approach.
+
+### Option C: Skip .storekit Entirely (Not Recommended)
+
+Commit without `.storekit` configuration, test only in sandbox.
+
+**Why this is problematic**:
+- Teammates can't test purchases locally
+- No validation of product IDs before runtime
+- Harder iteration (requires App Store Connect)
+- Missing documentation of product structure
+
+**Bottom line**: Choose Option A if possible, Option B if pragmatic, never Option C.
+
+---
+
+## Core Philosophy: Testing-First Workflow
+
+> **Best Practice**: Create and test StoreKit configuration BEFORE writing production purchase code.
+
+### Why .storekit-First Matters
+
+The recommended workflow is to create `.storekit` configuration before writing any purchase code. This isn't arbitrary - it provides concrete benefits:
+
+**Immediate product ID validation**:
+- Typos caught in Xcode, not at runtime
+- Product configuration visible in project
+- No App Store Connect dependency for testing
+
+**Faster iteration**:
+- Test purchases in simulator instantly
+- No network requests during development
+- Accelerated subscription renewal for testing
+
+**Team benefits**:
+- Anyone can test purchase flows locally
+- Product catalog documented in code
+- Code review includes purchase testing
+
+**Common objections addressed**:
+
+❓ **"I already tested in sandbox"** - Sandbox testing is valuable but comes later. Local testing with .storekit is faster and enables true TDD.
+
+❓ **"My code works"** - Working code is great! Adding .storekit makes it easier for teammates to verify and maintain.
+
+❓ **"I've done this before"** - Experience is valuable. The .storekit-first workflow makes experienced developers even more productive.
+
+❓ **"Time pressure"** - Creating .storekit takes 10-15 minutes. The time saved in iteration pays back immediately.
+
+### The Recommended Workflow
+
+```
+StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
+ ↓ ↓ ↓ ↓ ↓
+ .storekit Test purchases StoreManager Mock store Integration test
+```
+
+**Why this order helps**:
+1. **StoreKit Config First**: Defines products without App Store Connect dependency
+2. **Local Testing**: Validates product IDs and purchase flows immediately
+3. **Production Code**: Implements against validated product configuration
+4. **Unit Tests**: Verifies business logic with mocked store responses
+5. **Sandbox Testing**: Final validation in App Store environment
+
+**Benefits of following this workflow**:
+- Product IDs validated before writing code
+- Faster development iteration
+- Easier team collaboration
+- Better test coverage
+
+---
+
+## Mandatory Checklist
+
+Before marking IAP implementation complete, **ALL** items must be verified:
+
+### Phase 1: Testing Foundation
+- [ ] Created `.storekit` configuration file with all products
+- [ ] Verified each product type renders correctly in StoreKit preview
+- [ ] Tested successful purchase flow for each product in Xcode
+- [ ] Tested purchase failure scenarios (insufficient funds, cancelled)
+- [ ] Tested restore purchases flow
+- [ ] For subscriptions: tested renewal, expiration, and upgrade/downgrade
+
+### Phase 2: Architecture
+- [ ] Centralized StoreManager class exists (single source of truth)
+- [ ] StoreManager is ObservableObject (SwiftUI) or uses NotificationCenter
+- [ ] Transaction observer listens for updates via `Transaction.updates`
+- [ ] All transaction verification uses `VerificationResult`
+- [ ] All transactions call `.finish()` after entitlement granted
+- [ ] Product loading happens at app launch or before displaying store
+
+### Phase 3: Purchase Flow
+- [ ] Purchase uses new `purchase(confirmIn:options:)` with UI context (iOS 18.2+)
+- [ ] Purchase handles all `PurchaseResult` cases (success, userCancelled, pending)
+- [ ] Purchase verifies transaction signature before granting entitlement
+- [ ] Purchase stores transaction receipt/identifier for support
+- [ ] appAccountToken set for all purchases (if using server backend)
+
+### Phase 4: Subscription Management (if applicable)
+- [ ] Subscription status tracked via `Product.SubscriptionInfo.Status`
+- [ ] Current entitlements checked via `Transaction.currentEntitlements(for:)`
+- [ ] Renewal info accessed for expiration, renewal date, offer status
+- [ ] Subscription views use ProductView or SubscriptionStoreView
+- [ ] Win-back offers implemented for expired subscriptions
+- [ ] Grace period and billing retry states handled
+
+### Phase 5: Restore & Sync
+- [ ] Restore purchases implemented (required by App Store Review)
+- [ ] Restore uses `Transaction.currentEntitlements` or `Transaction.all`
+- [ ] Family Sharing transactions identified (if supported)
+- [ ] Server sync implemented (if using backend)
+- [ ] Cross-device entitlement sync tested
+
+### Phase 6: Error Handling
+- [ ] Network errors handled gracefully (retries, user messaging)
+- [ ] Invalid product IDs detected and logged
+- [ ] Purchase failures show user-friendly error messages
+- [ ] Transaction verification failures logged and reported
+- [ ] Refund notifications handled (via App Store Server Notifications)
+
+### Phase 7: Testing & Validation
+- [ ] Unit tests verify purchase logic with mocked Product/Transaction
+- [ ] Unit tests verify subscription status determination
+- [ ] Integration tests with StoreKit configuration pass
+- [ ] Sandbox testing with real Apple ID completed
+- [ ] TestFlight testing completed before production release
+
+---
+
+## Step 1: Create StoreKit Configuration (FIRST!)
+
+**DO THIS BEFORE WRITING ANY PURCHASE CODE.**
+
+### Create Configuration File
+
+1. **Xcode → File → New → File → StoreKit Configuration File**
+2. **Save as**: `Products.storekit` (or your app name)
+3. **Add to target**: ✅ (include in app bundle for testing)
+
+### Add Products
+
+Click "+" and add each product type:
+
+#### Consumable
+```
+Product ID: com.yourapp.coins_100
+Reference Name: 100 Coins
+Price: $0.99
+```
+
+#### Non-Consumable
+```
+Product ID: com.yourapp.premium
+Reference Name: Premium Upgrade
+Price: $4.99
+```
+
+#### Auto-Renewable Subscription
+```
+Product ID: com.yourapp.pro_monthly
+Reference Name: Pro Monthly
+Price: $9.99/month
+Subscription Group ID: pro_tier
+```
+
+### Test Immediately
+
+1. **Run app in simulator**
+2. **Scheme → Edit Scheme → Run → Options**
+3. **StoreKit Configuration**: Select `Products.storekit`
+4. **Verify**: Products load, purchases complete, transactions appear
+
+---
+
+## Step 2: Implement StoreManager Architecture
+
+### Required Pattern: Centralized StoreManager
+
+**All purchase logic must go through a single StoreManager.** No scattered `Product.purchase()` calls throughout app.
+
+```swift
+import StoreKit
+
+@MainActor
+final class StoreManager: ObservableObject {
+ // Published state for UI
+ @Published private(set) var products: [Product] = []
+ @Published private(set) var purchasedProductIDs: Set = []
+
+ // Product IDs from StoreKit configuration
+ private let productIDs = [
+ "com.yourapp.coins_100",
+ "com.yourapp.premium",
+ "com.yourapp.pro_monthly"
+ ]
+
+ private var transactionListener: Task?
+
+ init() {
+ // Start transaction listener immediately
+ transactionListener = listenForTransactions()
+
+ Task {
+ await loadProducts()
+ await updatePurchasedProducts()
+ }
+ }
+
+ deinit {
+ transactionListener?.cancel()
+ }
+}
+```
+
+**Why @MainActor**: Published properties must update on main thread for UI binding.
+
+### Load Products (At Launch)
+
+```swift
+extension StoreManager {
+ func loadProducts() async {
+ do {
+ // Load products from App Store
+ let loadedProducts = try await Product.products(for: productIDs)
+
+ // Update published property on main thread
+ self.products = loadedProducts
+
+ } catch {
+ print("Failed to load products: \(error)")
+ // Show error to user
+ }
+ }
+}
+```
+
+**Call from**: `App.init()` or first view's `.task` modifier
+
+### Listen for Transactions (REQUIRED)
+
+```swift
+extension StoreManager {
+ func listenForTransactions() -> Task {
+ Task.detached { [weak self] in
+ // Listen for ALL transaction updates
+ for await verificationResult in Transaction.updates {
+ await self?.handleTransaction(verificationResult)
+ }
+ }
+ }
+
+ @MainActor
+ private func handleTransaction(_ result: VerificationResult) async {
+ // Verify transaction signature
+ guard let transaction = try? result.payloadValue else {
+ print("Transaction verification failed")
+ return
+ }
+
+ // Grant entitlement to user
+ await grantEntitlement(for: transaction)
+
+ // CRITICAL: Always finish transaction
+ await transaction.finish()
+
+ // Update purchased products
+ await updatePurchasedProducts()
+ }
+}
+```
+
+**Why detached**: Transaction listener runs independently of view lifecycle
+
+---
+
+## Step 3: Implement Purchase Flow
+
+### Purchase with UI Context (iOS 18.2+)
+
+```swift
+extension StoreManager {
+ func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
+ // Perform purchase with UI context for payment sheet
+ let result = try await product.purchase(confirmIn: scene)
+
+ switch result {
+ case .success(let verificationResult):
+ // Verify the transaction
+ guard let transaction = try? verificationResult.payloadValue else {
+ print("Transaction verification failed")
+ return false
+ }
+
+ // Grant entitlement
+ await grantEntitlement(for: transaction)
+
+ // CRITICAL: Finish transaction
+ await transaction.finish()
+
+ // Update state
+ await updatePurchasedProducts()
+
+ return true
+
+ case .userCancelled:
+ // User tapped "Cancel" in payment sheet
+ return false
+
+ case .pending:
+ // Purchase requires action (Ask to Buy, payment issue)
+ // Will be delivered via Transaction.updates when approved
+ return false
+
+ @unknown default:
+ return false
+ }
+ }
+}
+```
+
+### SwiftUI Purchase (Using Environment)
+
+```swift
+struct ProductRow: View {
+ let product: Product
+ @Environment(\.purchase) private var purchase
+
+ var body: some View {
+ Button("Buy \(product.displayPrice)") {
+ Task {
+ do {
+ let result = try await purchase(product)
+ // Handle result
+ } catch {
+ print("Purchase failed: \(error)")
+ }
+ }
+ }
+ }
+}
+```
+
+### Set appAccountToken (If Using Backend)
+
+```swift
+func purchase(
+ _ product: Product,
+ confirmIn scene: UIWindowScene,
+ accountToken: UUID
+) async throws -> Bool {
+ // Purchase with appAccountToken for server-side association
+ let result = try await product.purchase(
+ confirmIn: scene,
+ options: [
+ .appAccountToken(accountToken)
+ ]
+ )
+
+ // ... handle result
+}
+```
+
+**When to use**: When your backend needs to associate purchases with user accounts
+
+---
+
+## Step 4: Verify Transactions (MANDATORY)
+
+### Always Use VerificationResult
+
+```swift
+func handleTransaction(_ result: VerificationResult) async {
+ switch result {
+ case .verified(let transaction):
+ // ✅ Transaction signed by App Store
+ await grantEntitlement(for: transaction)
+ await transaction.finish()
+
+ case .unverified(let transaction, let error):
+ // ❌ Transaction signature invalid
+ print("Unverified transaction: \(error)")
+ // DO NOT grant entitlement
+ // DO finish transaction to clear from queue
+ await transaction.finish()
+ }
+}
+```
+
+**Why verify**: Prevents granting entitlements for:
+- Fraudulent receipts
+- Jailbroken device receipts
+- Man-in-the-middle attacks
+
+### Check Transaction Fields
+
+```swift
+func grantEntitlement(for transaction: Transaction) async {
+ // Check transaction hasn't been revoked
+ guard transaction.revocationDate == nil else {
+ print("Transaction was refunded")
+ await revokeEntitlement(for: transaction.productID)
+ return
+ }
+
+ // Grant based on product type
+ switch transaction.productType {
+ case .consumable:
+ await addConsumable(productID: transaction.productID)
+
+ case .nonConsumable:
+ await unlockFeature(productID: transaction.productID)
+
+ case .autoRenewable:
+ await activateSubscription(productID: transaction.productID)
+
+ default:
+ break
+ }
+}
+```
+
+---
+
+## Step 5: Track Current Entitlements
+
+### Check What User Owns
+
+```swift
+extension StoreManager {
+ func updatePurchasedProducts() async {
+ var purchased: Set = []
+
+ // Iterate through all current entitlements
+ for await result in Transaction.currentEntitlements {
+ guard let transaction = try? result.payloadValue else {
+ continue
+ }
+
+ // Only include active entitlements (not revoked)
+ if transaction.revocationDate == nil {
+ purchased.insert(transaction.productID)
+ }
+ }
+
+ self.purchasedProductIDs = purchased
+ }
+}
+```
+
+### Check Specific Product
+
+```swift
+func isEntitled(to productID: String) async -> Bool {
+ // Check current entitlements for specific product
+ for await result in Transaction.currentEntitlements(for: productID) {
+ if let transaction = try? result.payloadValue,
+ transaction.revocationDate == nil {
+ return true
+ }
+ }
+
+ return false
+}
+```
+
+---
+
+## Step 6: Implement Subscription Management
+
+### Track Subscription Status
+
+```swift
+extension StoreManager {
+ func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
+ // Get subscription statuses for group
+ guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
+ let status = result.first else {
+ return nil
+ }
+
+ return status.state
+ }
+}
+```
+
+### Handle Subscription States
+
+```swift
+func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
+ switch status.state {
+ case .subscribed:
+ // User has active subscription
+ showSubscribedContent()
+
+ case .expired:
+ // Subscription expired - show win-back offer
+ showResubscribeOffer()
+
+ case .inGracePeriod:
+ // Billing issue - show payment update prompt
+ showUpdatePaymentPrompt()
+
+ case .inBillingRetryPeriod:
+ // Apple retrying payment - maintain access
+ showBillingRetryMessage()
+
+ case .revoked:
+ // Family Sharing access removed
+ removeAccess()
+
+ @unknown default:
+ break
+ }
+}
+```
+
+### Use StoreKit Views (iOS 17+)
+
+```swift
+struct SubscriptionView: View {
+ var body: some View {
+ SubscriptionStoreView(groupID: "pro_tier") {
+ // Marketing content
+ VStack {
+ Image("premium-icon")
+ Text("Unlock all features")
+ }
+ }
+ .subscriptionStoreControlStyle(.prominentPicker)
+ }
+}
+```
+
+---
+
+## Step 7: Implement Restore Purchases (REQUIRED)
+
+### Restore Flow
+
+```swift
+extension StoreManager {
+ func restorePurchases() async {
+ // Sync all transactions from App Store
+ try? await AppStore.sync()
+
+ // Update current entitlements
+ await updatePurchasedProducts()
+ }
+}
+```
+
+### UI Button
+
+```swift
+struct SettingsView: View {
+ @StateObject private var store = StoreManager()
+
+ var body: some View {
+ Button("Restore Purchases") {
+ Task {
+ await store.restorePurchases()
+ }
+ }
+ }
+}
+```
+
+**App Store Requirement**: Apps with IAP must provide restore functionality for non-consumables and subscriptions.
+
+---
+
+## Step 8: Handle Refunds
+
+### Listen for Refund Notifications
+
+```swift
+extension StoreManager {
+ func listenForTransactions() -> Task {
+ Task.detached { [weak self] in
+ for await verificationResult in Transaction.updates {
+ await self?.handleTransaction(verificationResult)
+ }
+ }
+ }
+
+ @MainActor
+ private func handleTransaction(_ result: VerificationResult) async {
+ guard let transaction = try? result.payloadValue else {
+ return
+ }
+
+ // Check if transaction was refunded
+ if let revocationDate = transaction.revocationDate {
+ print("Transaction refunded on \(revocationDate)")
+ await revokeEntitlement(for: transaction.productID)
+ } else {
+ await grantEntitlement(for: transaction)
+ }
+
+ await transaction.finish()
+ }
+}
+```
+
+---
+
+## Step 9: Unit Testing
+
+### Mock Store Responses
+
+```swift
+protocol StoreProtocol {
+ func products(for ids: [String]) async throws -> [Product]
+ func purchase(_ product: Product) async throws -> PurchaseResult
+}
+
+// Production
+final class StoreManager: StoreProtocol {
+ func products(for ids: [String]) async throws -> [Product] {
+ try await Product.products(for: ids)
+ }
+}
+
+// Testing
+final class MockStore: StoreProtocol {
+ var mockProducts: [Product] = []
+ var mockPurchaseResult: PurchaseResult?
+
+ func products(for ids: [String]) async throws -> [Product] {
+ mockProducts
+ }
+
+ func purchase(_ product: Product) async throws -> PurchaseResult {
+ mockPurchaseResult ?? .userCancelled
+ }
+}
+```
+
+### Test Purchase Logic
+
+```swift
+@Test func testSuccessfulPurchase() async {
+ let mockStore = MockStore()
+ let manager = StoreManager(store: mockStore)
+
+ // Given: Mock successful purchase
+ mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
+
+ // When: Purchase product
+ let result = await manager.purchase(mockProduct)
+
+ // Then: Entitlement granted
+ #expect(result == true)
+ #expect(manager.purchasedProductIDs.contains("com.app.premium"))
+}
+
+@Test func testCancelledPurchase() async {
+ let mockStore = MockStore()
+ let manager = StoreManager(store: mockStore)
+
+ // Given: User cancels
+ mockStore.mockPurchaseResult = .userCancelled
+
+ // When: Purchase product
+ let result = await manager.purchase(mockProduct)
+
+ // Then: No entitlement granted
+ #expect(result == false)
+ #expect(manager.purchasedProductIDs.isEmpty)
+}
+```
+
+---
+
+## Common Anti-Patterns (NEVER DO THIS)
+
+### ❌ No StoreKit Configuration
+
+```swift
+// ❌ WRONG: Writing purchase code without .storekit file
+let products = try await Product.products(for: productIDs)
+// Can't test this without App Store Connect setup!
+```
+
+✅ **Correct**: Create `.storekit` file FIRST, test in Xcode, THEN implement.
+
+### ❌ Code Before .storekit Config
+
+```swift
+// ❌ Less ideal: Write code, test in sandbox, add .storekit later
+let products = try await Product.products(for: productIDs)
+let result = try await product.purchase(confirmIn: scene)
+// "I tested this in sandbox, it works! I'll add .storekit config later."
+```
+
+✅ **Recommended**: Create `.storekit` config first, then write code.
+
+**If you're in this situation**: See "Already Wrote Code Before Creating .storekit Config?" section above for your options (A, B, or C).
+
+**Why .storekit-first is better**:
+- Product ID typos caught in Xcode, not at runtime
+- Faster iteration without network requests
+- Teammates can test locally
+- Documents product structure in code
+
+**Sandbox testing is valuable** - it validates against real App Store infrastructure. But starting with .storekit makes sandbox testing easier because you've already validated product IDs locally.
+
+### ❌ Scattered Purchase Calls
+
+```swift
+// ❌ WRONG: Purchase calls scattered throughout app
+Button("Buy") {
+ try await product.purchase() // In view 1
+}
+
+Button("Subscribe") {
+ try await subscriptionProduct.purchase() // In view 2
+}
+```
+
+✅ **Correct**: All purchases through centralized StoreManager.
+
+### ❌ Forgetting to Finish Transactions
+
+```swift
+// ❌ WRONG: Never calling finish()
+func handleTransaction(_ transaction: Transaction) {
+ grantEntitlement(for: transaction)
+ // Missing: await transaction.finish()
+}
+```
+
+✅ **Correct**: ALWAYS call `transaction.finish()` after granting entitlement.
+
+### ❌ Not Verifying Transactions
+
+```swift
+// ❌ WRONG: Using unverified transaction
+for await transaction in Transaction.all {
+ grantEntitlement(for: transaction) // Unsafe!
+}
+```
+
+✅ **Correct**: Always check `VerificationResult` before granting.
+
+### ❌ Ignoring Transaction Listener
+
+```swift
+// ❌ WRONG: Only handling purchases in purchase() method
+func purchase() {
+ let result = try await product.purchase()
+ // What about pending purchases, family sharing, restore?
+}
+```
+
+✅ **Correct**: Listen to `Transaction.updates` for ALL transaction sources.
+
+### ❌ Not Implementing Restore
+
+```swift
+// ❌ WRONG: No restore button
+// App Store will REJECT your app!
+```
+
+✅ **Correct**: Provide visible "Restore Purchases" button in settings.
+
+---
+
+## Validation
+
+Before marking IAP implementation complete, verify:
+
+### Code Inspection
+
+Run these searches to verify compliance:
+
+```bash
+# Check StoreKit configuration exists
+find . -name "*.storekit"
+
+# Check transaction.finish() is called
+rg "transaction\.finish\(\)" --type swift
+
+# Check VerificationResult usage
+rg "VerificationResult" --type swift
+
+# Check Transaction.updates listener
+rg "Transaction\.updates" --type swift
+
+# Check restore implementation
+rg "AppStore\.sync|Transaction\.all" --type swift
+```
+
+### Functional Testing
+
+- [ ] Can purchase each product type in StoreKit configuration
+- [ ] Can cancel purchase and state remains consistent
+- [ ] Can restore purchases and regain access
+- [ ] Subscription renewal/expiration works as expected
+- [ ] Refunded transactions revoke access
+- [ ] Family Sharing transactions identified (if supported)
+
+### Sandbox Testing
+
+- [ ] Real Apple ID sandbox purchases complete
+- [ ] TestFlight beta testers confirm purchase flows work
+- [ ] Server-side validation works (if using backend)
+
+### App Store Connect Submission (see **app-store-submission** for full checklist)
+
+- [ ] **Review screenshot uploaded** for each IAP product (shows purchase UI — review-only, not on App Store)
+- [ ] **IAP products attached to this version** (first submission: App Version → In-App Purchases section → Select → checkbox each product)
+- [ ] **Terms of Use + Privacy Policy links on purchase screen** (required by DPLA Schedule 2; `SubscriptionStoreView` handles this automatically)
+- [ ] Subscription terms explicit: price, period, auto-renewal, cancellation
+
+---
+
+## Resources
+
+**WWDC**: 2025-241, 2025-249, 2023-10013, 2021-10114
+
+**Docs**: /storekit, /appstoreserverapi
+
+**Skills**: axiom-storekit-ref
diff --git a/.claude/skills/axiom-in-app-purchases/agents/openai.yaml b/.claude/skills/axiom-in-app-purchases/agents/openai.yaml
new file mode 100644
index 0000000..a735c03
--- /dev/null
+++ b/.claude/skills/axiom-in-app-purchases/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "In App Purchases"
+ short_description: "Implementing in-app purchases, StoreKit 2, subscriptions, or transaction handling"
diff --git a/.claude/skills/axiom-ios-accessibility/.openskills.json b/.claude/skills/axiom-ios-accessibility/.openskills.json
new file mode 100644
index 0000000..81adacb
--- /dev/null
+++ b/.claude/skills/axiom-ios-accessibility/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-accessibility",
+ "installedAt": "2026-04-12T08:05:35.617Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-accessibility/SKILL.md b/.claude/skills/axiom-ios-accessibility/SKILL.md
new file mode 100644
index 0000000..98480a3
--- /dev/null
+++ b/.claude/skills/axiom-ios-accessibility/SKILL.md
@@ -0,0 +1,93 @@
+---
+name: axiom-ios-accessibility
+description: Use when fixing or auditing ANY accessibility issue - VoiceOver, Dynamic Type, color contrast, touch targets, WCAG compliance, App Store accessibility review.
+license: MIT
+---
+
+# iOS Accessibility Router
+
+**You MUST use this skill for ANY accessibility work including VoiceOver, Dynamic Type, color contrast, and WCAG compliance.**
+
+## When to Use
+
+Use this router when:
+- Fixing VoiceOver issues
+- Implementing Dynamic Type
+- Checking color contrast
+- Ensuring touch target sizes
+- Preparing for App Store accessibility review
+- WCAG compliance auditing
+- Assistive Access support (cognitive disabilities, iOS 17+)
+
+## Routing Logic
+
+### Accessibility Issues
+
+**All accessibility work** → `/skill axiom-accessibility-diag`
+- VoiceOver labels and hints
+- Dynamic Type scaling
+- Color contrast (WCAG)
+- Touch target sizes
+- Keyboard navigation
+- Reduce Motion support
+- Assistive Access (cognitive disabilities, iOS 17+)
+- Accessibility Inspector usage
+- App Store Review preparation
+
+### Automated Scanning
+
+**Accessibility audit** → Launch `accessibility-auditor` agent or `/axiom:audit accessibility` (VoiceOver issues, Dynamic Type violations, color contrast failures, WCAG compliance scanning)
+
+## Decision Tree
+
+1. ANY accessibility issue → accessibility-diag
+2. Want automated accessibility scan? → accessibility-auditor (Agent)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I'll add VoiceOver labels when I'm done building" | Accessibility is foundational, not polish. accessibility-diag prevents App Store rejection. |
+| "My app doesn't need accessibility" | All apps need accessibility. It's required by App Store guidelines and benefits all users. |
+| "Dynamic Type just needs .scaledFont" | Dynamic Type has 7 common violations. accessibility-diag catches them all. |
+| "Color contrast looks fine to me" | Visual assessment is unreliable. WCAG ratios require measurement. accessibility-diag validates. |
+
+## Critical Patterns
+
+#### Image Accessibility
+
+- Use `Image(decorative: "photo")` for purely decorative images — automatically hidden from VoiceOver (equivalent to `accessibilityHidden(true)` but semantically clearer)
+- Use `accessibilityInputLabels()` for buttons with complex or changing labels — improves Voice Control accuracy by providing alternative labels
+- Respect `accessibilityDifferentiateWithoutColor` environment value — when active, provide non-color cues (icons, patterns, labels) alongside color indicators
+
+#### accessibility-diag Coverage
+
+- 8 critical accessibility issues (including Assistive Access)
+- WCAG compliance levels (A, AA, AAA)
+- Assistive Access mode (cognitive disabilities, iOS 17+)
+- Accessibility Inspector workflows
+- VoiceOver testing checklist
+- App Store Review requirements
+
+## Example Invocations
+
+User: "My button isn't being read by VoiceOver"
+→ Invoke: `/skill axiom-accessibility-diag`
+
+User: "How do I support Dynamic Type?"
+→ Invoke: `/skill axiom-accessibility-diag`
+
+User: "Check my app for accessibility issues"
+→ Invoke: `/skill axiom-accessibility-diag`
+
+User: "Prepare for App Store accessibility review"
+→ Invoke: `/skill axiom-accessibility-diag`
+
+User: "Scan my app for accessibility issues automatically"
+→ Invoke: `accessibility-auditor` agent
+
+User: "How do I support Assistive Access?"
+→ Invoke: `/skill axiom-accessibility-diag`
+
+User: "My app doesn't show up in Assistive Access"
+→ Invoke: `/skill axiom-accessibility-diag`
diff --git a/.claude/skills/axiom-ios-ai/.openskills.json b/.claude/skills/axiom-ios-ai/.openskills.json
new file mode 100644
index 0000000..fb3316f
--- /dev/null
+++ b/.claude/skills/axiom-ios-ai/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ai",
+ "installedAt": "2026-04-12T08:05:35.618Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-ai/SKILL.md b/.claude/skills/axiom-ios-ai/SKILL.md
new file mode 100644
index 0000000..056c484
--- /dev/null
+++ b/.claude/skills/axiom-ios-ai/SKILL.md
@@ -0,0 +1,135 @@
+---
+name: axiom-ios-ai
+description: Use when implementing ANY Apple Intelligence or on-device AI feature. Covers Foundation Models, @Generable, LanguageModelSession, structured output, Tool protocol, iOS 26 AI integration.
+license: MIT
+---
+
+# iOS Apple Intelligence Router
+
+**You MUST use this skill for ANY Apple Intelligence or Foundation Models work.**
+
+## When to Use
+
+Use this router when:
+- Implementing Apple Intelligence features
+- Using Foundation Models
+- Working with LanguageModelSession
+- Generating structured output with @Generable
+- Debugging AI generation issues
+- iOS 26 on-device AI
+
+## AI Approach Triage
+
+**First, determine which kind of AI the developer needs:**
+
+| Developer Intent | Route To |
+|-----------------|----------|
+| On-device text generation (Apple Intelligence) | **Stay here** → Foundation Models skills |
+| Custom ML model deployment (PyTorch, TensorFlow) | **Route to ios-ml** → CoreML conversion, compression |
+| Computer vision (image analysis, OCR, segmentation) | **Route to ios-vision** → Vision framework |
+| Cloud API integration (OpenAI, etc.) | **Route to ios-networking** → URLSession patterns |
+| System AI features (Writing Tools, Genmoji) | No custom code needed — these are system-provided |
+
+**Key boundary: ios-ai vs ios-ml**
+- ios-ai = Apple's Foundation Models framework (LanguageModelSession, @Generable, on-device LLM)
+- ios-ml = Custom model deployment (CoreML conversion, quantization, MLTensor, speech-to-text)
+- If developer says "run my own model" → ios-ml. If "use Apple Intelligence" → ios-ai.
+
+## Cross-Domain Routing
+
+**Foundation Models + concurrency** (session blocking main thread, UI freezes):
+- Foundation Models sessions are async — blocking likely means missing `await` or running on @MainActor
+- **Fix here first** using async session patterns in foundation-models skill
+- If concurrency issue is broader than Foundation Models → **also invoke ios-concurrency**
+
+**Foundation Models + data** (@Generable decoding errors, structured output issues):
+- @Generable output problems are Foundation Models-specific, NOT generic Codable issues
+- **Stay here** → foundation-models-diag handles structured output debugging
+- If developer also has general Codable/serialization questions → **also invoke ios-data**
+
+## Routing Logic
+
+### Foundation Models Work
+
+**Implementation patterns** → `/skill axiom-foundation-models`
+- LanguageModelSession basics
+- @Generable structured output
+- Tool protocol integration
+- Streaming with PartiallyGenerated
+- Dynamic schemas
+- 26 WWDC code examples
+
+**API reference** → `/skill axiom-foundation-models-ref`
+- Complete API documentation
+- All @Generable examples
+- Tool protocol patterns
+- Streaming generation patterns
+
+**Diagnostics** → `/skill axiom-foundation-models-diag`
+- AI response blocked
+- Generation slow
+- Guardrail violations
+- Context limits exceeded
+- Model unavailable
+
+**Automated scanning** → Launch `foundation-models-auditor` agent or `/axiom:audit foundation-models` (missing availability checks, main thread blocking, manual JSON parsing, session lifecycle issues)
+
+## Decision Tree
+
+1. Custom ML model / CoreML / PyTorch conversion? → **Route to ios-ml** (not this router)
+2. Computer vision / image analysis / OCR? → **Route to ios-vision** (not this router)
+3. Cloud AI API integration? → **Route to ios-networking** (not this router)
+4. Implementing Foundation Models / @Generable / Tool protocol? → foundation-models
+5. Need API reference / code examples? → foundation-models-ref
+6. Debugging AI issues (blocked, slow, guardrails)? → foundation-models-diag
+7. Foundation Models + UI freezing? → foundation-models (async patterns) + also invoke ios-concurrency if needed
+8. Want automated Foundation Models code scan? → foundation-models-auditor (Agent)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Foundation Models is just LanguageModelSession" | Foundation Models has @Generable, Tool protocol, streaming, and guardrails. foundation-models covers all. |
+| "I'll figure out the AI patterns as I go" | AI APIs have specific error handling and fallback requirements. foundation-models prevents runtime failures. |
+| "I've used LLMs before, this is similar" | Apple's on-device models have unique constraints (guardrails, context limits). foundation-models is Apple-specific. |
+
+## Critical Patterns
+
+**foundation-models**:
+- LanguageModelSession setup
+- @Generable for structured output
+- Tool protocol for function calling
+- Streaming generation
+- Dynamic schema evolution
+
+**foundation-models-diag**:
+- Blocked response handling
+- Performance optimization
+- Guardrail violations
+- Context management
+
+## Example Invocations
+
+User: "How do I use Apple Intelligence to generate structured data?"
+→ Invoke: `/skill axiom-foundation-models`
+
+User: "My AI generation is being blocked"
+→ Invoke: `/skill axiom-foundation-models-diag`
+
+User: "Show me @Generable examples"
+→ Invoke: `/skill axiom-foundation-models-ref`
+
+User: "Implement streaming AI generation"
+→ Invoke: `/skill axiom-foundation-models`
+
+User: "I want to add AI to my app"
+→ First ask: Apple Intelligence (Foundation Models) or custom ML model? Route accordingly.
+
+User: "My Foundation Models session is blocking the UI"
+→ Invoke: `/skill axiom-foundation-models` (async patterns) + also invoke `ios-concurrency` if needed
+
+User: "Review my Foundation Models code for issues"
+→ Invoke: `foundation-models-auditor` agent
+
+User: "I want to run my PyTorch model on device"
+→ Route to: `ios-ml` router (CoreML conversion, not Foundation Models)
diff --git a/.claude/skills/axiom-ios-build/.openskills.json b/.claude/skills/axiom-ios-build/.openskills.json
new file mode 100644
index 0000000..bec17fb
--- /dev/null
+++ b/.claude/skills/axiom-ios-build/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-build",
+ "installedAt": "2026-04-12T08:05:35.618Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-build/SKILL.md b/.claude/skills/axiom-ios-build/SKILL.md
new file mode 100644
index 0000000..c76c49d
--- /dev/null
+++ b/.claude/skills/axiom-ios-build/SKILL.md
@@ -0,0 +1,423 @@
+---
+name: axiom-ios-build
+description: Use when ANY iOS build fails, test crashes, Xcode misbehaves, or environment issue occurs before debugging code. Covers build failures, compilation errors, dependency conflicts, simulator problems, environment-first diagnostics.
+license: MIT
+---
+
+# iOS Build & Environment Router
+
+**You MUST use this skill for ANY build, environment, or Xcode-related issue before debugging application code.**
+
+## When to Use
+
+Use this router when you encounter:
+- Build failures (`BUILD FAILED`, compilation errors, linker errors)
+- Test crashes or hangs
+- Simulator issues (won't boot, device errors)
+- Xcode misbehavior (stale builds, zombie processes)
+- Dependency conflicts (CocoaPods, SPM)
+- Build performance issues (slow compilation)
+- Environment issues before debugging code
+
+## Routing Logic
+
+This router invokes specialized skills based on the specific issue:
+
+### 1. Environment-First Issues → **xcode-debugging**
+**Triggers**:
+- `BUILD FAILED` without obvious code cause
+- Tests crash in clean project
+- Simulator hangs or won't boot
+- "No such module" after SPM changes
+- Zombie `xcodebuild` processes
+- Stale builds (old code still running)
+- Clean build differs from incremental build
+
+**Why xcode-debugging first**: 90% of mysterious issues are environment, not code. Check this BEFORE debugging code.
+
+**Invoke**: `/skill axiom-xcode-debugging`
+
+---
+
+### 2. Slow Builds → **build-performance**
+**Triggers**:
+- Compilation takes too long
+- Type checking bottlenecks
+- Want to optimize build time
+- Build Timeline shows slow phases
+
+**Invoke**: `/skill axiom-build-performance`
+
+---
+
+### 3. SPM Dependency Conflicts → **spm-conflict-resolver** (Agent)
+**Triggers**:
+- SPM resolution failures
+- "No such module" after adding package
+- Duplicate symbol linker errors
+- Version conflicts between packages
+- Swift 6 package compatibility issues
+- Package.swift / Package.resolved conflicts
+
+**Why spm-conflict-resolver**: Specialized agent that analyzes Package.swift and Package.resolved to diagnose and resolve Swift Package Manager conflicts.
+
+**Invoke**: Launch `spm-conflict-resolver` agent
+
+---
+
+### 4. Security & Privacy Audit → **security-privacy-scanner** (Agent)
+**Triggers**:
+- App Store submission prep
+- Privacy Manifest requirements (iOS 17+)
+- Hardcoded credentials in code
+- Sensitive data storage concerns
+- ATS violations
+- Required Reason API declarations
+
+**Why security-privacy-scanner**: Specialized agent that scans for security vulnerabilities and privacy compliance issues.
+
+**Invoke**: Launch `security-privacy-scanner` agent or `/axiom:audit security`
+
+---
+
+### 5. iOS 17→18 Modernization → **modernization-helper** (Agent)
+**Triggers**:
+- Migrate ObservableObject to @Observable
+- Update @StateObject to @State
+- Adopt modern SwiftUI patterns
+- Deprecated API cleanup
+- iOS 17+ migration
+
+**Why modernization-helper**: Specialized agent that scans for legacy patterns and provides migration paths with code examples.
+
+**Invoke**: Launch `modernization-helper` agent or `/axiom:audit modernization`
+
+---
+
+### 6. Build Failure Auto-Fix → **build-fixer** (Agent)
+**Triggers**:
+- BUILD FAILED with no clear error details
+- Build sometimes succeeds, sometimes fails
+- App builds but runs old code
+- "Unable to boot simulator" error
+- Want automated environment-first diagnostics
+
+**Why build-fixer**: Autonomous agent that checks zombie processes, Derived Data, SPM cache, and simulator state before investigating code. Saves 30+ minutes on environment issues.
+
+**Invoke**: Launch `build-fixer` agent or `/axiom:fix-build`
+
+---
+
+### 7. Slow Build Optimization → **build-optimizer** (Agent)
+**Triggers**:
+- Builds take too long
+- Want to identify slow type checking
+- Expensive build phase scripts
+- Suboptimal build settings
+- Want parallelization opportunities
+
+**Why build-optimizer**: Scans Xcode projects for build performance optimizations — slow type checking, expensive scripts, suboptimal settings — to reduce build times by 30-50%.
+
+**Invoke**: Launch `build-optimizer` agent or `/axiom:optimize-build`
+
+---
+
+### 8. General Dependency Issues → **build-debugging**
+**Triggers**:
+- CocoaPods resolution failures
+- "Multiple commands produce" errors
+- Framework version mismatches
+- Non-SPM dependency graph conflicts
+
+**Invoke**: `/skill axiom-build-debugging`
+
+---
+
+### 9. TestFlight Crash Triage → **testflight-triage**
+**Triggers**:
+- Beta tester reported a crash
+- Crash reports in Xcode Organizer
+- Crash logs aren't symbolicated
+- TestFlight feedback with screenshots
+- App was killed but no crash report
+
+**Why testflight-triage**: Systematic workflow for investigating TestFlight crashes and reviewing beta feedback. Covers symbolication, crash interpretation, common patterns, and Claude-assisted analysis.
+
+**Invoke**: `/skill axiom-testflight-triage`
+
+---
+
+### 10. App Store Connect Navigation → **app-store-connect-ref**
+**Triggers**:
+- How to find crashes in App Store Connect
+- ASC metrics dashboard navigation
+- Understanding crash-free users percentage
+- Comparing crash rates between versions
+- Exporting crash data from ASC
+- App Store Connect API for crash data
+
+**Why app-store-connect-ref**: Reference for navigating ASC crash analysis, metrics dashboards, and data export workflows.
+
+**Invoke**: `/skill axiom-app-store-connect-ref`
+
+---
+
+### 11. Crash Log Analysis → **crash-analyzer** (Agent)
+**Triggers**:
+- User has .ips or .crash file to analyze
+- User pasted crash report text
+- Need to parse crash log programmatically
+- Identify crash pattern from exception type
+- Check symbolication status
+
+**Why crash-analyzer**: Autonomous agent that parses crash reports, identifies patterns (null pointer, Swift runtime, watchdog, jetsam), and generates actionable analysis.
+
+**Invoke**: Launch `crash-analyzer` agent or `/axiom:analyze-crash`
+
+---
+
+### 12. MetricKit API Reference → **metrickit-ref**
+**Triggers**:
+- MetricKit setup and subscription
+- MXMetricPayload parsing (CPU, memory, launches, hitches)
+- MXDiagnosticPayload parsing (crashes, hangs, disk writes)
+- MXCallStackTree decoding and symbolication
+- Field crash/hang collection
+- Background exit metrics
+
+**Why metrickit-ref**: Complete MetricKit API reference with setup patterns, payload parsing, and integration with crash reporting systems.
+
+**Invoke**: `/skill axiom-metrickit-ref`
+
+---
+
+### 13. Hang Diagnostics → **hang-diagnostics**
+**Triggers**:
+- App hangs or freezes
+- Main thread blocked for >1 second
+- UI unresponsive to touches
+- Xcode Organizer shows hang diagnostics
+- MXHangDiagnostic from MetricKit
+- Watchdog terminations (app killed during launch/background transition)
+
+**Why hang-diagnostics**: Systematic diagnosis of hangs with decision tree for busy vs blocked main thread, tool selection (Time Profiler, System Trace), and 8 common hang patterns with fixes.
+
+**Invoke**: `/skill axiom-hang-diagnostics`
+
+---
+
+### 14. Live Debugging → **axiom-lldb**
+**Triggers**:
+- Need to reproduce a crash interactively
+- Want to set breakpoints and inspect state
+- Crash report analyzed, now need live investigation
+- Need to attach debugger to running app
+
+**Why axiom-lldb**: Crash reports tell you WHAT crashed. LLDB tells you WHY.
+
+**Invoke**: `/skill axiom-lldb`
+
+---
+
+### 16. Runtime Console Capture → **xclog-ref**
+**Triggers**:
+- Need to see what the app is logging at runtime
+- App crashes but no crash report (need console output)
+- Silent failures (network, data, auth) with no UI feedback
+- Want to capture print()/os_log() output from simulator
+- Need structured log output for analysis
+- "What is the app printing?"
+
+**Why xclog-ref**: Xcode's debug console isn't accessible externally. xclog combines simctl stdout/stderr with `log stream` JSON to capture everything print(), NSLog(), os_log(), and Logger emit — with structured fields (level, subsystem, category) for automated analysis.
+
+**Invoke**: `/skill axiom-xclog-ref` or `/axiom:console`
+
+---
+
+### 15. Code Signing Issues → **code-signing**
+**Triggers**:
+- "No signing certificate found"
+- "Provisioning profile doesn't include signing certificate"
+- errSecInternalComponent in CI
+- ITMS-90035 Invalid Signature on upload
+- Ambiguous identity / multiple certificates
+- Entitlement mismatch or missing capability
+- Setting up CI/CD code signing (GitHub Actions, fastlane match)
+- Certificate expired or revoked
+
+**Why code-signing**: Code signing errors are NEVER code bugs — they are 100% configuration (certificates, profiles, entitlements, keychains). Diagnosing with CLI tools takes 5 minutes vs hours of guessing.
+
+**Invoke**: `/skill axiom-code-signing` (workflows) or `/skill axiom-code-signing-diag` (troubleshooting)
+
+---
+
+## Decision Tree
+
+1. Mysterious/intermittent/clean build fails? → xcode-debugging (environment-first)
+2. SPM dependency conflict? → spm-conflict-resolver (Agent)
+3. CocoaPods/other dependency conflict? → build-debugging
+4. Slow build time? → build-performance
+5. Security/privacy/App Store prep? → security-privacy-scanner (Agent)
+6. Want automated build fix (environment-first diagnostics)? → build-fixer (Agent)
+7. Want build time optimization scan? → build-optimizer (Agent)
+8. Modernization/deprecated APIs? → modernization-helper (Agent)
+9. TestFlight crash/feedback? → testflight-triage
+10. Navigating App Store Connect? → app-store-connect-ref
+11. Have a crash log (.ips/.crash)? → crash-analyzer (Agent)
+12. MetricKit setup/parsing? → metrickit-ref
+13. App hang/freeze/watchdog? → hang-diagnostics
+14. Need to reproduce crash interactively / inspect runtime state? → axiom-lldb
+15. Code signing error (certificate, profile, entitlement, Keychain)? → code-signing / code-signing-diag
+16. Need to see runtime console output (print/os_log)? → xclog-ref or `/axiom:console`
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I know how to fix this linker error" | Linker errors have 4+ root causes. xcode-debugging diagnoses all in 2 min. |
+| "Let me just clean the build folder" | Clean builds mask the real issue. xcode-debugging finds the root cause. |
+| "It's just an SPM issue, I'll fix Package.swift" | SPM conflicts cascade. spm-conflict-resolver analyzes the full dependency graph. |
+| "The simulator is just slow today" | Simulator issues indicate environment corruption. xcode-debugging checks systematically. |
+| "I'll skip environment checks, it compiles locally" | Environment-first saves 30+ min. Every time. |
+| "I'll read the crash report more carefully instead of reproducing" | Crash reports show WHAT crashed, not WHY. Reproducing in LLDB with breakpoints reveals the actual state. axiom-lldb has the workflow. |
+| "I know my certificate is fine, let me check the code" | Code signing errors are NEVER code bugs. 100% configuration. code-signing diagnoses with CLI in 5 min. |
+| "I can't see what the app is logging without Xcode" | xclog captures print() + os_log from the simulator. Structured JSON output with level, subsystem, category. `/axiom:console` or `/skill axiom-xclog-ref`. |
+
+## When NOT to Use (Conflict Resolution)
+
+**Do NOT use ios-build for these — use the correct router instead:**
+
+| Error Type | Correct Router | Why NOT ios-build |
+|------------|----------------|-------------------|
+| Swift 6 concurrency errors | **ios-concurrency** | Code error, not environment |
+| SwiftData migration errors | **ios-data** | Schema issue, not build environment |
+| "Sending 'self' risks data race" | **ios-concurrency** | Language error, not Xcode issue |
+| Type mismatch / compilation errors | Fix the code | These are code bugs |
+
+**ios-build is for environment mysteries**, not code errors:
+- ✅ "No such module" when code is correct
+- ✅ Simulator won't boot
+- ✅ Clean build fails, incremental works
+- ✅ Zombie xcodebuild processes
+- ❌ Swift concurrency warnings/errors
+- ❌ Database migration failures
+- ❌ Type checking errors in valid code
+
+## Example Invocations
+
+User: "My build failed with a linker error"
+→ Invoke: `/skill axiom-xcode-debugging` (environment-first diagnostic)
+
+User: "Builds are taking 10 minutes"
+→ Invoke: `/skill axiom-build-performance`
+
+User: "SPM won't resolve dependencies"
+→ Invoke: `spm-conflict-resolver` agent
+
+User: "Two packages require different versions of the same dependency"
+→ Invoke: `spm-conflict-resolver` agent
+
+User: "Duplicate symbol linker error"
+→ Invoke: `spm-conflict-resolver` agent
+
+User: "I need to prepare for App Store security review"
+→ Invoke: `security-privacy-scanner` agent
+
+User: "Do I need a Privacy Manifest?"
+→ Invoke: `security-privacy-scanner` agent
+
+User: "Are there hardcoded credentials in my code?"
+→ Invoke: `security-privacy-scanner` agent
+
+User: "How do I migrate from ObservableObject to @Observable?"
+→ Invoke: `modernization-helper` agent
+
+User: "Update my code to use modern SwiftUI patterns"
+→ Invoke: `modernization-helper` agent
+
+User: "Should I still use @StateObject?"
+→ Invoke: `modernization-helper` agent
+
+User: "A beta tester said my app crashed"
+→ Invoke: `/skill axiom-testflight-triage`
+
+User: "I see crashes in App Store Connect but don't know how to investigate"
+→ Invoke: `/skill axiom-testflight-triage`
+
+User: "My crash logs aren't symbolicated"
+→ Invoke: `/skill axiom-testflight-triage`
+
+User: "I need to review TestFlight feedback"
+→ Invoke: `/skill axiom-testflight-triage`
+
+User: "How do I find crashes in App Store Connect?"
+→ Invoke: `/skill axiom-app-store-connect-ref`
+
+User: "Where's the crash-free users metric in ASC?"
+→ Invoke: `/skill axiom-app-store-connect-ref`
+
+User: "How do I export crash data from App Store Connect?"
+→ Invoke: `/skill axiom-app-store-connect-ref`
+
+User: "Analyze this crash log" [pastes .ips content]
+→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash`
+
+User: "Parse this .ips file: ~/Library/Logs/DiagnosticReports/MyApp.ips"
+→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash`
+
+User: "Why did my app crash? Here's the report..."
+→ Invoke: `crash-analyzer` agent or `/axiom:analyze-crash`
+
+User: "How do I set up MetricKit to collect crash data?"
+→ Invoke: `/skill axiom-metrickit-ref`
+
+User: "How do I parse MXDiagnosticPayload?"
+→ Invoke: `/skill axiom-metrickit-ref`
+
+User: "What's in MXCallStackTree and how do I decode it?"
+→ Invoke: `/skill axiom-metrickit-ref`
+
+User: "My app hangs sometimes"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "The main thread is blocked and UI is unresponsive"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "Xcode Organizer shows hang diagnostics for my app"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "My app was killed by watchdog during launch"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "I have a crash report and need to reproduce it in the debugger"
+→ Invoke: `/skill axiom-lldb`
+
+User: "How do I set breakpoints to catch this crash?"
+→ Invoke: `/skill axiom-lldb`
+
+User: "My build is failing with BUILD FAILED but no error details"
+→ Invoke: `build-fixer` agent or `/axiom:fix-build`
+
+User: "Build sometimes succeeds, sometimes fails"
+→ Invoke: `build-fixer` agent or `/axiom:fix-build`
+
+User: "How can I speed up my Xcode build times?"
+→ Invoke: `build-optimizer` agent or `/axiom:optimize-build`
+
+User: "No signing certificate found when I try to build"
+→ Invoke: `/skill axiom-code-signing-diag`
+
+User: "errSecInternalComponent in my GitHub Actions CI"
+→ Invoke: `/skill axiom-code-signing-diag`
+
+User: "How do I set up code signing for GitHub Actions?"
+→ Invoke: `/skill axiom-code-signing`
+
+User: "What is my app printing to the console?"
+→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console`
+
+User: "I need to see the simulator console output"
+→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console`
+
+User: "The app fails silently, no error in the UI"
+→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console`
diff --git a/.claude/skills/axiom-ios-concurrency/.openskills.json b/.claude/skills/axiom-ios-concurrency/.openskills.json
new file mode 100644
index 0000000..e8e9037
--- /dev/null
+++ b/.claude/skills/axiom-ios-concurrency/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-concurrency",
+ "installedAt": "2026-04-12T08:05:35.619Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-concurrency/SKILL.md b/.claude/skills/axiom-ios-concurrency/SKILL.md
new file mode 100644
index 0000000..e1d1c3f
--- /dev/null
+++ b/.claude/skills/axiom-ios-concurrency/SKILL.md
@@ -0,0 +1,209 @@
+---
+name: axiom-ios-concurrency
+description: Use when writing ANY code with async, actors, threads, or seeing ANY concurrency error. Covers Swift 6 concurrency, @MainActor, Sendable, data races, async/await patterns, performance optimization.
+license: MIT
+---
+
+# iOS Concurrency Router
+
+**You MUST use this skill for ANY concurrency, async/await, threading, or Swift 6 concurrency work.**
+
+## When to Use
+
+Use this router when:
+- Writing async/await code
+- Seeing concurrency errors (data races, actor isolation)
+- Working with @MainActor
+- Dealing with Sendable conformance
+- Optimizing Swift performance
+- Migrating to Swift 6 concurrency
+- **App freezes during loading** (likely main thread blocking)
+
+## Conflict Resolution
+
+**ios-concurrency vs ios-performance**: When app freezes or feels slow:
+1. **Try ios-concurrency FIRST** — Main thread blocking is the #1 cause of UI freezes. Check for synchronous work on @MainActor before profiling.
+2. **Only use ios-performance** if concurrency fixes don't help — Profile after ruling out obvious blocking.
+
+**ios-concurrency vs ios-build**: When seeing Swift 6 concurrency errors:
+- **Use ios-concurrency, NOT ios-build** — Concurrency errors are CODE issues, not environment issues
+- ios-build is for "No such module", simulator issues, build failures unrelated to Swift language errors
+
+**ios-concurrency vs ios-data**: When concurrency errors involve Core Data or SwiftData:
+- Core Data threading (NSManagedObjectContext thread confinement, performBackgroundTask) → **use ios-data first** — Core Data has its own threading model distinct from Swift concurrency
+- SwiftData + @MainActor ModelContext → **use ios-concurrency** — This is Swift concurrency isolation
+- General "background saves losing data" → **use ios-data first** — Framework-specific threading rules take priority
+
+**Rationale**: A 2-second freeze during data loading is almost always `await` on main thread or missing background dispatch. Domain knowledge solves this faster than Time Profiler. Core Data threading violations need Core Data-specific fixes, not generic concurrency patterns.
+
+## Routing Logic
+
+### Swift Concurrency Issues
+
+**Swift 6 concurrency patterns** → `/skill axiom-swift-concurrency`
+- async/await patterns
+- @MainActor usage
+- Actor isolation
+- Sendable conformance
+- Data race prevention
+- Swift 6 migration
+
+**Swift concurrency API reference** → `/skill axiom-swift-concurrency-ref`
+- Actor definition, reentrancy, global actors
+- Sendable patterns, @unchecked Sendable
+- Task/TaskGroup/cancellation API
+- AsyncStream, continuations
+- DispatchQueue → actor migration
+
+**Swift performance** → `/skill axiom-swift-performance`
+- Value vs reference types
+- Copy-on-write optimization
+- ARC overhead
+- Generic specialization
+- Collection performance
+
+**Synchronous actor access** → `/skill axiom-assume-isolated`
+- MainActor.assumeIsolated
+- @preconcurrency protocol conformances
+- Legacy delegate callbacks
+- Testing MainActor code synchronously
+
+**Thread-safe primitives** → `/skill axiom-synchronization`
+- Mutex (iOS 18+)
+- OSAllocatedUnfairLock (iOS 16+)
+- Atomic types
+- Lock vs actor decision
+
+**Parameter ownership** → `/skill axiom-ownership-conventions`
+- borrowing/consuming modifiers
+- Noncopyable types (~Copyable)
+- ARC traffic reduction
+- consume operator
+
+**Concurrency profiling** → `/skill axiom-concurrency-profiling`
+- Swift Concurrency Instruments template
+- Actor contention diagnosis
+- Thread pool exhaustion
+- Task visualization
+
+**Combine reactive patterns** → `/skill axiom-combine-patterns`
+- Publisher/Subscriber lifecycle, AnyCancellable
+- Combine vs async/await decision
+- @Published + ObservableObject
+- Operator patterns, bridging
+
+### Automated Scanning
+
+**Concurrency audit** → Launch `concurrency-auditor` agent or `/axiom:audit concurrency` (5-phase semantic audit: maps isolation architecture, detects 8 anti-patterns, reasons about missing concurrency patterns, correlates compound risks, scores Swift 6.3 readiness)
+
+## Decision Tree
+
+1. Data races / actor isolation / @MainActor / Sendable? → swift-concurrency
+1a. Need specific API syntax (actor definition, TaskGroup, AsyncStream, continuation)? → swift-concurrency-ref
+2. Writing async/await code? → swift-concurrency
+3. Swift 6 migration? → swift-concurrency
+4. assumeIsolated / @preconcurrency? → assume-isolated
+5. Mutex / lock / synchronization? → synchronization
+6. borrowing / consuming / ~Copyable? → ownership-conventions
+7. Profile async performance / actor contention? → concurrency-profiling
+8. Value type / ARC / generic optimization? → swift-performance
+9. Want automated concurrency scan? → concurrency-auditor (Agent)
+10. Combine / @Published / AnyCancellable / reactive streams? → combine-patterns
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Just add @MainActor and it'll work" | @MainActor has isolation inheritance rules. swift-concurrency covers all patterns. |
+| "I'll use nonisolated(unsafe) to silence the warning" | Silencing warnings hides data races. swift-concurrency shows the safe pattern. |
+| "It's just one async call" | Even single async calls have cancellation and isolation implications. swift-concurrency covers them. |
+| "I know how actors work" | Actor reentrancy and isolation rules changed in Swift 6.2. swift-concurrency is current. |
+| "I'll fix the Sendable warnings later" | Sendable violations cause runtime crashes. swift-concurrency fixes them correctly now. |
+| "Combine is dead, just use async/await" | Combine has no deprecation notice. Rewriting working pipelines wastes time and introduces bugs. combine-patterns covers incremental migration. |
+
+## Critical Patterns
+
+**Swift 6 Concurrency** (swift-concurrency):
+- Progressive journey: single-threaded → async → concurrent → actors
+- @concurrent attribute for forced background execution
+- Isolated conformances
+- Main actor mode for approachable concurrency
+- 11 copy-paste patterns
+
+**Swift Performance** (swift-performance):
+- ~Copyable for non-copyable types
+- Copy-on-write (COW) patterns
+- Value vs reference type decisions
+- ARC overhead reduction
+- Generic specialization
+
+## Example Invocations
+
+User: "I'm getting 'data race' errors in Swift 6"
+→ Invoke: `/skill axiom-swift-concurrency`
+
+User: "How do I use @MainActor correctly?"
+→ Invoke: `/skill axiom-swift-concurrency`
+
+User: "My app is slow due to unnecessary copying"
+→ Invoke: `/skill axiom-swift-performance`
+
+User: "Should I use async/await for this network call?"
+→ Invoke: `/skill axiom-swift-concurrency`
+
+User: "How do I use assumeIsolated?"
+→ Invoke: `/skill axiom-assume-isolated`
+
+User: "My delegate callback runs on main thread, how do I access MainActor state?"
+→ Invoke: `/skill axiom-assume-isolated`
+
+User: "Should I use Mutex or actor?"
+→ Invoke: `/skill axiom-synchronization`
+
+User: "What's the difference between os_unfair_lock and OSAllocatedUnfairLock?"
+→ Invoke: `/skill axiom-synchronization`
+
+User: "What does borrowing do in Swift?"
+→ Invoke: `/skill axiom-ownership-conventions`
+
+User: "How do I use ~Copyable types?"
+→ Invoke: `/skill axiom-ownership-conventions`
+
+User: "My async code is slow, how do I profile it?"
+→ Invoke: `/skill axiom-concurrency-profiling`
+
+User: "I think I have actor contention, how do I diagnose it?"
+→ Invoke: `/skill axiom-concurrency-profiling`
+
+User: "My Core Data saves lose data from background tasks"
+→ Route to: `ios-data` router (Core Data threading is framework-specific)
+
+User: "How do I create a TaskGroup?"
+→ Invoke: `/skill axiom-swift-concurrency-ref`
+
+User: "What's the AsyncStream API?"
+→ Invoke: `/skill axiom-swift-concurrency-ref`
+
+User: "How do I create a custom global actor?"
+→ Invoke: `/skill axiom-swift-concurrency-ref`
+
+User: "How do I convert a completion handler to async?"
+→ Invoke: `/skill axiom-swift-concurrency-ref`
+
+User: "What are the actor reentrancy rules?"
+→ Invoke: `/skill axiom-swift-concurrency-ref`
+
+User: "My Combine pipeline silently stopped producing values"
+→ Invoke: `/skill axiom-combine-patterns`
+
+User: "Should I use Combine or async/await for this data flow?"
+→ Invoke: `/skill axiom-combine-patterns`
+
+User: "How do I bridge a Combine publisher into async/await code?"
+→ Invoke: `/skill axiom-combine-patterns`
+
+User: "AnyCancellable is leaking memory"
+→ Invoke: `/skill axiom-combine-patterns`
+
+User: "Check my code for Swift 6 concurrency issues"
+→ Invoke: `concurrency-auditor` agent
diff --git a/.claude/skills/axiom-ios-data/.openskills.json b/.claude/skills/axiom-ios-data/.openskills.json
new file mode 100644
index 0000000..6542c37
--- /dev/null
+++ b/.claude/skills/axiom-ios-data/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-data",
+ "installedAt": "2026-04-12T08:05:35.620Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-data/SKILL.md b/.claude/skills/axiom-ios-data/SKILL.md
new file mode 100644
index 0000000..106914b
--- /dev/null
+++ b/.claude/skills/axiom-ios-data/SKILL.md
@@ -0,0 +1,186 @@
+---
+name: axiom-ios-data
+description: Use when working with ANY data persistence, database, axiom-storage, CloudKit, migration, or serialization. Covers SwiftData, Core Data, GRDB, SQLite, CloudKit sync, file storage, Codable, migrations.
+license: MIT
+---
+
+# iOS Data & Persistence Router
+
+**You MUST use this skill for ANY data persistence, database, axiom-storage, CloudKit, or serialization work.**
+
+## When to Use
+
+Use this router when working with:
+- Databases (SwiftData, Core Data, GRDB, SQLiteData)
+- Schema migrations
+- CloudKit sync
+- File storage (iCloud Drive, local storage)
+- Data serialization (Codable, JSON)
+- Storage strategy decisions
+- Keychain / secure credential storage
+- Encryption, signing, key management (CryptoKit)
+
+## Routing Logic
+
+### SwiftData
+
+**Working with SwiftData** → `/skill axiom-swiftdata`
+**Schema migration** → `/skill axiom-swiftdata-migration`
+**Migration issues** → `/skill axiom-swiftdata-migration-diag`
+**Migrating from Realm** → `/skill axiom-realm-migration-ref`
+**SwiftData vs SQLiteData** → `/skill axiom-sqlitedata-migration`
+
+### Other Databases
+
+**GRDB queries** → `/skill axiom-grdb`
+**SQLiteData** → `/skill axiom-sqlitedata`
+**Advanced SQLiteData** → `/skill axiom-sqlitedata-ref`
+**Core Data patterns** → `/skill axiom-core-data`
+**Core Data issues** → `/skill axiom-core-data-diag`
+
+### Migrations
+
+**Database migration safety** → `/skill axiom-database-migration` (critical - prevents data loss)
+
+### Serialization
+
+**Codable issues** → `/skill axiom-codable`
+
+### Cloud Storage
+
+**Cloud sync patterns** → `/skill axiom-cloud-sync`
+**CloudKit** → `/skill axiom-cloudkit-ref`
+**iCloud Drive** → `/skill axiom-icloud-drive-ref`
+**Cloud sync errors** → `/skill axiom-cloud-sync-diag`
+
+### Keychain & Encryption
+
+**Keychain / secure credential storage** → `/skill axiom-keychain`
+**Keychain errors** → `/skill axiom-keychain-diag`
+**Keychain API reference** → `/skill axiom-keychain-ref`
+**Encryption / signing / key management** → `/skill axiom-cryptokit`
+**CryptoKit API reference** → `/skill axiom-cryptokit-ref`
+
+### File Storage
+
+**Storage strategy** → `/skill axiom-storage`
+**Storage issues** → `/skill axiom-storage-diag`
+**Storage management** → `/skill axiom-storage-management-ref`
+**File protection** → `/skill axiom-file-protection-ref`
+
+### tvOS Storage
+
+**tvOS data persistence** → `/skill axiom-tvos` (CRITICAL: no persistent local storage on tvOS)
+**tvOS + CloudKit** → `/skill axiom-sqlitedata` (recommended: SyncEngine as persistent store)
+
+### Automated Scanning
+
+**Core Data audit** → Launch `core-data-auditor` agent or `/axiom:audit core-data` (migration risks, thread-confinement, N+1 queries, production data loss)
+**Codable audit** → Launch `codable-auditor` agent or `/axiom:audit codable` (try? swallowing errors, JSONSerialization, date handling)
+**iCloud audit** → Launch `icloud-auditor` agent or `/axiom:audit icloud` (entitlement checks, file coordination, CloudKit anti-patterns)
+**Storage audit** → Launch `storage-auditor` agent or `/axiom:audit storage` (wrong file locations, missing backup exclusions, data loss risks)
+**Database schema audit** → Launch `database-schema-auditor` agent or `/axiom:audit database-schema` (unsafe ALTER TABLE, DROP operations, missing idempotency, foreign key misuse)
+**SwiftData audit** → Launch `swiftdata-auditor` agent or `/axiom:audit swiftdata` (struct models, missing VersionedSchema, relationship defaults, background context misuse, N+1 patterns)
+
+## Decision Tree
+
+1. SwiftData? → swiftdata, swiftdata-migration
+2. Core Data? → core-data, core-data-diag
+3. GRDB? → grdb
+4. SQLiteData? → sqlitedata, sqlitedata-ref
+5. ANY schema migration? → database-migration (ALWAYS — prevents data loss)
+6. Realm migration? → realm-migration-ref
+7. SwiftData vs SQLiteData? → sqlitedata-migration
+8. Cloud sync architecture? → cloud-sync
+9. CloudKit? → cloudkit-ref
+10. iCloud Drive? → icloud-drive-ref
+11. Cloud sync errors? → cloud-sync-diag
+12. Codable/JSON serialization? → codable
+13. File storage strategy? → storage, storage-diag, storage-management-ref
+14. File protection? → file-protection-ref
+15. Keychain / storing tokens, passwords, secrets securely? → keychain, keychain-diag, keychain-ref
+16. SecItem errors (errSecDuplicateItem, errSecItemNotFound, errSecInteractionNotAllowed)? → keychain-diag
+17. Encryption, signing, Secure Enclave, CryptoKit? → cryptokit, cryptokit-ref
+18. Quantum-secure cryptography, HPKE, ML-KEM? → cryptokit
+19. Want Core Data safety scan? → core-data-auditor (Agent)
+16. Want Codable anti-pattern scan? → codable-auditor (Agent)
+17. Want iCloud sync audit? → icloud-auditor (Agent)
+18. Want storage location audit? → storage-auditor (Agent)
+19. Want database schema/migration safety scan? → database-schema-auditor (Agent)
+20. Want SwiftData code audit? → swiftdata-auditor (Agent)
+21. tvOS data persistence? → axiom-tvos (CRITICAL: no persistent local storage) + axiom-sqlitedata (CloudKit SyncEngine)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Just adding a column, no migration needed" | Schema changes without migration crash users. database-migration prevents data loss. |
+| "I'll handle the migration manually" | Manual migrations miss edge cases. database-migration covers rollback and testing. |
+| "Simple query, I don't need the skill" | Query patterns prevent N+1 and thread-safety issues. The skill has copy-paste solutions. |
+| "CloudKit sync is straightforward" | CloudKit has 15+ failure modes. cloud-sync-diag diagnoses them systematically. |
+| "I know Codable well enough" | Codable has silent data loss traps (try? swallows errors). codable skill prevents production bugs. |
+| "I'll use local storage on tvOS" | tvOS has NO persistent local storage. System deletes Caches at any time. axiom-tvos explains the iCloud-first pattern. |
+| "UserDefaults is fine for this token" | UserDefaults is unencrypted, backed up to iCloud, and visible to MDM profiles. One audit catches it. keychain stores tokens securely. |
+| "I'll encrypt it myself with CommonCrypto" | CryptoKit replaced CommonCrypto's buffer-management nightmares with one-line APIs. cryptokit prevents misuse. |
+
+## Critical Pattern: Migrations
+
+**ALWAYS invoke `/skill axiom-database-migration` when adding/modifying database columns.**
+
+This prevents:
+- "FOREIGN KEY constraint failed" errors
+- "no such column" crashes
+- Data loss from unsafe migrations
+
+## Example Invocations
+
+User: "I need to add a column to my SwiftData model"
+→ Invoke: `/skill axiom-database-migration` (critical - prevents data loss)
+
+User: "How do I query SwiftData with complex filters?"
+→ Invoke: `/skill axiom-swiftdata`
+
+User: "CloudKit sync isn't working"
+→ Invoke: `/skill axiom-cloud-sync-diag`
+
+User: "Should I use SwiftData or SQLiteData?"
+→ Invoke: `/skill axiom-sqlitedata-migration`
+
+User: "Check my Core Data code for safety issues"
+→ Invoke: `core-data-auditor` agent
+
+User: "Scan for Codable anti-patterns before release"
+→ Invoke: `codable-auditor` agent
+
+User: "Audit my iCloud sync implementation"
+→ Invoke: `icloud-auditor` agent
+
+User: "Check if my files are stored in the right locations"
+→ Invoke: `storage-auditor` agent
+
+User: "Audit my database migrations for safety"
+→ Invoke: `database-schema-auditor` agent
+
+User: "Check my SwiftData models for issues"
+→ Invoke: `swiftdata-auditor` agent
+
+User: "How do I persist data on tvOS?"
+→ Invoke: `/skill axiom-tvos` + `/skill axiom-sqlitedata`
+
+User: "My tvOS app loses data between launches"
+→ Invoke: `/skill axiom-tvos`
+
+User: "How do I store an auth token securely?"
+→ Invoke: `/skill axiom-keychain`
+
+User: "errSecDuplicateItem but I checked and the item doesn't exist"
+→ Invoke: `/skill axiom-keychain-diag`
+
+User: "How do I encrypt data with AES in Swift?"
+→ Invoke: `/skill axiom-cryptokit`
+
+User: "I need to sign data with the Secure Enclave"
+→ Invoke: `/skill axiom-cryptokit`
+
+User: "What's ML-KEM and should I use it?"
+→ Invoke: `/skill axiom-cryptokit`
diff --git a/.claude/skills/axiom-ios-games/.openskills.json b/.claude/skills/axiom-ios-games/.openskills.json
new file mode 100644
index 0000000..6fbb364
--- /dev/null
+++ b/.claude/skills/axiom-ios-games/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-games",
+ "installedAt": "2026-04-12T08:05:35.620Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-games/SKILL.md b/.claude/skills/axiom-ios-games/SKILL.md
new file mode 100644
index 0000000..1fd7993
--- /dev/null
+++ b/.claude/skills/axiom-ios-games/SKILL.md
@@ -0,0 +1,288 @@
+---
+name: axiom-ios-games
+description: Use when building ANY 2D or 3D game, game prototype, or interactive simulation with SpriteKit, SceneKit, or RealityKit. Covers scene graphs, ECS architecture, physics, actions, game loops, rendering, SwiftUI integration, SceneKit migration.
+license: MIT
+---
+
+# iOS Games Router
+
+**You MUST use this skill for ANY game development, SpriteKit, SceneKit, RealityKit, or interactive simulation work.**
+
+## When to Use
+
+Use this router when:
+- Building a new SpriteKit game or prototype (2D)
+- Building a 3D game with SceneKit or RealityKit
+- Implementing physics (collisions, contacts, forces, joints)
+- Setting up game architecture (scenes, layers, cameras, ECS)
+- Debugging SpriteKit, SceneKit, or RealityKit issues
+- Optimizing game performance (draw calls, node counts, entity counts, batching)
+- Managing game loop, delta time, or pause handling
+- Implementing touch/input handling in a game context
+- Integrating SpriteKit or RealityKit with SwiftUI
+- Working with particle effects, texture atlases, or 3D models
+- Looking up SpriteKit, SceneKit, or RealityKit API details
+- Migrating from SceneKit to RealityKit
+- Building AR games with RealityKit
+
+## Routing Logic
+
+### SpriteKit (2D)
+
+**Architecture, patterns, and best practices** → `/skill axiom-spritekit`
+- Scene graph model, coordinate systems, anchor points
+- Physics engine: bitmask discipline, contact detection, body types
+- Actions system: sequencing, grouping, named actions, timing
+- Input handling: touches, coordinate conversion
+- Performance: draw calls, batching, object pooling, SKShapeNode trap
+- Game loop: frame cycle, delta time, pause handling
+- Scene transitions and data passing
+- SwiftUI integration (SpriteView, UIViewRepresentable)
+- Metal integration (SKRenderer)
+- Anti-patterns and code review checklist
+- Pressure scenarios with push-back templates
+
+**API reference and lookup** → `/skill axiom-spritekit-ref`
+- All 16 node types with properties and performance notes
+- SKPhysicsBody creation methods and properties
+- Complete SKAction catalog (movement, rotation, scaling, fading, composition, physics)
+- Texture and atlas management
+- SKConstraint types and SKRange
+- SKView configuration and scale modes
+- SKEmitterNode properties and presets
+- SKRenderer setup and SKShader syntax
+
+**Troubleshooting and diagnostics** → `/skill axiom-spritekit-diag`
+- Physics contacts not firing (6-branch decision tree)
+- Objects tunneling through walls (5-branch)
+- Poor frame rate (4 top branches, 12 leaves)
+- Touches not registering (6-branch)
+- Memory spikes and crashes (5-branch)
+- Coordinate confusion (5-branch)
+- Scene transition crashes (5-branch)
+
+**Automated scanning** → Launch `spritekit-auditor` agent or `/axiom:audit spritekit` (physics bitmasks, draw call waste, node accumulation, action memory leaks, coordinate confusion, touch handling, missing object pooling, missing debug overlays)
+
+### SceneKit (3D — Deprecated)
+
+**SceneKit is soft-deprecated as of iOS 26.** Use for maintenance of existing code only. New 3D projects should use RealityKit.
+
+**Maintenance and migration planning** → `/skill axiom-scenekit`
+- Scene graph architecture, coordinate system, transforms
+- Rendering: SCNView, SceneView (deprecated), SCNViewRepresentable
+- Geometry, PBR materials, shader modifiers
+- Lighting, animation (SCNAction, SCNTransaction, CAAnimation bridge)
+- Physics: bodies, collision categories, contact delegate
+- Asset pipeline: Model I/O, USD/DAE/SCN formats
+- ARKit integration (legacy ARSCNView)
+- Migration decision tree (when to migrate vs maintain)
+- Anti-patterns and pressure scenarios
+
+**API reference and migration mapping** → `/skill axiom-scenekit-ref`
+- Complete SceneKit → RealityKit concept mapping table
+- Scene graph API: SCNScene, SCNNode, SCNGeometry
+- Materials: lighting models, PBR properties, shader modifiers
+- Lighting: all light types with properties
+- Camera: SCNCamera properties
+- Physics: body types, shapes, joints
+- Animation: SCNAction catalog, timing functions
+- Constraints: all constraint types
+
+### RealityKit (3D — Modern)
+
+**For non-game 3D content display (product viewers, AR try-on, spatial computing), the ios-graphics router also routes to these RealityKit skills.**
+
+**Architecture, ECS, and best practices** → `/skill axiom-realitykit`
+- Entity-Component-System mental model and paradigm shift
+- Entity hierarchy, transforms, world-space queries
+- Built-in and custom components, component lifecycle
+- System protocol, update ordering, event handling
+- SwiftUI integration: RealityView, Model3D, attachments
+- AR on iOS: AnchorEntity types, SpatialTrackingSession
+- Interaction: ManipulationComponent, gestures, hit testing
+- Materials: SimpleMaterial, PBR, Unlit, Occlusion, ShaderGraph, Custom
+- Physics: collision shapes, groups/filters, events, forces
+- Animation: transform, USD playback, playback control
+- Audio: spatial, ambient, channel
+- Performance: instancing, component churn, shape optimization
+- Multiplayer: synchronization, ownership
+- Anti-patterns and code review checklist
+- Pressure scenarios
+
+**API reference and lookup** → `/skill axiom-realitykit-ref`
+- Entity API: properties, hierarchy, subclasses
+- Complete component catalog with all properties
+- MeshResource generators
+- ShapeResource types and performance
+- System protocol and EntityQuery
+- Scene events catalog
+- RealityView API: initializers, content, gestures
+- Model3D API
+- Material system: all types with full property listings
+- Animation timing functions and playback control
+- Audio components and playback
+- RealityRenderer (Metal integration)
+
+**Troubleshooting and diagnostics** → `/skill axiom-realitykit-diag`
+- Entity not visible (8-branch decision tree)
+- Anchor not tracking (6-branch)
+- Gesture not responding (6-branch)
+- Performance problems (7-branch)
+- Material looks wrong (6-branch)
+- Physics not working (6-branch)
+- Multiplayer sync issues (5-branch)
+
+## Decision Tree
+
+1. Building/designing a 2D SpriteKit game? → axiom-spritekit
+2. How to use a specific SpriteKit API? → axiom-spritekit-ref
+3. SpriteKit broken or performing badly? → axiom-spritekit-diag
+4. Maintaining existing SceneKit code? → axiom-scenekit
+5. SceneKit API reference or migration mapping? → axiom-scenekit-ref
+6. Building new 3D game or experience? → axiom-realitykit
+7. How to use a specific RealityKit API? → axiom-realitykit-ref
+8. RealityKit entity not visible, gestures broken, performance? → axiom-realitykit-diag
+9. Migrating SceneKit to RealityKit? → axiom-scenekit (migration tree) + axiom-scenekit-ref (mapping table)
+10. Building AR game? → axiom-realitykit
+11. Physics contacts not working (SpriteKit)? → axiom-spritekit-diag (Symptom 1)
+12. Frame rate dropping (SpriteKit)? → axiom-spritekit-diag (Symptom 3)
+13. Coordinate/position confusion (SpriteKit)? → axiom-spritekit-diag (Symptom 6)
+14. Need the complete action list? → axiom-spritekit-ref (Part 3)
+15. Physics body setup reference? → axiom-spritekit-ref (Part 2)
+16. Entity not visible (RealityKit)? → axiom-realitykit-diag (Symptom 1)
+17. Gesture not responding (RealityKit)? → axiom-realitykit-diag (Symptom 3)
+18. Want automated SpriteKit code scan? → spritekit-auditor (Agent)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "SpriteKit is simple, I don't need a skill" | Physics bitmasks default to 0xFFFFFFFF and cause phantom collisions. The bitmask checklist catches this in 2 min. |
+| "I'll just use SKShapeNode, it's quick" | Each SKShapeNode is a separate draw call. 50 of them = 50 draw calls. axiom-spritekit has the pre-render-to-texture pattern. |
+| "I can figure out the coordinate system" | SpriteKit uses bottom-left origin (opposite of UIKit). Anchor points add another layer. axiom-spritekit-diag Symptom 6 resolves in 5 min. |
+| "Physics is straightforward" | Three different bitmask properties, modification rules inside callbacks, and tunneling edge cases. axiom-spritekit Section 3 covers all gotchas. |
+| "The performance is fine on my device" | Performance varies dramatically across devices. axiom-spritekit Section 6 has the debug overlay checklist. |
+| "SceneKit is fine for our new project" | SceneKit is soft-deprecated iOS 26. No new features, only security patches. axiom-scenekit has the migration decision tree. |
+| "I'll learn RealityKit later" | Every line of SceneKit is migration debt. axiom-scenekit-ref has the concept mapping table so the transition is concrete, not abstract. |
+| "ECS is overkill for a simple 3D app" | You're already using ECS — Entity + ModelComponent. axiom-realitykit shows how to scale from simple to complex. |
+| "I don't need collision shapes for taps" | RealityKit gestures require CollisionComponent. axiom-realitykit-diag diagnoses this in 2 min vs 30 min guessing. |
+| "I'll just use a Timer for game updates" | Timer-based updates miss frames and aren't synchronized with rendering. axiom-realitykit has the System pattern. |
+
+## Critical Patterns
+
+**axiom-spritekit**:
+- PhysicsCategory struct with explicit bitmasks (default `0xFFFFFFFF` causes phantom collisions)
+- Camera node pattern for viewport + HUD separation
+- SKShapeNode pre-render-to-texture conversion
+- `[weak self]` in all `SKAction.run` closures
+- Delta time with spiral-of-death clamping
+
+**axiom-spritekit-ref**:
+- Complete node type table (16 types with batching behavior)
+- Physics body creation methods (circle cheapest, texture most expensive)
+- Full action catalog with composition patterns
+- SKView debug overlays and scale mode matrix
+
+**axiom-spritekit-diag**:
+- 5-step bitmask checklist (2 min vs 30-120 min guessing)
+- Debug overlays as mandatory first diagnostic step
+- Tunneling prevention flowchart
+- Memory growth diagnosis via `showsNodeCount` trending
+
+**axiom-scenekit**:
+- Migration decision tree (new project → RealityKit, existing → maintain or migrate)
+- USDZ asset conversion before migration (`xcrun scntool`)
+- SceneView deprecation and SCNViewRepresentable replacement
+- Pressure scenarios for "just use SceneKit" rationalization
+
+**axiom-scenekit-ref**:
+- Complete SceneKit → RealityKit concept mapping table
+- All material lighting models and properties
+- Full constraint catalog
+
+**axiom-realitykit**:
+- ECS mental shift table (scene graph thinking → ECS thinking)
+- Custom component registration (`registerComponent()`)
+- Read-modify-write pattern for component updates
+- CollisionComponent required for all interaction
+- System-based updates instead of timers
+
+**axiom-realitykit-ref**:
+- Complete component catalog with all properties
+- MeshResource generators and ShapeResource types
+- Scene events catalog
+- Material system with all PBR properties
+
+**axiom-realitykit-diag**:
+- Entity visibility checklist (8 branches, 2-5 min vs 30-60 min)
+- Gesture debugging (CollisionComponent first check)
+- Performance diagnosis (entity count, resource sharing, component churn)
+- Physics constraint: entities must share an anchor
+
+## Example Invocations
+
+User: "I'm building a SpriteKit game"
+→ Invoke: `/skill axiom-spritekit`
+
+User: "My physics contacts aren't firing"
+→ Invoke: `/skill axiom-spritekit-diag`
+
+User: "How do I create a physics body from a texture?"
+→ Invoke: `/skill axiom-spritekit-ref`
+
+User: "Frame rate is dropping in my game"
+→ Invoke: `/skill axiom-spritekit-diag`
+
+User: "How do I set up SpriteKit with SwiftUI?"
+→ Invoke: `/skill axiom-spritekit`
+
+User: "What action types are available?"
+→ Invoke: `/skill axiom-spritekit-ref`
+
+User: "Objects pass through walls"
+→ Invoke: `/skill axiom-spritekit-diag`
+
+User: "I need to build a 3D game"
+→ Invoke: `/skill axiom-realitykit`
+
+User: "How do I add a 3D model to my SwiftUI app?"
+→ Invoke: `/skill axiom-realitykit`
+
+User: "My RealityKit entity isn't showing up"
+→ Invoke: `/skill axiom-realitykit-diag`
+
+User: "How do I set up physics in RealityKit?"
+→ Invoke: `/skill axiom-realitykit-ref`
+
+User: "I'm migrating from SceneKit to RealityKit"
+→ Invoke: `/skill axiom-scenekit` + `/skill axiom-scenekit-ref`
+
+User: "What's the RealityKit equivalent of SCNNode?"
+→ Invoke: `/skill axiom-scenekit-ref`
+
+User: "Should I use SceneKit for my new 3D project?"
+→ Invoke: `/skill axiom-scenekit`
+
+User: "Tap gestures don't work on my RealityKit entity"
+→ Invoke: `/skill axiom-realitykit-diag`
+
+User: "How do I set up ECS in RealityKit?"
+→ Invoke: `/skill axiom-realitykit`
+
+User: "My AR content isn't tracking"
+→ Invoke: `/skill axiom-realitykit-diag`
+
+User: "What materials are available in RealityKit?"
+→ Invoke: `/skill axiom-realitykit-ref`
+
+User: "How do I animate entities in RealityKit?"
+→ Invoke: `/skill axiom-realitykit-ref`
+
+User: "Memory keeps growing during gameplay"
+→ Invoke: `/skill axiom-spritekit-diag`
+
+User: "What particle emitter settings should I use for fire?"
+→ Invoke: `/skill axiom-spritekit-ref`
+
+User: "Can you scan my SpriteKit code for common issues?"
+→ Invoke: `spritekit-auditor` agent
diff --git a/.claude/skills/axiom-ios-graphics/.openskills.json b/.claude/skills/axiom-ios-graphics/.openskills.json
new file mode 100644
index 0000000..81d5408
--- /dev/null
+++ b/.claude/skills/axiom-ios-graphics/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-graphics",
+ "installedAt": "2026-04-12T08:05:35.621Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-graphics/SKILL.md b/.claude/skills/axiom-ios-graphics/SKILL.md
new file mode 100644
index 0000000..971573b
--- /dev/null
+++ b/.claude/skills/axiom-ios-graphics/SKILL.md
@@ -0,0 +1,196 @@
+---
+name: axiom-ios-graphics
+description: Use when working with ANY GPU rendering, Metal, OpenGL migration, shaders, 3D content, RealityKit, AR, or display performance. Covers Metal migration, shader conversion, RealityKit ECS, RealityView, variable refresh rate, ProMotion.
+license: MIT
+---
+
+# iOS Graphics Router
+
+**You MUST use this skill for ANY GPU rendering, graphics programming, 3D content display, or display performance work.**
+
+## When to Use
+
+Use this router when:
+- Porting OpenGL/OpenGL ES code to Metal
+- Porting DirectX code to Metal
+- Converting GLSL/HLSL shaders to Metal Shading Language
+- Setting up MTKView or CAMetalLayer
+- Debugging GPU rendering issues (black screen, wrong colors, crashes)
+- Evaluating translation layers (MetalANGLE, MoltenVK)
+- Optimizing GPU performance or fixing thermal throttling
+- App stuck at 60fps on ProMotion device
+- Configuring CADisplayLink or render loops
+- Variable refresh rate display issues
+- Displaying 3D content in a non-game SwiftUI app
+- Building AR experiences with RealityKit
+- Using RealityView or Model3D in SwiftUI
+- Spatial computing or visionOS 3D content
+
+## Routing Logic
+
+### Metal Migration
+
+**Strategy decisions** → `/skill axiom-metal-migration`
+- Translation layer vs native rewrite decision
+- Project assessment and migration planning
+- Anti-patterns and common mistakes
+- Pressure scenarios for deadline resistance
+
+**API reference & conversion** → `/skill axiom-metal-migration-ref`
+- GLSL → MSL shader conversion tables
+- HLSL → MSL shader conversion tables
+- GL/D3D API → Metal API equivalents
+- MTKView setup, render pipelines, compute shaders
+- Complete WWDC code examples
+
+**Diagnostics** → `/skill axiom-metal-migration-diag`
+- Black screen after porting
+- Shader compilation errors
+- Wrong colors or coordinate systems
+- Performance regressions
+- Time-cost analysis per diagnostic path
+
+### Display Performance
+
+**Frame rate & render loops** → `/skill axiom-display-performance`
+- App stuck at 60fps on ProMotion (120Hz) device
+- MTKView or CADisplayLink configuration
+- Variable refresh rate optimization
+- System caps (Low Power Mode, Limit Frame Rate, Thermal, Adaptive Power)
+- Frame budget math (8.33ms for 120Hz)
+- Measuring actual vs reported frame rate
+
+### RealityKit (Non-Game 3D Content)
+
+For 3D content in non-game SwiftUI apps, AR experiences, and spatial computing, use the RealityKit skills. **For game-specific RealityKit patterns, use the ios-games router instead.**
+
+**Architecture, ECS, and best practices** → `/skill axiom-realitykit`
+- Entity-Component-System architecture
+- SwiftUI integration: RealityView, Model3D, attachments
+- AR on iOS: AnchorEntity types, SpatialTrackingSession
+- Materials, physics, interaction
+- Performance optimization
+
+**API reference** → `/skill axiom-realitykit-ref`
+- Complete component catalog
+- RealityView and Model3D API
+- Material system (PBR, Unlit, Occlusion, Custom)
+- RealityRenderer (Metal integration)
+
+**Troubleshooting** → `/skill axiom-realitykit-diag`
+- Entity not visible, anchor not tracking
+- Gesture not responding, performance issues
+- Material problems, physics issues
+
+## Decision Tree
+
+1. Translation layer vs native rewrite? → metal-migration
+2. Porting / converting code to Metal? → metal-migration
+3. API reference / shader conversion tables? → metal-migration-ref
+4. MTKView / render pipeline setup? → metal-migration-ref
+5. Something broken after porting (black screen, wrong colors)? → metal-migration-diag
+6. Stuck at 60fps on ProMotion device? → display-performance
+7. CADisplayLink / variable refresh rate? → display-performance
+8. Frame rate not as expected? → display-performance
+9. Display a 3D model in SwiftUI? → axiom-realitykit
+10. Build an AR experience? → axiom-realitykit
+11. RealityView or Model3D setup? → axiom-realitykit-ref
+12. 3D content not visible or not tracking? → axiom-realitykit-diag
+13. Custom Metal rendering of RealityKit content? → axiom-realitykit-ref (RealityRenderer)
+14. Building a 3D game? → Use ios-games router instead
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I'll just translate the shaders line by line" | GLSL→MSL has type, coordinate, and precision differences. metal-migration-ref has conversion tables. |
+| "MetalANGLE will handle everything" | Translation layers have significant limitations for production. metal-migration evaluates the trade-offs. |
+| "It's just a black screen, probably a simple bug" | Black screen has 6 distinct causes. metal-migration-diag diagnoses in 5 min vs 30+ min. |
+| "My app runs at 60fps, that's fine" | ProMotion devices support 120Hz. display-performance configures the correct frame rate. |
+| "I'll just use SceneKit for the 3D model" | SceneKit is soft-deprecated. RealityView and Model3D are the modern path. axiom-realitykit covers SwiftUI integration. |
+| "I don't need ECS for one 3D model" | Model3D shows one model with zero ECS. RealityView scales to complex scenes. axiom-realitykit shows both paths. |
+
+## Critical Patterns
+
+**metal-migration**:
+- Translation layer (MetalANGLE) for quick demos
+- Native Metal rewrite for production
+- State management differences (GL stateful → Metal explicit)
+- Coordinate system gotchas (Y-flip, NDC differences)
+
+**metal-migration-ref**:
+- Complete shader type mappings
+- API equivalent tables
+- MTKView vs CAMetalLayer decision
+- Render pipeline setup patterns
+
+**metal-migration-diag**:
+- GPU Frame Capture workflow (2-5 min vs 30+ min guessing)
+- Shader debugger for variable inspection
+- Metal validation layer for API misuse
+- Performance regression diagnosis
+
+**display-performance**:
+- MTKView defaults to 60fps (must set preferredFramesPerSecond = 120)
+- CADisplayLink preferredFrameRateRange for explicit rate control
+- System caps: Low Power Mode, Limit Frame Rate, Thermal, Adaptive Power (iOS 26)
+- 8.33ms frame budget for 120Hz
+- UIScreen.maximumFramesPerSecond lies; CADisplayLink tells truth
+
+**axiom-realitykit** (non-game 3D):
+- RealityView make/update closure pattern
+- Model3D for simple model display
+- AR anchoring with AnchorEntity
+- Material selection (SimpleMaterial, PBR, Occlusion)
+
+**axiom-realitykit-ref** (API):
+- RealityRenderer for custom Metal rendering of RealityKit content
+- Complete material property reference
+- RealityView gesture integration
+
+## Example Invocations
+
+User: "Should I use MetalANGLE or rewrite in native Metal?"
+→ Invoke: `/skill axiom-metal-migration`
+
+User: "I'm porting projectM from OpenGL ES to iOS"
+→ Invoke: `/skill axiom-metal-migration`
+
+User: "How do I convert this GLSL shader to Metal?"
+→ Invoke: `/skill axiom-metal-migration-ref`
+
+User: "Setting up MTKView for the first time"
+→ Invoke: `/skill axiom-metal-migration-ref`
+
+User: "My ported app shows a black screen"
+→ Invoke: `/skill axiom-metal-migration-diag`
+
+User: "Performance is worse after porting to Metal"
+→ Invoke: `/skill axiom-metal-migration-diag`
+
+User: "My app is stuck at 60fps on iPhone Pro"
+→ Invoke: `/skill axiom-display-performance`
+
+User: "How do I configure CADisplayLink for 120Hz?"
+→ Invoke: `/skill axiom-display-performance`
+
+User: "ProMotion not working in my Metal app"
+→ Invoke: `/skill axiom-display-performance`
+
+User: "How do I show a 3D model in my SwiftUI app?"
+→ Invoke: `/skill axiom-realitykit`
+
+User: "I need to display a USDZ model"
+→ Invoke: `/skill axiom-realitykit`
+
+User: "How do I set up RealityView?"
+→ Invoke: `/skill axiom-realitykit-ref`
+
+User: "My 3D model isn't showing in RealityView"
+→ Invoke: `/skill axiom-realitykit-diag`
+
+User: "How do I use RealityRenderer with Metal?"
+→ Invoke: `/skill axiom-realitykit-ref`
+
+User: "I need AR in my app"
+→ Invoke: `/skill axiom-realitykit`
diff --git a/.claude/skills/axiom-ios-integration/.openskills.json b/.claude/skills/axiom-ios-integration/.openskills.json
new file mode 100644
index 0000000..7c425c7
--- /dev/null
+++ b/.claude/skills/axiom-ios-integration/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-integration",
+ "installedAt": "2026-04-12T08:05:35.622Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-integration/SKILL.md b/.claude/skills/axiom-ios-integration/SKILL.md
new file mode 100644
index 0000000..e7c9878
--- /dev/null
+++ b/.claude/skills/axiom-ios-integration/SKILL.md
@@ -0,0 +1,434 @@
+---
+name: axiom-ios-integration
+description: Use when integrating ANY iOS system feature - Siri, Shortcuts, widgets, IAP, camera, photo library, audio, ShazamKit, haptics, localization, privacy, alarms, calendar, reminders, contacts. Covers App Intents, WidgetKit, StoreKit, AVFoundation, ShazamKit, Core Haptics, Spotlight, EventKit, Contacts.
+license: MIT
+---
+
+# iOS System Integration Router
+
+**You MUST use this skill for ANY iOS system integration including Siri, Shortcuts, widgets, in-app purchases, camera, photo library, audio, axiom-haptics, and more.**
+
+## When to Use
+
+Use this router for:
+- Siri & Shortcuts (App Intents)
+- Apple Intelligence integration
+- Widgets & Live Activities
+- In-app purchases (StoreKit)
+- Camera capture (AVCaptureSession)
+- Photo library & pickers (PHPicker, PhotosPicker)
+- Audio, haptics, & audio recognition (ShazamKit)
+- Localization
+- Privacy & permissions
+- Spotlight search
+- App discoverability
+- Alarms (AlarmKit)
+- Background processing (BGTaskScheduler)
+- Location services (Core Location)
+- Maps & MapKit (Map, MKMapView, annotations, search, directions)
+- Passkeys & authentication (ASAuthorizationController, WebAuthn)
+- App integrity & fraud prevention (App Attest, DeviceCheck)
+- Calendar events & reminders (EventKit, EventKitUI)
+- Contacts & contact pickers (Contacts, ContactsUI, ContactProvider)
+
+## Cross-Domain Routing
+
+When integration issues overlap with other domains:
+
+**Widget + data sync issues** (widget not showing updated data):
+- Widget timeline not refreshing → **stay in ios-integration** (extensions-widgets)
+- SwiftData/Core Data not shared with extension → **also invoke ios-data** — App Groups and shared containers are data-layer concerns
+- Background refresh timing → **also invoke ios-concurrency** if async patterns are involved
+
+**Live Activity + push notification issues**:
+- ActivityKit push token setup, Live Activity not updating → **stay in ios-integration** (extensions-widgets)
+- Push notification delivery failures, APNs errors → **also invoke ios-networking** (networking-diag)
+- Entitlements/certificates misconfigured → **also invoke ios-build** (xcode-debugging)
+
+**Camera + permissions + privacy**:
+- Camera code issues → **stay in ios-integration** (camera-capture)
+- Privacy manifest or Info.plist issues → **stay in ios-integration** (privacy-ux)
+- Build/entitlement errors → **also invoke ios-build**
+
+**MapKit + location issues** (user location not showing on map):
+- Map display, annotations, search → **stay in ios-integration** (mapkit)
+- Location authorization, monitoring, background location → **also invoke ios-performance** or **ios-integration** (core-location)
+- Map performance with many annotations → **also invoke ios-performance** if profiling needed
+
+**Push notification + Live Activity issues** (push not updating Live Activity):
+- Push transport, APNs headers, token management → **stay in ios-integration** (push-notifications, push-notifications-diag)
+- ActivityKit UI, attributes, Dynamic Island → **also invoke ios-integration** (extensions-widgets)
+- Background execution timing → **also invoke ios-concurrency** if async patterns are involved
+
+**Push notification + background processing** (silent push not triggering background work):
+- Push payload and delivery → **stay in ios-integration** (push-notifications-diag)
+- Background execution, BGTaskScheduler → **also invoke ios-integration** (background-processing)
+
+**Calendar + data sync issues** (events not syncing, stale calendar data):
+- EventKit store changes, EKEventStoreChanged → **stay in ios-integration** (eventkit)
+- Shared data with widget via App Groups → **also invoke ios-data** for shared container patterns
+- Background refresh for calendar sync → **also invoke ios-integration** (background-processing)
+
+**Contacts + privacy issues** (contact access denied, limited access confusion):
+- Contact permission model, Contact Access Button → **stay in ios-integration** (contacts)
+- Privacy manifest or Info.plist for contacts → **stay in ios-integration** (privacy-ux)
+- Contact Provider extension architecture → **also invoke ios-build** if extension target issues
+
+**ShazamKit + microphone permissions** (no match results, permission denied):
+- Microphone NSMicrophoneUsageDescription / Info.plist → **stay in ios-integration** (privacy-ux)
+- ShazamKit App Service not enabled → **stay in ios-integration** (shazamkit)
+
+**ShazamKit + AVFoundation** (custom SHSession with AVAudioEngine buffers):
+- Audio engine setup, buffer formats, session configuration → **stay in ios-integration** (shazamkit)
+- AVAudioEngine pipeline, audio session category, format conversion → **also invoke avfoundation-ref**
+
+**ShazamKit + MusicKit** (play matched song via Apple Music):
+- Match result with appleMusicID/appleMusicURL → **stay in ios-integration** (shazamkit)
+- MusicKit playback, ApplicationMusicPlayer → **also invoke ios-integration** (now-playing-musickit)
+
+## Routing Logic
+
+### Apple Intelligence & Siri
+
+**App Intents** → `/skill axiom-app-intents-ref`
+**App Shortcuts** → `/skill axiom-app-shortcuts-ref`
+**App discoverability** → `/skill axiom-app-discoverability`
+**Core Spotlight** → `/skill axiom-core-spotlight-ref`
+
+### Widgets & Extensions
+
+**Widgets/Live Activities** → `/skill axiom-extensions-widgets`
+**Widget reference** → `/skill axiom-extensions-widgets-ref`
+
+### In-App Purchases
+
+**IAP implementation** → `/skill axiom-in-app-purchases`
+**StoreKit 2 reference** → `/skill axiom-storekit-ref`
+**IAP audit** → Launch `iap-auditor` agent (missing transaction.finish(), weak receipt validation, missing restore, subscription tracking)
+**IAP full implementation** → Launch `iap-implementation` agent (StoreKit config, StoreManager, transaction handling, restore purchases)
+
+### Camera & Photos
+
+**Camera capture implementation** → `/skill axiom-camera-capture`
+**Camera API reference** → `/skill axiom-camera-capture-ref`
+**Camera debugging** → `/skill axiom-camera-capture-diag`
+**Camera audit** → Launch `camera-auditor` agent or `/axiom:audit camera` (deprecated APIs, missing interruption handlers, threading violations, permission anti-patterns)
+**Photo pickers & library** → `/skill axiom-photo-library`
+**Photo library API reference** → `/skill axiom-photo-library-ref`
+
+### Audio, Haptics & Audio Recognition
+
+**Audio (AVFoundation)** → `/skill axiom-avfoundation-ref`
+**Haptics** → `/skill axiom-haptics`
+**Now Playing** → `/skill axiom-now-playing`
+**CarPlay Now Playing** → `/skill axiom-now-playing-carplay`
+**MusicKit integration** → `/skill axiom-now-playing-musickit`
+
+**ShazamKit implementation** → `/skill axiom-shazamkit`
+- Song recognition (Shazam catalog), custom audio matching
+- SHManagedSession (modern) vs SHSession (legacy) decision
+- Custom catalogs, signature generation, Shazam CLI
+- Library management (SHLibrary)
+
+**ShazamKit API reference** → `/skill axiom-shazamkit-ref`
+- SHManagedSession, SHSession, SHCustomCatalog, SHSignatureGenerator
+- SHMediaItem, SHMatchedMediaItem, SHLibrary, SHError
+- Complete property keys and error codes
+
+### Localization & Privacy
+
+**Localization** → `/skill axiom-localization`
+**Privacy UX** → `/skill axiom-privacy-ux`
+
+### Authentication & Credentials
+
+**Passkeys / WebAuthn sign-in** → `/skill axiom-passkeys`
+
+### App Integrity & Fraud Prevention
+
+**App Attest / DeviceCheck** → `/skill axiom-app-attest`
+
+### Alarms
+
+**AlarmKit (iOS 26+)** → `/skill axiom-alarmkit-ref`
+- Alarm scheduling and authorization
+- Live Activity integration
+- SwiftUI alarm management views
+
+### Calendar & Reminders (EventKit)
+
+**EventKit implementation** → `/skill axiom-eventkit`
+- Permission model (no access, write-only, full access)
+- Event creation patterns (EventKitUI vs direct EventKit)
+- Reminder patterns (DateComponents, EKSource selection)
+- Store lifecycle (singleton, change notifications)
+- Migration from pre-iOS 17 APIs
+
+**EventKit API reference** → `/skill axiom-eventkit-ref`
+- EKEventStore, EKEvent, EKReminder, EKAlarm, EKRecurrenceRule
+- EventKitUI view controllers
+- Siri Event Suggestions (INReservation donation)
+- Virtual conference extensions
+- Location-based reminders
+
+### Contacts
+
+**Contacts implementation** → `/skill axiom-contacts`
+- Permission model (limited vs full access)
+- Contact Access Button (iOS 18+)
+- Picker vs store access decisions
+- CNContactStore patterns
+- Contact Provider extensions
+
+**Contacts API reference** → `/skill axiom-contacts-ref`
+- CNContactStore, CNMutableContact, CNSaveRequest
+- CNContactFormatter, CNContactVCardSerialization
+- CNContactPickerViewController, ContactAccessButton
+- ContactProvider framework
+- Change history (CNChangeHistoryFetchRequest)
+
+### Background Processing
+
+**BGTaskScheduler implementation** → `/skill axiom-background-processing`
+**Background task debugging** → `/skill axiom-background-processing-diag`
+**Background task API reference** → `/skill axiom-background-processing-ref`
+
+### Push Notifications
+
+**Push notification implementation** → `/skill axiom-push-notifications`
+**Push notification API reference** → `/skill axiom-push-notifications-ref`
+**Push notification debugging** → `/skill axiom-push-notifications-diag`
+
+### Location Services
+
+**Implementation patterns** → `/skill axiom-core-location`
+**API reference** → `/skill axiom-core-location-ref`
+**Debugging location issues** → `/skill axiom-core-location-diag`
+
+### Maps & MapKit
+
+**MapKit implementation patterns** → `/skill axiom-mapkit`
+- SwiftUI Map vs MKMapView decision
+- Annotation strategies by count
+- Search and directions
+- 8 anti-patterns
+
+**MapKit API reference** → `/skill axiom-mapkit-ref`
+- SwiftUI Map API
+- MKMapView delegates
+- MKLocalSearch, MKDirections, Look Around
+- Platform availability matrix
+
+**MapKit troubleshooting** → `/skill axiom-mapkit-diag`
+- Annotations not appearing
+- Region jumping / infinite loops
+- Clustering issues
+- Search failures
+
+## Decision Tree
+
+1. App Intents / Siri / Apple Intelligence? → app-intents-ref
+2. App Shortcuts? → app-shortcuts-ref
+3. App discoverability / Spotlight? → app-discoverability, core-spotlight-ref
+4. Widgets / Live Activities? → extensions-widgets, extensions-widgets-ref
+5. In-app purchases / StoreKit? → in-app-purchases, storekit-ref
+6. Want IAP audit (missing finish, receipt validation)? → iap-auditor (Agent)
+7. Want full IAP implementation? → iap-implementation (Agent)
+8. Camera capture? → camera-capture (patterns), camera-capture-diag (debugging), camera-capture-ref (API)
+9. Want camera code audit? → camera-auditor (Agent)
+10. Photo pickers / library? → photo-library (patterns), photo-library-ref (API)
+11. Audio / AVFoundation? → avfoundation-ref
+12. Now Playing? → now-playing, now-playing-carplay, now-playing-musickit
+13. Haptics? → haptics
+14. Audio recognition / ShazamKit / song identification? → shazamkit (patterns), shazamkit-ref (API)
+15. Custom audio catalogs / second-screen sync / audio matching? → shazamkit (patterns), shazamkit-ref (API)
+16. Localization? → localization
+17. Privacy / permissions? → privacy-ux
+18. Background processing? → background-processing (patterns), background-processing-diag (debugging), background-processing-ref (API)
+19. Push notification implementation, APNs, or remote notification handling? → push-notifications (patterns), push-notifications-ref (API), push-notifications-diag (debugging)
+20. Need APNs payload format, headers, or JWT auth details? → push-notifications-ref
+21. Push notifications not arriving, token issues, or delivery failures? → push-notifications-diag
+22. Location services? → core-location (patterns), core-location-diag (debugging), core-location-ref (API)
+23. Maps / MapKit / annotations / directions? → mapkit (patterns), mapkit-ref (API), mapkit-diag (debugging)
+24. Alarms / AlarmKit? → alarmkit-ref
+25. Passkeys / WebAuthn / replacing passwords / ASAuthorizationController? → passkeys
+26. App Attest / DeviceCheck / fraud prevention / app integrity? → app-attest
+27. Calendar events / EventKit / EventKitUI / add to calendar? → eventkit (patterns), eventkit-ref (API)
+28. Reminders / EKReminder / reminder lists? → eventkit (patterns), eventkit-ref (API)
+29. Siri Event Suggestions / INReservation? → eventkit-ref (API, Part 9)
+30. Virtual conference extension / EKVirtualConferenceProvider? → eventkit-ref (API, Part 8)
+31. Contacts / contact picker / CNContactStore? → contacts (patterns), contacts-ref (API)
+32. Contact Access Button / limited access / iOS 18 contacts? → contacts (patterns), contacts-ref (API)
+33. Contact Provider extension / expose contacts to system? → contacts-ref (API, Part 10)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "App Intents are just a protocol conformance" | App Intents have parameter validation, entity queries, and background execution. app-intents-ref covers all. |
+| "Widgets are simple, I've done them before" | Widgets have timeline, interactivity, and Live Activity patterns that evolve yearly. extensions-widgets is current. |
+| "I'll add haptics with a simple API call" | Haptic design has patterns for each interaction type. haptics skill matches HIG guidelines. |
+| "Localization is just String Catalogs" | Xcode 26 has type-safe localization, generated symbols, and #bundle macro. localization skill is current. |
+| "Camera capture is just AVCaptureSession setup" | Camera has interruption handlers, rotation, and threading requirements. camera-capture covers all. |
+| "I'll just use MKMapView, I know it already" | SwiftUI Map is 10x less code for standard map features. mapkit has the decision tree. |
+| "MapKit search doesn't work, I'll use Google Maps SDK" | MapKit search needs region bias and resultTypes configuration. mapkit-diag fixes this in 5 minutes. |
+| "Alarm scheduling is just UNNotificationRequest" | AlarmKit (iOS 26+) has dedicated alarm UI, authorization, and Live Activity integration. alarmkit-ref covers the framework. |
+| "Push notifications are just a payload and a token" | Token lifecycle, Focus interruption levels, service extension gotchas, and sandbox/production mismatch cause 80% of push bugs. push-notifications covers all. |
+| "Our users aren't ready for passkeys" | Apple, Google, and Microsoft ship passkeys across all platforms. Users don't need to understand crypto — they just tap. passkeys covers the migration path. |
+| "App Attest is overkill for our app" | Any app with server-side value (premium content, virtual currency, user accounts) is a fraud target. app-attest has a gradual rollout strategy. |
+| "Just request full Calendar access" | Most apps only need to add events — EventKitUI does that with zero permissions. eventkit has the access tier decision tree. |
+| "ShazamKit is just SHSession + a delegate" | iOS 17+ has SHManagedSession which eliminates all AVAudioEngine boilerplate. shazamkit has the era decision tree. |
+| "I'll use CNContactStore directly for contact picking" | CNContactPickerViewController needs no authorization and shows all contacts. contacts has the access level decision tree. |
+| "Contacts access is simple, just request and fetch" | iOS 18 limited access means your app may only see a subset. ContactAccessButton handles this gracefully. contacts covers the full model. |
+
+## Example Invocations
+
+User: "How do I add Siri support for my app?"
+→ Invoke: `/skill axiom-app-intents-ref`
+
+User: "My widget isn't updating"
+→ Invoke: `/skill axiom-extensions-widgets`
+
+User: "My widget isn't showing updated SwiftData content"
+→ Invoke: `/skill axiom-extensions-widgets` + also invoke `ios-data` router for App Group/shared container setup
+
+User: "My Live Activity isn't updating and I'm getting push notification errors"
+→ Invoke: `/skill axiom-extensions-widgets` for ActivityKit + also invoke `ios-networking` router for push delivery
+
+User: "Implement in-app purchases with StoreKit 2"
+→ Invoke: `/skill axiom-in-app-purchases`
+
+User: "How do I localize my app strings?"
+→ Invoke: `/skill axiom-localization`
+
+User: "Implement haptic feedback for button taps"
+→ Invoke: `/skill axiom-haptics`
+
+User: "How do I set up a camera preview?"
+→ Invoke: `/skill axiom-camera-capture`
+
+User: "Camera freezes when I get a phone call"
+→ Invoke: `/skill axiom-camera-capture-diag`
+
+User: "What is RotationCoordinator?"
+→ Invoke: `/skill axiom-camera-capture-ref`
+
+User: "How do I let users pick photos in SwiftUI?"
+→ Invoke: `/skill axiom-photo-library`
+
+User: "User can't see their photos after granting access"
+→ Invoke: `/skill axiom-photo-library`
+
+User: "How do I save a photo to the camera roll?"
+→ Invoke: `/skill axiom-photo-library`
+
+User: "My background task never runs"
+→ Invoke: `/skill axiom-background-processing-diag`
+
+User: "How do I implement BGTaskScheduler?"
+→ Invoke: `/skill axiom-background-processing`
+
+User: "What's the difference between BGAppRefreshTask and BGProcessingTask?"
+→ Invoke: `/skill axiom-background-processing-ref`
+
+User: "How do I implement geofencing?"
+→ Invoke: `/skill axiom-core-location`
+
+User: "Location updates not working in background"
+→ Invoke: `/skill axiom-core-location-diag`
+
+User: "What is CLServiceSession?"
+→ Invoke: `/skill axiom-core-location-ref`
+
+User: "Review my in-app purchase implementation"
+→ Invoke: `iap-auditor` agent
+
+User: "Implement in-app purchases for my app"
+→ Invoke: `iap-implementation` agent
+
+User: "Check my camera code for issues"
+→ Invoke: `camera-auditor` agent
+
+User: "How do I add a map to my SwiftUI app?"
+→ Invoke: `/skill axiom-mapkit`
+
+User: "My annotations aren't showing on the map"
+→ Invoke: `/skill axiom-mapkit-diag`
+
+User: "How do I implement search with autocomplete on a map?"
+→ Invoke: `/skill axiom-mapkit-ref`
+
+User: "My map region keeps jumping when I scroll"
+→ Invoke: `/skill axiom-mapkit-diag`
+
+User: "How do I add directions between two points?"
+→ Invoke: `/skill axiom-mapkit-ref`
+
+User: "How do I schedule alarms in iOS 26?"
+→ Invoke: `/skill axiom-alarmkit-ref`
+
+User: "How do I integrate AlarmKit with Live Activities?"
+→ Invoke: `/skill axiom-alarmkit-ref`
+
+User: "How do I implement push notifications?"
+→ Invoke: `/skill axiom-push-notifications`
+
+User: "What APNs headers do I need?"
+→ Invoke: `/skill axiom-push-notifications-ref`
+
+User: "Push notifications work in dev but not production"
+→ Invoke: `/skill axiom-push-notifications-diag`
+
+User: "My Live Activity isn't updating via push"
+→ Invoke: `/skill axiom-push-notifications-diag` + `/skill axiom-extensions-widgets`
+
+User: "Should I use FCM or direct APNs?"
+→ Invoke: `/skill axiom-push-notifications`
+
+User: "How do I use pushTokenUpdates for Live Activities?"
+→ Invoke: `/skill axiom-extensions-widgets` (ActivityKit API owns push token observation)
+
+User: "How do I test push notifications without a real server?"
+→ Invoke: `/skill axiom-push-notifications-ref` (command-line testing section)
+
+User: "How do I implement passkey sign-in?"
+→ Invoke: `/skill axiom-passkeys`
+
+User: "How do I replace passwords with passkeys?"
+→ Invoke: `/skill axiom-passkeys`
+
+User: "How do I verify my app hasn't been tampered with?"
+→ Invoke: `/skill axiom-app-attest`
+
+User: "How do I prevent promotional fraud?"
+→ Invoke: `/skill axiom-app-attest`
+
+User: "How do I add an event to the user's calendar?"
+→ Invoke: `/skill axiom-eventkit`
+
+User: "What's the difference between write-only and full Calendar access?"
+→ Invoke: `/skill axiom-eventkit`
+
+User: "How do I create reminders programmatically?"
+→ Invoke: `/skill axiom-eventkit`
+
+User: "What is EKEventEditViewController?"
+→ Invoke: `/skill axiom-eventkit-ref`
+
+User: "How do I implement Siri Event Suggestions?"
+→ Invoke: `/skill axiom-eventkit-ref`
+
+User: "How do I let users pick a contact?"
+→ Invoke: `/skill axiom-contacts`
+
+User: "What is the Contact Access Button?"
+→ Invoke: `/skill axiom-contacts`
+
+User: "How do I search and fetch contacts?"
+→ Invoke: `/skill axiom-contacts-ref`
+
+User: "How do I build a Contact Provider extension?"
+→ Invoke: `/skill axiom-contacts-ref`
+
+User: "How do I detect contact changes for sync?"
+→ Invoke: `/skill axiom-contacts-ref`
diff --git a/.claude/skills/axiom-ios-ml/.openskills.json b/.claude/skills/axiom-ios-ml/.openskills.json
new file mode 100644
index 0000000..430e506
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ml",
+ "installedAt": "2026-04-12T08:05:35.624Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-ml/SKILL.md b/.claude/skills/axiom-ios-ml/SKILL.md
new file mode 100644
index 0000000..f7dcd1c
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/SKILL.md
@@ -0,0 +1,136 @@
+---
+name: axiom-ios-ml
+description: Use when deploying ANY machine learning model on-device, converting models to CoreML, compressing models, or implementing speech-to-text. Covers CoreML conversion, MLTensor, model compression (quantization/palettization/pruning), stateful models, KV-cache, multi-function models, async prediction, SpeechAnalyzer, SpeechTranscriber.
+license: MIT
+---
+
+# iOS Machine Learning Router
+
+**You MUST use this skill for ANY on-device machine learning or speech-to-text work.**
+
+## When to Use
+
+Use this router when:
+- Converting PyTorch/TensorFlow models to CoreML
+- Deploying ML models on-device
+- Compressing models (quantization, palettization, pruning)
+- Working with large language models (LLMs)
+- Implementing KV-cache for transformers
+- Using MLTensor for model stitching
+- Building speech-to-text features
+- Transcribing audio (live or recorded)
+
+## Boundary with ios-ai
+
+**ios-ml vs ios-ai — know the difference:**
+
+| Developer Intent | Router |
+|-----------------|--------|
+| "Use Apple Intelligence / Foundation Models" | **ios-ai** — Apple's on-device LLM |
+| "Run my own ML model on device" | **ios-ml** — CoreML conversion + deployment |
+| "Add text generation with @Generable" | **ios-ai** — Foundation Models structured output |
+| "Deploy a custom LLM with KV-cache" | **ios-ml** — Custom model optimization |
+| "Use Vision framework for image analysis" | **ios-vision** — Not ML deployment |
+| "Use pre-trained Apple NLP models" | **ios-ai** — Apple's models, not custom |
+
+**Rule of thumb**: If the developer is converting/compressing/deploying their own model → ios-ml. If they're using Apple's built-in AI → ios-ai. If they're doing computer vision → ios-vision.
+
+## Routing Logic
+
+### CoreML Work
+
+**Implementation patterns** → `/skill coreml`
+- Model conversion workflow
+- MLTensor for model stitching
+- Stateful models with KV-cache
+- Multi-function models (adapters/LoRA)
+- Async prediction patterns
+- Compute unit selection
+
+**API reference** → `/skill coreml-ref`
+- CoreML Tools Python API
+- MLModel lifecycle
+- MLTensor operations
+- MLComputeDevice availability
+- State management APIs
+- Performance reports
+
+**Diagnostics** → `/skill coreml-diag`
+- Model won't load
+- Slow inference
+- Memory issues
+- Compression accuracy loss
+- Compute unit problems
+
+### Speech Work
+
+**Implementation patterns** → `/skill speech`
+- SpeechAnalyzer setup (iOS 26+)
+- SpeechTranscriber configuration
+- Live transcription
+- File transcription
+- Volatile vs finalized results
+- Model asset management
+
+## Decision Tree
+
+1. Implementing / converting ML models? → coreml
+2. CoreML API reference? → coreml-ref
+3. Debugging ML issues (load, inference, compression)? → coreml-diag
+4. Speech-to-text / transcription? → speech
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "CoreML is just load and predict" | CoreML has compression, stateful models, compute unit selection, and async prediction. coreml covers all. |
+| "My model is small, no optimization needed" | Even small models benefit from compute unit selection and async prediction. coreml has the patterns. |
+| "I'll just use SFSpeechRecognizer" | iOS 26 has SpeechAnalyzer with better accuracy and offline support. speech skill covers the modern API. |
+
+## Critical Patterns
+
+**coreml**:
+- Model conversion (PyTorch → CoreML)
+- Compression (palettization, quantization, pruning)
+- Stateful KV-cache for LLMs
+- Multi-function models for adapters
+- MLTensor for pipeline stitching
+- Async concurrent prediction
+
+**coreml-diag**:
+- Load failures and caching
+- Inference performance issues
+- Memory pressure from models
+- Accuracy degradation from compression
+
+**speech**:
+- SpeechAnalyzer + SpeechTranscriber setup
+- AssetInventory model management
+- Live transcription with volatile results
+- Audio format conversion
+
+## Example Invocations
+
+User: "How do I convert a PyTorch model to CoreML?"
+→ Invoke: `/skill coreml`
+
+User: "Compress my model to fit on iPhone"
+→ Invoke: `/skill coreml`
+
+User: "Implement KV-cache for my language model"
+→ Invoke: `/skill coreml`
+
+User: "Model loads slowly on first launch"
+→ Invoke: `/skill coreml-diag`
+
+User: "My compressed model has bad accuracy"
+→ Invoke: `/skill coreml-diag`
+
+User: "Add live transcription to my app"
+→ Invoke: `/skill speech`
+
+User: "Transcribe audio files with SpeechAnalyzer"
+→ Invoke: `/skill speech`
+
+User: "What's MLTensor and how do I use it?"
+→ Invoke: `/skill coreml-ref`
diff --git a/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md b/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md
new file mode 100644
index 0000000..d2bdcbf
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/coreml-diag/SKILL.md
@@ -0,0 +1,473 @@
+---
+name: coreml-diag
+description: CoreML diagnostics - model load failures, slow inference, memory issues, compression accuracy loss, compute unit problems, conversion errors.
+license: MIT
+version: 1.0.0
+---
+
+# CoreML Diagnostics
+
+## Quick Reference
+
+| Symptom | First Check | Pattern |
+|---------|-------------|---------|
+| Model won't load | Deployment target | 1a-1c |
+| Slow first load | Cache miss | 2a |
+| Slow inference | Compute units | 2b-2c |
+| High memory | Concurrent predictions | 3a-3b |
+| Bad accuracy after compression | Granularity | 4a-4c |
+| Conversion fails | Operation support | 5a-5b |
+
+## Decision Tree
+
+```
+CoreML issue
+├─ Load failure?
+│ ├─ "Unsupported model version" → 1a
+│ ├─ "Failed to create compute plan" → 1b
+│ └─ Other load error → 1c
+├─ Performance issue?
+│ ├─ First load slow, subsequent fast? → 2a
+│ ├─ All predictions slow? → 2b
+│ └─ Slow only on specific device? → 2c
+├─ Memory issue?
+│ ├─ Memory grows during predictions? → 3a
+│ └─ Out of memory on load? → 3b
+├─ Accuracy degraded?
+│ ├─ After palettization? → 4a
+│ ├─ After quantization? → 4b
+│ └─ After pruning? → 4c
+└─ Conversion issue?
+ ├─ Operation not supported? → 5a
+ └─ Wrong output? → 5b
+```
+
+---
+
+## Pattern 1a - "Unsupported model version"
+
+**Symptom**: Model fails to load with version error.
+
+**Cause**: Model compiled for newer OS than device supports.
+
+**Diagnosis**:
+```python
+# Check model's minimum deployment target
+import coremltools as ct
+model = ct.models.MLModel("Model.mlpackage")
+print(model.get_spec().specificationVersion)
+```
+
+| Spec Version | Minimum iOS |
+|--------------|-------------|
+| 4 | iOS 13 |
+| 5 | iOS 14 |
+| 6 | iOS 15 |
+| 7 | iOS 16 |
+| 8 | iOS 17 |
+| 9 | iOS 18 |
+
+**Fix**: Re-convert with lower deployment target:
+```python
+mlmodel = ct.convert(
+ traced,
+ minimum_deployment_target=ct.target.iOS16 # Lower target
+)
+```
+
+**Tradeoff**: Loses newer optimizations (SDPA fusion, per-block quantization, MLTensor).
+
+---
+
+## Pattern 1b - "Failed to create compute plan"
+
+**Symptom**: Model loads on some devices but not others.
+
+**Cause**: Unsupported operations for target compute unit.
+
+**Diagnosis**:
+1. Open model in Xcode
+2. Create Performance Report
+3. Check "Unsupported" operations
+4. Hover for hints
+
+**Fix**:
+```swift
+// Force CPU-only to bypass unsupported GPU/NE operations
+let config = MLModelConfiguration()
+config.computeUnits = .cpuOnly
+let model = try MLModel(contentsOf: url, configuration: config)
+```
+
+**Better fix**: Update model precision or operations during conversion:
+```python
+# Float16 often better supported
+mlmodel = ct.convert(traced, compute_precision=ct.precision.FLOAT16)
+```
+
+---
+
+## Pattern 1c - General Load Failures
+
+**Symptom**: Model fails to load with unclear error.
+
+**Checklist**:
+1. Check file exists and is readable
+2. Check compiled vs source model (runtime needs `.mlmodelc`)
+3. Check available disk space (cache needs room)
+4. Check model isn't corrupted (re-convert)
+
+```swift
+// Debug logging
+let config = MLModelConfiguration()
+config.parameters = [.reporter: { print($0) }] // iOS 17+
+```
+
+---
+
+## Pattern 2a - Slow First Load (Cache Miss)
+
+**Symptom**: First prediction after install/update is slow, subsequent are fast.
+
+**Cause**: Device specialization not cached.
+
+**Diagnosis**:
+1. Profile with Core ML Instrument
+2. Look at Load event label:
+ - "prepare and cache" = cache miss (slow)
+ - "cached" = cache hit (fast)
+
+**Why cache misses**:
+- First launch after install
+- System update invalidated cache
+- Low disk space cleared cache
+- Model file was modified
+
+**Mitigation**:
+```swift
+// Warm cache in background at app launch
+Task.detached(priority: .background) {
+ _ = try? await MLModel.load(contentsOf: modelURL)
+}
+```
+
+**Note**: Cache is tied to (model path + configuration + device). Different configs = different cache entries.
+
+---
+
+## Pattern 2b - All Predictions Slow
+
+**Symptom**: Predictions consistently slow, not just first one.
+
+**Diagnosis**:
+1. Create Xcode Performance Report
+2. Check compute unit distribution
+3. Look for high-cost operations
+
+**Common causes**:
+
+| Cause | Fix |
+|-------|-----|
+| Running on CPU when GPU/NE available | Check `computeUnits` config |
+| Model too large for Neural Engine | Compress model |
+| Frequent CPU↔GPU↔NE transfers | Adjust segmentation |
+| Dynamic shapes recompiling | Use fixed/enumerated shapes |
+
+**Profile compute unit usage**:
+```swift
+let plan = try await MLComputePlan.load(contentsOf: modelURL)
+for op in plan.modelStructure.operations {
+ let info = plan.computeDeviceInfo(for: op)
+ print("\(op.name): \(info.preferredDevice)")
+}
+```
+
+---
+
+## Pattern 2c - Slow on Specific Device
+
+**Symptom**: Fast on Mac, slow on iPhone (or vice versa).
+
+**Cause**: Different hardware characteristics.
+
+**Diagnosis**:
+```swift
+// Check available compute
+let devices = MLModel.availableComputeDevices
+print(devices) // Different per device
+```
+
+**Common issues**:
+
+| Scenario | Cause | Fix |
+|----------|-------|-----|
+| Fast on M-series Mac, slow on iPhone | Model optimized for GPU | Use palettization (Neural Engine) |
+| Fast on iPhone, slow on Intel Mac | No Neural Engine | Use quantization (GPU) |
+| Slow on older devices | Less compute power | Use more aggressive compression |
+
+**Recommendation**: Profile on target devices, not just development Mac.
+
+---
+
+## Pattern 3a - Memory Grows During Predictions
+
+**Symptom**: Memory increases with each prediction, doesn't release.
+
+**Cause**: Input/output buffers accumulating from concurrent predictions.
+
+**Diagnosis**:
+```
+Instruments → Allocations + Core ML template
+Look for: Many concurrent prediction intervals
+Check: MLMultiArray allocations growing
+```
+
+**Fix**: Limit concurrent predictions:
+```swift
+actor PredictionLimiter {
+ private let maxConcurrent = 2
+ private var inFlight = 0
+
+ func predict(_ model: MLModel, input: MLFeatureProvider) async throws -> MLFeatureProvider {
+ while inFlight >= maxConcurrent {
+ await Task.yield()
+ }
+ inFlight += 1
+ defer { inFlight -= 1 }
+ return try await model.prediction(from: input)
+ }
+}
+```
+
+---
+
+## Pattern 3b - Out of Memory on Load
+
+**Symptom**: App crashes or model fails to load on memory-constrained devices.
+
+**Cause**: Model too large for device memory.
+
+**Diagnosis**:
+```bash
+# Check model size
+ls -lh Model.mlpackage/Data/com.apple.CoreML/weights/
+```
+
+**Fix options**:
+
+| Approach | Compression | Memory Impact |
+|----------|-------------|---------------|
+| 8-bit palettization | 2x smaller | 2x less memory |
+| 4-bit palettization | 4x smaller | 4x less memory |
+| Pruning (50%) | ~2x smaller | ~2x less memory |
+
+**Note**: Compressed weights are decompressed just-in-time (iOS 17+), so smaller on-disk = smaller in memory.
+
+---
+
+## Pattern 4a - Bad Accuracy After Palettization
+
+**Symptom**: Model output degraded after palettization.
+
+**Diagnosis**:
+1. What bit depth? (2-bit most likely to fail)
+2. What granularity? (per-tensor loses more than per-grouped-channel)
+
+**Fix progression**:
+
+```python
+# Step 1: Try grouped channels (iOS 18+)
+config = OpPalettizerConfig(
+ nbits=4,
+ granularity="per_grouped_channel",
+ group_size=16
+)
+
+# Step 2: If still bad, try more bits
+config = OpPalettizerConfig(nbits=6, ...)
+
+# Step 3: If still need 4-bit, use calibration
+from coremltools.optimize.torch.palettization import DKMPalettizer
+# ... training-time compression
+```
+
+**Key insight**: 4-bit per-tensor has only 16 clusters for entire weight matrix. Grouped channels = 16 clusters per 16 channels = much better granularity.
+
+---
+
+## Pattern 4b - Bad Accuracy After Quantization
+
+**Symptom**: Model output degraded after INT8/INT4 quantization.
+
+**Diagnosis**:
+1. What bit depth?
+2. What granularity?
+
+**Fix progression**:
+
+```python
+# Step 1: Use per-block (iOS 18+)
+config = OpLinearQuantizerConfig(
+ dtype="int4",
+ granularity="per_block",
+ block_size=32
+)
+
+# Step 2: Use calibration data
+from coremltools.optimize.torch.quantization import LayerwiseCompressor
+compressor = LayerwiseCompressor(model, config)
+quantized = compressor.compress(calibration_loader)
+```
+
+**Note**: INT4 quantization works best on Mac GPU. For Neural Engine, prefer palettization.
+
+---
+
+## Pattern 4c - Bad Accuracy After Pruning
+
+**Symptom**: Model output degraded after weight pruning.
+
+**Diagnosis**:
+1. What sparsity level?
+2. Post-training or training-time?
+
+**Thresholds** (model-dependent):
+- 0-30% sparsity: Usually safe
+- 30-50% sparsity: May need calibration
+- 50%+ sparsity: Usually needs training-time
+
+**Fix**:
+```python
+# Use calibration-based pruning
+from coremltools.optimize.torch.pruning import LayerwiseCompressor
+
+config = MagnitudePrunerConfig(
+ target_sparsity=0.4,
+ n_samples=128
+)
+compressor = LayerwiseCompressor(model, config)
+sparse = compressor.compress(calibration_loader)
+```
+
+---
+
+## Pattern 5a - Operation Not Supported
+
+**Symptom**: Conversion fails with unsupported operation error.
+
+**Diagnosis**:
+```
+Error: "Op 'custom_op' is not supported for conversion"
+```
+
+**Options**:
+
+1. **Check if op is in coremltools**: May need newer version
+```bash
+pip install --upgrade coremltools
+```
+
+2. **Use composite ops**: Split into supported primitives
+```python
+# Instead of custom_op(x)
+# Use: supported_op1(supported_op2(x))
+```
+
+3. **Register custom op**: Advanced, requires MIL programming
+```python
+from coremltools.converters.mil import Builder as mb
+
+@mb.register_torch_op
+def custom_op(context, node):
+ # Map to MIL operations
+ ...
+```
+
+---
+
+## Pattern 5b - Conversion Succeeds but Wrong Output
+
+**Symptom**: Model converts but predictions differ from PyTorch.
+
+**Diagnosis checklist**:
+
+1. **Input normalization**: Ensure preprocessing matches
+```python
+# PyTorch often uses ImageNet normalization
+# CoreML may need explicit preprocessing
+```
+
+2. **Shape ordering**: PyTorch (NCHW) vs CoreML (NHWC for some ops)
+```python
+# Check shapes in conversion
+ct.convert(..., inputs=[ct.ImageType(shape=(1, 3, 224, 224))])
+```
+
+3. **Precision differences**: Float16 may differ from Float32
+```python
+# Force Float32 to match PyTorch
+ct.convert(..., compute_precision=ct.precision.FLOAT32)
+```
+
+4. **Random ops**: Dropout, random initialization differ
+```python
+# Ensure eval mode
+model.eval()
+```
+
+**Debug**:
+```python
+# Compare outputs layer by layer
+import numpy as np
+
+torch_output = model(input).detach().numpy()
+coreml_output = mlmodel.predict({"input": input.numpy()})["output"]
+
+print(f"Max diff: {np.max(np.abs(torch_output - coreml_output))}")
+```
+
+---
+
+## Pressure Scenario - "Model works on simulator but not device"
+
+**Wrong approach**: Assume simulator bug, ignore.
+
+**Right approach**:
+1. Check model spec version vs device iOS version (Pattern 1a)
+2. Check compute unit availability (Pattern 2c)
+3. Profile on actual device, not simulator
+4. Simulator uses host Mac's GPU/CPU, not device Neural Engine
+
+---
+
+## Pressure Scenario - "Ship now, optimize later"
+
+**Wrong approach**: Compress to smallest possible size without testing.
+
+**Right approach**:
+1. Ship Float16 baseline first
+2. Profile on target devices
+3. Apply compression incrementally with accuracy testing
+4. Document compression settings for future optimization
+
+---
+
+## Diagnostic Checklist
+
+When CoreML isn't working:
+
+- [ ] Check deployment target matches device iOS
+- [ ] Check model file is compiled (.mlmodelc)
+- [ ] Profile load: cached vs uncached
+- [ ] Profile prediction: which compute units
+- [ ] Check memory: concurrent predictions limited
+- [ ] For compression issues: try higher granularity
+- [ ] For conversion issues: check op support, precision
+
+## Resources
+
+**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161
+
+**Docs**: /coreml, /coreml/mlmodel
+
+**Skills**: coreml, coreml-ref
diff --git a/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md b/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md
new file mode 100644
index 0000000..e60d344
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/coreml-ref/SKILL.md
@@ -0,0 +1,467 @@
+---
+name: coreml-ref
+description: CoreML API reference - MLModel lifecycle, MLTensor operations, coremltools conversion, compression APIs, state management, compute device availability, performance profiling.
+license: MIT
+version: 1.0.0
+---
+
+# CoreML API Reference
+
+## Part 1 - Model Lifecycle
+
+### MLModel Loading
+
+```swift
+// Synchronous load (blocks thread)
+let model = try MLModel(contentsOf: compiledModelURL)
+
+// Async load (preferred)
+let model = try await MLModel.load(contentsOf: compiledModelURL)
+
+// With configuration
+let config = MLModelConfiguration()
+config.computeUnits = .all // .cpuOnly, .cpuAndGPU, .cpuAndNeuralEngine
+let model = try await MLModel.load(contentsOf: url, configuration: config)
+```
+
+### Model Asset Types
+
+| Type | Extension | Purpose |
+|------|-----------|---------|
+| Source | `.mlmodel`, `.mlpackage` | Development, editing |
+| Compiled | `.mlmodelc` | Runtime execution |
+
+**Note**: Xcode compiles source models automatically. At runtime, use compiled models.
+
+### Caching Behavior
+
+First load triggers device specialization (can be slow). Subsequent loads use cache.
+
+```
+Load flow:
+ ├─ Check cache for (model path + configuration + device)
+ │ ├─ Found → Cached load (fast)
+ │ └─ Not found → Device specialization
+ │ ├─ Parse model
+ │ ├─ Optimize operations
+ │ ├─ Segment for compute units
+ │ ├─ Compile for each unit
+ │ └─ Cache result
+```
+
+Cache invalidated by: system updates, low disk space, model modification.
+
+### Multi-Function Models
+
+```swift
+// Load specific function
+let config = MLModelConfiguration()
+config.functionName = "sticker" // Function name from model
+
+let model = try MLModel(contentsOf: url, configuration: config)
+```
+
+---
+
+## Part 2 - Compute Availability
+
+### MLComputeDevice (iOS 17+)
+
+```swift
+// Check available compute devices
+let devices = MLModel.availableComputeDevices
+
+// Check for Neural Engine
+let hasNeuralEngine = devices.contains { device in
+ if case .neuralEngine = device { return true }
+ return false
+}
+
+// Check for specific GPU
+for device in devices {
+ switch device {
+ case .cpu:
+ print("CPU available")
+ case .gpu(let gpu):
+ print("GPU: \(gpu.name)")
+ case .neuralEngine(let ne):
+ print("Neural Engine: \(ne.totalCoreCount) cores")
+ @unknown default:
+ break
+ }
+}
+```
+
+### MLModelConfiguration.ComputeUnits
+
+| Value | Behavior |
+|-------|----------|
+| `.all` | Best performance (default) |
+| `.cpuOnly` | CPU only |
+| `.cpuAndGPU` | Exclude Neural Engine |
+| `.cpuAndNeuralEngine` | Exclude GPU |
+
+---
+
+## Part 3 - Prediction APIs
+
+### Synchronous Prediction
+
+```swift
+// Single prediction (NOT thread-safe)
+let output = try model.prediction(from: input)
+
+// Batch prediction
+let outputs = try model.predictions(from: batch)
+```
+
+### Async Prediction (iOS 17+)
+
+```swift
+// Single prediction (thread-safe, supports concurrency)
+let output = try await model.prediction(from: input)
+
+// With cancellation
+let output = try await withTaskCancellationHandler {
+ try await model.prediction(from: input)
+} onCancel: {
+ // Prediction will be cancelled
+}
+```
+
+### State-Based Prediction
+
+```swift
+// Create state from model
+let state = model.makeState()
+
+// Prediction with state (state updated in-place)
+let output = try model.prediction(from: input, using: state)
+
+// Async with state
+let output = try await model.prediction(from: input, using: state)
+```
+
+---
+
+## Part 4 - MLTensor (iOS 18+)
+
+### Creating Tensors
+
+```swift
+import CoreML
+
+// From MLShapedArray
+let shapedArray = MLShapedArray(scalars: [1, 2, 3, 4], shape: [2, 2])
+let tensor = MLTensor(shapedArray)
+
+// From nested collections
+let tensor = MLTensor([[1.0, 2.0], [3.0, 4.0]])
+
+// Zeros/ones
+let zeros = MLTensor(zeros: [3, 3], scalarType: Float.self)
+```
+
+### Math Operations
+
+```swift
+// Element-wise
+let sum = tensor1 + tensor2
+let product = tensor1 * tensor2
+let scaled = tensor * 2.0
+
+// Reductions
+let mean = tensor.mean()
+let sum = tensor.sum()
+let max = tensor.max()
+
+// Comparison
+let mask = tensor .> mean // Boolean mask
+
+// Softmax
+let probs = tensor.softmax()
+```
+
+### Indexing and Reshaping
+
+```swift
+// Slicing (Python-like syntax)
+let row = tensor[0] // First row
+let col = tensor[.all, 0] // First column
+let slice = tensor[0..<2, 1..<3]
+
+// Reshaping
+let reshaped = tensor.reshaped(to: [4])
+let expanded = tensor.expandingShape(at: 0)
+```
+
+### Materialization
+
+**Critical**: Tensor operations are async. Must materialize to access data.
+
+```swift
+// Materialize to MLShapedArray (blocks until complete)
+let array = await tensor.shapedArray(of: Float.self)
+
+// Access scalars
+let values = array.scalars
+```
+
+---
+
+## Part 5 - Core ML Tools (Python)
+
+### Basic Conversion
+
+```python
+import coremltools as ct
+import torch
+
+# Trace PyTorch model
+model.eval()
+traced = torch.jit.trace(model, example_input)
+
+# Convert
+mlmodel = ct.convert(
+ traced,
+ inputs=[ct.TensorType(shape=example_input.shape)],
+ outputs=[ct.TensorType(name="output")],
+ minimum_deployment_target=ct.target.iOS18
+)
+
+mlmodel.save("Model.mlpackage")
+```
+
+### Dynamic Shapes
+
+```python
+# Fixed shape
+ct.TensorType(shape=(1, 3, 224, 224))
+
+# Range dimension
+ct.TensorType(shape=(1, ct.RangeDim(1, 2048)))
+
+# Enumerated shapes
+ct.TensorType(shape=ct.EnumeratedShapes(shapes=[(1, 256), (1, 512), (1, 1024)]))
+```
+
+### State Types
+
+```python
+# For stateful models (KV-cache)
+states = [
+ ct.StateType(
+ name="keyCache",
+ wrapped_type=ct.TensorType(shape=(1, 32, 2048, 128))
+ ),
+ ct.StateType(
+ name="valueCache",
+ wrapped_type=ct.TensorType(shape=(1, 32, 2048, 128))
+ )
+]
+
+mlmodel = ct.convert(traced, inputs=inputs, states=states, ...)
+```
+
+---
+
+## Part 6 - Compression APIs (coremltools.optimize)
+
+### Post-Training Palettization
+
+```python
+from coremltools.optimize.coreml import (
+ OpPalettizerConfig,
+ OptimizationConfig,
+ palettize_weights
+)
+
+# Per-tensor (iOS 17+)
+config = OpPalettizerConfig(mode="kmeans", nbits=4)
+
+# Per-grouped-channel (iOS 18+, better accuracy)
+config = OpPalettizerConfig(
+ mode="kmeans",
+ nbits=4,
+ granularity="per_grouped_channel",
+ group_size=16
+)
+
+opt_config = OptimizationConfig(global_config=config)
+compressed = palettize_weights(model, opt_config)
+```
+
+### Post-Training Quantization
+
+```python
+from coremltools.optimize.coreml import (
+ OpLinearQuantizerConfig,
+ OptimizationConfig,
+ linear_quantize_weights
+)
+
+# INT8 per-channel (iOS 17+)
+config = OpLinearQuantizerConfig(mode="linear", dtype="int8")
+
+# INT4 per-block (iOS 18+)
+config = OpLinearQuantizerConfig(
+ mode="linear",
+ dtype="int4",
+ granularity="per_block",
+ block_size=32
+)
+
+opt_config = OptimizationConfig(global_config=config)
+compressed = linear_quantize_weights(model, opt_config)
+```
+
+### Post-Training Pruning
+
+```python
+from coremltools.optimize.coreml import (
+ OpMagnitudePrunerConfig,
+ OptimizationConfig,
+ prune_weights
+)
+
+config = OpMagnitudePrunerConfig(target_sparsity=0.5)
+opt_config = OptimizationConfig(global_config=config)
+sparse = prune_weights(model, opt_config)
+```
+
+### Training-Time Palettization (PyTorch)
+
+```python
+from coremltools.optimize.torch.palettization import (
+ DKMPalettizerConfig,
+ DKMPalettizer
+)
+
+config = DKMPalettizerConfig(global_config={"n_bits": 4})
+palettizer = DKMPalettizer(model, config)
+
+# Prepare (inserts palettization layers)
+prepared = palettizer.prepare()
+
+# Training loop
+for epoch in range(epochs):
+ train_one_epoch(prepared, data_loader)
+ palettizer.step()
+
+# Finalize
+final = palettizer.finalize()
+```
+
+### Calibration-Based Compression
+
+```python
+from coremltools.optimize.torch.pruning import (
+ MagnitudePrunerConfig,
+ LayerwiseCompressor
+)
+
+config = MagnitudePrunerConfig(
+ target_sparsity=0.4,
+ n_samples=128
+)
+
+compressor = LayerwiseCompressor(model, config)
+compressed = compressor.compress(calibration_loader)
+```
+
+---
+
+## Part 7 - Multi-Function Models
+
+### Merging Models
+
+```python
+from coremltools.models import MultiFunctionDescriptor
+from coremltools.models.utils import save_multifunction
+
+# Create descriptor
+desc = MultiFunctionDescriptor()
+desc.add_function("function_a", "model_a.mlpackage")
+desc.add_function("function_b", "model_b.mlpackage")
+
+# Merge (deduplicates shared weights)
+save_multifunction(desc, "merged.mlpackage")
+```
+
+### Inspecting Functions (Xcode)
+
+Open model in Xcode → Predictions tab → Functions listed above inputs.
+
+---
+
+## Part 8 - Performance Profiling
+
+### MLComputePlan (iOS 18+)
+
+```swift
+let plan = try await MLComputePlan.load(contentsOf: modelURL)
+
+// Inspect operations
+for op in plan.modelStructure.operations {
+ let info = plan.computeDeviceInfo(for: op)
+ print("Op: \(op.name)")
+ print(" Preferred: \(info.preferredDevice)")
+ print(" Estimated cost: \(info.estimatedCost)")
+}
+```
+
+### Xcode Performance Reports
+
+1. Open model in Xcode
+2. Select Performance tab
+3. Click + to create report
+4. Select device and compute units
+5. Click "Run Test"
+
+**New in iOS 18**: Shows estimated time per operation, compute device support hints.
+
+### Core ML Instrument
+
+```
+Instruments → Core ML template
+ ├─ Load events: "cached" vs "prepare and cache"
+ ├─ Prediction intervals
+ ├─ Compute unit usage
+ └─ Neural Engine activity
+```
+
+---
+
+## Part 9 - Deployment Targets
+
+| Target | Key Features |
+|--------|--------------|
+| iOS 16 | Weight compression (palettization, quantization, pruning) |
+| iOS 17 | Async prediction, MLComputeDevice, activation quantization |
+| iOS 18 | MLTensor, State, SDPA fusion, per-block quantization, multi-function |
+
+**Recommendation**: Always set `minimum_deployment_target=ct.target.iOS18` for best optimizations.
+
+---
+
+## Part 10 - Conversion Pass Pipelines
+
+```python
+# Default pipeline
+mlmodel = ct.convert(traced, ...)
+
+# With palettization support
+mlmodel = ct.convert(
+ traced,
+ pass_pipeline=ct.PassPipeline.DEFAULT_PALETTIZATION,
+ ...
+)
+```
+
+## Resources
+
+**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161
+
+**Docs**: /coreml, /coreml/mlmodel, /coreml/mltensor, /documentation/coremltools
+
+**Skills**: coreml, coreml-diag
diff --git a/.claude/skills/axiom-ios-ml/coreml/SKILL.md b/.claude/skills/axiom-ios-ml/coreml/SKILL.md
new file mode 100644
index 0000000..aa3d002
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/coreml/SKILL.md
@@ -0,0 +1,468 @@
+---
+name: coreml
+description: Use when deploying custom ML models on-device, converting PyTorch models, compressing models, implementing LLM inference, or optimizing CoreML performance. Covers model conversion, compression, stateful models, KV-cache, multi-function models, MLTensor.
+license: MIT
+version: 1.0.0
+---
+
+# CoreML On-Device Machine Learning
+
+## Overview
+
+CoreML enables on-device machine learning inference across all Apple platforms. It abstracts hardware details while leveraging Apple Silicon's CPU, GPU, and Neural Engine for high-performance, private, and efficient execution.
+
+**Key principle**: Start with the simplest approach, then optimize based on profiling. Don't over-engineer compression or caching until you have real performance data.
+
+## Decision Tree - CoreML vs Foundation Models
+
+```
+Need on-device ML?
+ ├─ Text generation (LLM)?
+ │ ├─ Simple prompts, structured output? → Foundation Models (ios-ai skill)
+ │ └─ Custom model, fine-tuned, specific architecture? → CoreML
+ ├─ Custom trained model?
+ │ └─ Yes → CoreML
+ ├─ Image/audio/sensor processing?
+ │ └─ Yes → CoreML
+ └─ Apple's built-in intelligence?
+ └─ Yes → Foundation Models (ios-ai skill)
+```
+
+## Red Flags
+
+Use this skill when you see:
+- "Convert PyTorch model to CoreML"
+- "Model too large for device"
+- "Slow inference performance"
+- "LLM on-device"
+- "KV-cache" or "stateful model"
+- "Model compression" or "quantization"
+- MLModel, MLTensor, or coremltools in context
+
+## Pattern 1 - Basic Model Conversion
+
+The standard PyTorch → CoreML workflow.
+
+```python
+import coremltools as ct
+import torch
+
+# Trace the model
+model.eval()
+traced_model = torch.jit.trace(model, example_input)
+
+# Convert to CoreML
+mlmodel = ct.convert(
+ traced_model,
+ inputs=[ct.TensorType(shape=example_input.shape)],
+ minimum_deployment_target=ct.target.iOS18
+)
+
+# Save
+mlmodel.save("MyModel.mlpackage")
+```
+
+**Critical**: Always set `minimum_deployment_target` to enable latest optimizations.
+
+## Pattern 2 - Model Compression (Post-Training)
+
+Three techniques, each with different tradeoffs:
+
+### Palettization (Best for Neural Engine)
+
+Clusters weights into lookup tables. Use per-grouped-channel for better accuracy.
+
+```python
+from coremltools.optimize.coreml import (
+ OpPalettizerConfig,
+ OptimizationConfig,
+ palettize_weights
+)
+
+# 4-bit with grouped channels (iOS 18+)
+op_config = OpPalettizerConfig(
+ mode="kmeans",
+ nbits=4,
+ granularity="per_grouped_channel",
+ group_size=16
+)
+
+config = OptimizationConfig(global_config=op_config)
+compressed_model = palettize_weights(model, config)
+```
+
+| Bits | Compression | Accuracy Impact |
+|------|-------------|-----------------|
+| 8-bit | 2x | Minimal |
+| 6-bit | 2.7x | Low |
+| 4-bit | 4x | Moderate (use grouped channels) |
+| 2-bit | 8x | High (requires training-time) |
+
+### Quantization (Best for GPU on Mac)
+
+Linear mapping to INT8/INT4. Use per-block for better accuracy.
+
+```python
+from coremltools.optimize.coreml import (
+ OpLinearQuantizerConfig,
+ OptimizationConfig,
+ linear_quantize_weights
+)
+
+# INT4 per-block quantization (iOS 18+)
+op_config = OpLinearQuantizerConfig(
+ mode="linear",
+ dtype="int4",
+ granularity="per_block",
+ block_size=32
+)
+
+config = OptimizationConfig(global_config=op_config)
+compressed_model = linear_quantize_weights(model, config)
+```
+
+### Pruning (Combine with other techniques)
+
+Sets weights to zero for sparse representation. Can combine with palettization.
+
+```python
+from coremltools.optimize.coreml import (
+ OpMagnitudePrunerConfig,
+ OptimizationConfig,
+ prune_weights
+)
+
+op_config = OpMagnitudePrunerConfig(
+ target_sparsity=0.4 # 40% zeros
+)
+
+config = OptimizationConfig(global_config=op_config)
+sparse_model = prune_weights(model, config)
+```
+
+## Pattern 3 - Training-Time Compression
+
+When post-training compression loses too much accuracy, fine-tune with compression.
+
+```python
+from coremltools.optimize.torch.palettization import (
+ DKMPalettizerConfig,
+ DKMPalettizer
+)
+
+# Configure 4-bit palettization
+config = DKMPalettizerConfig(global_config={"n_bits": 4})
+
+# Prepare model
+palettizer = DKMPalettizer(model, config)
+prepared_model = palettizer.prepare()
+
+# Fine-tune (your training loop)
+for epoch in range(num_epochs):
+ train_epoch(prepared_model, data_loader)
+ palettizer.step()
+
+# Finalize
+final_model = palettizer.finalize()
+```
+
+**Tradeoff**: Better accuracy than post-training, but requires training data and time.
+
+## Pattern 4 - Calibration-Based Compression (iOS 18+)
+
+Middle ground: uses calibration data without full training.
+
+```python
+from coremltools.optimize.torch.pruning import (
+ MagnitudePrunerConfig,
+ LayerwiseCompressor
+)
+
+# Configure
+config = MagnitudePrunerConfig(
+ target_sparsity=0.4,
+ n_samples=128 # Calibration samples
+)
+
+# Create pruner
+pruner = LayerwiseCompressor(model, config)
+
+# Calibrate
+sparse_model = pruner.compress(calibration_data_loader)
+```
+
+## Pattern 5 - Stateful Models (KV-Cache for LLMs)
+
+For transformer models, use state to avoid recomputing key/value vectors.
+
+### PyTorch Model with State
+
+```python
+class StatefulLLM(nn.Module):
+ def __init__(self):
+ super().__init__()
+ # Register state buffers
+ self.register_buffer("keyCache", torch.zeros(batch, heads, seq_len, dim))
+ self.register_buffer("valueCache", torch.zeros(batch, heads, seq_len, dim))
+
+ def forward(self, input_ids, causal_mask):
+ # Update caches in-place during forward
+ # ... attention with KV-cache ...
+ return logits
+```
+
+### Conversion with State
+
+```python
+import coremltools as ct
+
+mlmodel = ct.convert(
+ traced_model,
+ inputs=[
+ ct.TensorType(name="input_ids", shape=(1, ct.RangeDim(1, 2048))),
+ ct.TensorType(name="causal_mask", shape=(1, 1, ct.RangeDim(1, 2048), ct.RangeDim(1, 2048)))
+ ],
+ states=[
+ ct.StateType(name="keyCache", ...),
+ ct.StateType(name="valueCache", ...)
+ ],
+ minimum_deployment_target=ct.target.iOS18
+)
+```
+
+### Using State at Runtime
+
+```swift
+// Create state from model
+let state = model.makeState()
+
+// Run prediction with state (updated in-place)
+let output = try model.prediction(from: input, using: state)
+```
+
+**Performance**: 1.6x speedup on Mistral-7B (M3 Max) compared to manual KV-cache I/O.
+
+## Pattern 6 - Multi-Function Models (Adapters/LoRA)
+
+Deploy multiple adapters in a single model, sharing base weights.
+
+```python
+from coremltools.models import MultiFunctionDescriptor
+from coremltools.models.utils import save_multifunction
+
+# Convert individual models
+sticker_model = ct.convert(sticker_adapter_model, ...)
+storybook_model = ct.convert(storybook_adapter_model, ...)
+
+# Save individually
+sticker_model.save("sticker.mlpackage")
+storybook_model.save("storybook.mlpackage")
+
+# Merge with shared weights
+desc = MultiFunctionDescriptor()
+desc.add_function("sticker", "sticker.mlpackage")
+desc.add_function("storybook", "storybook.mlpackage")
+
+save_multifunction(desc, "MultiAdapter.mlpackage")
+```
+
+### Loading Specific Function
+
+```swift
+let config = MLModelConfiguration()
+config.functionName = "sticker" // or "storybook"
+
+let model = try MLModel(contentsOf: modelURL, configuration: config)
+```
+
+## Pattern 7 - MLTensor for Pipeline Stitching (iOS 18+)
+
+Simplifies computation between models (decoding, post-processing).
+
+```swift
+import CoreML
+
+// Create tensors
+let scores = MLTensor(shape: [1, vocab_size], scalars: logits)
+
+// Operations (executed asynchronously on Apple Silicon)
+let topK = scores.topK(k: 10)
+let probs = (topK.values / temperature).softmax()
+
+// Sample from distribution
+let sampled = probs.multinomial(numSamples: 1)
+
+// Materialize to access data (blocks until complete)
+let shapedArray = await sampled.shapedArray(of: Int32.self)
+```
+
+**Key insight**: MLTensor operations are async. Call `shapedArray()` to materialize results.
+
+## Pattern 8 - Async Prediction for Concurrency
+
+Thread-safe concurrent predictions for throughput.
+
+```swift
+class ImageProcessor {
+ let model: MLModel
+
+ func processImages(_ images: [CGImage]) async throws -> [Output] {
+ try await withThrowingTaskGroup(of: Output.self) { group in
+ for image in images {
+ group.addTask {
+ // Check cancellation before expensive work
+ try Task.checkCancellation()
+
+ let input = try self.prepareInput(image)
+ // Async prediction - thread safe!
+ return try await self.model.prediction(from: input)
+ }
+ }
+
+ return try await group.reduce(into: []) { $0.append($1) }
+ }
+ }
+}
+```
+
+**Warning**: Limit concurrent predictions to avoid memory pressure from multiple input/output buffers.
+
+```swift
+// Limit concurrency
+let semaphore = AsyncSemaphore(value: 2)
+
+for image in images {
+ group.addTask {
+ await semaphore.wait()
+ defer { semaphore.signal() }
+ return try await process(image)
+ }
+}
+```
+
+## Anti-Patterns
+
+### Don't - Load models on main thread at launch
+
+```swift
+// BAD - blocks UI
+class AppDelegate {
+ let model = try! MLModel(contentsOf: url) // Blocks!
+}
+
+// GOOD - lazy async loading
+class ModelManager {
+ private var model: MLModel?
+
+ func getModel() async throws -> MLModel {
+ if let model { return model }
+ model = try await Task.detached {
+ try MLModel(contentsOf: url)
+ }.value
+ return model!
+ }
+}
+```
+
+### Don't - Reload model for each prediction
+
+```swift
+// BAD - reloads every time
+func predict(_ input: Input) throws -> Output {
+ let model = try MLModel(contentsOf: url) // Expensive!
+ return try model.prediction(from: input)
+}
+
+// GOOD - keep model loaded
+class Predictor {
+ private let model: MLModel
+
+ func predict(_ input: Input) throws -> Output {
+ try model.prediction(from: input)
+ }
+}
+```
+
+### Don't - Compress without profiling first
+
+```swift
+// BAD - blind compression
+let compressed = palettize_weights(model, 2bit_config) // May break accuracy!
+
+// GOOD - profile, then compress iteratively
+// 1. Profile Float16 baseline
+// 2. Try 8-bit → check accuracy
+// 3. Try 6-bit → check accuracy
+// 4. Try 4-bit with grouped channels → check accuracy
+// 5. Only use 2-bit with training-time compression
+```
+
+### Don't - Ignore deployment target
+
+```python
+# BAD - misses optimizations
+mlmodel = ct.convert(traced_model, inputs=[...])
+
+# GOOD - enables SDPA fusion, per-block quantization, etc.
+mlmodel = ct.convert(
+ traced_model,
+ inputs=[...],
+ minimum_deployment_target=ct.target.iOS18
+)
+```
+
+## Pressure Scenarios
+
+### Scenario 1 - "Model is 5GB, need it under 2GB for iPhone"
+
+**Wrong approach**: Jump straight to 2-bit palettization.
+
+**Right approach**:
+1. Start with 8-bit palettization → check accuracy
+2. Try 6-bit → check accuracy
+3. Try 4-bit with `per_grouped_channel` → check accuracy
+4. If still too large, use calibration-based compression
+5. If still losing accuracy, use training-time compression
+
+### Scenario 2 - "LLM inference is too slow"
+
+**Wrong approach**: Try different compute units randomly.
+
+**Right approach**:
+1. Profile with Core ML Instrument
+2. Check if load is cached (look for "cached" vs "prepare and cache")
+3. Enable stateful KV-cache
+4. Check SDPA optimization is enabled (iOS 18+ deployment target)
+5. Consider INT4 quantization for GPU on Mac
+
+### Scenario 3 - "Need multiple LoRA adapters in one app"
+
+**Wrong approach**: Ship separate models for each adapter.
+
+**Right approach**:
+1. Convert each adapter model separately
+2. Use `MultiFunctionDescriptor` to merge with shared base
+3. Load specific function via `config.functionName`
+4. Weights are deduplicated automatically
+
+## Checklist
+
+Before deploying a CoreML model:
+
+- [ ] Set `minimum_deployment_target` to latest supported iOS
+- [ ] Profile baseline Float16 performance
+- [ ] Check if model load is cached
+- [ ] Consider compression only if size/performance requires it
+- [ ] Test accuracy after each compression step
+- [ ] Use async prediction for concurrent workloads
+- [ ] Limit concurrent predictions to manage memory
+- [ ] Use state for transformer KV-cache
+- [ ] Use multi-function for adapter variants
+
+## Resources
+
+**WWDC**: 2023-10047, 2023-10049, 2024-10159, 2024-10161
+
+**Docs**: /coreml, /coreml/mlmodel, /coreml/mltensor
+
+**Skills**: coreml-ref, coreml-diag, axiom-ios-ai (Foundation Models)
diff --git a/.claude/skills/axiom-ios-ml/speech/SKILL.md b/.claude/skills/axiom-ios-ml/speech/SKILL.md
new file mode 100644
index 0000000..2882bcd
--- /dev/null
+++ b/.claude/skills/axiom-ios-ml/speech/SKILL.md
@@ -0,0 +1,496 @@
+---
+name: speech
+description: Use when implementing speech-to-text, live transcription, or audio transcription. Covers SpeechAnalyzer (iOS 26+), SpeechTranscriber, volatile/finalized results, AssetInventory model management, audio format handling.
+license: MIT
+version: 1.0.0
+---
+
+# Speech-to-Text with SpeechAnalyzer
+
+## Overview
+
+SpeechAnalyzer is Apple's new speech-to-text API introduced in iOS 26. It powers Notes, Voice Memos, Journal, and Call Summarization. The on-device model is faster, more accurate, and better for long-form/distant audio than SFSpeechRecognizer.
+
+**Key principle**: SpeechAnalyzer is modular—add transcription modules to an analysis session. Results stream asynchronously using Swift's AsyncSequence.
+
+## Decision Tree - SpeechAnalyzer vs SFSpeechRecognizer
+
+```
+Need speech-to-text?
+ ├─ iOS 26+ only?
+ │ └─ Yes → SpeechAnalyzer (preferred)
+ ├─ Need iOS 10-25 support?
+ │ └─ Yes → SFSpeechRecognizer (or DictationTranscriber)
+ ├─ Long-form audio (meetings, lectures)?
+ │ └─ Yes → SpeechAnalyzer
+ ├─ Distant audio (across room)?
+ │ └─ Yes → SpeechAnalyzer
+ └─ Short dictation commands?
+ └─ Either works
+```
+
+**SpeechAnalyzer advantages**:
+- Better for long-form and conversational audio
+- Works well with distant speakers (meetings)
+- On-device, private
+- Model managed by system (no app size increase)
+- Powers Notes, Voice Memos, Journal
+
+**DictationTranscriber** (iOS 26+): Same languages as SFSpeechRecognizer, but doesn't require user to enable Siri/dictation in Settings.
+
+## Red Flags
+
+Use this skill when you see:
+- "Live transcription"
+- "Transcribe audio"
+- "Speech-to-text"
+- "SpeechAnalyzer" or "SpeechTranscriber"
+- "Volatile results"
+- Building Notes-like or Voice Memos-like features
+
+## Pattern 1 - File Transcription (Simplest)
+
+Transcribe an audio file to text in one function.
+
+```swift
+import Speech
+
+func transcribe(file: URL, locale: Locale) async throws -> AttributedString {
+ // Set up transcriber
+ let transcriber = SpeechTranscriber(
+ locale: locale,
+ preset: .offlineTranscription
+ )
+
+ // Collect results asynchronously
+ async let transcriptionFuture = try transcriber.results
+ .reduce(AttributedString()) { str, result in
+ str + result.text
+ }
+
+ // Set up analyzer with transcriber module
+ let analyzer = SpeechAnalyzer(modules: [transcriber])
+
+ // Analyze the file
+ if let lastSample = try await analyzer.analyzeSequence(from: file) {
+ try await analyzer.finalizeAndFinish(through: lastSample)
+ } else {
+ await analyzer.cancelAndFinishNow()
+ }
+
+ return try await transcriptionFuture
+}
+```
+
+**Key points**:
+- `analyzeSequence(from:)` reads file and feeds audio to analyzer
+- `finalizeAndFinish(through:)` ensures all results are finalized
+- Results are `AttributedString` with timing metadata
+
+## Pattern 2 - Live Transcription Setup
+
+For real-time transcription from microphone.
+
+### Step 1 - Configure SpeechTranscriber
+
+```swift
+import Speech
+
+class TranscriptionManager: ObservableObject {
+ private var transcriber: SpeechTranscriber?
+ private var analyzer: SpeechAnalyzer?
+ private var analyzerFormat: AudioFormatDescription?
+ private var inputBuilder: AsyncStream.Continuation?
+
+ @Published var finalizedTranscript = AttributedString()
+ @Published var volatileTranscript = AttributedString()
+
+ func setUp() async throws {
+ // Create transcriber with options
+ transcriber = SpeechTranscriber(
+ locale: Locale.current,
+ transcriptionOptions: [],
+ reportingOptions: [.volatileResults], // Enable real-time updates
+ attributeOptions: [.audioTimeRange] // Include timing
+ )
+
+ guard let transcriber else { throw TranscriptionError.setupFailed }
+
+ // Create analyzer with transcriber module
+ analyzer = SpeechAnalyzer(modules: [transcriber])
+
+ // Get required audio format
+ analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(
+ compatibleWith: [transcriber]
+ )
+
+ // Ensure model is available
+ try await ensureModel(for: transcriber)
+
+ // Create input stream
+ let (stream, builder) = AsyncStream.makeStream()
+ inputBuilder = builder
+
+ // Start analyzer
+ try await analyzer?.start(inputSequence: stream)
+ }
+}
+```
+
+### Step 2 - Ensure Model Availability
+
+```swift
+func ensureModel(for transcriber: SpeechTranscriber) async throws {
+ let locale = Locale.current
+
+ // Check if language is supported
+ let supported = await SpeechTranscriber.supportedLocales
+ guard supported.contains(where: {
+ $0.identifier(.bcp47) == locale.identifier(.bcp47)
+ }) else {
+ throw TranscriptionError.localeNotSupported
+ }
+
+ // Check if model is installed
+ let installed = await SpeechTranscriber.installedLocales
+ if installed.contains(where: {
+ $0.identifier(.bcp47) == locale.identifier(.bcp47)
+ }) {
+ return // Already installed
+ }
+
+ // Download model
+ if let downloader = try await AssetInventory.assetInstallationRequest(
+ supporting: [transcriber]
+ ) {
+ // Track progress if needed
+ let progress = downloader.progress
+ try await downloader.downloadAndInstall()
+ }
+}
+```
+
+**Note**: Models are stored in system storage, not app storage. Limited number of languages can be allocated at once.
+
+### Step 3 - Handle Results
+
+```swift
+func startResultHandling() {
+ Task {
+ guard let transcriber else { return }
+
+ do {
+ for try await result in transcriber.results {
+ let text = result.text
+
+ if result.isFinal {
+ // Finalized result - won't change
+ finalizedTranscript += text
+ volatileTranscript = AttributedString()
+
+ // Access timing info
+ for run in text.runs {
+ if let timeRange = run.audioTimeRange {
+ print("Time: \(timeRange)")
+ }
+ }
+ } else {
+ // Volatile result - will be replaced
+ volatileTranscript = text
+ }
+ }
+ } catch {
+ print("Transcription failed: \(error)")
+ }
+ }
+}
+```
+
+## Pattern 3 - Audio Recording and Streaming
+
+Connect AVAudioEngine to SpeechAnalyzer.
+
+```swift
+import AVFoundation
+
+class AudioRecorder {
+ private let audioEngine = AVAudioEngine()
+ private var outputContinuation: AsyncStream.Continuation?
+ private let transcriptionManager: TranscriptionManager
+
+ func startRecording() async throws {
+ // Request permission
+ guard await AVAudioApplication.requestRecordPermission() else {
+ throw RecordingError.permissionDenied
+ }
+
+ // Configure audio session (iOS)
+ #if os(iOS)
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(.playAndRecord, mode: .spokenAudio)
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
+ #endif
+
+ // Set up transcriber
+ try await transcriptionManager.setUp()
+ transcriptionManager.startResultHandling()
+
+ // Stream audio to transcriber
+ for await buffer in try audioStream() {
+ try await transcriptionManager.streamAudio(buffer)
+ }
+ }
+
+ private func audioStream() throws -> AsyncStream {
+ let inputNode = audioEngine.inputNode
+ let format = inputNode.outputFormat(forBus: 0)
+
+ inputNode.installTap(
+ onBus: 0,
+ bufferSize: 4096,
+ format: format
+ ) { [weak self] buffer, time in
+ self?.outputContinuation?.yield(buffer)
+ }
+
+ audioEngine.prepare()
+ try audioEngine.start()
+
+ return AsyncStream { continuation in
+ outputContinuation = continuation
+ }
+ }
+}
+```
+
+### Stream Audio with Format Conversion
+
+```swift
+extension TranscriptionManager {
+ private var converter: AVAudioConverter?
+
+ func streamAudio(_ buffer: AVAudioPCMBuffer) async throws {
+ guard let inputBuilder, let analyzerFormat else {
+ throw TranscriptionError.notSetUp
+ }
+
+ // Convert to analyzer's required format
+ let converted = try convertBuffer(buffer, to: analyzerFormat)
+
+ // Send to analyzer
+ let input = AnalyzerInput(buffer: converted)
+ inputBuilder.yield(input)
+ }
+
+ private func convertBuffer(
+ _ buffer: AVAudioPCMBuffer,
+ to format: AudioFormatDescription
+ ) throws -> AVAudioPCMBuffer {
+ // Lazy initialize converter
+ if converter == nil {
+ let sourceFormat = buffer.format
+ let destFormat = AVAudioFormat(cmAudioFormatDescription: format)!
+ converter = AVAudioConverter(from: sourceFormat, to: destFormat)
+ }
+
+ guard let converter else {
+ throw TranscriptionError.conversionFailed
+ }
+
+ let outputBuffer = AVAudioPCMBuffer(
+ pcmFormat: converter.outputFormat,
+ frameCapacity: buffer.frameLength
+ )!
+
+ try converter.convert(to: outputBuffer, from: buffer)
+ return outputBuffer
+ }
+}
+```
+
+## Pattern 4 - Stopping Transcription
+
+Properly finalize to get remaining volatile results as finalized.
+
+```swift
+func stopRecording() async {
+ // Stop audio
+ audioEngine.stop()
+ audioEngine.inputNode.removeTap(onBus: 0)
+ outputContinuation?.finish()
+
+ // Finalize transcription (converts remaining volatile to final)
+ try? await analyzer?.finalizeAndFinishThroughEndOfInput()
+
+ // Cancel any pending tasks
+ recognizerTask?.cancel()
+}
+```
+
+**Critical**: Always call `finalizeAndFinishThroughEndOfInput()` to ensure volatile results are finalized.
+
+## Pattern 5 - Model Asset Management
+
+### Check Supported Languages
+
+```swift
+// Languages the API supports
+let supported = await SpeechTranscriber.supportedLocales
+
+// Languages currently installed on device
+let installed = await SpeechTranscriber.installedLocales
+```
+
+### Deallocate Languages
+
+Limited number of languages can be allocated. Deallocate unused ones.
+
+```swift
+func deallocateLanguages() async {
+ let allocated = await AssetInventory.allocatedLocales
+ for locale in allocated {
+ await AssetInventory.deallocate(locale: locale)
+ }
+}
+```
+
+## Pattern 6 - Displaying Results with Timing
+
+Highlight text during audio playback using timing metadata.
+
+```swift
+struct TranscriptView: View {
+ let transcript: AttributedString
+ @Binding var playbackTime: CMTime
+
+ var body: some View {
+ Text(highlightedTranscript)
+ }
+
+ var highlightedTranscript: AttributedString {
+ var result = transcript
+
+ for (range, run) in transcript.runs {
+ guard let timeRange = run.audioTimeRange else { continue }
+
+ let isActive = timeRange.containsTime(playbackTime)
+ if isActive {
+ result[range].backgroundColor = .yellow
+ }
+ }
+
+ return result
+ }
+}
+```
+
+## Anti-Patterns
+
+### Don't - Forget to finalize
+
+```swift
+// BAD - volatile results lost
+func stopRecording() {
+ audioEngine.stop()
+ // Missing finalize!
+}
+
+// GOOD - volatile results become finalized
+func stopRecording() async {
+ audioEngine.stop()
+ try? await analyzer?.finalizeAndFinishThroughEndOfInput()
+}
+```
+
+### Don't - Ignore format conversion
+
+```swift
+// BAD - format mismatch may fail silently
+inputBuilder.yield(AnalyzerInput(buffer: rawBuffer))
+
+// GOOD - convert to analyzer's format
+let format = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber])
+let converted = try convertBuffer(rawBuffer, to: format)
+inputBuilder.yield(AnalyzerInput(buffer: converted))
+```
+
+### Don't - Skip model availability check
+
+```swift
+// BAD - may crash if model not installed
+let transcriber = SpeechTranscriber(locale: locale, ...)
+// Start using immediately
+
+// GOOD - ensure model is ready
+let transcriber = SpeechTranscriber(locale: locale, ...)
+try await ensureModel(for: transcriber)
+// Now safe to use
+```
+
+## Presets Reference
+
+| Preset | Use Case |
+|--------|----------|
+| `.offlineTranscription` | File transcription, no real-time feedback needed |
+| `.progressiveLiveTranscription` | Live transcription with volatile updates |
+
+## Options Reference
+
+### TranscriptionOptions
+- Default: None (standard transcription)
+
+### ReportingOptions
+- `.volatileResults`: Enable real-time approximate results
+
+### AttributeOptions
+- `.audioTimeRange`: Include CMTimeRange for each text segment
+
+## Platform Availability
+
+| Platform | SpeechTranscriber | DictationTranscriber |
+|----------|-------------------|---------------------|
+| iOS 26+ | Yes | Yes |
+| macOS Tahoe+ | Yes | Yes |
+| watchOS 26+ | No | Yes |
+| tvOS 26+ | TBD | TBD |
+
+**Hardware requirements**: Varies by device. Use `supportedLocales` to check.
+
+## Integration with Apple Intelligence
+
+Combine with Foundation Models for summarization:
+
+```swift
+import FoundationModels
+
+func generateTitle(for transcript: String) async throws -> String {
+ let session = LanguageModelSession()
+ let prompt = "Generate a short, clever title for this story: \(transcript)"
+ let response = try await session.respond(to: prompt)
+ return response.content
+}
+```
+
+See `axiom-ios-ai` skill for Foundation Models details.
+
+## Checklist
+
+Before shipping speech-to-text:
+
+- [ ] Check locale support with `supportedLocales`
+- [ ] Ensure model with `AssetInventory.assetInstallationRequest`
+- [ ] Handle download progress for user feedback
+- [ ] Convert audio to `bestAvailableAudioFormat`
+- [ ] Enable `.volatileResults` for live transcription
+- [ ] Call `finalizeAndFinishThroughEndOfInput()` on stop
+- [ ] Handle timing with `.audioTimeRange` if needed
+- [ ] Clear volatile results when finalized result arrives
+- [ ] Request microphone permission before recording
+
+## Resources
+
+**WWDC**: 2025-277
+
+**Docs**: /speech, /speech/speechanalyzer, /speech/speechtranscriber
+
+**Skills**: coreml (on-device ML), axiom-ios-ai (Foundation Models)
diff --git a/.claude/skills/axiom-ios-networking/.openskills.json b/.claude/skills/axiom-ios-networking/.openskills.json
new file mode 100644
index 0000000..d60f4b4
--- /dev/null
+++ b/.claude/skills/axiom-ios-networking/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-networking",
+ "installedAt": "2026-04-12T08:05:35.624Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-networking/SKILL.md b/.claude/skills/axiom-ios-networking/SKILL.md
new file mode 100644
index 0000000..208bafd
--- /dev/null
+++ b/.claude/skills/axiom-ios-networking/SKILL.md
@@ -0,0 +1,141 @@
+---
+name: axiom-ios-networking
+description: Use when implementing or debugging ANY network connection, API call, or socket. Covers URLSession, Network.framework, NetworkConnection, deprecated APIs, connection diagnostics, structured concurrency networking.
+license: MIT
+---
+
+# iOS Networking Router
+
+**You MUST use this skill for ANY networking work including HTTP requests, WebSockets, TCP connections, or network debugging.**
+
+## When to Use
+
+Use this router when:
+- Implementing network requests (URLSession)
+- Using Network.framework or NetworkConnection
+- Debugging connection failures
+- Migrating from deprecated networking APIs
+- Network performance issues
+
+## Pressure Resistance
+
+**When user has invested significant time in custom implementation:**
+
+Do NOT capitulate to sunk cost pressure. The correct approach is:
+
+1. **Diagnose first** — Understand what's actually failing before recommending changes
+2. **Recommend correctly** — If standard APIs (URLSession, Network.framework) would solve the problem, say so professionally
+3. **Respect but don't enable** — Acknowledge their work while providing honest technical guidance
+
+**Example pressure scenario:**
+> "I spent 2 days on custom networking. Just help me fix it, don't tell me to use URLSession."
+
+**Correct response:**
+> "Let me diagnose the cellular failure first. [After diagnosis] The issue is [X]. URLSession handles this automatically via [Y]. I recommend migrating the affected code path — it's 30 minutes vs continued debugging. Your existing work on [Z] can be preserved."
+
+**Why this matters:** Users often can't see that migration is faster than continued debugging. Honest guidance serves them better than false comfort.
+
+## Routing Logic
+
+### Network Implementation
+
+**Networking patterns** → `/skill axiom-networking`
+- URLSession with structured concurrency
+- Network.framework migration
+- Modern networking patterns
+- Deprecated API migration
+
+**Network.framework reference** → `/skill axiom-network-framework-ref`
+**Legacy iOS 12-25 patterns** → `/skill axiom-networking-legacy`
+**Migration guides** → `/skill axiom-networking-migration`
+- NWConnection (iOS 12-25)
+- NetworkConnection (iOS 26+)
+- TCP connections
+- TLV framing
+- Wi-Fi Aware
+
+### App Store Compliance
+
+**ATS / HTTP security** → `/skill axiom-networking-diag`
+- App Transport Security (ATS) configuration
+- HTTP → HTTPS migration
+- App Store rejection for insecure connections
+- NSAllowsArbitraryLoads exceptions
+
+**Deprecated API rejection** → Launch `networking-auditor` agent
+- UIWebView → WKWebView migration
+- SCNetworkReachability → NWPathMonitor
+- CFSocket → Network.framework
+
+### Network Debugging
+
+**Connection issues** → `/skill axiom-networking-diag`
+- Connection timeouts
+- TLS handshake failures
+- Data not arriving
+- Connection drops
+- VPN/proxy problems
+
+### Automated Scanning
+
+**Networking audit** → Launch `networking-auditor` agent or `/axiom:audit networking` (deprecated APIs like SCNetworkReachability, CFSocket, NSStream; anti-patterns like reachability checks, hardcoded IPs, missing error handling)
+
+## Decision Tree
+
+1. URLSession with structured concurrency? → networking
+2. Network.framework / NetworkConnection (iOS 26+)? → network-framework-ref
+3. NWConnection (iOS 12-25)? → networking-legacy
+4. Migrating from sockets/URLSession? → networking-migration
+5. Connection issues / debugging? → networking-diag
+6. ATS / HTTP / App Store rejection for networking? → networking-diag + networking-auditor
+7. UIWebView or deprecated API rejection? → networking-auditor (Agent)
+8. Want deprecated API / anti-pattern scan? → networking-auditor (Agent)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "URLSession is simple, I don't need a skill" | URLSession with structured concurrency has async/cancellation patterns. networking skill covers them. |
+| "I'll debug the connection timeout myself" | Connection failures have 8 causes (DNS, TLS, proxy, cellular). networking-diag diagnoses systematically. |
+| "I just need a basic HTTP request" | Even basic requests need error handling, retry, and cancellation patterns. networking has them. |
+| "My custom networking layer works fine" | Custom layers miss cellular/proxy edge cases. Standard APIs handle them automatically. |
+
+## Critical Patterns
+
+**Networking** (networking):
+- URLSession with structured concurrency
+- Socket migration to Network.framework
+- Deprecated API replacement
+
+**Network Framework Reference** (network-framework-ref):
+- NWConnection for iOS 12-25
+- NetworkConnection for iOS 26+
+- Connection lifecycle management
+
+**Networking Diagnostics** (networking-diag):
+- Connection timeout diagnosis
+- TLS debugging
+- Network stack inspection
+
+## Example Invocations
+
+User: "My API request is failing with a timeout"
+→ Invoke: `/skill axiom-networking-diag`
+
+User: "How do I use URLSession with async/await?"
+→ Invoke: `/skill axiom-networking`
+
+User: "I need to implement a TCP connection"
+→ Invoke: `/skill axiom-network-framework-ref`
+
+User: "Should I use NWConnection or NetworkConnection?"
+→ Invoke: `/skill axiom-network-framework-ref`
+
+User: "My app was rejected for using HTTP connections"
+→ Invoke: `/skill axiom-networking-diag` (ATS compliance)
+
+User: "App Store says I'm using UIWebView"
+→ Invoke: `networking-auditor` agent (deprecated API scan)
+
+User: "Check my networking code for deprecated APIs"
+→ Invoke: `networking-auditor` agent
diff --git a/.claude/skills/axiom-ios-performance/.openskills.json b/.claude/skills/axiom-ios-performance/.openskills.json
new file mode 100644
index 0000000..075e2e3
--- /dev/null
+++ b/.claude/skills/axiom-ios-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-performance",
+ "installedAt": "2026-04-12T08:05:35.625Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-performance/SKILL.md b/.claude/skills/axiom-ios-performance/SKILL.md
new file mode 100644
index 0000000..a53eab5
--- /dev/null
+++ b/.claude/skills/axiom-ios-performance/SKILL.md
@@ -0,0 +1,311 @@
+---
+name: axiom-ios-performance
+description: Use when app feels slow, memory grows, battery drains, or diagnosing ANY performance issue. Covers memory leaks, profiling, Instruments workflows, retain cycles, performance optimization.
+license: MIT
+---
+
+# iOS Performance Router
+
+**You MUST use this skill for ANY performance issue including memory leaks, slow execution, battery drain, or profiling.**
+
+## When to Use
+
+Use this router when:
+- App feels slow or laggy
+- Memory usage grows over time
+- Battery drains quickly
+- Device gets hot during use
+- High energy usage in Battery Settings
+- Diagnosing performance with Instruments
+- Memory leaks or retain cycles
+- App crashes with memory warnings
+
+## Routing Logic
+
+### Memory Issues
+
+**Memory leaks (Swift)** → `/skill axiom-memory-debugging`
+- Systematic leak diagnosis
+- 5 common leak patterns
+- Instruments workflows
+- deinit not called
+
+**Memory leak scan** → Launch `memory-auditor` agent or `/axiom:audit memory` (5-phase semantic audit: maps resource ownership, detects 6 leak patterns, reasons about missing cleanup, correlates compound risks, scores lifecycle health)
+
+**Memory leaks (Objective-C blocks)** → `/skill axiom-objc-block-retain-cycles`
+- Block retain cycles
+- Weak-strong pattern
+- Network callback leaks
+
+### Performance Profiling
+
+**Performance profiling (GUI)** → `/skill axiom-performance-profiling`
+- Time Profiler (CPU)
+- Allocations (memory growth)
+- Core Data profiling (N+1 queries)
+- Decision trees for tool selection
+
+**Automated profiling (CLI)** → `/skill axiom-xctrace-ref`
+- Headless xctrace profiling
+- CI/CD integration patterns
+- Command-line trace recording
+- Programmatic trace analysis
+
+**Run automated profile** → Use `performance-profiler` agent or `/axiom:profile`
+- Records trace via xctrace
+- Exports and analyzes data
+- Reports findings with severity
+
+### Hang/Freeze Issues
+
+**App hangs or freezes** → `/skill axiom-hang-diagnostics`
+- UI unresponsive for >1 second
+- Main thread blocked (busy or waiting)
+- Decision tree: busy vs blocked diagnosis
+- Time Profiler vs System Trace selection
+- 8 common hang patterns with fixes
+- Watchdog terminations
+
+### Energy Issues
+
+**Battery drain, high energy** → `/skill axiom-energy`
+- Power Profiler workflow
+- Subsystem diagnosis (CPU/GPU/Network/Location/Display)
+- Anti-pattern fixes
+- Background execution optimization
+
+**Symptom-based diagnosis** → `/skill axiom-energy-diag`
+- "App at top of Battery Settings"
+- "Device gets hot"
+- "Background battery drain"
+- Time-cost analysis for each path
+
+**API reference with code** → `/skill axiom-energy-ref`
+- Complete WWDC code examples
+- Timer, network, location efficiency
+- BGContinuedProcessingTask (iOS 26)
+- MetricKit setup
+
+**Energy scan** → Launch `energy-auditor` agent or `/axiom:audit energy` (8 anti-patterns: timer abuse, polling, continuous location, animation leaks, background mode misuse, network inefficiency, GPU waste, disk I/O)
+
+### Timer Safety
+
+**Timer crash patterns (DispatchSourceTimer)** → `/skill axiom-timer-patterns`
+- 4 crash scenarios causing EXC_BAD_INSTRUCTION
+- RunLoop mode gotcha (Timer stops during scroll)
+- SafeDispatchTimer wrapper
+- Timer vs DispatchSourceTimer decision
+
+**Timer API reference** → `/skill axiom-timer-patterns-ref`
+- Timer, DispatchSourceTimer, Combine, AsyncTimerSequence APIs
+- Lifecycle diagrams
+- Platform availability
+
+### Swift Performance
+
+**Swift performance optimization** → `/skill axiom-swift-performance`
+- Value vs reference types, copy-on-write
+- ARC overhead, generic specialization
+- Collection performance
+
+**Swift performance scan** → Launch `swift-performance-analyzer` agent or `/axiom:audit swift-performance` (unnecessary copies, ARC overhead, unspecialized generics, collection inefficiencies, actor isolation costs, memory layout)
+
+**Modern Swift idioms** → `/skill axiom-swift-modern`
+- Outdated API patterns (Date(), CGFloat, DateFormatter)
+- Foundation modernization (URL.documentsDirectory, FormatStyle)
+- Claude-specific hallucination corrections
+
+### MetricKit Integration
+
+**MetricKit API reference** → `/skill axiom-metrickit-ref`
+- MXMetricPayload parsing
+- MXDiagnosticPayload (crashes, hangs)
+- Field performance data collection
+- Integration with crash reporting
+
+### Runtime Console Capture
+
+**Capture simulator console output** → `/skill axiom-xclog-ref` or `/axiom:console`
+- Capture print(), os_log(), Logger output from simulator
+- Structured JSON with level, subsystem, category
+- Bounded collection with `--timeout` and `--max-lines`
+- Filter by subsystem or regex
+
+### Runtime State Inspection
+
+**LLDB interactive debugging** → `/skill axiom-lldb`
+- Set breakpoints, inspect variables at runtime
+- Crash reproduction from crash logs
+- Thread state analysis for hangs
+- Swift value inspection (po vs v)
+
+**LLDB command reference** → `/skill axiom-lldb-ref`
+- Complete command syntax
+- Breakpoint recipes
+- Expression evaluation patterns
+
+## Decision Tree
+
+1. Memory climbing + UI stutter/jank? → memory-debugging FIRST (memory pressure causes GC pauses that drop frames), then performance-profiling if memory is fixed but stutter remains
+2. Memory leak (Swift)? → memory-debugging
+3. Memory leak (Objective-C blocks)? → objc-block-retain-cycles
+4. App hang/freeze — is UI completely unresponsive (can't tap, no feedback)?
+ - YES → hang-diagnostics (busy vs blocked diagnosis)
+ - NO, just slow → performance-profiling (Time Profiler)
+ - First launch only? → Also check for synchronous I/O or lazy initialization in hang-diagnostics
+5. Slowdown when multiple async operations complete at once? → Cross-route to `axiom-ios-concurrency` (callback contention, not profiling)
+6. Battery drain (know the symptom)? → energy-diag
+7. Battery drain (need API reference)? → energy-ref
+8. Battery drain (general)? → energy
+9. MetricKit setup/parsing? → metrickit-ref
+10. Profile with GUI (Instruments)? → performance-profiling
+11. Profile with CLI (xctrace)? → xctrace-ref
+12. Run automated profile now? → performance-profiler agent
+13. General slow/lag? → performance-profiling
+14. Want proactive memory leak scan? → memory-auditor (Agent)
+15. Want energy anti-pattern scan? → energy-auditor (Agent)
+16. Want Swift performance audit (ARC, generics, collections)? → swift-performance-analyzer (Agent)
+17. Need to inspect variable/thread state at runtime? → axiom-lldb
+18. Need exact LLDB command syntax? → axiom-lldb-ref
+19. Timer stops during scrolling? → timer-patterns (RunLoop mode)
+20. EXC_BAD_INSTRUCTION crash with DispatchSourceTimer? → timer-patterns (4 crash patterns)
+21. Choosing between Timer, DispatchSourceTimer, Combine timer, async timer? → timer-patterns
+22. Need timer API syntax/lifecycle? → timer-patterns-ref
+23. Code review for outdated Swift patterns? → swift-modern
+24. Claude generating legacy APIs (DateFormatter, CGFloat, DispatchQueue)? → swift-modern
+25. Need to see runtime console output before profiling? → xclog-ref or `/axiom:console`
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "I know it's a memory leak, let me find it" | Memory leaks have 6 patterns. memory-debugging diagnoses the right one in 15 min vs 2 hours. |
+| "I'll just run Time Profiler" | Wrong Instruments template wastes time. performance-profiling selects the right tool first. |
+| "Battery drain is probably the network layer" | Energy issues span 8 subsystems. energy skill diagnoses the actual cause. |
+| "App feels slow, I'll optimize later" | Performance issues compound. Profiling now saves exponentially more time later. |
+| "It's just a UI freeze, probably a slow API call" | Freezes have busy vs blocked causes. hang-diagnostics has a decision tree for both. |
+| "Memory is climbing AND scrolling stutters — two separate bugs" | Memory pressure causes GC pauses that drop frames. Fix the leak first, then re-check scroll performance. |
+| "It only freezes on first launch, must be loading something" | First-launch hangs have 3 patterns: synchronous I/O, lazy initialization, main thread contention. hang-diagnostics diagnoses which. |
+| "UI locks up when network requests finish — that's slow" | Multiple callbacks completing at once = main thread contention = concurrency issue. Cross-route to ios-concurrency. |
+| "I'll just add print statements to debug this" | Print-debug cycles cost 3-5 min each (build + run + reproduce). An LLDB breakpoint costs 30 seconds. axiom-lldb has the commands. |
+| "I can't see what the app is logging" | xclog captures print() + os_log from the simulator with structured JSON. `/axiom:console` or `/skill axiom-xclog-ref`. |
+| "I'll just use Timer.scheduledTimer, it's simpler" | Timer stops during scrolling (`.default` mode), retains its target (leak). timer-patterns has the decision tree. |
+| "DispatchSourceTimer crashed but it's intermittent, let's ship" | DispatchSourceTimer has 4 crash patterns that are ALL deterministic. timer-patterns diagnoses which one. |
+| "Claude already knows modern Swift" | Claude defaults to pre-5.5 patterns (Date(), CGFloat, filter().count). swift-modern has the correction table. |
+
+## Critical Patterns
+
+**Memory Debugging** (memory-debugging):
+- 6 leak patterns: timers, observers, closures, delegates, view callbacks, PhotoKit
+- Instruments workflows
+- Leak vs caching distinction
+
+**Performance Profiling** (performance-profiling):
+- Time Profiler for CPU bottlenecks
+- Allocations for memory growth
+- Core Data SQL logging for N+1 queries
+- Self Time vs Total Time
+
+**Energy Optimization** (energy):
+- Power Profiler subsystem diagnosis
+- 8 anti-patterns: timers, polling, location, animations, background, network, GPU, disk
+- Audit checklists by subsystem
+- Pressure scenarios for deadline resistance
+
+## Example Invocations
+
+User: "My app's memory usage keeps growing"
+→ Invoke: `/skill axiom-memory-debugging`
+
+User: "I have a memory leak but deinit isn't being called"
+→ Invoke: `/skill axiom-memory-debugging`
+
+User: "My app feels slow, where do I start?"
+→ Invoke: `/skill axiom-performance-profiling`
+
+User: "My Objective-C block callback is leaking"
+→ Invoke: `/skill axiom-objc-block-retain-cycles`
+
+User: "My app drains battery quickly"
+→ Invoke: `/skill axiom-energy`
+
+User: "Users say the device gets hot when using my app"
+→ Invoke: `/skill axiom-energy-diag`
+
+User: "What's the best way to implement location tracking efficiently?"
+→ Invoke: `/skill axiom-energy-ref`
+
+User: "Profile my app's CPU usage"
+→ Use: `performance-profiler` agent (or `/axiom:profile`)
+
+User: "How do I run xctrace from the command line?"
+→ Invoke: `/skill axiom-xctrace-ref`
+
+User: "I need headless profiling for CI/CD"
+→ Invoke: `/skill axiom-xctrace-ref`
+
+User: "My app hangs sometimes"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "The UI freezes and becomes unresponsive"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "Main thread is blocked, how do I diagnose?"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "How do I set up MetricKit?"
+→ Invoke: `/skill axiom-metrickit-ref`
+
+User: "How do I parse MXMetricPayload?"
+→ Invoke: `/skill axiom-metrickit-ref`
+
+User: "Scan my code for memory leaks"
+→ Invoke: `memory-auditor` agent
+
+User: "Check my app for battery drain issues"
+→ Invoke: `energy-auditor` agent
+
+User: "Audit my Swift code for performance anti-patterns"
+→ Invoke: `swift-performance-analyzer` agent
+
+User: "How do I inspect this variable in the debugger?"
+→ Invoke: `/skill axiom-lldb`
+
+User: "What's the LLDB command for conditional breakpoints?"
+→ Invoke: `/skill axiom-lldb-ref`
+
+User: "I need to reproduce this crash in the debugger"
+→ Invoke: `/skill axiom-lldb`
+
+User: "My list scrolls slowly and memory keeps growing"
+→ Invoke: `/skill axiom-memory-debugging` first, then `/skill axiom-performance-profiling` if stutter remains
+
+User: "App freezes for a few seconds on first launch then works fine"
+→ Invoke: `/skill axiom-hang-diagnostics`
+
+User: "UI locks up when multiple API calls return at the same time"
+→ Cross-route: `/skill axiom-ios-concurrency` (callback contention)
+
+User: "My timer stops when the user scrolls"
+→ Invoke: `/skill axiom-timer-patterns`
+
+User: "EXC_BAD_INSTRUCTION crash in my timer code"
+→ Invoke: `/skill axiom-timer-patterns`
+
+User: "Should I use Timer or DispatchSourceTimer?"
+→ Invoke: `/skill axiom-timer-patterns`
+
+User: "How do I create an AsyncTimerSequence?"
+→ Invoke: `/skill axiom-timer-patterns-ref`
+
+User: "Review my Swift code for outdated patterns"
+→ Invoke: `/skill axiom-swift-modern`
+
+User: "Is there a more modern way to do this?"
+→ Invoke: `/skill axiom-swift-modern`
+
+User: "What is the app logging? I need to see console output"
+→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console`
+
+User: "Capture the simulator logs while I reproduce this bug"
+→ Invoke: `/skill axiom-xclog-ref` or `/axiom:console`
diff --git a/.claude/skills/axiom-ios-testing/.openskills.json b/.claude/skills/axiom-ios-testing/.openskills.json
new file mode 100644
index 0000000..bdf6bee
--- /dev/null
+++ b/.claude/skills/axiom-ios-testing/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-testing",
+ "installedAt": "2026-04-12T08:05:35.626Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-testing/SKILL.md b/.claude/skills/axiom-ios-testing/SKILL.md
new file mode 100644
index 0000000..cf76aa5
--- /dev/null
+++ b/.claude/skills/axiom-ios-testing/SKILL.md
@@ -0,0 +1,299 @@
+---
+name: axiom-ios-testing
+description: Use when writing ANY test, debugging flaky tests, making tests faster, or asking about Swift Testing vs XCTest. Covers unit tests, UI tests, fast tests without simulator, async testing, test architecture.
+license: MIT
+---
+
+# iOS Testing Router
+
+**You MUST use this skill for ANY testing-related question, including writing tests, debugging test failures, making tests faster, or choosing between testing approaches.**
+
+## When to Use
+
+Use this router when you encounter:
+- Writing new unit tests or UI tests
+- Swift Testing framework (@Test, #expect, @Suite)
+- XCTest or XCUITest questions
+- Making tests run faster (without simulator)
+- Flaky tests (pass sometimes, fail sometimes)
+- Testing async code reliably
+- Migrating from XCTest to Swift Testing
+- Test architecture decisions
+- Condition-based waiting patterns
+
+## Routing Logic
+
+This router invokes specialized skills based on the specific testing need:
+
+### 1. Unit Tests / Fast Tests → **swift-testing**
+
+**Triggers**:
+- Writing new unit tests
+- Swift Testing framework (`@Test`, `#expect`, `#require`, `@Suite`)
+- Making tests run without simulator
+- Testing async code reliably
+- `withMainSerialExecutor`, `TestClock`
+- Migrating from XCTest
+- Parameterized tests
+- Tags and traits
+- Host Application: None configuration
+- Swift Package tests (`swift test`)
+- Tests take >5 seconds for pure logic
+- Want TDD with fast feedback loop
+
+**Why swift-testing**: Modern Swift Testing framework with parallel execution, better async support, and the ability to run without launching simulator.
+
+**Invoke**: Read the `axiom-swift-testing` skill
+
+---
+
+### 2. UI Tests / XCUITest → **ui-testing**
+
+**Triggers**:
+- Recording UI Automation (Xcode 26)
+- XCUIApplication, XCUIElement
+- Flaky UI tests
+- Tests pass locally, fail in CI
+- `sleep()` or arbitrary timeouts
+- Condition-based waiting
+- Cross-device testing
+- Accessibility-first testing
+
+**Why ui-testing**: XCUITest requires simulator and has unique patterns for reliability.
+
+**Invoke**: Read the `axiom-ui-testing` skill
+
+---
+
+### 3. Flaky Tests / Race Conditions → **test-failure-analyzer** (Agent)
+
+**Triggers**:
+- Tests fail randomly in CI
+- Tests pass locally but fail in CI
+- Flaky tests (pass sometimes, fail sometimes)
+- Race conditions in Swift Testing
+- Missing `await confirmation` for async callbacks
+- Missing `@MainActor` on UI tests
+- Shared mutable state in `@Suite`
+- Tests pass individually, fail when run together
+
+**Why test-failure-analyzer**: Specialized agent that scans for patterns causing intermittent failures in Swift Testing.
+
+**Invoke**: Launch `test-failure-analyzer` agent
+
+---
+
+### 4. Async Testing Patterns → **testing-async**
+
+**Triggers**:
+- Testing async/await functions
+- `confirmation` for callbacks (Swift Testing)
+- `expectedCount` for multiple callbacks
+- Testing MainActor code with `@MainActor @Test`
+- Migrating XCTestExpectation → confirmation
+- Parallel test execution concerns
+- Test timeout configuration
+
+**Why testing-async**: Dedicated patterns for async code in Swift Testing framework.
+
+**Invoke**: Read the `axiom-testing-async` skill
+
+---
+
+### 5. Test Crashes / Environment Issues → **xcode-debugging**
+
+**Triggers**:
+- Tests crash before assertions run
+- Simulator won't boot for tests
+- Tests hang indefinitely
+- "Unable to boot simulator" errors
+- Clean test run differs from incremental
+
+**Why xcode-debugging**: Test failures from environment issues, not test logic.
+
+**Invoke**: Read the `axiom-xcode-debugging` skill
+
+---
+
+### 6. Running XCUITests from Command Line → **test-runner** (Agent)
+
+**Triggers**:
+- Run tests with xcodebuild
+- Parse xcresult bundles
+- Export failure screenshots/videos
+- Code coverage reports
+- CI/CD test execution
+
+**Why test-runner**: Specialized agent for command-line test execution with xcresulttool parsing.
+
+**Invoke**: Launch `test-runner` agent
+
+---
+
+### 7. Closed-Loop Test Debugging → **test-debugger** (Agent)
+
+**Triggers**:
+- Fix failing tests automatically
+- Debug persistent test failures
+- Run → analyze → fix → verify cycle
+- Need to iterate until tests pass
+- Analyze failure screenshots
+
+**Why test-debugger**: Automated cycle of running tests, analyzing failures, suggesting fixes, and re-running.
+
+**Invoke**: Launch `test-debugger` agent
+
+---
+
+### 8. Recording UI Automation (Xcode 26) → **ui-recording**
+
+**Triggers**:
+- Record user interactions in Xcode
+- Test plans for multi-config replay
+- Video review of test runs
+- Xcode 26 recording workflow
+- Enhancing recorded test code
+
+**Why ui-recording**: Focused guide for Xcode 26's Record/Replay/Review workflow.
+
+**Invoke**: Read the `axiom-ui-recording` skill
+
+---
+
+### 9. Test Quality Audit → **testing-auditor** (Agent)
+
+**Triggers**:
+- Want to audit test quality
+- Find flaky test patterns (sleep calls, shared mutable state)
+- Speed up test execution
+- Migrate from XCTest to Swift Testing
+- Check tests for Swift 6 concurrency issues
+
+**Why testing-auditor**: Maps test coverage shape against production code, detects flaky patterns and speed issues, identifies untested critical paths (auth, payments, persistence), and scores overall test health.
+
+**Invoke**: Launch `testing-auditor` agent or `/axiom:audit testing`
+
+---
+
+### 10. UI Automation Without XCUITest → **simulator-tester** + **axe-ref**
+
+**Triggers**:
+- Automate app without test target
+- AXe CLI usage (tap, swipe, type)
+- describe-ui for accessibility tree
+- Quick automation outside XCUITest
+- Scripted simulator interactions
+
+**Why simulator-tester + axe-ref**: AXe provides accessibility-based UI automation when XCUITest isn't available.
+
+**Invoke**: Launch `simulator-tester` agent (uses axiom-axe-ref)
+
+---
+
+## Decision Tree
+
+1. Writing unit tests / Swift Testing? → swift-testing
+2. Writing UI tests / XCUITest? → ui-testing
+3. Testing async/await code? → testing-async
+4. Flaky tests / race conditions (XCUITest)? → ui-testing
+5. Flaky tests / race conditions (Swift Testing)? → test-failure-analyzer (Agent)
+6. Tests crash / environment wrong? → xcode-debugging (via ios-build)
+7. Tests are slow / want swift test? → swift-testing (Strategy 1: Package extraction)
+8. Run tests from CLI / parse results? → test-runner (Agent)
+9. Fix failing tests automatically? → test-debugger (Agent)
+10. Want test quality audit (flaky patterns, migration)? → testing-auditor (Agent)
+11. Record UI interactions (Xcode 26)? → ui-recording
+12. Automate without XCUITest / AXe CLI? → simulator-tester + axe-ref
+
+## Swift Testing vs XCTest Quick Guide
+
+| Need | Use |
+|------|-----|
+| Unit tests (logic, models) | Swift Testing |
+| UI tests (tap, swipe, assert screens) | XCUITest (XCTest) |
+| Tests without simulator | Swift Testing + Package/Framework |
+| Parameterized tests | Swift Testing |
+| Performance measurements | XCTest (XCTMetric) |
+| Objective-C tests | XCTest |
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Simple test question, I don't need the skill" | Proper patterns prevent test debt. swift-testing has copy-paste solutions. |
+| "I know XCTest well enough" | Swift Testing is significantly better for unit tests. swift-testing covers migration. |
+| "Tests are slow but it's fine" | Fast tests enable TDD. swift-testing shows how to run without simulator. |
+| "I'll fix the flaky test with a sleep()" | sleep() makes tests slower AND flakier. ui-testing has condition-based waiting patterns. |
+| "I'll add tests later" | Tests written after implementation miss edge cases. swift-testing makes writing tests first easy. |
+
+## Example Invocations
+
+User: "How do I write a unit test in Swift?"
+→ Invoke: axiom-swift-testing
+
+User: "My UI tests are flaky in CI"
+→ Check codebase: XCUIApplication/XCUIElement patterns? → ui-testing
+→ Check codebase: @Test/#expect patterns? → test-failure-analyzer
+
+User: "Tests fail randomly, pass sometimes fail sometimes"
+→ Invoke: test-failure-analyzer (Agent)
+
+User: "Tests pass locally but fail in CI"
+→ Invoke: test-failure-analyzer (Agent)
+
+User: "How do I test async code without flakiness?"
+→ Invoke: testing-async
+
+User: "How do I test callback-based APIs with Swift Testing?"
+→ Invoke: testing-async
+
+User: "What's the Swift Testing equivalent of XCTestExpectation?"
+→ Invoke: testing-async
+
+User: "How do I use confirmation with expectedCount?"
+→ Invoke: testing-async
+
+User: "I want my tests to run faster"
+→ Invoke: axiom-swift-testing (Strategy 1: Package extraction)
+
+User: "My unit tests take 25 seconds to run"
+→ Invoke: axiom-swift-testing (Strategy 1: Package extraction)
+
+User: "How do I use swift test instead of xcodebuild test?"
+→ Invoke: axiom-swift-testing (Fast Tests section)
+
+User: "Should I use Swift Testing or XCTest?"
+→ Invoke: axiom-swift-testing (Migration section) + this decision tree
+
+User: "Tests crash before any assertions"
+→ Invoke: axiom-xcode-debugging
+
+User: "Run my tests and show me what failed"
+→ Invoke: test-runner (Agent)
+
+User: "Help me fix these failing tests"
+→ Invoke: test-debugger (Agent)
+
+User: "Parse the xcresult from my last test run"
+→ Invoke: test-runner (Agent)
+
+User: "Export failure screenshots from my tests"
+→ Invoke: test-runner (Agent)
+
+User: "How do I record UI automation in Xcode 26?"
+→ Invoke: axiom-ui-recording
+
+User: "How do I use test plans for multi-language testing?"
+→ Invoke: axiom-ui-recording
+
+User: "Can I automate my app without writing XCUITests?"
+→ Invoke: simulator-tester (Agent) + axiom-axe-ref
+
+User: "How do I tap a button using AXe?"
+→ Invoke: axiom-axe-ref (via simulator-tester)
+
+User: "Audit my tests for quality issues"
+→ Invoke: `testing-auditor` agent
+
+User: "Should I migrate to Swift Testing?"
+→ Invoke: `testing-auditor` agent
diff --git a/.claude/skills/axiom-ios-ui/.openskills.json b/.claude/skills/axiom-ios-ui/.openskills.json
new file mode 100644
index 0000000..005a58d
--- /dev/null
+++ b/.claude/skills/axiom-ios-ui/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-ui",
+ "installedAt": "2026-04-12T08:05:35.626Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-ui/SKILL.md b/.claude/skills/axiom-ios-ui/SKILL.md
new file mode 100644
index 0000000..4333041
--- /dev/null
+++ b/.claude/skills/axiom-ios-ui/SKILL.md
@@ -0,0 +1,249 @@
+---
+name: axiom-ios-ui
+description: Use when building, fixing, or improving ANY iOS UI including SwiftUI, UIKit, layout, navigation, animations, design guidelines. Covers view updates, layout bugs, navigation issues, performance, architecture, Apple design compliance.
+license: MIT
+---
+
+# iOS UI Router
+
+**You MUST use this skill for ANY iOS UI work including SwiftUI, UIKit, layout, navigation, animations, and design.**
+
+## When to Use
+
+Use this router when working with:
+- SwiftUI views, state, bindings
+- UIKit views and constraints
+- Layout issues (Auto Layout, SwiftUI layout)
+- Navigation (NavigationStack, deep linking)
+- Animations and transitions
+- Liquid Glass design (iOS 26+)
+- Apple Human Interface Guidelines
+- UI architecture and patterns
+- Accessibility UI issues
+
+## Conflict Resolution
+
+**ios-ui vs ios-performance**: When UI is slow (e.g., "SwiftUI List slow"):
+1. **Try ios-ui FIRST** — Domain-specific fixes (LazyVStack, view identity, @State optimization) often solve UI performance in 5 minutes
+2. **Only use ios-performance** if domain fixes don't help — Profiling takes longer and may confirm what domain knowledge already knows
+
+**Rationale**: Jumping to Instruments wastes time when the fix is a known SwiftUI pattern. Profile AFTER trying domain fixes, not before.
+
+## Routing Logic
+
+### SwiftUI Issues
+
+**View not updating** → `/skill axiom-swiftui-debugging`
+**Navigation issues** → `/skill axiom-swiftui-nav`
+**Performance/lag** → `/skill axiom-swiftui-performance`
+**Layout problems** → `/skill axiom-swiftui-layout`
+**Stacks/grids/outlines** → `/skill axiom-swiftui-containers-ref`
+**Animation issues** → `/skill axiom-swiftui-animation-ref`
+**Gesture conflicts** → `/skill axiom-swiftui-gestures`
+**Drag/drop, copy/paste, sharing** → `/skill axiom-transferable-ref`
+**Architecture/testability** → `/skill axiom-swiftui-architecture`
+**App-level composition** → `/skill axiom-app-composition`
+**Search implementation** → `/skill axiom-swiftui-search-ref`
+**iOS 26 features** → `/skill axiom-swiftui-26-ref`
+**UIKit bridging (Representable, HostingController)** → `/skill axiom-uikit-bridging`
+
+### UIKit Issues
+
+**Auto Layout conflicts** → `/skill axiom-auto-layout-debugging`
+**Animation timing issues** → `/skill axiom-uikit-animation-debugging`
+**SwiftUI embedding (HostingController, HostingConfiguration)** → `/skill axiom-uikit-bridging`
+
+### Design & Guidelines
+
+**Liquid Glass adoption** → `/skill axiom-liquid-glass`
+**SF Symbols (effects, rendering, custom)** → `/skill axiom-sf-symbols`
+**Design decisions** → `/skill axiom-hig`
+**Typography** → `/skill axiom-typography-ref`
+**TextKit/rich text** → `/skill axiom-textkit-ref`
+
+### tvOS
+
+**Focus Engine, remote input, TVUIKit, text input** → `/skill axiom-tvos`
+
+### Accessibility
+
+**VoiceOver, Dynamic Type** → `/skill axiom-accessibility-diag`
+
+### Testing
+
+**UI test flakiness** → `/skill axiom-ui-testing`
+
+### Automated Scanning
+
+**Architecture audit** → Launch `swiftui-architecture-auditor` agent (separation of concerns, logic in views, testability)
+**Performance scan** → Launch `swiftui-performance-analyzer` agent or `/axiom:audit swiftui-performance` (expensive view body ops, unnecessary updates)
+**Navigation audit** → Launch `swiftui-nav-auditor` agent or `/axiom:audit swiftui-nav` (deep link gaps, state restoration, wrong containers)
+**Layout audit** → Launch `swiftui-layout-auditor` agent or `/axiom:audit swiftui-layout` (GeometryReader misuse, missing adaptivity, hardcoded breakpoints, identity loss)
+**UX flow audit** → Launch `ux-flow-auditor` agent or `/axiom:audit ux-flow` (dead ends, dismiss traps, buried CTAs, missing empty/loading/error states)
+**Liquid Glass scan** → Launch `liquid-glass-auditor` agent or `/axiom:audit liquid-glass` (adoption opportunities, toolbar improvements)
+**TextKit scan** → Launch `textkit-auditor` agent or `/axiom:audit textkit` (TextKit 1 fallbacks, deprecated glyph APIs, Writing Tools)
+
+## Decision Tree
+
+```dot
+digraph ios_ui {
+ start [label="UI issue" shape=ellipse];
+ is_tvos [label="tvOS?" shape=diamond];
+ is_swiftui [label="SwiftUI?" shape=diamond];
+ is_uikit [label="UIKit?" shape=diamond];
+ is_design [label="Design/guidelines?" shape=diamond];
+
+ start -> is_tvos;
+ is_tvos -> "axiom-tvos" [label="focus, remote, TVUIKit, text input, storage"];
+ is_tvos -> is_swiftui [label="no"];
+ is_swiftui -> swiftui_type [label="yes"];
+ is_swiftui -> is_uikit [label="no"];
+ is_uikit -> uikit_type [label="yes"];
+ is_uikit -> is_design [label="no"];
+ is_design -> design_type [label="yes"];
+ is_design -> "accessibility-diag" [label="accessibility"];
+
+ swiftui_type [label="What's wrong?" shape=diamond];
+ swiftui_type -> "swiftui-debugging" [label="view not updating"];
+ swiftui_type -> "swiftui-nav" [label="navigation"];
+ swiftui_type -> "swiftui-performance" [label="slow/lag"];
+ swiftui_type -> "swiftui-layout" [label="adaptive layout"];
+ swiftui_type -> "swiftui-containers-ref" [label="stacks/grids/outlines"];
+ swiftui_type -> "swiftui-architecture" [label="feature architecture"];
+ swiftui_type -> "app-composition" [label="app-level (root, auth, scenes)"];
+ swiftui_type -> "swiftui-animation-ref" [label="animations"];
+ swiftui_type -> "swiftui-gestures" [label="gestures"];
+ swiftui_type -> "transferable-ref" [label="drag/drop, sharing, copy/paste"];
+ swiftui_type -> "swiftui-search-ref" [label="search"];
+ swiftui_type -> "swiftui-26-ref" [label="iOS 26 features"];
+ swiftui_type -> "uikit-bridging" [label="UIKit interop"];
+ swiftui_type -> "ux-flow-audit" [label="UX dead ends, dismiss traps"];
+
+ uikit_type [label="UIKit issue?" shape=diamond];
+ uikit_type -> "auto-layout-debugging" [label="Auto Layout"];
+ uikit_type -> "uikit-animation-debugging" [label="animations"];
+ uikit_type -> "uikit-bridging" [label="SwiftUI embedding"];
+ uikit_type -> "ux-flow-audit" [label="UX dead ends, dismiss traps"];
+
+ design_type [label="Design topic?" shape=diamond];
+ design_type -> "liquid-glass" [label="Liquid Glass"];
+ design_type -> "sf-symbols" [label="SF Symbols"];
+ design_type -> "hig" [label="HIG compliance"];
+ design_type -> "typography-ref" [label="typography"];
+ design_type -> "textkit-ref" [label="TextKit/rich text"];
+}
+```
+
+**Automated scanning agents:**
+- Want architecture audit (separation of concerns, testability)? → swiftui-architecture-auditor (Agent)
+- Want SwiftUI performance scan (view body ops, unnecessary updates)? → swiftui-performance-analyzer (Agent)
+- Want navigation audit (deep links, state restoration)? → swiftui-nav-auditor (Agent)
+- Want layout audit (GeometryReader, adaptivity, hardcoded sizes)? → swiftui-layout-auditor (Agent)
+- Want UX flow audit (dead ends, dismiss traps, missing states)? → ux-flow-auditor (Agent)
+- Want Liquid Glass adoption scan? → liquid-glass-auditor (Agent)
+- Want TextKit scan (Writing Tools, deprecated APIs)? → textkit-auditor (Agent)
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Simple SwiftUI layout, no need for the layout skill" | SwiftUI layout has 12 gotchas. swiftui-layout covers all of them. |
+| "I know how NavigationStack works" | Navigation has state restoration, deep linking, and identity traps. swiftui-nav prevents 2-hour debugging. |
+| "It's just a view not updating, I'll debug it" | View update failures have 4 root causes. swiftui-debugging diagnoses in 5 min. |
+| "I'll just add .animation() and fix later" | Animation issues compound. swiftui-animation-ref has the correct patterns. |
+| "This UI is simple, no architecture needed" | Even small features benefit from separation. swiftui-architecture prevents refactoring debt. |
+| "UX issues are just polish, we'll fix later" | Dead ends and dismiss traps cause 1-star reviews. ux-flow-audit catches them in minutes. |
+| "I know how .searchable works" | Search has 6 gotchas (navigation container, isSearching level, suggestion completion). swiftui-search-ref covers all of them. |
+| "I know SF Symbols, it's just Image(systemName:)" | 4 rendering modes, 12+ effects, 3 Draw playback modes, custom symbol authoring. sf-symbols has decision trees for all of them. |
+| "Drag and drop is just .draggable and .dropDestination" | UTType declarations, representation ordering, file lifecycle, cross-app transfer gotchas. transferable-ref covers all of them. |
+| "I'll just wrap this UIView real quick" | UIViewRepresentable has lifecycle, coordinator, sizing, and memory gotchas. uikit-bridging prevents 1-2 hour debugging sessions. |
+| "tvOS is just iOS on a TV" | tvOS has no persistent storage, no WebView, a dual focus system, and two remote generations. axiom-tvos covers all the traps. |
+
+## Example Invocations
+
+User: "My SwiftUI view isn't updating when I change the model"
+→ Invoke: `/skill axiom-swiftui-debugging`
+
+User: "How do I implement Liquid Glass in my toolbar?"
+→ Invoke: `/skill axiom-liquid-glass`
+
+User: "NavigationStack is popping unexpectedly"
+→ Invoke: `/skill axiom-swiftui-nav`
+
+User: "Should I use MVVM for this SwiftUI app?"
+→ Invoke: `/skill axiom-swiftui-architecture`
+
+User: "How do I switch between login and main screens?"
+→ Invoke: `/skill axiom-app-composition`
+
+User: "Where should auth state live in my app?"
+→ Invoke: `/skill axiom-app-composition`
+
+User: "How do I create a grid layout with LazyVGrid?"
+→ Invoke: `/skill axiom-swiftui-containers-ref`
+
+User: "What's the difference between VStack and LazyVStack?"
+→ Invoke: `/skill axiom-swiftui-containers-ref`
+
+User: "How do I display hierarchical data with OutlineGroup?"
+→ Invoke: `/skill axiom-swiftui-containers-ref`
+
+User: "How do I add search to my SwiftUI list?"
+→ Invoke: `/skill axiom-swiftui-search-ref`
+
+User: "My search suggestions aren't working"
+→ Invoke: `/skill axiom-swiftui-search-ref`
+
+User: "How do I animate an SF Symbol when tapped?"
+→ Invoke: `/skill axiom-sf-symbols`
+
+User: "My SF Symbol Draw animation isn't working on my custom symbol"
+→ Invoke: `/skill axiom-sf-symbols`
+
+User: "Which rendering mode should I use for my toolbar icons?"
+→ Invoke: `/skill axiom-sf-symbols`
+
+User: "How do I make my model draggable in SwiftUI?"
+→ Invoke: `/skill axiom-transferable-ref`
+
+User: "How do I add ShareLink with a custom preview?"
+→ Invoke: `/skill axiom-transferable-ref`
+
+User: "How do I wrap a UIKit view in SwiftUI?"
+→ Invoke: `/skill axiom-uikit-bridging`
+
+User: "How do I embed SwiftUI in my UIKit app?"
+→ Invoke: `/skill axiom-uikit-bridging`
+
+User: "My UIViewRepresentable isn't updating correctly"
+→ Invoke: `/skill axiom-uikit-bridging`
+
+User: "How do I use UIHostingConfiguration for collection view cells?"
+→ Invoke: `/skill axiom-uikit-bridging`
+
+User: "I'm building a tvOS app and focus navigation isn't working"
+→ Invoke: `/skill axiom-tvos`
+
+User: "How do I handle text input on tvOS?"
+→ Invoke: `/skill axiom-tvos`
+
+User: "Check my app for UX dead ends and dismiss traps"
+→ Invoke: `ux-flow-auditor` agent
+
+User: "Check my SwiftUI architecture for separation of concerns"
+→ Invoke: `swiftui-architecture-auditor` agent
+
+User: "Scan my SwiftUI views for performance issues"
+→ Invoke: `swiftui-performance-analyzer` agent
+
+User: "Audit my navigation for deep link gaps"
+→ Invoke: `swiftui-nav-auditor` agent
+
+User: "Check my layouts for iPad and multitasking issues"
+→ Invoke: `swiftui-layout-auditor` agent
+
+User: "Check my app for Liquid Glass adoption opportunities"
+→ Invoke: `liquid-glass-auditor` agent
+
+User: "Why isn't Writing Tools appearing in my text view?"
+→ Invoke: `textkit-auditor` agent
diff --git a/.claude/skills/axiom-ios-vision/.openskills.json b/.claude/skills/axiom-ios-vision/.openskills.json
new file mode 100644
index 0000000..67f8740
--- /dev/null
+++ b/.claude/skills/axiom-ios-vision/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": ".claude-plugin/plugins/axiom/skills/axiom-ios-vision",
+ "installedAt": "2026-04-12T08:05:35.627Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ios-vision/SKILL.md b/.claude/skills/axiom-ios-vision/SKILL.md
new file mode 100644
index 0000000..16c625e
--- /dev/null
+++ b/.claude/skills/axiom-ios-vision/SKILL.md
@@ -0,0 +1,151 @@
+---
+name: axiom-ios-vision
+description: Use when implementing ANY computer vision feature - image analysis, object detection, pose detection, person segmentation, subject lifting, hand/body pose tracking.
+license: MIT
+---
+
+# iOS Computer Vision Router
+
+**You MUST use this skill for ANY computer vision work using the Vision framework.**
+
+## When to Use
+
+Use this router when:
+- Analyzing images or video
+- Detecting objects, faces, or people
+- Tracking hand or body pose
+- Segmenting people or subjects
+- Lifting subjects from backgrounds
+- Recognizing text in images (OCR)
+- Detecting barcodes or QR codes
+- Scanning documents
+- Using VisionKit or DataScannerViewController
+- Integrating with Visual Intelligence (iOS 26+ system camera feature)
+
+## Routing Logic
+
+### Vision Work
+
+**Implementation patterns** → `/skill axiom-vision`
+- Subject segmentation (VisionKit)
+- Hand pose detection (21 landmarks)
+- Body pose detection (2D/3D)
+- Person segmentation
+- Face detection
+- Isolating objects while excluding hands
+- Text recognition (VNRecognizeTextRequest)
+- Barcode/QR detection (VNDetectBarcodesRequest)
+- Document scanning (VNDocumentCameraViewController)
+- Live scanning (DataScannerViewController)
+- Structured document extraction (RecognizeDocumentsRequest, iOS 26+)
+
+**API reference** → `/skill axiom-vision-ref`
+- Complete Vision framework API
+- VNDetectHumanHandPoseRequest
+- VNDetectHumanBodyPoseRequest
+- VNGenerateForegroundInstanceMaskRequest
+- VNRecognizeTextRequest (fast/accurate modes)
+- VNDetectBarcodesRequest (symbologies)
+- DataScannerViewController delegates
+- RecognizeDocumentsRequest (iOS 26+)
+- Coordinate conversion patterns
+
+**Visual Intelligence integration** → `/skill axiom-vision-ref` (see Visual Intelligence Integration section)
+- Making app content discoverable to Visual Intelligence camera
+- `IntentValueQuery` and `SemanticContentDescriptor`
+- Deep linking from Visual Intelligence results
+
+**Diagnostics** → `/skill axiom-vision-diag`
+- Subject not detected
+- Hand pose missing landmarks
+- Low confidence observations
+- Performance issues
+- Coordinate conversion bugs
+- Text not recognized or wrong characters
+- Barcodes not detected
+- DataScanner showing blank or no items
+- Document edges not detected
+
+## Decision Tree
+
+1. Implementing (pose, segmentation, OCR, barcodes, documents, live scanning)? → vision
+2. Visual Intelligence system integration (camera feature, iOS 26+)? → vision-ref (Visual Intelligence section)
+3. Need API reference / code examples? → vision-ref
+4. Debugging issues (detection failures, confidence, coordinates)? → vision-diag
+
+## Anti-Rationalization
+
+| Thought | Reality |
+|---------|---------|
+| "Vision framework is just a request/handler pattern" | Vision has coordinate conversion, confidence thresholds, and performance gotchas. vision covers them. |
+| "I'll handle text recognition without the skill" | VNRecognizeTextRequest has fast/accurate modes and language-specific settings. vision has the patterns. |
+| "Subject segmentation is straightforward" | Instance masks have HDR compositing and hand-exclusion patterns. vision covers complex scenarios. |
+| "Visual Intelligence is just the camera API" | Visual Intelligence is a system-level feature requiring IntentValueQuery and SemanticContentDescriptor. vision-ref has the integration section. |
+
+## Critical Patterns
+
+**vision**:
+- Subject segmentation with VisionKit
+- Hand pose detection (21 landmarks)
+- Body pose detection (2D/3D, up to 4 people)
+- Isolating objects while excluding hands
+- CoreImage HDR compositing
+- Text recognition (fast vs accurate modes)
+- Barcode detection (symbology selection)
+- Document scanning with perspective correction
+- Live scanning with DataScannerViewController
+- Structured document extraction (iOS 26+)
+
+**vision-diag**:
+- Subject detection failures
+- Landmark tracking issues
+- Performance optimization
+- Observation confidence thresholds
+- Text recognition failures (language, contrast)
+- Barcode detection issues (symbology, distance)
+- DataScanner troubleshooting
+- Document edge detection problems
+
+## Example Invocations
+
+User: "How do I detect hand pose in an image?"
+→ Invoke: `/skill axiom-vision`
+
+User: "Isolate a subject but exclude the user's hands"
+→ Invoke: `/skill axiom-vision`
+
+User: "How do I read text from an image?"
+→ Invoke: `/skill axiom-vision`
+
+User: "Scan QR codes with the camera"
+→ Invoke: `/skill axiom-vision`
+
+User: "How do I implement document scanning?"
+→ Invoke: `/skill axiom-vision`
+
+User: "Use DataScannerViewController for live text"
+→ Invoke: `/skill axiom-vision`
+
+User: "Subject detection isn't working"
+→ Invoke: `/skill axiom-vision-diag`
+
+User: "Text recognition returns wrong characters"
+→ Invoke: `/skill axiom-vision-diag`
+
+User: "Barcode not being detected"
+→ Invoke: `/skill axiom-vision-diag`
+
+User: "Show me VNDetectHumanBodyPoseRequest examples"
+→ Invoke: `/skill axiom-vision-ref`
+
+User: "What symbologies does VNDetectBarcodesRequest support?"
+→ Invoke: `/skill axiom-vision-ref`
+
+User: "RecognizeDocumentsRequest API reference"
+→ Invoke: `/skill axiom-vision-ref`
+
+User: "How do I make my app work with Visual Intelligence?"
+→ Invoke: `/skill axiom-vision-ref`
+
+User: "How do users discover my app content through the camera?"
+→ Invoke: `/skill axiom-vision-ref`
diff --git a/.claude/skills/axiom-keychain-diag/.openskills.json b/.claude/skills/axiom-keychain-diag/.openskills.json
new file mode 100644
index 0000000..549646c
--- /dev/null
+++ b/.claude/skills/axiom-keychain-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-keychain-diag",
+ "installedAt": "2026-04-12T08:06:25.387Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-keychain-diag/SKILL.md b/.claude/skills/axiom-keychain-diag/SKILL.md
new file mode 100644
index 0000000..e632988
--- /dev/null
+++ b/.claude/skills/axiom-keychain-diag/SKILL.md
@@ -0,0 +1,358 @@
+---
+name: axiom-keychain-diag
+description: Use when SecItem calls fail — errSecDuplicateItem from unexpected uniqueness, errSecItemNotFound despite item existing, errSecInteractionNotAllowed in background, keychain items disappearing after app update, access group entitlement errors, or Mac keychain shim issues. Covers systematic error diagnosis with decision trees.
+license: MIT
+---
+
+# Keychain Diagnostics
+
+Systematic troubleshooting for Security framework failures: uniqueness constraint violations, query mismatches, data protection timing, access group entitlements, disappearing items after updates, and Mac shim behavior differences.
+
+## Overview
+
+**Core Principle**: When keychain operations fail, the problem is usually:
+1. **Uniqueness constraint mismatch** (errSecDuplicateItem) — 25%
+2. **Query attribute confusion** (errSecItemNotFound) — 25%
+3. **Data protection / background timing** (errSecInteractionNotAllowed) — 20%
+4. **Access group / entitlement mismatch** (errSecMissingEntitlement) — 15%
+5. **Mac shim behavior differences** — 10%
+6. **Lost items after app update** (entitlement or App ID prefix change) — 5%
+
+**Always dump existing items and compare attributes BEFORE changing keychain code.**
+
+## Red Flags
+
+Symptoms that indicate keychain-specific issues:
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| errSecDuplicateItem when query returned not found | Non-unique attributes in add query — uniqueness is per-class + primary key attributes, not per your full query |
+| errSecItemNotFound but item was just added | Wrong `kSecClass`, erroneous attribute narrowing query, or access group mismatch |
+| errSecInteractionNotAllowed in background | `kSecAttrAccessibleWhenUnlocked` (default) + device locked + background execution |
+| errSecMissingEntitlement | Access group not listed in keychain-access-groups entitlement |
+| errSecNoSuchAttr | Attribute not supported for item class (e.g. `kSecAttrApplicationTag` on `kSecClassGenericPassword`) |
+| errSecAuthFailed on Mac | File-based keychain locked or timed out |
+| Items gone after app update | Access group or entitlement changed between versions |
+| Items gone after team change | App ID prefix changed — items keyed to old prefix are inaccessible |
+| SecItemDelete deleted everything | `kSecMatchLimit` is irrelevant for delete — it deletes ALL matching items |
+| Keychain works in simulator, fails on device | Simulator does not enforce data protection — device does |
+
+## Anti-Rationalization
+
+| Rationalization | Why It Fails | Time Cost |
+|----------------|--------------|-----------|
+| "The wrapper handles it" | Wrappers hide uniqueness constraints. When errSecDuplicateItem happens, you can't debug what you can't see. You end up reading the wrapper source. | 30+ min unwrapping the wrapper |
+| "I'll just delete and re-add" | Loses item metadata, breaks iCloud Keychain sync state, and if the delete query is broader than intended, silently deletes other items too. | 1-2 hours debugging missing credentials |
+| "UserDefaults is fine for this one token" | UserDefaults is unencrypted, backed up to iCloud, visible to MDM profiles, and readable via device backup extraction. One security audit catches it. | Hours migrating to keychain after rejection |
+| "errSecItemNotFound means it's not there" | It means your query didn't match. The item may exist with different attributes than you're searching for. Dump all items to check. | 30-60 min rewriting add logic when the item already exists |
+| "I'll fix the keychain code after launch" | Keychain bugs are silent data loss. Users lose credentials after an update, can't log in, and have no recovery path. You find out from 1-star reviews. | Days of emergency patches + user trust damage |
+
+## Mandatory First Steps
+
+Before changing keychain code, run these diagnostics:
+
+### Step 1: Dump All Items of the Relevant Class
+
+```swift
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecMatchLimit as String: kSecMatchLimitAll,
+ kSecReturnAttributes as String: true,
+ kSecReturnRef as String: true
+]
+var result: AnyObject?
+let status = SecItemCopyMatching(query as CFDictionary, &result)
+if status == errSecSuccess, let items = result as? [[String: Any]] {
+ for item in items {
+ print(item)
+ }
+}
+```
+
+This reveals every item of that class your app can see — including ones you forgot about.
+
+### Step 2: Compare Attributes Against Your Query
+
+Check each attribute in your add/update/search query against the dump output. Common mismatches:
+- `kSecAttrAccount` vs `kSecAttrService` — which one are you using for the key?
+- `kSecAttrAccessGroup` — are you specifying one that differs from the default?
+- Extra attributes narrowing the search (e.g. `kSecAttrLabel` you set on add but omit on search)
+
+### Step 3: Check Accessibility Class vs Device Lock State
+
+```swift
+// In your dump, look for:
+// kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked (default — fails when locked)
+// kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock (survives background)
+```
+
+If the app accesses keychain in background (push notification handlers, background fetch), `WhenUnlocked` will fail on a locked device.
+
+### Step 4: Verify Access Group Entitlements
+
+```bash
+codesign -d --entitlements - /path/to/YourApp.app 2>&1 | grep keychain-access-groups
+```
+
+The access group in your query must appear in this list. The default group is `$(AppIdentifierPrefix)$(CFBundleIdentifier)`.
+
+## Decision Trees
+
+### Tree 1: errSecDuplicateItem
+
+```dot
+digraph tree1 {
+ "errSecDuplicateItem?" [shape=diamond];
+ "Dump all items (Step 1)" [shape=box];
+ "Item with same primary keys exists?" [shape=diamond];
+ "Same kSecAttrAccount + kSecAttrService?" [shape=diamond];
+
+ "Use SecItemUpdate" [shape=box, label="Use SecItemUpdate instead.\nQuery with primary key attrs only.\nPass new values in attributesToUpdate."];
+ "Query-before-add" [shape=box, label="Search first, update if found:\nSecItemCopyMatching → exists?\n yes → SecItemUpdate\n no → SecItemAdd"];
+ "Different account/service" [shape=box, label="Your add query matches an existing\nitem on primary key attributes.\nkSecClassGenericPassword uniqueness:\n kSecAttrAccount + kSecAttrService\n + kSecAttrAccessGroup"];
+ "Check access group" [shape=box, label="Item exists in a different access\ngroup. Your search missed it but\nadd sees it. Specify kSecAttrAccessGroup\nexplicitly in both operations."];
+
+ "errSecDuplicateItem?" -> "Dump all items (Step 1)";
+ "Dump all items (Step 1)" -> "Item with same primary keys exists?" [label="inspect"];
+ "Item with same primary keys exists?" -> "Same kSecAttrAccount + kSecAttrService?" [label="yes"];
+ "Item with same primary keys exists?" -> "Check access group" [label="no visible match"];
+ "Same kSecAttrAccount + kSecAttrService?" -> "Use SecItemUpdate" [label="yes, want to overwrite"];
+ "Same kSecAttrAccount + kSecAttrService?" -> "Different account/service" [label="no, different values"];
+ "Use SecItemUpdate" -> "Query-before-add" [label="prevent future duplicates"];
+}
+```
+
+**Uniqueness constraints by class**:
+
+| Class | Primary Key Attributes |
+|-------|----------------------|
+| kSecClassGenericPassword | kSecAttrAccount + kSecAttrService + kSecAttrAccessGroup |
+| kSecClassInternetPassword | kSecAttrAccount + kSecAttrSecurityDomain + kSecAttrServer + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrPort + kSecAttrPath |
+| kSecClassCertificate | kSecAttrCertificateType + kSecAttrIssuer + kSecAttrSerialNumber |
+| kSecClassKey | kSecAttrKeyClass + kSecAttrKeyType + kSecAttrApplicationLabel + kSecAttrApplicationTag + kSecAttrEffectiveKeySize |
+
+### Tree 2: errSecItemNotFound
+
+```dot
+digraph tree2 {
+ "errSecItemNotFound?" [shape=diamond];
+ "Dump all items (Step 1)" [shape=box];
+ "Any items returned?" [shape=diamond];
+ "Correct kSecClass?" [shape=diamond];
+ "Erroneous attribute?" [shape=diamond];
+
+ "Class mismatch" [shape=box, label="Wrong kSecClass in query.\nGenericPassword vs InternetPassword\nis the most common confusion.\nKeys use kSecClassKey."];
+ "Narrow query" [shape=box, label="Erroneous attribute narrows\nquery to match nothing.\nRemove attributes one at a time\nuntil item is found.\nCommon: kSecAttrLabel, kSecAttrType"];
+ "Access group" [shape=box, label="Item exists in different\naccess group than query.\nCheck kSecAttrAccessGroup\nor omit it to use default."];
+ "Data protection" [shape=box, label="Item exists but device is locked\nand item has WhenUnlocked accessibility.\nSee Tree 3."];
+ "Not added yet" [shape=box, label="Item was never successfully added.\nCheck return value of SecItemAdd\n— was it errSecSuccess?"];
+
+ "errSecItemNotFound?" -> "Dump all items (Step 1)";
+ "Dump all items (Step 1)" -> "Any items returned?" [label="check"];
+ "Any items returned?" -> "Not added yet" [label="no items at all"];
+ "Any items returned?" -> "Correct kSecClass?" [label="yes, items exist"];
+ "Correct kSecClass?" -> "Class mismatch" [label="no"];
+ "Correct kSecClass?" -> "Erroneous attribute?" [label="yes"];
+ "Erroneous attribute?" -> "Narrow query" [label="yes, extra attrs"];
+ "Erroneous attribute?" -> "Access group" [label="no, attrs match"];
+ "Access group" -> "Data protection" [label="access group matches too"];
+}
+```
+
+### Tree 3: errSecInteractionNotAllowed
+
+```dot
+digraph tree3 {
+ "errSecInteractionNotAllowed?" [shape=diamond];
+ "Background execution?" [shape=diamond];
+ "Device locked?" [shape=diamond];
+ "Check accessibility" [shape=diamond];
+
+ "Change accessibility" [shape=box, label="Migrate item to\nkSecAttrAccessibleAfterFirstUnlock\nor AfterFirstUnlockThisDeviceOnly.\nRequires delete + re-add."];
+ "Timing issue" [shape=box, label="App launched in background\nbefore first unlock after reboot.\nDefer keychain access until\nUIApplication.protectedDataDidBecomeAvailable"];
+ "Delete trap" [shape=octagon, label="DANGER: Do NOT delete and re-add\njust to change accessibility.\nIf device is locked, the delete\nwill succeed but the add will FAIL\n— you lose the credential."];
+ "Not data protection" [shape=box, label="On Mac: file-based keychain\nmay be locked. Check\nsecurity unlock-keychain.\nOr keychain requires user\ninteraction (SecAccessControl)."];
+ "Check SecAccessControl" [shape=box, label="If using biometric protection\n(SecAccessControlCreateWithFlags),\nbackground access is impossible.\nStore a separate non-biometric\ncopy for background use."];
+
+ "errSecInteractionNotAllowed?" -> "Background execution?" [label="check context"];
+ "Background execution?" -> "Device locked?" [label="yes"];
+ "Background execution?" -> "Not data protection" [label="no, foreground"];
+ "Device locked?" -> "Check accessibility" [label="yes"];
+ "Device locked?" -> "Check SecAccessControl" [label="no, unlocked but still fails"];
+ "Check accessibility" -> "Timing issue" [label="WhenUnlocked + after reboot"];
+ "Check accessibility" -> "Change accessibility" [label="WhenUnlocked + normal lock"];
+ "Change accessibility" -> "Delete trap" [label="WARNING"];
+}
+```
+
+### Tree 4: errSecMissingEntitlement
+
+```dot
+digraph tree4 {
+ "errSecMissingEntitlement?" [shape=diamond];
+ "Using explicit access group?" [shape=diamond];
+ "Check entitlements (Step 4)" [shape=box];
+ "Group in entitlements?" [shape=diamond];
+
+ "Add to entitlements" [shape=box, label="Xcode > Target >\nSigning & Capabilities >\nKeychain Sharing >\nAdd access group"];
+ "Prefix mismatch" [shape=box, label="Access group must use\nApp ID prefix (Team ID or\nApp ID prefix from portal).\n$(AppIdentifierPrefix)com.your.group\nNOT just com.your.group"];
+ "Shared group config" [shape=box, label="For shared keychain between apps:\n1. Same Team ID\n2. Same access group string\n3. Both apps list group in\n Keychain Sharing capability"];
+ "Default group" [shape=box, label="If not specifying access group,\ndefault is AppIdentifierPrefix +\nbundle ID. Verify your app's\nprefix hasn't changed."];
+
+ "errSecMissingEntitlement?" -> "Using explicit access group?" [label="check query"];
+ "Using explicit access group?" -> "Check entitlements (Step 4)" [label="yes"];
+ "Using explicit access group?" -> "Default group" [label="no"];
+ "Check entitlements (Step 4)" -> "Group in entitlements?" [label="inspect"];
+ "Group in entitlements?" -> "Prefix mismatch" [label="no, group missing"];
+ "Group in entitlements?" -> "Shared group config" [label="yes but still fails"];
+ "Prefix mismatch" -> "Add to entitlements" [label="fix"];
+}
+```
+
+### Tree 5: Lost Keychain Items After App Update
+
+```dot
+digraph tree5 {
+ "Items gone after update?" [shape=diamond];
+ "Access group changed?" [shape=diamond];
+ "App ID prefix changed?" [shape=diamond];
+ "Entitlements file changed?" [shape=diamond];
+
+ "Restore access group" [shape=box, label="Add the OLD access group back\nto Keychain Sharing entitlement.\nItems are keyed to the group\nthey were created with."];
+ "Prefix migration" [shape=box, label="App ID prefix change means\nnew items are under new prefix.\nOld items are under old prefix.\nAdd both prefixes to entitlements\nor migrate items at first launch."];
+ "Entitlement restore" [shape=box, label="If Keychain Sharing was removed,\nthe default access group changed.\nRe-add Keychain Sharing with\nthe original group name."];
+ "Query change" [shape=box, label="Check if the query attributes\nchanged between versions.\nDump items (Step 1) to verify\nitems still exist under old attrs."];
+
+ "Items gone after update?" -> "Access group changed?" [label="check entitlements diff"];
+ "Access group changed?" -> "Restore access group" [label="yes"];
+ "Access group changed?" -> "App ID prefix changed?" [label="no"];
+ "App ID prefix changed?" -> "Prefix migration" [label="yes, team transfer"];
+ "App ID prefix changed?" -> "Entitlements file changed?" [label="no"];
+ "Entitlements file changed?" -> "Entitlement restore" [label="yes"];
+ "Entitlements file changed?" -> "Query change" [label="no, entitlements identical"];
+}
+```
+
+### Tree 6: Mac-Specific Issues
+
+```dot
+digraph tree6 {
+ "Mac keychain issue?" [shape=diamond];
+ "Catalyst or native?" [shape=diamond];
+ "File-based keychain?" [shape=diamond];
+
+ "Shim behavior" [shape=box, label="Mac Catalyst uses iOS-style\ndata-protection keychain by default.\nkSecUseDataProtectionKeychain = true\nis automatic on Catalyst.\nFile-based keychain quirks don't apply."];
+ "Native Mac" [shape=box, label="Native macOS apps default to\nfile-based keychain unless you set\nkSecUseDataProtectionKeychain = true.\nFile-based has different:\n- kSecMatchLimit defaults\n- Locking behavior\n- Access control prompts"];
+ "Match limit" [shape=box, label="File-based keychain default:\nkSecMatchLimit = kSecMatchLimitAll\nData-protection keychain default:\nkSecMatchLimit = kSecMatchLimitOne\nAlways set explicitly."];
+ "Lock timeout" [shape=box, label="File-based keychain locks after\ntimeout (default: sleep + 5 min idle).\nerrSecAuthFailed = locked keychain.\nsecurity unlock-keychain to test."];
+ "Use data protection" [shape=box, label="For cross-platform code,\nset kSecUseDataProtectionKeychain = true\non macOS. This gives iOS-identical\nbehavior on macOS 10.15+."];
+
+ "Mac keychain issue?" -> "Catalyst or native?" [label="check target"];
+ "Catalyst or native?" -> "Shim behavior" [label="Catalyst"];
+ "Catalyst or native?" -> "File-based keychain?" [label="native macOS"];
+ "File-based keychain?" -> "Match limit" [label="unexpected result count"];
+ "File-based keychain?" -> "Lock timeout" [label="errSecAuthFailed"];
+ "File-based keychain?" -> "Use data protection" [label="want iOS-identical behavior"];
+ "Shim behavior" -> "Native Mac" [label="opted out of shim"];
+}
+```
+
+### Tree 7: errSecNoSuchAttr
+
+```dot
+digraph tree7 {
+ "errSecNoSuchAttr?" [shape=diamond];
+ "Check attr vs class" [shape=box, label="Not all attributes work\nwith all item classes.\nDump item to see which\nattributes it actually has."];
+ "Common mistakes" [shape=diamond];
+
+ "Tag on password" [shape=box, label="kSecAttrApplicationTag is for\nkSecClassKey only.\nFor passwords, use\nkSecAttrAccount or kSecAttrService."];
+ "Label mismatch" [shape=box, label="kSecAttrLabel behavior differs:\n- Passwords: free-form string\n- Keys: computed from key data\n- Certs: computed from subject\nSetting it may be silently ignored."];
+ "Description on key" [shape=box, label="kSecAttrDescription is for\nkSecClassGenericPassword and\nkSecClassInternetPassword only.\nNot available on keys or certs."];
+
+ "errSecNoSuchAttr?" -> "Check attr vs class" [label="first"];
+ "Check attr vs class" -> "Common mistakes" [label="identify"];
+ "Common mistakes" -> "Tag on password" [label="kSecAttrApplicationTag + password"];
+ "Common mistakes" -> "Label mismatch" [label="kSecAttrLabel unexpected behavior"];
+ "Common mistakes" -> "Description on key" [label="kSecAttrDescription + key/cert"];
+}
+```
+
+## Quick Reference Table
+
+| Symptom | Check | Fix |
+|---------|-------|-----|
+| errSecDuplicateItem | Dump items (Step 1), compare primary key attrs | Use SecItemUpdate or query-before-add pattern |
+| errSecItemNotFound | Dump items, verify kSecClass + attributes match | Remove erroneous attributes, fix class |
+| errSecInteractionNotAllowed in background | Check kSecAttrAccessible value | Migrate to AfterFirstUnlock (delete + re-add while unlocked) |
+| errSecInteractionNotAllowed after reboot | Check if first unlock happened | Defer access until protectedDataDidBecomeAvailable |
+| errSecMissingEntitlement | `codesign -d --entitlements -` for access groups | Add group to Keychain Sharing capability |
+| errSecNoSuchAttr | Check attribute compatibility with item class | Use correct attribute for the class |
+| errSecAuthFailed on Mac | Check if file-based keychain is locked | `security unlock-keychain` or use data-protection keychain |
+| Items gone after update | Diff entitlements between versions | Restore old access group, migrate items |
+| Items gone after team change | Check App ID prefix change | Add both prefixes to entitlements |
+| Delete removed too many items | Review delete query specificity | Always specify all primary key attrs in delete query |
+| Works in simulator, fails on device | Check accessibility class | Simulator ignores data protection — test on device |
+| Inconsistent Mac vs iOS behavior | Check kSecUseDataProtectionKeychain | Set to true for consistent cross-platform behavior |
+| Query returns wrong item | Check kSecMatchLimit | Always set explicitly — defaults differ by keychain type |
+| Biometric item fails in background | Check SecAccessControl flags | Store separate non-biometric copy for background |
+| SecItemAdd returns errSecSuccess but search fails | Check if access groups differ between add and search | Specify kSecAttrAccessGroup explicitly in both |
+
+## Pressure Scenarios
+
+### Scenario 1: "Users can't log in after the update — just clear and re-store the token"
+
+**Context**: Version 2.1 shipped with a Keychain Sharing entitlement change. Users updating from 2.0 lose their auth tokens. Support tickets are flooding in.
+
+**Pressure**: "Just delete the old item and store a new one on first launch."
+
+**Reality**: The old item is inaccessible because the access group changed — SecItemDelete can't find it either. The "delete and re-add" approach silently does nothing. Meanwhile, the real fix is restoring the old access group in entitlements so existing items are readable again, then migrating to the new group.
+
+**Correct action**: Add the old access group back to the Keychain Sharing entitlement. On first launch, read from old group, write to new group, delete from old group. Ship as 2.1.1.
+
+**Push-back template**: "The delete won't work either — the old items are under the old access group that we can no longer read. We need to add the old access group back to our entitlements so we can read and migrate those items. This is a 30-minute fix, not a redesign."
+
+### Scenario 2: "errSecInteractionNotAllowed in push handler — just change to AfterFirstUnlock"
+
+**Context**: Background push notification handler reads an auth token from keychain to call an API. Fails with errSecInteractionNotAllowed when device is locked.
+
+**Pressure**: "Just change the accessibility to AfterFirstUnlock. Quick fix."
+
+**Reality**: Changing accessibility requires deleting the old item and adding a new one with the new accessibility class. If you do this in the push handler while the device is locked, the delete succeeds (it doesn't read data) but the add fails (AfterFirstUnlock still requires first unlock, and if the device just rebooted, first unlock hasn't happened). You just deleted the user's credential.
+
+**Correct action**: Change accessibility in foreground code (app launch, `protectedDataDidBecomeAvailable`). Never migrate keychain items in background execution paths.
+
+**Push-back template**: "We can't change accessibility in the push handler — the delete works but the re-add can fail if the device rebooted without unlocking. We need to migrate in the foreground on next app launch, and handle the push handler failure gracefully until then."
+
+### Scenario 3: "The keychain wrapper handles all this — just use it"
+
+**Context**: Team uses a third-party keychain wrapper (KeychainAccess, Valet, etc.). errSecDuplicateItem keeps happening despite the wrapper's "upsert" method.
+
+**Pressure**: "The wrapper documentation says it handles duplicates. Must be a bug in the wrapper."
+
+**Reality**: The wrapper's upsert does query-then-add or query-then-update. But if your query attributes don't match the uniqueness constraints of the item class, the search returns not-found while the add hits the existing item's primary key. The wrapper can't fix a query that uses the wrong attributes. You need to understand what makes items unique and ensure your wrapper configuration matches.
+
+**Correct action**: Dump all items (Step 1) to see what exists. Compare the wrapper's query attributes against the item class uniqueness constraints table. Fix the wrapper configuration to query on primary key attributes.
+
+**Push-back template**: "The wrapper works correctly — it's our configuration that doesn't match the keychain's uniqueness constraints. Let me dump the existing items and compare against our query. This is a 10-minute diagnosis."
+
+## Checklist
+
+Before declaring a keychain issue fixed:
+
+- [ ] Dumped all items of relevant class — understand what exists
+- [ ] Verified kSecClass matches the item type (GenericPassword vs InternetPassword vs Key)
+- [ ] Checked primary key attributes for uniqueness constraints
+- [ ] Confirmed kSecAttrAccessible suits the execution context (foreground vs background)
+- [ ] Verified access group in entitlements matches query
+- [ ] Tested on device (not just simulator — simulator ignores data protection)
+- [ ] Tested after device reboot + lock for background scenarios
+- [ ] If migrating accessibility: migration runs in foreground only, never background
+- [ ] If sharing between apps: both apps have same access group in Keychain Sharing
+
+## Resources
+
+**Docs**: /security/keychain_services, /security/keychain_services/keychain_items, /security/errSecDuplicateItem, /security/errSecItemNotFound, /security/errSecInteractionNotAllowed
+
+**Reference**: Quinn "The Eskimo" — SecItem Pitfalls and Best Practices (Apple Developer Forums), Keychain Items Fundamentals (Apple TN3137)
+
+**Skills**: axiom-keychain, axiom-keychain-ref
diff --git a/.claude/skills/axiom-keychain-diag/agents/openai.yaml b/.claude/skills/axiom-keychain-diag/agents/openai.yaml
new file mode 100644
index 0000000..91e5d9f
--- /dev/null
+++ b/.claude/skills/axiom-keychain-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Keychain Diagnostics"
+ short_description: "SecItem calls fail — errSecDuplicateItem from unexpected uniqueness, errSecItemNotFound despite item existing, errSec..."
diff --git a/.claude/skills/axiom-keychain-ref/.openskills.json b/.claude/skills/axiom-keychain-ref/.openskills.json
new file mode 100644
index 0000000..ea488db
--- /dev/null
+++ b/.claude/skills/axiom-keychain-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-keychain-ref",
+ "installedAt": "2026-04-12T08:06:25.915Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-keychain-ref/SKILL.md b/.claude/skills/axiom-keychain-ref/SKILL.md
new file mode 100644
index 0000000..fd93a2c
--- /dev/null
+++ b/.claude/skills/axiom-keychain-ref/SKILL.md
@@ -0,0 +1,585 @@
+---
+name: axiom-keychain-ref
+description: Use when needing SecItem function signatures, keychain attribute constants, item class uniqueness constraints, accessibility level details, SecAccessControlCreateFlags, kSecReturn behavior per class, LAContext keychain integration, or OSStatus error codes. Covers complete keychain API surface.
+license: MIT
+---
+
+# Keychain Services API Reference
+
+Comprehensive API reference for iOS/macOS Keychain Services: SecItem CRUD functions, item class attributes, uniqueness constraints, accessibility levels, access control flags, biometric integration, and error codes.
+
+## Quick Reference
+
+```swift
+// Add a generic password
+let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "user@example.com",
+ kSecValueData as String: "secret".data(using: .utf8)!
+]
+let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+
+// Read a generic password
+let readQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "user@example.com",
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+]
+var result: AnyObject?
+let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result)
+let data = result as? Data
+
+// Update a generic password
+let updateQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "user@example.com"
+]
+let updateAttributes: [String: Any] = [
+ kSecValueData as String: "newSecret".data(using: .utf8)!
+]
+let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
+
+// Delete a generic password
+let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "user@example.com"
+]
+let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
+```
+
+---
+
+## SecItem Functions
+
+### SecItemAdd
+
+```swift
+func SecItemAdd(_ attributes: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus
+```
+
+**`attributes` dictionary accepts**: Item class + item attributes + value properties + return type properties.
+
+Does NOT accept search properties (`kSecMatch*`). Providing `kSecMatchLimit` in an add query is an error.
+
+**`result`**: Pass `nil` if you don't need the added item back. Pass a pointer to receive the item in the format specified by `kSecReturn*` keys. Pass `nil` in most cases — requesting the result back forces an extra read.
+
+### SecItemCopyMatching
+
+```swift
+func SecItemCopyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus
+```
+
+**`query` dictionary accepts**: Item class + item attributes + search properties + return type properties.
+
+Does NOT accept value properties (`kSecValueData`) as search criteria.
+
+**`result`**: The type depends on which `kSecReturn*` keys are set:
+- `kSecReturnData` alone → `CFData`
+- `kSecReturnAttributes` alone → `CFDictionary`
+- `kSecReturnRef` alone → `SecKey` / `SecCertificate` / `SecIdentity`
+- `kSecReturnPersistentRef` alone → `CFData` (persistent reference)
+- Multiple `kSecReturn*` keys → `CFDictionary` containing requested types
+- `kSecMatchLimit = kSecMatchLimitAll` → `CFArray` of the above
+
+### SecItemUpdate
+
+```swift
+func SecItemUpdate(_ query: CFDictionary, _ attributesToUpdate: CFDictionary) -> OSStatus
+```
+
+**`query` dictionary accepts**: Item class + item attributes + search properties. Used to find items to update.
+
+**`attributesToUpdate` dictionary accepts**: Item attributes + value properties. These are applied to matched items. Does NOT accept item class or search properties.
+
+Updating `kSecValueData` replaces the stored secret. Updating attributes (e.g., `kSecAttrLabel`) changes metadata without touching the secret.
+
+### SecItemDelete
+
+```swift
+func SecItemDelete(_ query: CFDictionary) -> OSStatus
+```
+
+**`query` dictionary accepts**: Item class + item attributes + search properties.
+
+On macOS, deletes ALL matching items by default (implicit `kSecMatchLimitAll`). On iOS, also deletes all matches. There is no confirmation — deletion is immediate.
+
+---
+
+## Item Classes
+
+### kSecClassGenericPassword
+
+General-purpose secret storage. The most commonly used class.
+
+| Attribute | Key | Type |
+|-----------|-----|------|
+| Service | `kSecAttrService` | `CFString` |
+| Account | `kSecAttrAccount` | `CFString` |
+| Access Group | `kSecAttrAccessGroup` | `CFString` |
+| Accessible | `kSecAttrAccessible` | `CFString` (constant) |
+| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` |
+| Label | `kSecAttrLabel` | `CFString` |
+| Description | `kSecAttrDescription` | `CFString` |
+| Comment | `kSecAttrComment` | `CFString` |
+| Generic | `kSecAttrGeneric` | `CFData` |
+| Creator | `kSecAttrCreator` | `CFNumber` (FourCharCode) |
+| Type | `kSecAttrType` | `CFNumber` (FourCharCode) |
+| Creation Date | `kSecAttrCreationDate` | `CFDate` (read-only) |
+| Modification Date | `kSecAttrModificationDate` | `CFDate` (read-only) |
+
+### kSecClassInternetPassword
+
+URL-associated credentials. Rarely needed — most apps use generic passwords.
+
+| Attribute | Key | Type |
+|-----------|-----|------|
+| Server | `kSecAttrServer` | `CFString` |
+| Protocol | `kSecAttrProtocol` | `CFString` (constant) |
+| Port | `kSecAttrPort` | `CFNumber` |
+| Path | `kSecAttrPath` | `CFString` |
+| Account | `kSecAttrAccount` | `CFString` |
+| Authentication Type | `kSecAttrAuthenticationType` | `CFString` (constant) |
+| Security Domain | `kSecAttrSecurityDomain` | `CFString` |
+| Accessible | `kSecAttrAccessible` | `CFString` (constant) |
+| Access Group | `kSecAttrAccessGroup` | `CFString` |
+| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` |
+| Label | `kSecAttrLabel` | `CFString` |
+| Comment | `kSecAttrComment` | `CFString` |
+| Creator | `kSecAttrCreator` | `CFNumber` (FourCharCode) |
+| Type | `kSecAttrType` | `CFNumber` (FourCharCode) |
+
+### kSecClassCertificate
+
+X.509 certificates. Typically managed by the system, not app code.
+
+| Attribute | Key | Type |
+|-----------|-----|------|
+| Subject | `kSecAttrSubject` | `CFData` (read-only) |
+| Issuer | `kSecAttrIssuer` | `CFData` (read-only) |
+| Serial Number | `kSecAttrSerialNumber` | `CFData` (read-only) |
+| Subject Key ID | `kSecAttrSubjectKeyID` | `CFData` (read-only) |
+| Public Key Hash | `kSecAttrPublicKeyHash` | `CFData` (read-only) |
+| Certificate Type | `kSecAttrCertificateType` | `CFNumber` |
+| Certificate Encoding | `kSecAttrCertificateEncoding` | `CFNumber` |
+| Label | `kSecAttrLabel` | `CFString` |
+| Access Group | `kSecAttrAccessGroup` | `CFString` |
+| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` |
+
+### kSecClassKey
+
+Cryptographic keys (RSA, EC, AES). Used for encryption, signing, key agreement.
+
+| Attribute | Key | Type |
+|-----------|-----|------|
+| Key Class | `kSecAttrKeyClass` | `CFString` (constant) |
+| Application Label | `kSecAttrApplicationLabel` | `CFData` |
+| Application Tag | `kSecAttrApplicationTag` | `CFData` |
+| Key Type | `kSecAttrKeyType` | `CFString` (constant) |
+| Key Size in Bits | `kSecAttrKeySizeInBits` | `CFNumber` |
+| Effective Key Size | `kSecAttrEffectiveKeySize` | `CFNumber` |
+| Permanent | `kSecAttrIsPermanent` | `CFBoolean` |
+| Sensitive | `kSecAttrIsSensitive` | `CFBoolean` |
+| Extractable | `kSecAttrIsExtractable` | `CFBoolean` |
+| Label | `kSecAttrLabel` | `CFString` |
+| Access Group | `kSecAttrAccessGroup` | `CFString` |
+| Synchronizable | `kSecAttrSynchronizable` | `CFBoolean` |
+| Token ID | `kSecAttrTokenID` | `CFString` |
+
+### kSecClassIdentity
+
+A digital identity is a certificate paired with its private key. Not a distinct storage class — the system synthesizes it from a matching certificate and key. You cannot add a `kSecClassIdentity` item directly; add the certificate and key separately. Queries return an identity when both halves share the same `kSecAttrPublicKeyHash`.
+
+See Quinn "The Eskimo!"'s technote: "SecItem: Pitfalls and Best Practices" (forums/thread/724013) — digital identities are a virtual join, not a stored item.
+
+---
+
+## Uniqueness Constraints Per Class
+
+Each keychain item is uniquely identified by a subset of its attributes. Adding a second item with the same primary key returns `errSecDuplicateItem` (-25299). Use `SecItemUpdate` to modify existing items.
+
+| Class | Primary Key Attributes |
+|-------|----------------------|
+| Generic Password | `kSecAttrService` + `kSecAttrAccount` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` |
+| Internet Password | `kSecAttrServer` + `kSecAttrPort` + `kSecAttrProtocol` + `kSecAttrAuthenticationType` + `kSecAttrPath` + `kSecAttrAccount` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` |
+| Certificate | `kSecAttrCertificateType` + `kSecAttrIssuer` + `kSecAttrSerialNumber` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` |
+| Key | `kSecAttrApplicationLabel` + `kSecAttrApplicationTag` + `kSecAttrKeyType` + `kSecAttrKeySizeInBits` + `kSecAttrEffectiveKeySize` + `kSecAttrKeyClass` + `kSecAttrAccessGroup` + `kSecAttrSynchronizable` |
+| Identity | N/A (virtual join of certificate + key) |
+
+**Consequence**: If you store tokens for multiple users under the same `kSecAttrService` without unique `kSecAttrAccount` values, `SecItemAdd` returns `errSecDuplicateItem` for the second user.
+
+---
+
+## Attribute Constants Reference
+
+### Identity Attributes
+
+| Constant | Type | Used By |
+|----------|------|---------|
+| `kSecAttrService` | `CFString` | GenericPassword |
+| `kSecAttrAccount` | `CFString` | GenericPassword, InternetPassword |
+| `kSecAttrServer` | `CFString` | InternetPassword |
+| `kSecAttrLabel` | `CFString` | All classes |
+| `kSecAttrDescription` | `CFString` | GenericPassword, InternetPassword |
+| `kSecAttrComment` | `CFString` | GenericPassword, InternetPassword |
+| `kSecAttrGeneric` | `CFData` | GenericPassword |
+
+### Security Attributes
+
+| Constant | Type | Used By |
+|----------|------|---------|
+| `kSecAttrAccessible` | `CFString` (constant) | All classes |
+| `kSecAttrAccessControl` | `SecAccessControl` | All classes |
+| `kSecAttrAccessGroup` | `CFString` | All classes |
+| `kSecAttrSynchronizable` | `CFBoolean` | All classes |
+
+`kSecAttrAccessible` and `kSecAttrAccessControl` are mutually exclusive. Setting both is an error — `kSecAttrAccessControl` includes an accessibility level in its creation.
+
+### Token Attributes
+
+| Constant | Type | Purpose |
+|----------|------|---------|
+| `kSecAttrTokenID` | `CFString` | Bind key to hardware token |
+| `kSecAttrTokenIDSecureEnclave` | `CFString` (value) | Secure Enclave — EC keys only (256-bit) |
+
+### Key Metadata Attributes
+
+| Constant | Type | Values |
+|----------|------|--------|
+| `kSecAttrKeyType` | `CFString` | `kSecAttrKeyTypeRSA`, `kSecAttrKeyTypeECSECPrimeRandom` |
+| `kSecAttrKeySizeInBits` | `CFNumber` | 256 (EC), 2048/4096 (RSA) |
+| `kSecAttrKeyClass` | `CFString` | `kSecAttrKeyClassPublic`, `kSecAttrKeyClassPrivate`, `kSecAttrKeyClassSymmetric` |
+| `kSecAttrApplicationTag` | `CFData` | App-defined tag for key lookup |
+| `kSecAttrApplicationLabel` | `CFData` | SHA-1 hash of public key (auto-generated) |
+
+---
+
+## Search Properties
+
+Used in `SecItemCopyMatching`, `SecItemUpdate` (query parameter), and `SecItemDelete` queries.
+
+| Constant | Type | Purpose |
+|----------|------|---------|
+| `kSecMatchLimit` | `CFString` or `CFNumber` | Max results — `kSecMatchLimitOne`, `kSecMatchLimitAll`, or `CFNumber` for explicit integer limits (e.g., limit to 5 results) |
+| `kSecMatchCaseInsensitive` | `CFBoolean` | Case-insensitive string attribute matching |
+
+### kSecMatchLimit Defaults
+
+The default depends on context and is a common source of bugs:
+
+| Function | Default | Behavior |
+|----------|---------|----------|
+| `SecItemCopyMatching` | `kSecMatchLimitOne` | Returns first match |
+| `SecItemDelete` | All matches | Deletes every matching item |
+
+Always set `kSecMatchLimit` explicitly in `SecItemCopyMatching` to make intent clear. For `SecItemDelete`, omitting `kSecMatchLimit` deletes all matches — this is by design, not a bug.
+
+---
+
+## Return Type Properties
+
+Control what `SecItemCopyMatching` and `SecItemAdd` return. Set in the query dictionary.
+
+| Constant | Returns | Result Type |
+|----------|---------|-------------|
+| `kSecReturnData` | The secret (password bytes, key data) | `CFData` |
+| `kSecReturnAttributes` | Item metadata dictionary | `CFDictionary` |
+| `kSecReturnRef` | Keychain object reference | `SecKey`, `SecCertificate`, or `SecIdentity` |
+| `kSecReturnPersistentRef` | Persistent reference (survives app relaunch) | `CFData` |
+
+### Return Type Behavior Per Class
+
+| Class | `kSecReturnData` | `kSecReturnRef` |
+|-------|-------------------|-----------------|
+| Generic Password | Password bytes | N/A (no ref type) |
+| Internet Password | Password bytes | N/A (no ref type) |
+| Certificate | DER-encoded certificate data | `SecCertificate` |
+| Key | Key data (if extractable) | `SecKey` |
+| Identity | N/A | `SecIdentity` |
+
+### Multiple Return Types
+
+When multiple `kSecReturn*` keys are `true`, the result is a `CFDictionary` with keys:
+- `kSecValueData` → the data
+- `kSecValueRef` → the ref
+- `kSecValuePersistentRef` → the persistent ref
+- Plus all attribute keys if `kSecReturnAttributes` is `true`
+
+When `kSecMatchLimitAll` is set, the result is a `CFArray` of the above.
+
+---
+
+## Value Type Properties
+
+Used to provide or extract values in add, query, and update dictionaries.
+
+| Constant | Type | Purpose |
+|----------|------|---------|
+| `kSecValueData` | `CFData` | The secret (password, key material) |
+| `kSecValueRef` | `SecKey` / `SecCertificate` / `SecIdentity` | Keychain object reference |
+| `kSecValuePersistentRef` | `CFData` | Persistent reference to an item |
+
+### Behavior Per Operation
+
+| Property | SecItemAdd | SecItemCopyMatching | SecItemUpdate |
+|----------|------------|---------------------|---------------|
+| `kSecValueData` | Sets the secret | Not valid as search criteria | Replaces the secret |
+| `kSecValueRef` | Adds the referenced object | Finds by reference | Not valid |
+| `kSecValuePersistentRef` | Not valid | Finds by persistent ref | Not valid |
+
+---
+
+## Accessibility Constants
+
+Controls when keychain items are readable. Set via `kSecAttrAccessible`.
+
+| Constant | Available When | Survives Backup | Syncs via iCloud |
+|----------|---------------|-----------------|------------------|
+| `kSecAttrAccessibleWhenUnlocked` | Device unlocked | Yes | Yes (default) |
+| `kSecAttrAccessibleAfterFirstUnlock` | After first unlock until reboot | Yes | Yes |
+| `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` | Device unlocked + passcode set | No | No |
+| `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | Device unlocked | No | No |
+| `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | After first unlock until reboot | No | No |
+
+**Default**: `kSecAttrAccessibleWhenUnlocked` for new items.
+
+**`ThisDeviceOnly` variants**: Item is not included in encrypted backups and does not sync via iCloud Keychain. Use for device-bound secrets (biometric-gated tokens, Secure Enclave keys).
+
+**`WhenPasscodeSetThisDeviceOnly`**: Item is deleted if the user removes their passcode. Use for secrets that must not survive passcode removal.
+
+**`AfterFirstUnlock`**: Available in the background after the user unlocks once post-reboot. Required for background fetch, push notification handlers, and background URLSession completions.
+
+**Deprecated** (do not use): `kSecAttrAccessibleAlways`, `kSecAttrAccessibleAlwaysThisDeviceOnly`.
+
+---
+
+## SecAccessControlCreateFlags
+
+Fine-grained access control for keychain items. Created with `SecAccessControlCreateWithFlags` and set via `kSecAttrAccessControl`.
+
+### All Flags
+
+| Flag | Purpose |
+|------|---------|
+| `.userPresence` | Any biometric OR device passcode |
+| `.biometryAny` | Any enrolled biometric (survives new enrollment) |
+| `.biometryCurrentSet` | Current biometric set only (invalidated if biometrics change) |
+| `.devicePasscode` | Device passcode required |
+| `.privateKeyUsage` | Required for Secure Enclave key signing operations |
+| `.applicationPassword` | App-provided password (in addition to other factors) |
+| `.watch` | Paired Apple Watch can satisfy authentication |
+| `.or` | Combine flags with logical OR (any one satisfies) |
+| `.and` | Combine flags with logical AND (all must satisfy) |
+
+### Creating Access Control
+
+```swift
+var error: Unmanaged?
+guard let accessControl = SecAccessControlCreateWithFlags(
+ kCFAllocatorDefault,
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
+ [.biometryCurrentSet, .or, .devicePasscode],
+ &error
+) else {
+ let nsError = error!.takeRetainedValue() as Error
+ fatalError("Failed to create access control: \(nsError)")
+}
+
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "auth-token",
+ kSecAttrAccessControl as String: accessControl,
+ kSecValueData as String: tokenData
+]
+let status = SecItemAdd(query as CFDictionary, nil)
+```
+
+### Flag Combinations
+
+| Combination | Meaning |
+|-------------|---------|
+| `[.biometryAny]` | Any enrolled fingerprint/face |
+| `[.biometryCurrentSet]` | Current fingerprint/face set (re-enroll invalidates) |
+| `[.biometryCurrentSet, .or, .devicePasscode]` | Biometric OR passcode fallback |
+| `[.biometryCurrentSet, .and, .applicationPassword]` | Biometric AND app password |
+| `[.privateKeyUsage]` | Secure Enclave key operations (sign, decrypt) |
+| `[.biometryAny, .or, .watch]` | Biometric OR paired Watch |
+
+**`.biometryAny` vs `.biometryCurrentSet`**: Use `.biometryCurrentSet` for high-security items (banking tokens). If the user enrolls a new fingerprint, the item becomes inaccessible — your app must re-authenticate and re-store. Use `.biometryAny` for convenience items where new enrollment should not invalidate access.
+
+---
+
+## LocalAuthentication Integration
+
+### LAContext with Keychain
+
+Pre-evaluate biometrics with `LAContext`, then pass the context to the keychain query to avoid a second biometric prompt.
+
+```swift
+import LocalAuthentication
+
+let context = LAContext()
+context.localizedReason = "Access your credentials"
+
+var authError: NSError?
+guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &authError) else {
+ // Biometrics unavailable — handle error or fall back to passcode
+ return
+}
+
+context.evaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ localizedReason: "Authenticate to access credentials"
+) { success, error in
+ guard success else { return }
+
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "auth-token",
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecUseAuthenticationContext as String: context
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+}
+```
+
+### LAContext Keychain Keys
+
+| Key | Type | Purpose |
+|-----|------|---------|
+| `kSecUseAuthenticationContext` | `LAContext` | Reuse authenticated context (avoids double prompt) |
+| `kSecUseAuthenticationUI` | `CFString` | Control UI behavior: `kSecUseAuthenticationUIAllow` (default), `kSecUseAuthenticationUIFail`, `kSecUseAuthenticationUISkip` |
+
+**`kSecUseAuthenticationUIFail`**: Returns `errSecInteractionNotAllowed` instead of showing the biometric prompt. Use to check if an item exists without triggering UI.
+
+### LAPolicy Types
+
+| Policy | Requires |
+|--------|----------|
+| `.deviceOwnerAuthenticationWithBiometrics` | Face ID or Touch ID only |
+| `.deviceOwnerAuthentication` | Biometrics or passcode fallback |
+
+### BiometryType Detection
+
+```swift
+let context = LAContext()
+var error: NSError?
+context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+
+switch context.biometryType {
+case .faceID: // Face ID available
+case .touchID: // Touch ID available
+case .opticID: // Optic ID available (visionOS)
+case .none: // No biometric hardware
+@unknown default: break
+}
+```
+
+### LAError Codes
+
+| Error | Code | Cause |
+|-------|------|-------|
+| `.authenticationFailed` | -1 | User failed authentication |
+| `.userCancel` | -2 | User tapped Cancel |
+| `.userFallback` | -3 | User tapped "Enter Password" |
+| `.systemCancel` | -4 | System cancelled (app backgrounded) |
+| `.passcodeNotSet` | -5 | No passcode configured |
+| `.biometryNotAvailable` | -6 | Hardware unavailable or restricted |
+| `.biometryNotEnrolled` | -7 | No biometrics enrolled |
+| `.biometryLockout` | -8 | Too many failed attempts |
+
+---
+
+## OSStatus Error Codes
+
+Common keychain `OSStatus` values and their root causes.
+
+| Error | Code | Description | Common Cause |
+|-------|------|-------------|--------------|
+| `errSecSuccess` | 0 | Operation succeeded | — |
+| `errSecDuplicateItem` | -25299 | Item already exists | Adding with same primary key — use `SecItemUpdate` instead |
+| `errSecItemNotFound` | -25300 | No matching item | Wrong query attributes or item never stored |
+| `errSecInteractionNotAllowed` | -25308 | UI prompt blocked | Item requires auth but device locked, or `kSecUseAuthenticationUIFail` set |
+| `errSecAuthFailed` | -25293 | Authentication failed | Wrong password, failed biometric, or ACL denied |
+| `errSecMissingEntitlement` | -34018 | Missing keychain entitlement | App lacks `keychain-access-groups` entitlement — common in unit test targets |
+| `errSecNoSuchAttr` | -25303 | Attribute not found | Querying an attribute not valid for the item class |
+| `errSecParam` | -50 | Invalid parameter | Malformed query dictionary — check for type mismatches (e.g., String where Data expected) |
+| `errSecAllocate` | -108 | Memory allocation failed | System resource exhaustion |
+| `errSecDecode` | -26275 | Unable to decode data | Corrupted item or encoding mismatch |
+| `errSecNotAvailable` | -25291 | Keychain not available | No keychain database (rare — Simulator reset or corrupted install) |
+
+### Interpreting OSStatus in Swift
+
+```swift
+let status = SecItemAdd(query as CFDictionary, nil)
+if status != errSecSuccess {
+ let message = SecCopyErrorMessageString(status, nil) as? String ?? "Unknown error"
+ print("Keychain error \(status): \(message)")
+}
+```
+
+### -34018 on Test Targets
+
+Unit test runners (XCTest) often lack the `keychain-access-groups` entitlement. Workarounds:
+1. Add a Host Application to the test target (Xcode → Test Target → General → Host Application)
+2. Use an in-memory mock for unit tests, real keychain for integration tests only
+
+---
+
+## Keychain Sharing
+
+### Access Groups
+
+Items are isolated per app by default. To share between apps or extensions:
+
+1. Enable "Keychain Sharing" capability in Xcode
+2. Add shared access group identifiers
+3. Set `kSecAttrAccessGroup` when adding items
+
+```swift
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.shared",
+ kSecAttrAccount as String: "shared-token",
+ kSecAttrAccessGroup as String: "TEAMID.com.example.shared",
+ kSecValueData as String: tokenData
+]
+```
+
+The access group format is `$(TeamIdentifierPrefix)$(GroupIdentifier)`. Items without an explicit access group default to the app's first access group in its entitlements.
+
+### iCloud Keychain Sync
+
+Set `kSecAttrSynchronizable` to `true` to sync via iCloud Keychain:
+
+```swift
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.example.app",
+ kSecAttrAccount as String: "sync-token",
+ kSecAttrSynchronizable as String: true,
+ kSecValueData as String: tokenData
+]
+```
+
+Synchronizable items cannot use `ThisDeviceOnly` accessibility levels or `SecAccessControl`. They must use `kSecAttrAccessibleWhenUnlocked` or `kSecAttrAccessibleAfterFirstUnlock`.
+
+When querying, `kSecAttrSynchronizable` defaults to `kSecAttrSynchronizableAny` (returns both local and synced items). Set explicitly to `true` or `false` to filter.
+
+---
+
+## Resources
+
+**WWDC**: 2013-709, 2014-711, 2020-10147
+
+**Docs**: /security/keychain_services, /localauthentication, /security/secaccesscontrolcreateflags, /security/secitemadd(_:_:)
+
+**Skills**: axiom-code-signing-ref, axiom-app-attest
diff --git a/.claude/skills/axiom-keychain-ref/agents/openai.yaml b/.claude/skills/axiom-keychain-ref/agents/openai.yaml
new file mode 100644
index 0000000..708b39c
--- /dev/null
+++ b/.claude/skills/axiom-keychain-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Keychain Reference"
+ short_description: "Needing SecItem function signatures, keychain attribute constants, item class uniqueness constraints, accessibility l..."
diff --git a/.claude/skills/axiom-keychain/.openskills.json b/.claude/skills/axiom-keychain/.openskills.json
new file mode 100644
index 0000000..ddac75e
--- /dev/null
+++ b/.claude/skills/axiom-keychain/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-keychain",
+ "installedAt": "2026-04-12T08:06:24.824Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-keychain/SKILL.md b/.claude/skills/axiom-keychain/SKILL.md
new file mode 100644
index 0000000..deab801
--- /dev/null
+++ b/.claude/skills/axiom-keychain/SKILL.md
@@ -0,0 +1,544 @@
+---
+name: axiom-keychain
+description: Use when storing credentials, tokens, or secrets securely, debugging SecItem errors (errSecDuplicateItem, errSecItemNotFound, errSecInteractionNotAllowed), managing keychain access groups, or choosing accessibility classes. Covers SecItem API mental model, uniqueness constraints, data protection, biometric access control, sharing between apps, and Mac keychain differences.
+license: MIT
+---
+
+# Keychain Services
+
+Secure credential storage, SecItem API mental model, uniqueness constraints, data protection classes, biometric access control, keychain sharing, and Mac keychain differences for iOS/macOS apps.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Store tokens, passwords, API keys, or cryptographic keys securely
+- ☑ Debug errSecDuplicateItem, errSecItemNotFound, or errSecInteractionNotAllowed
+- ☑ Choose the right kSecAttrAccessible level for your use case
+- ☑ Add biometric or passcode protection to a keychain item
+- ☑ Share credentials between apps or app extensions via access groups
+- ☑ Migrate from UserDefaults/@AppStorage to keychain for sensitive data
+- ☑ Understand why SecItemCopyMatching returns something different than expected
+- ☑ Store or retrieve keychain items from a background context
+
+## Example Prompts
+
+"How do I store an auth token in the keychain?"
+"errSecDuplicateItem when saving to keychain but the item doesn't exist"
+"My keychain read fails in background refresh"
+"How do I add Face ID protection to a keychain item?"
+"Share keychain items between my app and widget extension"
+"What's the difference between kSecAttrAccessibleAfterFirstUnlock and WhenUnlocked?"
+"SecItemCopyMatching returns errSecItemNotFound but I just saved it"
+"How do I use the keychain on macOS with Catalyst?"
+"My keychain wrapper is returning nil — how do I debug this?"
+"errSecInteractionNotAllowed in background task"
+
+## Red Flags
+
+Signs you're making this harder than it needs to be:
+
+- ❌ Using a keychain wrapper without understanding SecItem — Wrappers hide the 4-function model and introduce their own bugs. Quinn "The Eskimo!" explicitly warns: most wrapper issues stem from the wrapper, not the keychain. Learn the model first.
+- ❌ Storing tokens in UserDefaults or @AppStorage — These are plist files readable by anyone with device access (or a backup). Credentials belong in the keychain. No exceptions.
+- ❌ Force-unwrapping SecItemCopyMatching results — The return type depends on which kSecReturn* flags you passed. A mismatch gives you a surprising type, not your data.
+- ❌ Not specifying kSecAttrAccessible — Defaults to kSecAttrAccessibleWhenUnlocked, which fails in background execution. If your app does background refresh, push processing, or VoIP, this will bite you.
+- ❌ Catching errSecDuplicateItem by deleting then re-adding — This creates a race condition and destroys metadata (creation date, access control). Use SecItemUpdate instead.
+- ❌ Using kSecMatchLimitAll without understanding delete semantics — SecItemDelete has no kSecMatchLimit; it deletes ALL matching items. One careless query can wipe credentials.
+- ❌ Ignoring errSecDuplicateItem — "It worked last time" means you have a query/uniqueness mismatch. This is the #1 keychain bug.
+
+## The 4-Function Model
+
+Quinn's mental model: the keychain is a database with per-class tables. The four SecItem functions map directly to SQL.
+
+| SecItem Function | SQL Equivalent | Purpose |
+|---|---|---|
+| SecItemAdd | INSERT | Create a new item |
+| SecItemCopyMatching | SELECT | Read one or more items |
+| SecItemUpdate | UPDATE | Modify an existing item |
+| SecItemDelete | DELETE | Remove items |
+
+### Per-Class Tables
+
+Each item class is a separate table with different columns (attributes):
+
+| Class | kSecClass Value | Typical Use |
+|---|---|---|
+| Generic password | kSecClassGenericPassword | App credentials, tokens, secrets |
+| Internet password | kSecClassInternetPassword | URL-associated credentials |
+| Certificate | kSecClassCertificate | X.509 certificates |
+| Key | kSecClassKey | Cryptographic keys |
+| Identity | kSecClassIdentity | Certificate + private key pair |
+
+For most iOS apps, you only need `kSecClassGenericPassword`. Internet passwords are for credentials tied to a specific server/protocol/port. Certificates and keys are for custom cryptographic operations.
+
+## Uniqueness Constraints
+
+This is where most keychain bugs originate. Each class has a **primary key** — a combination of attributes that must be unique.
+
+### Generic Password Primary Key
+
+```
+kSecAttrService + kSecAttrAccount + kSecAttrAccessGroup
+```
+
+### Internet Password Primary Key
+
+```
+kSecAttrServer + kSecAttrAccount + kSecAttrPort
+ + kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrSecurityDomain
+ + kSecAttrAccessGroup
+```
+
+See axiom-keychain-ref for the full attribute breakdown per class.
+
+### The errSecDuplicateItem Trap
+
+You call SecItemCopyMatching with a query and get errSecItemNotFound. You call SecItemAdd with what you think is the same item. You get errSecDuplicateItem. How?
+
+**The query attributes don't match the uniqueness attributes.** Your copy query might search by `kSecAttrService` alone, but uniqueness is `service + account + accessGroup`. An item exists with the same service but a different account — your query misses it, but your add hits the uniqueness constraint on the combination.
+
+```swift
+// This query finds nothing (searching by service + label, but label isn't a primary key)
+let copyQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrLabel as String: "auth-token",
+ kSecReturnData as String: true
+]
+// Result: errSecItemNotFound (no item has this label)
+
+// This add fails — an item with service "com.app.auth" + account "user-token"
+// already exists. The add hits the primary key (service + account + accessGroup).
+let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "user-token",
+ kSecValueData as String: tokenData
+]
+// Result: errSecDuplicateItem
+```
+
+**Fix**: Always specify ALL primary key attributes in every query. For generic passwords, always include `kSecAttrService` AND `kSecAttrAccount`.
+
+### The Safe Add-or-Update Pattern
+
+```swift
+func saveToKeychain(service: String, account: String, data: Data) -> OSStatus {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+
+ let attributes: [String: Any] = [
+ kSecValueData as String: data
+ ]
+
+ // Try update first
+ var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+
+ if status == errSecItemNotFound {
+ // Item doesn't exist — add it
+ var addQuery = query
+ addQuery[kSecValueData as String] = data
+ status = SecItemAdd(addQuery as CFDictionary, nil)
+ }
+
+ return status
+}
+```
+
+This is safer than delete-then-add because it preserves item metadata and avoids race conditions.
+
+## Parameter Block Anatomy
+
+Every SecItem function takes a dictionary. That dictionary contains properties from 5 groups, but which groups are valid depends on which function you're calling.
+
+### The 5 Property Groups
+
+| Group | Prefix/Pattern | Purpose |
+|---|---|---|
+| Class | kSecClass | Which table (generic password, key, etc.) |
+| Attributes | kSecAttr* | Column values (service, account, label) |
+| Search | kSecMatch* | Query modifiers (limit, case sensitivity) |
+| Return type | kSecReturn* | What shape the result takes |
+| Value type | kSecValue* | The actual data/reference |
+
+### Which Groups Apply to Which Function
+
+```dot
+digraph param_blocks {
+ rankdir=LR;
+
+ "SecItemAdd" [shape=box];
+ "SecItemCopyMatching" [shape=box];
+ "SecItemUpdate\n(query dict)" [shape=box];
+ "SecItemUpdate\n(attributes dict)" [shape=box];
+ "SecItemDelete" [shape=box];
+
+ "Class" [shape=ellipse];
+ "Attributes" [shape=ellipse];
+ "Search" [shape=ellipse];
+ "Return type" [shape=ellipse];
+ "Value type" [shape=ellipse];
+
+ "SecItemAdd" -> "Class";
+ "SecItemAdd" -> "Attributes";
+ "SecItemAdd" -> "Return type";
+ "SecItemAdd" -> "Value type";
+
+ "SecItemCopyMatching" -> "Class";
+ "SecItemCopyMatching" -> "Attributes";
+ "SecItemCopyMatching" -> "Search";
+ "SecItemCopyMatching" -> "Return type";
+
+ "SecItemUpdate\n(query dict)" -> "Class";
+ "SecItemUpdate\n(query dict)" -> "Attributes";
+ "SecItemUpdate\n(query dict)" -> "Search";
+
+ "SecItemUpdate\n(attributes dict)" -> "Attributes";
+ "SecItemUpdate\n(attributes dict)" -> "Value type";
+
+ "SecItemDelete" -> "Class";
+ "SecItemDelete" -> "Attributes";
+ "SecItemDelete" -> "Search";
+}
+```
+
+### kSecMatchLimit Defaults
+
+This is a documented pitfall from Quinn's thread. The default value of `kSecMatchLimit` depends on context:
+
+| Context | Default kSecMatchLimit | Behavior |
+|---|---|---|
+| SecItemCopyMatching with kSecReturnData | kSecMatchLimitOne | Returns one item |
+| SecItemCopyMatching with kSecReturnAttributes | kSecMatchLimitOne | Returns one item |
+| SecItemCopyMatching with kSecReturnRef | kSecMatchLimitOne | Returns one item |
+| SecItemDelete | No limit concept | Deletes ALL matches |
+
+SecItemDelete has no `kSecMatchLimit` parameter. It deletes every item matching your query. If your query is broad (only `kSecClass` and `kSecAttrService`), you will delete every item for that service across all accounts.
+
+### Return Type Determines Output Type
+
+| kSecReturn* Flags | Output Type |
+|---|---|
+| kSecReturnData only | CFData (the raw secret) |
+| kSecReturnAttributes only | CFDictionary (item metadata) |
+| kSecReturnRef only | SecKey / SecCertificate / SecIdentity |
+| kSecReturnData + kSecReturnAttributes | CFDictionary (metadata + kSecValueData key) |
+| kSecReturnPersistentRef | CFData (persistent reference, survives keychain resets) |
+
+Force-casting the result to the wrong type is a common crash source. Always match your cast to your return flags.
+
+## Accessibility and Data Protection
+
+`kSecAttrAccessible` controls when the keychain item's decryption key is available. This maps directly to the device's data protection classes.
+
+| Level | Available When | Use Case |
+|---|---|---|
+| kSecAttrAccessibleWhenUnlocked | Device unlocked | Default. UI-driven credentials |
+| kSecAttrAccessibleWhenUnlockedThisDeviceOnly | Device unlocked, no backup | High-security tokens |
+| kSecAttrAccessibleAfterFirstUnlock | After first unlock until reboot | Background refresh, push processing |
+| kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | After first unlock, no backup | Background + high security |
+| kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | Passcode set + unlocked | Biometric-gated items |
+
+### The Background Execution Trap
+
+This is the single most common keychain failure in production. Your app works perfectly during normal use but fails with `errSecInteractionNotAllowed` (-25308) during background refresh.
+
+**The dangerous pattern**:
+
+```swift
+// Background task tries to read a token
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "refresh-token",
+ kSecReturnData as String: true
+ // Item was stored with default WhenUnlocked accessibility (set at add time)
+]
+var result: AnyObject?
+let status = SecItemCopyMatching(query as CFDictionary, &result)
+// status == errSecInteractionNotAllowed when device is locked
+
+// Developer's instinct: "the token is corrupted, delete and re-auth"
+if status != errSecSuccess {
+ SecItemDelete(query as CFDictionary) // DELETES THE CREDENTIAL
+}
+```
+
+From Quinn: "This is a lesson that, once learnt, is never forgotten!"
+
+The item is fine. The device is locked, so the decryption key isn't available. The developer's error handler destroys a perfectly valid credential because it misinterprets the error.
+
+**The fix**: Store tokens needed in background with `kSecAttrAccessibleAfterFirstUnlock`:
+
+```swift
+let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "refresh-token",
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
+ kSecValueData as String: tokenData
+]
+```
+
+And never delete on `errSecInteractionNotAllowed` — it means "try again when the device is unlocked", not "this item is broken".
+
+## Access Control and Biometrics
+
+`SecAccessControl` gates individual keychain items behind biometric authentication or device passcode.
+
+### Creating a Biometric-Gated Item
+
+```swift
+var error: Unmanaged?
+guard let accessControl = SecAccessControlCreateWithFlags(
+ nil,
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
+ .biometryCurrentSet,
+ &error
+) else {
+ // Handle error — usually means device has no passcode
+ return
+}
+
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "biometric-secret",
+ kSecAttrAccessControl as String: accessControl,
+ kSecValueData as String: secretData
+]
+
+let status = SecItemAdd(query as CFDictionary, nil)
+```
+
+### Access Control Flag Selection
+
+```dot
+digraph acl_decision {
+ "What gates access?" [shape=diamond];
+ "Enrollment change\nmatters?" [shape=diamond];
+ "Biometric or\npasscode fallback?" [shape=diamond];
+
+ ".biometryCurrentSet" [shape=box, label=".biometryCurrentSet\nInvalidates if fingerprints/face change"];
+ ".biometryAny" [shape=box, label=".biometryAny\nSurvives enrollment changes"];
+ ".userPresence" [shape=box, label=".userPresence\nBiometry with passcode fallback"];
+ ".devicePasscode" [shape=box, label=".devicePasscode\nPasscode only, no biometry"];
+
+ "What gates access?" -> "Enrollment change\nmatters?" [label="biometry"];
+ "What gates access?" -> ".devicePasscode" [label="passcode only"];
+ "What gates access?" -> "Biometric or\npasscode fallback?" [label="either"];
+ "Enrollment change\nmatters?" -> ".biometryCurrentSet" [label="yes, re-auth\non change"];
+ "Enrollment change\nmatters?" -> ".biometryAny" [label="no, any enrolled\nbiometry"];
+ "Biometric or\npasscode fallback?" -> ".userPresence" [label="user convenience"];
+}
+```
+
+| Flag | Behavior | When to Use |
+|---|---|---|
+| .biometryCurrentSet | Invalidated if biometry enrollment changes | Banking, medical — re-auth on new fingerprint/face |
+| .biometryAny | Survives enrollment changes | Convenience auth, app lock |
+| .userPresence | Biometry with passcode fallback | Most common choice — works even if biometry fails |
+| .devicePasscode | Passcode only, no biometry prompt | Accessibility-first or biometry-averse users |
+
+### LAContext for Reuse Duration
+
+By default, each keychain read prompts for biometric auth. To allow reuse within a time window:
+
+```swift
+let context = LAContext()
+context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds
+
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "biometric-secret",
+ kSecUseAuthenticationContext as String: context,
+ kSecReturnData as String: true
+]
+```
+
+After one successful biometric auth, subsequent reads within 10 seconds skip the prompt. Maximum reuse duration is `LATouchIDAuthenticationMaximumAllowableReuseDuration` (5 minutes on current hardware).
+
+## Sharing and Access Groups
+
+### Keychain Access Groups vs App Groups
+
+| Feature | Keychain Access Groups | App Groups |
+|---|---|---|
+| Entitlement | keychain-access-groups | com.apple.security.application-groups |
+| Prefix | Team ID (auto-added) | No prefix (you control full string) |
+| Scope | Keychain items only | Files, UserDefaults, AND keychain |
+| Setup | Signing & Capabilities → Keychain Sharing | Signing & Capabilities → App Groups |
+
+### How Access Groups Work
+
+Every keychain item belongs to exactly one access group. If you don't specify `kSecAttrAccessGroup`, the item goes into your app's default access group (`TEAMID.your.bundle.id`).
+
+To share between apps or extensions:
+
+1. Add the same keychain access group to both targets (Signing & Capabilities → Keychain Sharing)
+2. Specify the group when writing:
+
+```swift
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.shared-auth",
+ kSecAttrAccount as String: "user-token",
+ kSecAttrAccessGroup as String: "TEAMID.com.app.shared",
+ kSecValueData as String: tokenData
+]
+```
+
+### The Team ID Prefix Trap
+
+Keychain access groups get the **Team ID** automatically prepended by the system. You write `com.app.shared` in the entitlement, the system stores it as `ABCD1234.com.app.shared`.
+
+But `kSecAttrAccessGroup` in your code must include the prefix: `"ABCD1234.com.app.shared"`.
+
+App Groups do NOT get this prefix. If you're using an App Group for keychain sharing (via `kSecAttrAccessGroup` with a `group.` prefix), use the full string as-is.
+
+### The App ID Prefix Change Trap
+
+From Quinn's pitfalls: if an app changes its App ID prefix (Team ID), it loses access to all existing keychain items. This happens when:
+- Transferring an app between developer accounts
+- Certain legacy App ID configurations
+
+There is no recovery. The items are orphaned. Plan for this in migration logic — detect the condition and prompt the user to re-authenticate.
+
+## Mac Keychain Differences
+
+macOS has two keychain systems. Understanding the difference prevents "it works on iOS but fails on Mac" bugs.
+
+### File-Based Keychain (Legacy)
+
+The traditional macOS keychain (`~/Library/Keychains/login.keychain-db`):
+- User-visible in Keychain Access.app
+- Supports ACLs (access control lists) for per-app access
+- Items can be shared across all apps by default
+- No data protection classes
+
+### Data Protection Keychain (Modern)
+
+Aligned with iOS keychain behavior (from TN3137):
+- Not visible in Keychain Access.app (prior to macOS 15)
+- Uses data protection classes (kSecAttrAccessible)
+- Items scoped to access groups (same as iOS)
+- Required for Catalyst and SwiftUI multiplatform apps
+
+### Always Use Data Protection Keychain
+
+```swift
+let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: "com.app.auth",
+ kSecAttrAccount as String: "token",
+ kSecUseDataProtectionKeychain as String: true, // Critical for Mac
+ kSecValueData as String: tokenData
+]
+```
+
+Set `kSecUseDataProtectionKeychain: true` for ALL keychain operations on macOS. This gives you consistent behavior across iOS, macOS, Catalyst, and Mac Catalyst.
+
+Without this flag on macOS, items go into the file-based keychain where:
+- Access control behaves differently (ACLs instead of data protection)
+- Sharing semantics change (any app can read by default)
+- Items appear in Keychain Access.app (user confusion)
+- Migration between file-based and data-protection keychains is one-way and manual
+
+## Anti-Rationalization
+
+| Rationalization | Why It Fails | Time Cost |
+|---|---|---|
+| "UserDefaults is fine for tokens, it's a private app" | Backup extraction, device access tools, and MDM profiles all read UserDefaults. The keychain is encrypted at the hardware level. | 4-8 hours for a security incident response |
+| "I'll wrap it in a KeychainHelper class to simplify things" | Wrappers abstract the 4-function model, hiding uniqueness and parameter block semantics. When they break, you debug the wrapper AND the keychain. | 2-4 hours debugging a wrapper bug vs 30 min learning SecItem |
+| "Delete then re-add is simpler than update" | Destroys creation date, access control settings, and persistent references. Creates a TOCTOU race if another process reads between delete and add. | 1-2 hours debugging intermittent failures |
+| "WhenUnlocked is secure enough" | Correct for UI-driven reads. Fatal for background refresh, push processing, silent notifications. errSecInteractionNotAllowed at 3 AM with no user to unlock. | 2-6 hours diagnosing a background failure that only reproduces on locked devices |
+| "I tested it on Simulator, it works" | Simulator has no Secure Enclave, no data protection enforcement, and different keychain behavior. Biometric items always succeed. Accessibility constraints are not enforced. | 4-8 hours debugging device-only failures |
+| "errSecInteractionNotAllowed means the token is corrupted" | It means the device is locked. The token is fine. Deleting it destroys a valid credential and forces re-authentication. | 15-30 min user complaint cycle per incident |
+| "Access groups are too complicated, I'll use App Groups for everything" | App Groups add keychain sharing as a side effect, but the semantics differ from keychain access groups. Team ID prefix behavior is different. Mixing both creates invisible access scope bugs. | 2-4 hours debugging cross-target access failures |
+
+## Pressure Scenarios
+
+### Scenario 1: "Just store it in UserDefaults for now"
+
+**Context**: Sprint deadline, new feature requires storing an API token. Keychain code looks complicated.
+
+**Pressure**: "UserDefaults is faster to implement. We'll move it to keychain in the security sprint."
+
+**Reality**: Moving from UserDefaults to keychain later requires migration code (read from UserDefaults, write to keychain, delete from UserDefaults, handle partial migration). That migration itself becomes a security surface — the token exists in both locations during the transition. The "security sprint" never happens because there's always a higher-priority feature. Meanwhile, the token is in plaintext in the app's plist, included in every iCloud backup.
+
+**Correct action**: Use the safe add-or-update pattern from this skill. It's 15 lines of code. The SecItem call itself is not meaningfully harder than UserDefaults — the perceived complexity comes from not understanding the model.
+
+**Push-back template**: "The keychain call is 15 lines, same as UserDefaults. Doing it in UserDefaults now means writing migration code later — which is actually harder. Let me write it correctly the first time."
+
+### Scenario 2: "Delete the keychain item when we get an error"
+
+**Context**: Background refresh fails intermittently with errSecInteractionNotAllowed. The quick fix: delete the "corrupted" token and force re-login.
+
+**Pressure**: "Users are complaining about stale data. Just clear the token and re-auth on next launch."
+
+**Reality**: The token is not corrupted. The device is locked and `kSecAttrAccessibleWhenUnlocked` prevents access. Deleting it forces every user to re-authenticate after any background failure — which is every night when their phone locks. The actual fix is changing the accessibility level to `kSecAttrAccessibleAfterFirstUnlock`, which takes one line.
+
+**Correct action**: Change the item's accessibility to `kSecAttrAccessibleAfterFirstUnlock`. For existing items already stored with the wrong level, add a one-time migration on app launch (read, delete, re-add with correct accessibility).
+
+**Push-back template**: "The token isn't corrupted — the device is locked. Deleting it forces every user to re-login every morning. The fix is one line: change the accessibility class to AfterFirstUnlock. I can also add a migration for existing users."
+
+### Scenario 3: "Use this keychain wrapper library, everyone uses it"
+
+**Context**: Team is integrating a popular open-source keychain wrapper to "simplify" keychain access.
+
+**Pressure**: "It has 5,000 GitHub stars and handles all the edge cases."
+
+**Reality**: Quinn documents this pattern explicitly: most keychain issues reported on Apple Developer Forums trace back to wrapper libraries, not the keychain itself. Wrappers often hardcode kSecMatchLimit, assume return types, conflate accessibility with access control, or use delete-then-add instead of update. When the wrapper breaks, you debug both the wrapper's abstraction AND the underlying SecItem call. You also inherit the wrapper's opinion about access groups, accessibility, and error handling — which may not match your requirements.
+
+**Correct action**: Write a thin extension or function specific to your app's needs. The safe add-or-update pattern, a read function, and a delete function cover 95% of use cases in under 50 lines.
+
+**Push-back template**: "Most keychain bugs on Apple's forums come from wrapper libraries, not the keychain itself. Our needs are 3 functions — save, read, delete. That's 50 lines of code we fully understand, versus a dependency that makes its own decisions about access groups and error handling."
+
+## Checklist
+
+Before shipping keychain code:
+
+**Model**:
+- [ ] Using SecItem functions directly (not a wrapper you don't understand)
+- [ ] All queries include ALL primary key attributes (service + account for generic passwords)
+- [ ] Using update-then-add pattern, not delete-then-add
+- [ ] kSecMatchLimit explicitly set where needed (never relying on defaults)
+
+**Data Protection**:
+- [ ] kSecAttrAccessible set explicitly on every add operation
+- [ ] Background-accessed items use AfterFirstUnlock (not WhenUnlocked)
+- [ ] errSecInteractionNotAllowed handled without deleting the item
+- [ ] Tested on a locked physical device (not just Simulator)
+
+**Access Control**:
+- [ ] Biometric flag matches security requirement (currentSet vs any)
+- [ ] LAContext reuse duration is appropriate (not excessively long)
+- [ ] Graceful fallback if biometry is unavailable or fails
+
+**Sharing**:
+- [ ] Keychain access group entitlement matches code (including Team ID prefix)
+- [ ] All sharing targets have the same access group in their entitlements
+- [ ] Access group specified explicitly in queries (not relying on default)
+
+**Mac Compatibility** (if multiplatform):
+- [ ] kSecUseDataProtectionKeychain set to true on all macOS operations
+- [ ] Tested on macOS (not just iOS)
+
+**Error Handling**:
+- [ ] Every SecItem call checks the OSStatus return value
+- [ ] errSecDuplicateItem handled (not swallowed or logged-and-ignored)
+- [ ] errSecItemNotFound handled as a normal case (not an error in read flows)
+- [ ] Error context includes service, account, and operation attempted
+
+## Resources
+
+**WWDC**: 2019-709
+
+**Docs**: /security/keychain_services, /security/certificate_key_and_trust_services, /technotes/tn3137-on-mac-keychains
+
+**Apple Forums**: thread/724023 (SecItem Fundamentals), thread/724013 (SecItem Pitfalls)
+
+**Skills**: axiom-keychain-diag, axiom-keychain-ref, axiom-cryptokit, axiom-code-signing, axiom-app-attest
diff --git a/.claude/skills/axiom-keychain/agents/openai.yaml b/.claude/skills/axiom-keychain/agents/openai.yaml
new file mode 100644
index 0000000..dde261f
--- /dev/null
+++ b/.claude/skills/axiom-keychain/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Keychain"
+ short_description: "Storing credentials, tokens, or secrets securely, debugging SecItem errors (errSecDuplicateItem, errSecItemNotFound, ..."
diff --git a/.claude/skills/axiom-liquid-glass-ref/.openskills.json b/.claude/skills/axiom-liquid-glass-ref/.openskills.json
new file mode 100644
index 0000000..e834d4e
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-liquid-glass-ref",
+ "installedAt": "2026-04-12T08:06:26.822Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-liquid-glass-ref/SKILL.md b/.claude/skills/axiom-liquid-glass-ref/SKILL.md
new file mode 100644
index 0000000..2229d35
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass-ref/SKILL.md
@@ -0,0 +1,649 @@
+---
+name: axiom-liquid-glass-ref
+description: Use when planning comprehensive Liquid Glass adoption across an app, auditing existing interfaces for Liquid Glass compatibility, implementing app icon updates, or understanding platform-specific Liquid Glass behavior - comprehensive reference guide covering all aspects of Liquid Glass adoption from WWDC 2025
+license: MIT
+compatibility: iOS/iPadOS 26+, macOS Tahoe+, tvOS, watchOS, visionOS 3+
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-01"
+---
+
+# Liquid Glass Adoption — Reference Guide
+
+## When to Use This Skill
+
+Use when:
+- Planning comprehensive Liquid Glass adoption across your entire app
+- Auditing existing interfaces for Liquid Glass compatibility
+- Implementing app icon updates with Icon Composer
+- Understanding platform-specific Liquid Glass behavior (iOS, iPadOS, macOS, tvOS, watchOS)
+- Migrating from previous materials (blur effects, custom translucency)
+- Ensuring accessibility compliance with Liquid Glass interfaces
+- Reviewing search, navigation, or organizational component updates
+
+#### Related Skills
+- Use `axiom-liquid-glass` for implementing the Liquid Glass material itself and design review pressure scenarios
+- Use `axiom-swiftui-performance` for profiling Liquid Glass rendering performance
+- Use `axiom-accessibility-diag` for accessibility testing
+
+---
+
+## Overview
+
+Adopting Liquid Glass doesn't mean reinventing your app from the ground up. Start by building your app in the latest version of Xcode to see the changes. If your app uses standard components from SwiftUI, UIKit, or AppKit, your interface picks up the latest look and feel automatically on the latest platform releases.
+
+#### Key Adoption Strategy
+1. Build with latest Xcode SDKs
+2. Run on latest platform releases
+3. Review changes using this reference
+4. Adopt best practices incrementally
+
+---
+
+## Visual Refresh
+
+### What Changes Automatically
+
+#### Standard Components Get Liquid Glass
+- Navigation bars, tab bars, toolbars
+- Sheets, popovers, action sheets
+- Buttons, sliders, toggles, and controls
+- Sidebars, split views, menus
+
+#### How It Works
+- Liquid Glass combines optical properties of glass with fluidity
+- Forms distinct functional layer for controls and navigation
+- Adapts in response to overlap, focus state, and environment
+- Helps bring focus to underlying content
+
+### Leverage System Frameworks
+
+#### ✅ DO: Use Standard Components
+
+Standard components from SwiftUI, UIKit, and AppKit automatically adopt Liquid Glass with minimal code changes.
+
+```swift
+// ✅ Standard components get Liquid Glass automatically
+NavigationView {
+ List(items) { item in
+ Text(item.name)
+ }
+ .toolbar {
+ ToolbarItem {
+ Button("Add") { }
+ }
+ }
+}
+// Recompile with Xcode 26 → Liquid Glass applied
+```
+
+#### ❌ DON'T: Override with Custom Backgrounds
+
+```swift
+// ❌ Custom backgrounds interfere with Liquid Glass
+NavigationView { }
+ .background(Color.blue.opacity(0.5)) // Breaks Liquid Glass effects
+ .toolbar {
+ ToolbarItem { }
+ .background(LinearGradient(...)) // Overlays system effects
+ }
+```
+
+#### What to Audit
+- Split views
+- Tab bars
+- Toolbars
+- Navigation bars
+- Any component with custom background/appearance
+
+**Solution** Remove custom effects and let the system determine background appearance.
+
+### Test with Accessibility Settings
+
+Liquid Glass adapts to: Reduce Transparency (frostier), Increase Contrast (black/white borders), Reduce Motion (no elastic animations). Verify legibility maintained under each setting and that custom elements provide fallback experiences. For detailed accessibility testing workflows, see `axiom-liquid-glass` discipline skill.
+
+```swift
+app.launchArguments += ["-UIAccessibilityIsReduceTransparencyEnabled", "1",
+ "-UIAccessibilityButtonShapesEnabled", "1", "-UIAccessibilityIsReduceMotionEnabled", "1"]
+```
+
+### Avoid Overusing Liquid Glass
+
+Liquid Glass brings attention to underlying content. Overusing it on multiple custom controls distracts from content. Apply `.glassEffect()` only to important functional elements (navigation, primary actions) — not content cards, list rows, or decorative elements.
+
+```swift
+// ✅ Content layer: no glass. Navigation layer: glass on functional buttons only.
+ZStack {
+ ScrollView { ForEach(articles) { ArticleCard($0) } }
+ VStack {
+ Spacer()
+ HStack {
+ Button("Filter") { }.glassEffect()
+ Spacer()
+ Button("Sort") { }.glassEffect()
+ }.padding()
+ }
+}
+```
+
+---
+
+## App Icons
+
+App icons now take on a design that's dynamic and expressive. Updates to the icon grid result in standardized iconography that's visually consistent across devices. App icons contain layers that dynamically respond to lighting and visual effects.
+
+### Platform Support
+
+Layered icons: iOS/iPadOS 26+, macOS Tahoe+, watchOS (circular mask). Appearance variants: default (light), dark, clear, tinted (Home Screen personalization).
+
+### Design Principles
+
+Design clean, simplified layers with solid fills and semi-transparent overlays. Let the system handle effects (reflection, refraction, shadow, blur, masking). Do NOT bake in pre-applied blur, manual shadows, hardcoded highlights, or fixed masking.
+
+### Design Using Layers
+
+Three layers: foreground (primary elements), middle (supporting), background (foundation). Export each layer as PNG or SVG at @1x/@2x/@3x with transparency preserved.
+
+### Icon Composer
+
+Included in Xcode 26+ (also standalone from developer.apple.com/design/resources). Drag and drop layers, add optional background, adjust attributes (opacity, position, scale), preview with system effects and all appearance variants, export directly to asset catalog.
+
+### Preview Against Updated Grids
+
+Grids: iOS/iPadOS/macOS use rounded rectangle mask; watchOS uses circular mask. Download from developer.apple.com/design/resources. Keep elements centered to avoid clipping, test at all sizes, verify all appearance variants look intentional.
+
+---
+
+## Controls
+
+Controls have refreshed look across platforms and come to life during interaction. Knobs transform into Liquid Glass during interaction, buttons fluidly morph into menus/popovers. Hardware shape informs curvature of controls (rounder forms nestle into corners).
+
+### Updated Appearance
+
+Bordered buttons default to capsule shape (mini/small/medium on macOS retain rounded-rectangle). Knobs transform into glass during interaction; buttons morph into menus/popovers. New `controlSize(.extraLarge)` option; heights slightly taller on macOS. Use `controlSize(.small)` for backward-compatible high-density layouts. Standard controls adopt automatically — remove hard-coded `.frame()` dimensions.
+
+### Review Updated Controls
+
+Audit sliders, toggles, buttons, steppers, pickers, segmented controls, and progress indicators. Verify appearance matches interface, spacing looks natural, controls aren't cropped, and interaction feedback is responsive.
+
+### Color in Controls
+
+Use system colors (`.tint(.blue)`, `.accentColor`) — they adapt to light/dark contexts automatically. Avoid hard-coded RGB values (`Color(red:green:blue:)`) which may not adapt. Test in both modes and verify WCAG AA contrast ratios.
+
+### Check for Crowding or Overlapping
+
+Liquid Glass elements need breathing room. Use default `HStack` spacing (not `spacing: 4`) for glass buttons. Overcrowding or layering glass-on-glass creates visual noise. Use `GlassEffectContainer` when multiple glass elements must be close together.
+
+### Optimize for Legibility with Scroll Edge Effects
+
+Use `.scrollEdgeEffectStyle(.hard, for: .top)` to obscure content scrolling beneath controls. System bars (toolbars, navigation bars, tab bars) adopt this automatically; custom bars need it explicitly.
+
+### Align Control Shapes with Containers
+
+Use `containerRelativeShape()` to align control curvature with containers — creates concentric visual continuity from controls to sheets to windows to display.
+
+### New Button Styles
+
+Use built-in styles instead of custom glass effects: `.borderedProminent` (primary, with `.tint()`), `.bordered` (secondary), `.plain` + `.glassEffect()` (tertiary/custom). Each adapts to Liquid Glass automatically.
+
+---
+
+## Navigation
+
+Liquid Glass applies to topmost layer where you define navigation. Key navigation elements like tab bars and sidebars float in this Liquid Glass layer to help people focus on underlying content.
+
+### Clear Navigation Hierarchy
+
+Maintain two distinct layers: **Navigation** (tab bar, sidebar, toolbar — Liquid Glass) floats above **Content** (articles, photos, data — no glass). Do NOT apply `.glassEffect()` to content items like list rows — glass on the content layer blurs the boundary and competes with navigation.
+
+### Tab Bar Adapting to Sidebar
+
+Use `.tabViewStyle(.sidebarAdaptable)` (iOS 26) to let the tab bar adapt to sidebar on iPad/macOS while remaining a tab bar on iPhone. Transitions fluidly with adaptive window sizes.
+
+```swift
+TabView {
+ ContentView().tabItem { Label("Home", systemImage: "house") }
+ SearchView().tabItem { Label("Search", systemImage: "magnifyingglass") }
+}
+.tabViewStyle(.sidebarAdaptable)
+```
+
+### Split Views for Sidebar + Inspector Layouts
+
+Use `NavigationSplitView` with sidebar, content, and detail columns. Liquid Glass applies automatically to sidebars and inspectors. iOS adapts column visibility; iPadOS/macOS shows all columns on large screens.
+
+```swift
+NavigationSplitView {
+ List(folders, selection: $selectedFolder) { Label($0.name, systemImage: $0.icon) }
+ .navigationTitle("Folders")
+} content: {
+ List(items, selection: $selectedItem) { ItemRow($0) }
+} detail: {
+ InspectorView(item: selectedItem)
+}
+```
+
+### Check Content Safe Areas
+
+Verify content peeks through appropriately beneath sidebars/inspectors. Use `.safeAreaInset(edge:)` when content needs to account for sidebar/inspector space.
+
+#### Padding with Edge-to-Edge Glass
+
+When glass extends edge-to-edge via `.ignoresSafeArea()`, use `.safeAreaPadding()` (not `.padding()`) on the content layer to respect device safe areas (notch, Dynamic Island, home indicator):
+
+```swift
+// ❌ .padding(.horizontal, 20) — doesn't account for safe areas
+// ✅ .safeAreaPadding(.horizontal, 20) — 20pt beyond safe areas
+```
+
+Applies to: full-screen sheets with materials, edge-to-edge toolbars, floating panels, custom glass navigation bars. Requires iOS 17+. See `axiom-swiftui-layout-ref` for full `.safeAreaPadding()` vs `.padding()` guidance.
+
+Verify: content visible beneath sidebar/inspector, not cropped, peek-through looks intentional, properly inset from notch/Dynamic Island/home indicator.
+
+### Background Extension Effect
+
+Mirrors and blurs content under sidebar/inspector for an immersive edge-to-edge feel, without actually scrolling content there. Best for hero images, photo galleries, and media-rich split views.
+
+```swift
+NavigationSplitView {
+ SidebarView()
+} detail: {
+ DetailView()
+ .backgroundExtensionEffect()
+}
+```
+
+### Automatically Minimize Tab Bar (iOS)
+
+Tab bars can recede when scrolling via `.tabBarMinimizeBehavior()` (iOS 26). Options: `.onScrollDown` (recommended for reading/media apps), `.onScrollUp`, `.automatic`, `.never`. Tab bar expands when scrolling in opposite direction.
+
+---
+
+## Menus and Toolbars
+
+Menus have refreshed look across platforms. They adopt Liquid Glass, and menu items for common actions use icons to help people quickly scan and identify actions. iPadOS now has menu bar for faster access to common commands.
+
+### Cross-Platform Menu Consistency
+
+Menus now have consistent layout across iOS and macOS — icons on leading edge, same API (`Label` or standard control initializers) produces the same visual result on both platforms.
+
+### Menu Icons for Standard Actions
+
+#### Automatic Icon Adoption
+
+```swift
+// ✅ Standard selectors get icons automatically
+Menu("Actions") {
+ Button(action: cut) {
+ Text("Cut")
+ }
+ Button(action: copy) {
+ Text("Copy")
+ }
+ Button(action: paste) {
+ Text("Paste")
+ }
+}
+// System uses selector to determine icon
+// cut() → scissors icon
+// copy() → documents icon
+// paste() → clipboard icon
+```
+
+#### Standard Selectors
+- `cut()` → ✂️ scissors
+- `copy()` → 📄 documents
+- `paste()` → 📋 clipboard
+- `delete()` → 🗑️ trash
+- `share()` → ↗️ share arrow
+- Many more...
+
+#### Custom Actions
+```swift
+// ✅ Provide icon for custom actions
+Button {
+ customAction()
+} label: {
+ Label("Custom Action", systemImage: "star.fill")
+}
+```
+
+### Match Top Menu Actions to Swipe Actions
+
+#### For consistency and predictability
+
+```swift
+// ✅ Swipe actions match contextual menu
+List(emails) { email in
+ EmailRow(email)
+ .swipeActions(edge: .leading) {
+ Button("Archive", systemImage: "archivebox") {
+ archive(email)
+ }
+ }
+ .swipeActions(edge: .trailing) {
+ Button("Delete", systemImage: "trash", role: .destructive) {
+ delete(email)
+ }
+ }
+ .contextMenu {
+ // ✅ Same actions appear at top
+ Button("Archive", systemImage: "archivebox") {
+ archive(email)
+ }
+ Button("Delete", systemImage: "trash", role: .destructive) {
+ delete(email)
+ }
+
+ Divider()
+
+ // Additional actions below
+ Button("Mark Unread") { }
+ }
+}
+```
+
+**Why** Users expect swipe actions and menu actions to match. Consistency builds trust and predictability.
+
+### Toolbar Grouping, Spacers, and Morphing
+
+See `axiom-swiftui-26-ref` for complete toolbar API coverage: `ToolbarSpacer`, `ToolbarItemGroup` visual grouping, `.sharedBackgroundVisibility(.hidden)`, toolbar morphing, `DefaultToolbarItem`, user-customizable toolbars, monochrome icon rendering, backward-compatible toolbar labels, and floating glass buttons.
+
+**Liquid Glass-specific toolbar guidance:**
+- Pick one style (icons OR text) per toolbar background group — mixing creates inconsistent visual weight under glass
+- Use `.tint()` only to convey meaning (call to action, next step), not for decoration — monochrome reduces visual noise under Liquid Glass
+
+### Provide Accessibility Labels for Icons
+
+All icon-only buttons need `.accessibilityLabel("Action Name")` for VoiceOver and Voice Control users. Use `Label("Share", systemImage: "square.and.arrow.up")` to get automatic accessibility support.
+
+### Audit Toolbar Customizations
+
+Verify custom spacers, items, and visibility work with Liquid Glass backgrounds. Common issue: conditionally hiding content inside `ToolbarItem` creates empty pills — move the `if` outside to hide the entire `ToolbarItem` instead.
+
+---
+
+## Windows and Modals
+
+Windows adopt rounder corners to fit controls and navigation elements. iPadOS apps show window controls and support continuous window resizing. Sheets and action sheets adopt Liquid Glass with increased corner radius.
+
+### Arbitrary Window Sizes (iPadOS)
+
+iPadOS 26 windows resize continuously (no preset size transitions). Use `.windowResizability(.contentSize)` and flexible layouts. Remove hard-coded size assumptions and test at various window sizes.
+
+### Split Views for Fluid Column Resizing
+
+Use `NavigationSplitView(columnVisibility:)` for automatic content reflow during continuous window resizing — avoids manual layout calculations and custom animation code.
+
+### Use Layout Guides and Safe Areas
+
+Use `.safeAreaInset(edge:)` so content automatically adjusts around window controls, title bars, and chrome.
+
+### Sheets: Increased Corner Radius
+
+Sheets have increased corner radius; half sheets are inset from edge (content peeks through) and become more opaque when transitioning to full height. Check that content isn't cropped by rounder corners and that background peek-through looks intentional.
+
+### Remove presentationBackground
+
+Remove `.presentationBackground()` from sheets — the system applies Liquid Glass sheet material automatically. Custom backgrounds interfere with the new material.
+
+### Audit Sheet/Popover Backgrounds
+
+Remove custom `VisualEffectView`/`UIBlurEffect` backgrounds from popovers and sheets. The system applies Liquid Glass automatically — no background modifier needed.
+
+### Action Sheets: Inline Appearance
+
+Action sheets now originate from the source element (not bottom edge) and allow interaction with other parts of the interface. Use `.confirmationDialog()` attached to the triggering button — the system positions the sheet automatically.
+
+---
+
+## Organization and Layout
+
+Lists, tables, and forms have larger row height and padding to give content room to breathe. Sections have increased corner radius to match curvature of controls.
+
+### Larger Row Height and Padding
+
+Lists, tables, forms, and sections all have increased height, padding, spacing, and corner radius. Standard components adopt automatically. Remove hard-coded `.frame(height:)` and `.padding(.vertical:)` — let the system determine row height and padding.
+
+### Section Header Capitalization
+
+iOS 26 no longer uppercases section headers — they render exactly as provided. Update to title-style capitalization: `Section(header: Text("User Settings"))` not `"user settings"`.
+
+### Adopt Forms for Platform-Optimized Layouts
+
+Use `.formStyle(.grouped)` for automatic row height, padding, spacing, and section corner radius that matches controls across platforms.
+
+---
+
+## Search
+
+Platform conventions for search location and behavior optimize experience for each device. Review search field design conventions to provide engaging search experience.
+
+### Keyboard Layout When Activating Search
+
+#### What Changed (iOS)
+
+When a person taps search field to give it focus, it slides upwards as keyboard appears.
+
+#### Testing
+- Tap search field
+- Verify smooth upward slide
+- Keyboard appears without covering search field
+- Consistent with system search experiences (Spotlight, Safari)
+
+#### No Code Changes Required
+```swift
+// ✅ Existing searchable modifier adopts new behavior
+List(items) { item in
+ Text(item.name)
+}
+.searchable(text: $searchText)
+```
+
+### Semantic Search Tabs
+
+For Tab API patterns including `.tabRole(.search)`, see swiftui-nav-ref skill Section 5 (Tab Navigation Integration).
+
+---
+
+## Platform Considerations
+
+Liquid Glass can have distinct appearance and behavior across platforms, contexts, and input methods. Test across devices to understand material appearance.
+
+### watchOS and tvOS
+
+| Platform | Adoption | Key Requirement |
+|----------|----------|-----------------|
+| watchOS | Automatic on latest release, even without latest SDK | Use standard toolbar APIs and `.buttonStyle(.bordered)` from watchOS 10 |
+| tvOS | Focus-based — glass appears when controls gain focus (Apple TV 4K 2nd gen+) | Use `.focusable()` on standard controls; for custom controls, apply `.glassEffect()` with `@FocusState`-driven opacity |
+
+### glassBackgroundEffect()
+
+For custom views that need to reflect content behind them (not just apply glass material on top), use `.glassBackgroundEffect()`. This creates a glass-like background that shows through underlying content, distinct from `.glassEffect()` which applies glass as an overlay material.
+
+```swift
+// Custom floating panel with glass background reflecting content behind it
+struct FloatingPanel: View {
+ var body: some View {
+ VStack {
+ Text("Panel Content")
+ // ...
+ }
+ .padding()
+ .glassBackgroundEffect() // Reflects content beneath, not on top
+ }
+}
+```
+
+**`.glassEffect()` vs `.glassBackgroundEffect()`**: Use `.glassEffect()` for controls and navigation elements (buttons, toolbars). Use `.glassBackgroundEffect()` for content containers that should show through to underlying layers (panels, cards that need depth).
+
+### ScrollView + Glass Interaction
+
+When Liquid Glass elements overlay scrollable content, handle clipping and visibility carefully:
+
+```swift
+ZStack {
+ ScrollView {
+ LazyVStack {
+ ForEach(items) { item in
+ ItemRow(item)
+ }
+ }
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ Color.clear.frame(height: 80) // Space for floating glass controls
+ }
+ }
+
+ VStack {
+ Spacer()
+ HStack {
+ Button("Action") { }
+ .glassEffect()
+ }
+ .padding()
+ }
+}
+```
+
+**Common issue**: Glass elements can clip or lose their effect at scroll view bounds. Use `.clipped()` on the scroll content (not the glass element) and ensure glass elements are outside the scroll view's hierarchy, not inside it.
+
+### UIBlurEffect Migration Mapping
+
+| Legacy (Pre-iOS 26) | Liquid Glass Equivalent |
+|---------------------|------------------------|
+| `UIBlurEffect(style: .systemMaterial)` | `.glassEffect()` (standard) |
+| `UIBlurEffect(style: .systemUltraThinMaterial)` | `.glassEffect(.clear)` (with conditions) |
+| `UIBlurEffect(style: .systemChromeMaterial)` | System toolbar/navigation glass (automatic) |
+| `UIVisualEffectView` with blur | Remove entirely — use `.glassEffect()` on SwiftUI view |
+| `.background(.thinMaterial)` | `.glassEffect()` or keep material (adapts automatically) |
+| `.background(.ultraThinMaterial)` | `.glassBackgroundEffect()` for content containers |
+| Custom `NSVisualEffectView` (macOS) | `.glassEffect()` or system components |
+
+**Migration steps**: (1) Remove `UIVisualEffectView`/`NSVisualEffectView` wrappers, (2) Replace with `.glassEffect()` on the SwiftUI view, (3) Test with Reduce Transparency to verify fallback, (4) Profile performance — glass effects use GPU compositing.
+
+### Combining Custom Liquid Glass Effects
+
+Wrap multiple `.glassEffect()` views in `GlassEffectContainer { }` to optimize rendering, enable fluid morphing between glass shapes, and reduce compositor overhead. Use for nearby glass elements, morphing animations, and performance-critical interfaces.
+
+### Performance Testing
+
+Profile scrolling, animations, memory, and CPU with Instruments (Time Profiler, SwiftUI, Allocations, Core Animation). See `axiom-swiftui-performance` for SwiftUI Instrument workflows and `axiom-performance-profiling` for Instruments decision trees.
+
+### Backward Compatibility
+
+Add `UIDesignRequiresCompatibility = true` to Info.plist to ship with iOS 26 SDK while maintaining iOS 18 appearance (Liquid Glass disabled, previous blur/material styles used). Migration strategy: ship with key enabled, audit changes in separate build, update incrementally, remove key when ready.
+
+---
+
+## Quick Reference: API Checklist
+
+### Core Liquid Glass APIs
+- [ ] `glassEffect()` - Apply Liquid Glass material
+- [ ] `glassEffect(.clear)` - Clear variant (requires 3 conditions)
+- [ ] `glassEffect(in: Shape)` - Custom shape
+- [ ] `glassBackgroundEffect()` - For custom views reflecting content
+
+### Scroll Edge Effects
+- [ ] `scrollEdgeEffectStyle(_:for:)` - Maintain legibility where glass meets scrolling content
+- [ ] `.hard` style for pinned accessory views
+- [ ] `.soft` style for gradual fade
+
+### Controls and Shapes
+- [ ] `containerRelativeShape()` - Align control shapes with containers
+- [ ] `.borderedProminent` button style
+- [ ] `.bordered` button style
+- [ ] System colors with `.tint()` for adaptation
+
+### Navigation
+- [ ] `.tabViewStyle(.sidebarAdaptable)` - Tab bar adapts to sidebar
+- [ ] `.tabBarMinimizeBehavior(_:)` - Minimize on scroll
+- [ ] `.tabRole(.search)` - Semantic search tabs
+- [ ] `NavigationSplitView` for sidebar + inspector layouts
+
+### Toolbars and Menus
+- [ ] `ToolbarSpacer(.fixed)` - Separate toolbar groups
+- [ ] Standard selectors for automatic menu icons
+- [ ] Match contextual menu actions to swipe actions
+
+### Organization and Layout
+- [ ] `.formStyle(.grouped)` - Platform-optimized form layouts
+- [ ] Title-style capitalization for section headers
+- [ ] Respect automatic row height and padding
+
+### Performance
+- [ ] `GlassEffectContainer` - Combine multiple glass effects
+- [ ] Profile with Instruments
+- [ ] Test with accessibility settings
+
+### Backward Compatibility
+- [ ] `UIDesignRequiresCompatibility` in Info.plist (if needed)
+
+---
+
+## Audit Checklist
+
+Use this checklist when auditing app for Liquid Glass adoption. 30 highest-impact items grouped by category:
+
+### Build and Test
+- [ ] Built with Xcode 26 SDK and run on latest platform releases
+- [ ] Tested with Reduce Transparency, Increase Contrast, and Reduce Motion
+- [ ] Performance profiled with Instruments (scrolling, animations, memory)
+
+### Remove Custom Overrides
+- [ ] Custom backgrounds removed from navigation bars, toolbars, tab bars
+- [ ] `presentationBackground` removed from sheets and popovers
+- [ ] Hard-coded control heights and row heights removed
+- [ ] Custom blur/material backgrounds removed from sheets and popovers
+
+### Icons and App Icon
+- [ ] App icon uses foreground/middle/background layers, composed in Icon Composer
+- [ ] All appearance variants tested (light/dark/clear/tinted)
+- [ ] Accessibility labels provided for all toolbar/menu icons
+
+### Controls
+- [ ] New capsule button shapes reviewed; `controlSize(.small)` for high-density layouts
+- [ ] System colors used (not hard-coded RGB); `.borderedProminent`/`.bordered` adopted
+- [ ] Controls have adequate spacing (no crowding glass-on-glass)
+- [ ] Scroll edge effects applied where glass meets scrolling content
+
+### Navigation and Layout
+- [ ] Clear hierarchy: navigation layer (glass) vs content layer (no glass)
+- [ ] Tab bar adapts to sidebar where appropriate (`.sidebarAdaptable`)
+- [ ] Content safe areas checked; `.safeAreaPadding()` for edge-to-edge glass
+- [ ] Background extension effect considered for split views
+- [ ] Section headers updated to title-style capitalization
+- [ ] `.formStyle(.grouped)` adopted for forms
+
+### Menus and Toolbars
+- [ ] Standard selectors used for automatic menu icons
+- [ ] Swipe actions match contextual menu actions
+- [ ] Toolbar items grouped logically (see `axiom-swiftui-26-ref`)
+
+### Windows and Modals
+- [ ] Arbitrary window sizes supported (iPadOS); flexible layouts used
+- [ ] Sheet content checked around increased corner radius
+
+### Platform
+- [ ] watchOS: Standard toolbar APIs and button styles adopted
+- [ ] tvOS: Standard focus APIs for Liquid Glass on focus
+- [ ] `GlassEffectContainer` used for multiple nearby glass effects
+- [ ] `UIDesignRequiresCompatibility` key considered if needed
+
+---
+
+## Resources
+
+**WWDC**: 2025-219, 2025-323 (Build a SwiftUI app with the new design)
+
+**Docs**: /TechnologyOverviews/liquid-glass, /TechnologyOverviews/adopting-liquid-glass, /design/Human-Interface-Guidelines/materials
+
+**Sample Code**: /SwiftUI/Landmarks-Building-an-app-with-Liquid-Glass
+
+**Skills**: axiom-liquid-glass, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-accessibility-diag
+
+---
+
+**Last Updated**: 2025-12-01
+**Minimum Platform**: iOS/iPadOS 26, macOS Tahoe, tvOS, watchOS, visionOS 3
+**Xcode Version**: Xcode 26+
+**Skill Type**: Reference (comprehensive adoption guide)
diff --git a/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml b/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml
new file mode 100644
index 0000000..e158f54
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Liquid Glass Reference"
+ short_description: "Planning comprehensive Liquid Glass adoption across an app, auditing existing interfaces for Liquid Glass compatibili..."
diff --git a/.claude/skills/axiom-liquid-glass/.openskills.json b/.claude/skills/axiom-liquid-glass/.openskills.json
new file mode 100644
index 0000000..0796111
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-liquid-glass",
+ "installedAt": "2026-04-12T08:06:26.426Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-liquid-glass/SKILL.md b/.claude/skills/axiom-liquid-glass/SKILL.md
new file mode 100644
index 0000000..760adeb
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass/SKILL.md
@@ -0,0 +1,609 @@
+---
+name: axiom-liquid-glass
+description: Use when implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing performance, or requesting expert review of Liquid Glass implementation - provides comprehensive design principles, API patterns, and troubleshooting guidance from WWDC 2025. Includes design review pressure handling and professional push-back frameworks
+license: MIT
+compatibility: iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+
+metadata:
+ version: "1.2.0"
+ last-updated: "Added new iOS 26 APIs and backward compatibility guidance"
+---
+
+# Liquid Glass — Apple's New Material Design System
+
+## When to Use This Skill
+
+Use when:
+- Implementing Liquid Glass effects in your app
+- Reviewing existing UI for Liquid Glass adoption opportunities
+- Debugging visual artifacts with Liquid Glass materials
+- Optimizing Liquid Glass performance
+- **Requesting expert review of Liquid Glass implementation**
+- Understanding when to use Regular vs Clear variants
+- Troubleshooting tinting, legibility, or adaptive behavior issues
+
+#### Related Skills
+- Use `axiom-liquid-glass-ref` for comprehensive app-wide adoption guidance (app icons, controls, navigation, menus, windows, platform considerations)
+
+## Example Prompts
+
+- "How is Liquid Glass different from blur effects? Should I adopt it?"
+- "My lensing effect looks like a regular blur. What am I missing?"
+- "Liquid Glass looks odd on iPad vs iPhone. How do I adjust?"
+- "How do I ensure text contrast on top of Liquid Glass?"
+- "What are the expert criteria for reviewing a Liquid Glass implementation?"
+
+---
+
+## What is Liquid Glass?
+
+Liquid Glass is Apple's next-generation material design system introduced at WWDC 2025. It represents a significant evolution from previous materials (Aqua, iOS 7 blurs, Dynamic Island) by creating a new digital meta-material that:
+
+- **Dynamically bends and shapes light** (lensing) rather than scattering it
+- **Moves organically** like a lightweight liquid, responding to touch and app dynamism
+- **Adapts automatically** to size, environment, content, and light/dark modes
+- **Unifies design language** across all Apple platforms (iOS, iPadOS, macOS, axiom-visionOS)
+
+**Core Philosophy**: Liquid Glass complements the evolution of rounded, immersive screens with rounded, floating forms that feel natural to touch interaction while letting content shine through.
+
+---
+
+## Visual Properties
+
+### 1. Lensing (Primary Visual Characteristic)
+
+Liquid Glass defines itself through **lensing** — warping and bending light to communicate presence, motion, and form. Elements materialize in/out by modulating light bending (not fading). Controls feel ultra-lightweight yet visually distinguishable.
+
+### 2. Motion & Fluidity
+
+- Responds to interaction by flexing with light
+- Gel-like flexibility communicates transient, malleable nature
+- Elements lift into Liquid Glass on interaction (controls)
+- Dynamic morphing between app states as a singular floating plane
+
+### 3. Adaptive Behavior
+
+Liquid Glass **continuously adapts** without fixed light/dark appearance:
+- Shadows intensify when text scrolls underneath; tint shifts for legibility
+- Small elements (navbars) independently flip light/dark; large elements (menus, sidebars) don't flip but adapt depth
+- Ambient environment subtly spills onto surface
+
+---
+
+## Implementation Guide
+
+### Basic API Usage
+
+#### SwiftUI: `glassEffect` Modifier
+
+```swift
+// Basic usage - applies glass within capsule shape
+Text("Hello")
+ .glassEffect()
+
+// Custom shape
+Text("Hello")
+ .glassEffect(in: RoundedRectangle(cornerRadius: 12))
+
+// Interactive elements (iOS - for controls/containers)
+Button("Tap Me") {
+ // action
+}
+.glassEffect()
+.interactive() // Add for custom controls on iOS
+```
+
+**Automatic Adoption**: Simply recompiling with Xcode 26 brings Liquid Glass to standard controls automatically.
+
+### Variants: Regular vs Clear
+
+**CRITICAL DECISION**: Never mix Regular and Clear in the same interface.
+
+#### Regular Variant (Default — 95% of Cases)
+
+Most versatile. Full adaptive effects, automatic legibility, works in any size over any content. Use for navigation bars, tab bars, toolbars, buttons, menus, sidebars.
+
+#### Clear Variant (Special Cases Only)
+
+Permanently more transparent, no adaptive behaviors. **Requires dimming layer** for legibility.
+
+**Use ONLY when ALL three conditions are met**:
+1. Element is over **media-rich content**
+2. Content layer won't be negatively affected by **dimming layer**
+3. Content above glass is **bold and bright**
+
+Using Clear without meeting all three conditions results in poor legibility. See `axiom-liquid-glass-ref` for implementation examples.
+
+---
+
+## Layered System Architecture
+
+Liquid Glass is composed of four layers working together:
+
+1. **Highlights** — Light sources produce highlights responding to geometry; some respond to device motion
+2. **Shadows** — Content-aware: stronger over text, weaker over light backgrounds
+3. **Internal Glow** — Material illuminates from within on interaction; spreads to nearby glass elements
+4. **Adaptive Tinting** — Multiple layers adapt together to maintain hierarchy; all built-in automatically
+
+---
+
+## Scroll Edge Effects
+
+Scroll edge effects dissolve content into background as it scrolls, lifting glass above moving content. Use `.scrollEdgeEffect(.hard)` when pinned accessory views exist (e.g., column headers) for extra visual separation. See `axiom-liquid-glass-ref` for full API details.
+
+---
+
+## Tinting & Color
+
+Liquid Glass introduces **adaptive tinting** — selecting a color generates tones mapped to content brightness underneath, inspired by colored glass in reality. Compatible with all glass behaviors (morphing, adaptation, interaction).
+
+### Tinting Rules
+
+```swift
+// ✅ Tint primary actions only
+Button("View Bag") { }.tint(.red).glassEffect()
+
+// ❌ Don't tint everything — when everything is tinted, nothing stands out
+VStack {
+ Button("Action 1").tint(.blue).glassEffect()
+ Button("Action 2").tint(.green).glassEffect() // No hierarchy
+}
+
+// ❌ Solid fills break Liquid Glass character
+Button("Action") { }.background(.red) // Opaque, wrong
+
+// ✅ Use .tint() instead of solid fills
+Button("Action") { }.tint(.red).glassEffect() // Grounded in environment
+```
+
+Reserve tinting for primary UI actions. Use color in the content layer for overall app color scheme.
+
+---
+
+## Legibility & Contrast
+
+SwiftUI automatically uses **vibrant text and tint colors** within glass effects — no manual adjustment needed. Small elements (navbars, tabbars) flip light/dark for discernibility. Large elements (menus, sidebars) adapt but don't flip (too distracting for large surface area). Symbols/glyphs mirror glass behavior and maximize contrast automatically.
+
+Use custom tint colors selectively for distinct functional purpose (e.g., `.tint(.orange)` on a single toolbar button for emphasis).
+
+---
+
+## Accessibility
+
+Liquid Glass offers several accessibility features that modify material **without sacrificing its magic**:
+
+### Reduced Transparency
+- Makes Liquid Glass frostier
+- Obscures more content behind it
+- Applied automatically when system setting enabled
+
+### Increased Contrast
+- Makes elements predominantly black or white
+- Highlights with contrasting border
+- Applied automatically when system setting enabled
+
+### Reduced Motion
+- Decreases intensity of effects
+- Disables elastic properties
+- Applied automatically when system setting enabled
+
+**Developer Action Required**: None - all features available automatically when using Liquid Glass.
+
+---
+
+## Performance Considerations
+
+### View Hierarchy Impact
+
+**Concern**: Liquid Glass rendering cost in complex view hierarchies
+
+**Guidance**:
+- Regular variant optimized for performance
+- Larger elements (menus, sidebars) use more pronounced effects but managed by system
+- Avoid excessive nesting of glass elements
+
+**Optimization**:
+```swift
+// ❌ Avoid deep nesting
+ZStack {
+ GlassContainer1()
+ .glassEffect()
+ ZStack {
+ GlassContainer2()
+ .glassEffect()
+ // More nesting...
+ }
+}
+
+// ✅ Flatten hierarchy
+VStack {
+ GlassContainer1()
+ .glassEffect()
+
+ GlassContainer2()
+ .glassEffect()
+}
+```
+
+### Rendering Costs
+
+**Adaptive behaviors have computational cost**:
+- Light/dark switching
+- Shadow adjustments
+- Tint calculations
+- Lensing effects
+
+**System handles optimization**, but be mindful:
+- Don't animate Liquid Glass elements unnecessarily
+- Use Clear variant sparingly (requires dimming layer computation)
+- Profile with Instruments if experiencing performance issues
+
+---
+
+## Testing Liquid Glass
+
+Test across these configurations:
+- Light/dark modes
+- Reduced Transparency enabled
+- Increased Contrast enabled
+- Reduced Motion enabled
+- Dynamic Type (larger text sizes)
+- Content scrolling (verify scroll edge effects)
+- Right-to-left languages
+
+See `axiom-ui-testing` for comprehensive UI testing patterns including visual regression and accessibility testing.
+
+---
+
+## Design Review Pressure: Defending Your Implementation
+
+### The Problem
+
+Under design review pressure, you'll face requests to:
+- "Use Clear variant everywhere — Regular is too opaque"
+- "Glass on all controls for visual cohesion"
+- "More transparency to let content shine through"
+
+These sound reasonable. **But they violate the framework.** Your job: defend using evidence, not opinion.
+
+### Red Flags — Designer Requests That Violate Skill Guidelines
+
+If you hear ANY of these, **STOP and reference the skill**:
+
+- ❌ **"Use Clear everywhere"** – Clear requires three specific conditions, not design preference
+- ❌ **"Glass looks better than fills"** – Correct layer (navigation vs content) trumps aesthetics
+- ❌ **"Users won't notice the difference"** – Clear variant fails legibility tests in low-contrast scenarios
+- ❌ **"Stack glass on glass for consistency"** – Explicitly prohibited; use fills instead
+- ❌ **"Apply glass to Lists for sophistication"** – Lists are content layer; causes visual confusion
+
+### How to Push Back Professionally
+
+#### Step 1: Show the Framework
+
+```
+"I want to make this change, but let me show you Apple's guidance on Clear variant.
+It requires THREE conditions:
+
+1. Media-rich content background
+2. Dimming layer for legibility
+3. Bold, bright controls on top
+
+Let me show which screens meet all three..."
+```
+
+#### Step 2: Demonstrate the Risk
+
+Open the app on a device. Show:
+- Clear variant in low-contrast scenario (unreadable)
+- Regular variant in same scenario (legible)
+
+#### Step 3: Offer Compromise
+
+```
+"Clear can work beautifully in these 6 hero sections where all three conditions apply.
+Regular handles everything else with automatic legibility. Best of both worlds."
+```
+
+#### Step 4: Document the Decision
+
+If overruled (designer insists on Clear everywhere):
+
+```
+Slack message to PM + designer:
+
+"Design review decided to use Clear variant across all controls.
+Important: Clear variant requires legibility testing in low-contrast scenarios
+(bright sunlight, dark content). If we see accessibility issues after launch,
+we'll need an expedited follow-up. I'm flagging this proactively."
+```
+
+#### Why this works
+- You're not questioning their taste (you like Clear too)
+- You're raising accessibility/legibility risk
+- You're offering a solution that preserves their vision in hero sections
+- You're documenting the decision (protects you post-launch)
+
+### Real-World Example: App Store Launch Blocker (36-Hour Deadline)
+
+#### Scenario
+- 36 hours to launch
+- Chief designer says: "Clear variant everywhere"
+- Client watching the review meeting
+- You already implemented Regular per the skill
+
+#### What to do
+
+```swift
+// In the meeting, demo side-by-side:
+
+// Regular variant (current implementation)
+NavigationBar()
+ .glassEffect() // Automatic legibility
+
+// Clear variant (requested)
+NavigationBar()
+ .glassEffect(.clear) // Requires dimming layer below
+
+// Show the three-condition checklist
+// Demonstrate which screens pass/fail
+// Offer: Clear in hero sections, Regular elsewhere
+```
+
+#### Result
+- 30-minute compromise demo
+- 90 minutes to implement changes
+- Launch on schedule with optimal legibility
+- No post-launch accessibility complaints
+
+### When to Accept the Design Decision (Even If You Disagree)
+
+Sometimes designers have valid reasons to override the skill. Accept if:
+
+- [ ] They understand the three-condition framework
+- [ ] They're willing to accept legibility risks
+- [ ] You document the decision in writing
+- [ ] They commit to monitoring post-launch feedback
+
+#### Document in Slack
+
+```
+"Design review decided to use Clear variant [in these locations].
+We understand this requires:
+- All three conditions met: [list them]
+- Potential legibility issues in low-contrast scenarios
+- Accessibility testing across brightness levels
+
+Monitoring plan:
+- Gather user feedback first 48 hours
+- Run accessibility audit
+- Have fallback to Regular variant ready for push if needed"
+```
+
+This protects both of you and shows you're not blocking - just de-risking.
+
+---
+
+## Expert Review Checklist
+
+When reviewing Liquid Glass implementation (your code or others'), check:
+
+### 1. Material Appropriateness
+- [ ] Is Liquid Glass used only on navigation layer (not content)?
+- [ ] Are standard controls getting glass automatically via Xcode 26 recompile?
+- [ ] Is glass avoided on glass situations?
+
+### 2. Variant Selection
+- [ ] Is Regular variant used for most cases?
+- [ ] If Clear variant used, do all three conditions apply?
+ - [ ] Over media-rich content?
+ - [ ] Dimming layer acceptable?
+ - [ ] Content above is bold and bright?
+- [ ] Are Regular and Clear never mixed in same interface?
+
+### 3. Legibility & Contrast
+- [ ] Are primary actions selectively tinted (not everything)?
+- [ ] Is color used in content layer for overall app color scheme?
+- [ ] Are solid fills avoided on glass elements?
+- [ ] Do elements maintain legibility on various backgrounds?
+
+### 4. Layering & Hierarchy
+- [ ] Are content intersections avoided in steady states?
+- [ ] Are elements on top of glass using fills/transparency (not glass)?
+- [ ] Is visual hierarchy clear (navigation layer vs content layer)?
+
+### 5. Scroll Edge Effects
+- [ ] Are scroll edge effects applied where Liquid Glass meets scrolling content?
+- [ ] Is hard style used for pinned accessory views?
+
+### 6. Accessibility
+- [ ] Does implementation work with Reduced Transparency?
+- [ ] Does implementation work with Increased Contrast?
+- [ ] Does implementation work with Reduced Motion?
+- [ ] Are interactive elements hittable in all configurations?
+
+### 7. Performance
+- [ ] Is view hierarchy reasonably flat?
+- [ ] Are glass elements animated only when necessary?
+- [ ] Is Clear variant used sparingly?
+
+---
+
+## Common Mistakes & Solutions
+
+### Glass Placement Errors
+
+```swift
+// ❌ Glass on content layer — competes with navigation
+List(landmarks) { landmark in
+ LandmarkRow(landmark).glassEffect()
+}
+
+// ✅ Glass on navigation layer only
+.toolbar {
+ ToolbarItem { Button("Add") { }.glassEffect() }
+}
+
+// ❌ Clear without dimming — poor legibility
+ZStack {
+ VideoPlayer(player: player)
+ PlayButton().glassEffect(.clear)
+}
+
+// ✅ Clear with dimming layer
+ZStack {
+ VideoPlayer(player: player)
+ .overlay(.black.opacity(0.4))
+ PlayButton().glassEffect(.clear)
+}
+```
+
+### Over-Tinting
+
+Tint primary action only. When everything is tinted, nothing stands out.
+
+### Static Material Expectations
+
+Don't hardcode shadows or fixed opacity. Embrace adaptive behavior — test across light/dark modes and backgrounds.
+
+---
+
+## Troubleshooting
+
+### Visual Artifacts
+
+**Issue**: Glass appears too transparent or invisible
+
+**Check**:
+1. Are you using Clear variant? (Switch to Regular if inappropriate)
+2. Is background content extremely light or dark? (Glass adapts - this may be correct behavior)
+3. Is Reduced Transparency enabled? (Check accessibility settings)
+
+**Issue**: Glass appears opaque or has harsh edges
+
+**Check**:
+1. Are you using solid fills on glass? (Remove, use tinting)
+2. Is Increased Contrast enabled? (Expected behavior)
+3. Is custom shape too complex? (Simplify geometry)
+
+### Dark Mode Issues
+
+**Issue**: Glass doesn't flip to dark style on dark backgrounds
+
+**Check**:
+1. Is element large (menu, sidebar)? (Large elements don't flip - by design)
+2. Is background actually dark? (Use Color Picker to verify)
+3. Are you overriding appearance? (Remove `.preferredColorScheme()` if unintended)
+
+**Issue**: Content on glass not legible in dark mode
+
+**Fix**:
+```swift
+// Let SwiftUI handle contrast automatically
+Text("Label")
+ .foregroundStyle(.primary) // ✅ Adapts automatically
+
+// Don't hardcode colors
+Text("Label")
+ .foregroundColor(.black) // ❌ Won't adapt to dark mode
+```
+
+### Performance Issues
+
+**Issue**: Scrolling feels janky with Liquid Glass
+
+**Debug**:
+1. Profile with Instruments (see `axiom-swiftui-performance` skill)
+2. Check for excessive view body updates
+3. Simplify view hierarchy under glass
+4. Verify not applying glass to content layer (major performance hit)
+
+**Issue**: Animations stuttering
+
+**Check**:
+1. Are you animating glass shape changes? (Expensive)
+2. Profile with SwiftUI Instrument for long view updates
+3. Consider reducing glass usage if critical path
+
+---
+
+## Migration from Previous Materials
+
+### From UIBlurEffect / NSVisualEffectView
+
+**Before** (UIKit):
+```swift
+let blurEffect = UIBlurEffect(style: .systemMaterial)
+let blurView = UIVisualEffectView(effect: blurEffect)
+view.addSubview(blurView)
+```
+
+**After** (SwiftUI with Liquid Glass):
+```swift
+ZStack {
+ // Content
+}
+.glassEffect()
+```
+
+**Benefits**: Automatic adaptation (no manual style switching), built-in interaction feedback, platform-appropriate appearance, accessibility features included.
+
+### From Custom Materials
+
+1. **Try Liquid Glass first** — may provide desired effect automatically
+2. **Evaluate Regular vs Clear** — Clear may match custom transparency needs
+3. **Test across configurations** — Liquid Glass adapts automatically
+
+**When to keep custom materials**: Specific artistic effect not achievable with Liquid Glass, backward compatibility with iOS < 26 required, or non-standard UI paradigm incompatible with Liquid Glass principles.
+
+### UIKit + SwiftUI Interop
+
+When migrating incrementally, glass effects apply per-framework:
+- SwiftUI views get `.glassEffect()` / `.glassBackgroundEffect()`
+- UIKit views use the UIKit Liquid Glass APIs (see `axiom-liquid-glass-ref` for migration mapping)
+- Hosted SwiftUI views inside `UIHostingController` get glass effects independently
+
+See `axiom-liquid-glass-ref` for complete UIBlurEffect migration mapping table.
+
+---
+
+## Backward Compatibility
+
+### UIDesignRequiresCompatibility Key (iOS 26)
+
+To ship with latest SDKs while maintaining previous appearance:
+
+```xml
+UIDesignRequiresCompatibility
+
+```
+
+**Effect**: App built with iOS 26 SDK, appearance matches iOS 18 and earlier, Liquid Glass effects disabled, previous blur/material styles used.
+
+**When to use**: Need time to audit interface changes, gradual adoption strategy, or maintain exact appearance temporarily.
+
+**Migration strategy**:
+1. Ship with `UIDesignRequiresCompatibility` enabled
+2. Audit interface changes in separate build
+3. Update interface incrementally
+4. Remove key when ready for Liquid Glass
+
+---
+
+## API Reference
+
+For complete API reference including `glassEffect()`, `glassBackgroundEffect()`, toolbar modifiers, scroll edge effects, navigation/search APIs, controls/layout, `GlassEffectContainer`, `glassEffectID`, types, and backward compatibility, see `axiom-liquid-glass-ref`.
+
+---
+
+## Resources
+
+**WWDC**: 2025-219, 2025-256, 2025-323 (Build a SwiftUI app with the new design)
+
+**Docs**: /technologyoverviews/adopting-liquid-glass, /swiftui/landmarks-building-an-app-with-liquid-glass, /swiftui/applying-liquid-glass-to-custom-views
+
+**Skills**: axiom-liquid-glass-ref
+
+---
+
+**Platforms:** iOS 26+, iPadOS 26+, macOS Tahoe, axiom-visionOS 3
+**Xcode:** 26+
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-liquid-glass/agents/openai.yaml b/.claude/skills/axiom-liquid-glass/agents/openai.yaml
new file mode 100644
index 0000000..94e991e
--- /dev/null
+++ b/.claude/skills/axiom-liquid-glass/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Liquid Glass"
+ short_description: "Implementing Liquid Glass effects, reviewing UI for Liquid Glass adoption, debugging visual artifacts, optimizing per..."
diff --git a/.claude/skills/axiom-lldb-ref/.openskills.json b/.claude/skills/axiom-lldb-ref/.openskills.json
new file mode 100644
index 0000000..3b4d9bc
--- /dev/null
+++ b/.claude/skills/axiom-lldb-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-lldb-ref",
+ "installedAt": "2026-04-12T08:06:27.426Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-lldb-ref/SKILL.md b/.claude/skills/axiom-lldb-ref/SKILL.md
new file mode 100644
index 0000000..68b8b91
--- /dev/null
+++ b/.claude/skills/axiom-lldb-ref/SKILL.md
@@ -0,0 +1,480 @@
+---
+name: axiom-lldb-ref
+description: Complete LLDB command reference — variable inspection, breakpoints, threads, expression evaluation, process control, memory commands, and .lldbinit customization
+license: MIT
+---
+
+# LLDB Command Reference
+
+Complete command reference for LLDB in Xcode. Organized by task so you can find the exact command you need.
+
+For debugging workflows and decision trees, see `/skill axiom-lldb`.
+
+---
+
+## Part 1: Variable Inspection
+
+### `v` / `frame variable`
+
+Reads memory directly. No compilation. Most reliable for Swift values.
+
+```
+(lldb) v # All variables in current frame
+(lldb) v self # Self in current context
+(lldb) v self.propertyName # Specific property
+(lldb) v localVariable # Local variable
+(lldb) v self.array[0] # Collection element
+(lldb) v self._showDetails # SwiftUI @State backing store (underscore prefix)
+```
+
+**Flags:**
+
+| Flag | Effect |
+|------|--------|
+| `-d run` | Run dynamic type resolution (slower but more accurate) |
+| `-T` | Show types |
+| `-R` | Show raw (unformatted) output |
+| `-D N` | Limit depth of nested types to N levels |
+| `-P N` | Limit pointer depth to N levels |
+| `-F` | Flat output (no hierarchy) |
+
+**Limitations:** Cannot evaluate expressions, computed properties, or function calls. Use `p` for those.
+
+### `p` / `expression` (with format)
+
+Compiles and executes an expression. Shows formatted result.
+
+```
+(lldb) p self.computedProperty
+(lldb) p items.count
+(lldb) p someFunction()
+(lldb) p String(describing: someValue)
+(lldb) p (1...10).map { $0 * 2 }
+```
+
+Result stored in numbered variables:
+
+```
+(lldb) p someValue
+$R0 = 42
+(lldb) p $R0 + 10
+$R1 = 52
+```
+
+### `po` / `expression --object-description`
+
+Calls `debugDescription` (or `description`) on the result.
+
+```
+(lldb) po myObject
+(lldb) po error
+(lldb) po notification.userInfo
+(lldb) po NSHomeDirectory()
+```
+
+**When `po` adds value:** Classes with `CustomDebugStringConvertible`, `NSError`, `NSNotification`, collections of objects.
+
+**When `po` fails:** Swift structs without `CustomDebugStringConvertible`, protocol-typed values (use `v` instead — it performs iterative dynamic type resolution that `po` doesn't).
+
+### `expression` (full form)
+
+Full expression evaluation with all options.
+
+```
+(lldb) expression self.view.backgroundColor = UIColor.red
+(lldb) expression self.debugFlag = true
+(lldb) expression myArray.append("test")
+(lldb) expression CATransaction.flush() # Force UI update
+(lldb) expression Self._printChanges() # SwiftUI debug
+```
+
+**Flags:**
+
+| Flag | Effect |
+|------|--------|
+| `-l objc` | Evaluate as Objective-C |
+| `-l swift` | Evaluate as Swift (default) |
+| `-O` | Object description (same as `po`) |
+| `-i false` | Stop on breakpoints hit during evaluation (default: ignore) |
+| `--` | Separator between flags and expression |
+
+**ObjC expressions for Swift debugging:**
+
+```
+(lldb) expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription]
+(lldb) expr -l objc -- (void)[CATransaction flush]
+(lldb) expr -l objc -- (int)[[UIApplication sharedApplication] _isForeground]
+```
+
+### `register read`
+
+Low-level register inspection:
+
+```
+(lldb) register read
+(lldb) register read x0 x1 # Specific registers (ARM64)
+(lldb) register read --all # All register sets
+```
+
+---
+
+## Part 2: Breakpoints
+
+### Setting Breakpoints
+
+```
+(lldb) breakpoint set -f File.swift -l 42 # File + line
+(lldb) b File.swift:42 # Short form
+(lldb) breakpoint set -n methodName # By function name
+(lldb) breakpoint set -n "MyClass.myMethod" # Qualified name
+(lldb) breakpoint set -S layoutSubviews # ObjC selector
+(lldb) breakpoint set -r "viewDid.*" # Regex on name
+(lldb) breakpoint set -a 0x100abc123 # Memory address
+```
+
+### Conditional Breakpoints
+
+```
+(lldb) breakpoint set -f File.swift -l 42 -c "value == nil"
+(lldb) breakpoint set -f File.swift -l 42 -c "index > 100"
+(lldb) breakpoint set -f File.swift -l 42 -c 'name == "test"'
+```
+
+### Ignore Count
+
+```
+(lldb) breakpoint set -f File.swift -l 42 -i 50 # Skip first 50 hits
+```
+
+### One-Shot Breakpoints
+
+```
+(lldb) breakpoint set -f File.swift -l 42 -o # Delete after first hit
+```
+
+### Breakpoint Commands (Logpoints)
+
+Add commands that execute when breakpoint hits:
+
+```
+(lldb) breakpoint command add 1
+> v self.state
+> p self.items.count
+> continue
+> DONE
+```
+
+Or in one line:
+
+```
+(lldb) breakpoint command add 1 -o "v self.state"
+```
+
+### Exception Breakpoints
+
+```
+(lldb) breakpoint set -E swift # All Swift errors
+(lldb) breakpoint set -E objc # All ObjC exceptions
+# Filtering by exception name requires Xcode's GUI (Edit Breakpoint → Exception field)
+```
+
+### Symbolic Breakpoints
+
+```
+(lldb) breakpoint set -n UIViewAlertForUnsatisfiableConstraints # Auto Layout
+(lldb) breakpoint set -n "-[UIApplication _run]" # App launch
+(lldb) breakpoint set -n swift_willThrow # Swift throw
+```
+
+### Managing Breakpoints
+
+```
+(lldb) breakpoint list # List all
+(lldb) breakpoint list -b # Brief format
+(lldb) breakpoint enable 3 # Enable breakpoint 3
+(lldb) breakpoint disable 3 # Disable breakpoint 3
+(lldb) breakpoint delete 3 # Delete breakpoint 3
+(lldb) breakpoint delete # Delete ALL (asks confirmation)
+(lldb) breakpoint modify 3 -c "x > 10" # Add condition to existing
+```
+
+### Watchpoints
+
+Break when a variable's memory changes:
+
+```
+(lldb) watchpoint set variable self.count # Watch for write
+(lldb) watchpoint set variable -w read_write myGlobal # Watch for read or write
+(lldb) watchpoint set expression -- &myVariable # Watch memory address
+(lldb) watchpoint list # List all
+(lldb) watchpoint delete 1 # Delete watchpoint 1
+(lldb) watchpoint modify 1 -c "self.count > 10" # Add condition
+```
+
+**Note:** Hardware watchpoints are limited (~4 per process). Use sparingly.
+
+---
+
+## Part 3: Thread & Backtrace
+
+### Backtraces
+
+```
+(lldb) bt # Current thread backtrace
+(lldb) bt 10 # Limit to 10 frames
+(lldb) bt all # All threads
+(lldb) thread backtrace all # Same as bt all
+```
+
+### Thread Navigation
+
+```
+(lldb) thread list # List all threads with state
+(lldb) thread info # Current thread details + stop reason
+(lldb) thread select 3 # Switch to thread 3
+```
+
+### Frame Navigation
+
+```
+(lldb) frame info # Current frame details
+(lldb) frame select 5 # Jump to frame 5
+(lldb) up # Go up one frame (toward caller)
+(lldb) down # Shortcut: go down one frame
+```
+
+### Thread Return (Skip Code)
+
+Force an early return from the current function:
+
+```
+(lldb) thread return # Return void
+(lldb) thread return 42 # Return specific value
+```
+
+**Use with caution** — skips cleanup code, can leave state inconsistent.
+
+---
+
+## Part 4: Expression Evaluation
+
+### Swift Expressions
+
+```
+(lldb) expr let x = 42; print(x)
+(lldb) expr self.view.backgroundColor = UIColor.red
+(lldb) expr UIApplication.shared.windows.first?.rootViewController
+(lldb) expr UserDefaults.standard.set(true, forKey: "debug")
+```
+
+### Objective-C Expressions
+
+Switch to ObjC when Swift expression parser fails:
+
+```
+(lldb) expr -l objc -- (void)[CATransaction flush]
+(lldb) expr -l objc -- (id)[[UIApplication sharedApplication] keyWindow]
+(lldb) expr -l objc -- (void)[[NSNotificationCenter defaultCenter] postNotificationName:@"test" object:nil]
+```
+
+### UI Debugging Expressions
+
+```
+(lldb) expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription]
+(lldb) po UIApplication.shared.windows.first?.rootViewController?.view.recursiveDescription()
+```
+
+### SwiftUI Debugging
+
+```
+(lldb) expr Self._printChanges() # Print what triggered body re-eval (inside view body only)
+```
+
+### Runtime Type Information
+
+```
+(lldb) expr type(of: someValue)
+(lldb) expr String(describing: type(of: someValue))
+```
+
+---
+
+## Part 5: Process Control
+
+### Execution Control
+
+```
+(lldb) continue # Resume execution (c)
+(lldb) c # Short form
+(lldb) process interrupt # Pause running process
+(lldb) thread step-over # Step over (n / next)
+(lldb) n # Short form
+(lldb) thread step-in # Step into (s / step)
+(lldb) s # Short form
+(lldb) thread step-out # Step out (finish)
+(lldb) finish # Short form
+(lldb) thread step-inst # Step one instruction (assembly-level)
+(lldb) ni # Step over one instruction
+```
+
+### Process Management
+
+```
+(lldb) process launch # Launch/restart
+(lldb) process attach --pid 1234 # Attach to running process
+(lldb) process attach --name MyApp # Attach by name
+(lldb) process detach # Detach without killing
+(lldb) kill # Kill debugged process
+```
+
+---
+
+## Part 6: Memory & Image
+
+### Memory Reading
+
+```
+(lldb) memory read 0x100abc123 # Read memory at address
+(lldb) memory read -c 64 0x100abc123 # Read 64 bytes
+(lldb) memory read -f x 0x100abc123 # Format as hex
+(lldb) memory read -f s 0x100abc123 # Format as string
+```
+
+### Memory Search
+
+```
+(lldb) memory find -s "searchString" -- 0x100000000 0x200000000
+```
+
+### Image/Module Inspection
+
+```
+(lldb) image lookup -a 0x100abc123 # Lookup symbol at address
+(lldb) image lookup -n myFunction # Find function by name
+(lldb) image lookup -rn "MyClass.*" # Regex search
+(lldb) image list # List all loaded images/frameworks
+(lldb) image list -b # Brief format
+```
+
+**Common use:** Finding which framework a crash address belongs to:
+
+```
+(lldb) image lookup -a 0x1a2b3c4d5
+```
+
+---
+
+## Part 7: .lldbinit & Customization
+
+### File Location
+
+LLDB reads `~/.lldbinit` at startup. Per-project init files are also supported when configured in Xcode's scheme settings.
+
+### Useful Aliases
+
+Add to `~/.lldbinit`:
+
+```
+# Quick reload — flush UI changes made via expression
+command alias flush expr -l objc -- (void)[CATransaction flush]
+
+# Print view hierarchy
+command alias views expr -l objc -- (void)[[[UIApplication sharedApplication] keyWindow] recursiveDescription]
+
+# Print auto layout constraints
+command alias constraints po [[UIWindow keyWindow] _autolayoutTrace]
+```
+
+### Custom Type Summaries
+
+```
+# Show CLLocationCoordinate2D as "lat, lon"
+type summary add CLLocationCoordinate2D --summary-string "${var.latitude}, ${var.longitude}"
+```
+
+### Settings
+
+```
+(lldb) settings show target.language # Current language
+(lldb) settings set target.language swift # Force Swift mode
+(lldb) settings set target.max-children-count 100 # Show more collection items
+```
+
+### Per-Project .lldbinit
+
+In Xcode: Edit Scheme → Run → Options → "LLDB Init File" field.
+
+Put project-specific aliases and breakpoints in a `.lldbinit` file in your project root.
+
+---
+
+## Part 8: Troubleshooting LLDB Itself
+
+### "expression failed to parse"
+
+**Cause:** Swift expression parser can't resolve types from the current module.
+
+**Fixes:**
+1. Use `v` instead (no compilation needed)
+2. Simplify the expression
+3. Try `expr -l objc -- ...` for ObjC-bridge types
+4. Clean derived data and rebuild
+
+### "variable not available"
+
+**Cause:** Compiler optimized the variable out.
+
+**Fixes:**
+1. Switch to Debug build configuration
+2. Set `-Onone` for the specific file (Build Settings → per-file compiler flags)
+3. Use `register read` to check if the value is in a register
+
+### "wrong language mode"
+
+**Cause:** LLDB defaults to ObjC in some contexts (especially in frameworks).
+
+**Fix:**
+```
+(lldb) settings set target.language swift
+(lldb) expr -l swift -- mySwiftExpression
+```
+
+### "expression caused a crash"
+
+**Cause:** The expression you evaluated had a side effect that crashed.
+
+**Fix:**
+1. Don't evaluate expressions that modify state unless you intend to
+2. Use `v` for read-only inspection
+3. If the crash corrupted state, restart the debug session
+
+### LLDB Hangs or Is Slow
+
+**Cause:** Usually compiling a complex expression or resolving types in a large project.
+
+**Fix:**
+1. Use `v` instead of `p`/`po` (no compilation)
+2. Reduce expression complexity
+3. If LLDB hangs during `po`, Ctrl+C to cancel and use `v` instead
+
+### Breakpoint Not Hit
+
+**Causes and fixes:**
+
+| Cause | Fix |
+|-------|-----|
+| Wrong file/line (code moved) | Re-set breakpoint on current code |
+| Breakpoint disabled | `breakpoint enable N` |
+| Code not executed | Verify the code path is reached |
+| Optimized out (Release) | Switch to Debug configuration |
+| In a framework/SPM package | Set symbolic breakpoint by function name |
+
+---
+
+## Resources
+
+**WWDC**: 2019-429, 2018-412, 2022-110370, 2015-402
+
+**Docs**: /xcode/stepping-through-code-and-inspecting-variables-to-isolate-bugs, /xcode/setting-breakpoints-to-pause-your-running-app
+
+**Skills**: axiom-lldb, axiom-xcode-debugging
diff --git a/.claude/skills/axiom-lldb-ref/agents/openai.yaml b/.claude/skills/axiom-lldb-ref/agents/openai.yaml
new file mode 100644
index 0000000..68ba2e3
--- /dev/null
+++ b/.claude/skills/axiom-lldb-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "LLDB Reference"
+ short_description: "Complete LLDB command reference"
diff --git a/.claude/skills/axiom-lldb/.openskills.json b/.claude/skills/axiom-lldb/.openskills.json
new file mode 100644
index 0000000..9e1712a
--- /dev/null
+++ b/.claude/skills/axiom-lldb/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-lldb",
+ "installedAt": "2026-04-12T08:06:27.156Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-lldb/SKILL.md b/.claude/skills/axiom-lldb/SKILL.md
new file mode 100644
index 0000000..9677fa9
--- /dev/null
+++ b/.claude/skills/axiom-lldb/SKILL.md
@@ -0,0 +1,631 @@
+---
+name: axiom-lldb
+description: Use when ANY runtime debugging is needed — setting breakpoints, inspecting variables, evaluating expressions, analyzing threads, or reproducing crashes interactively with LLDB
+license: MIT
+---
+
+# LLDB Debugging
+
+Interactive debugging with LLDB. The debugger freezes time so you can interrogate your running app — inspect variables, evaluate expressions, navigate threads, and understand exactly why something went wrong.
+
+**Core insight:** "LLDB is useless" really means "I don't know which command to use for Swift types." This is a knowledge-gap problem, not a tool problem.
+
+## Red Flags — Check This Skill When
+
+| Symptom | This Skill Applies |
+|---------|-------------------|
+| Need to inspect a variable at runtime | Yes — breakpoint + inspect |
+| Crash you can reproduce locally | Yes — breakpoint before crash site |
+| Wrong value at runtime but code looks correct | Yes — step through and inspect |
+| Need to understand thread state during hang | Yes — pause + thread backtrace |
+| `po` doesn't work / shows garbage | Yes — Playbook 3 has alternatives |
+| Crash log analyzed, need to reproduce | Yes — set breakpoints from crash context |
+| Need to test a fix without rebuilding | Yes — expression evaluation |
+| Want to break on all exceptions | Yes — exception breakpoints |
+| App feels slow but responsive | No — use axiom-performance-profiling |
+| Memory grows over time | No — use axiom-memory-debugging first |
+| App completely frozen | Maybe — use axiom-hang-diagnostics first, then LLDB for thread inspection |
+| Crash in production, no local repro | No — use axiom-testflight-triage first |
+
+## LLDB vs Other Tools
+
+```dot
+digraph tool_selection {
+ "What do you need?" [shape=diamond];
+
+ "axiom-testflight-triage" [shape=box];
+ "axiom-hang-diagnostics" [shape=box];
+ "axiom-memory-debugging" [shape=box];
+ "axiom-performance-profiling" [shape=box];
+ "LLDB (this skill)" [shape=box, style=bold];
+
+ "What do you need?" -> "axiom-testflight-triage" [label="Crash log from field,\ncan't reproduce locally"];
+ "What do you need?" -> "axiom-hang-diagnostics" [label="App frozen,\nneed diagnosis approach"];
+ "What do you need?" -> "axiom-memory-debugging" [label="Memory growing,\nneed leak pattern"];
+ "What do you need?" -> "axiom-performance-profiling" [label="Need to measure\nCPU/memory over time"];
+ "What do you need?" -> "LLDB (this skill)" [label="Need to inspect state\nat a specific moment"];
+}
+```
+
+**Rule of thumb:** Instruments *measures*. LLDB *inspects*. If you need to understand what's happening at a specific moment in time, use LLDB. If you need to understand trends over time, use Instruments.
+
+## Response Format
+
+When helping with LLDB debugging, structure your output as:
+
+1. **Immediate diagnosis** (1-3 bullets, confidence-tagged: HIGH/MEDIUM/LOW)
+2. **Commands to run** (numbered, copy-paste ready, with `(lldb)` prefix)
+3. **What to look for** (command → expected output → interpretation)
+4. **Likely root causes** (ranked by probability)
+5. **Next breakpoint plan** (catch it earlier next time)
+6. **If no debugger attached** (crash-log-only fallback path)
+
+---
+
+## Playbook 1: Crash Triage
+
+**Goal:** Understand why the app crashed, starting from the stop point.
+
+### Step 1: Read the Stop Reason
+
+When the debugger stops, the first thing to check:
+
+```
+(lldb) thread info
+```
+
+This shows the stop reason. Common stop reasons:
+
+| Stop Reason | Meaning | Next Step |
+|-------------|---------|-----------|
+| `EXC_BAD_ACCESS (SIGSEGV)` | Accessed invalid memory (null pointer, dangling reference) | Check the address — `0x0` to `0x10` = nil dereference |
+| `EXC_BAD_ACCESS (SIGBUS)` | Misaligned or invalid address | Usually C interop or unsafe pointer issue |
+| `EXC_BREAKPOINT (SIGTRAP)` | Hit a trap — Swift runtime check failed | Check for `fatalError()`, `preconditionFailure()`, force-unwrap of nil, array out of bounds |
+| `EXC_CRASH (SIGABRT)` | Deliberate abort — assertion or uncaught exception | Look at "Application Specific Information" for the message |
+| `breakpoint` | Your breakpoint was hit | Normal — inspect state |
+
+### Step 2: Get the Backtrace
+
+```
+(lldb) bt
+```
+
+Read top-to-bottom. Find the first frame in YOUR code (not system frameworks). That's where to start investigating.
+
+```
+(lldb) bt 10
+```
+
+Limit to 10 frames if the full trace is noisy.
+
+### Step 3: Navigate to Your Frame
+
+```
+(lldb) frame select 3
+```
+
+Jump to frame 3 (or whichever frame is in your code).
+
+### Step 4: Inspect State
+
+```
+(lldb) v
+(lldb) v self.someProperty
+(lldb) v localVariable
+```
+
+Use `v` (not `po`) for reliable Swift value inspection. See Playbook 3 for details.
+
+### Step 5: Classify and Fix
+
+| Exception Type | Typical Cause | Fix Pattern |
+|----------------|---------------|-------------|
+| `EXC_BAD_ACCESS` at low address | Force-unwrap nil optional | `guard let` / `if let` |
+| `EXC_BAD_ACCESS` at high address | Use-after-free / dangling pointer | Check object lifetime, `[weak self]` |
+| `EXC_BREAKPOINT` | Swift runtime trap (bounds, unwrap, precondition) | Fix the violated precondition |
+| `SIGABRT` | Uncaught ObjC exception or `fatalError()` | Read the exception message, fix the root cause |
+
+### Step 6: Set a Conditional Breakpoint to Catch It Earlier
+
+```
+(lldb) breakpoint set -f MyFile.swift -l 42 -c "value == nil"
+```
+
+This breaks only when `value` is nil at line 42 — catches the problem before the crash.
+
+---
+
+## Playbook 2: Hang/Deadlock Diagnosis
+
+**Goal:** Understand why the app is frozen by inspecting all thread states.
+
+### Step 1: Pause the App
+
+If the app is hung, press the pause button in Xcode (⌃⌘Y) or:
+
+```
+(lldb) process interrupt
+```
+
+### Step 2: Get All Thread Backtraces
+
+```
+(lldb) thread backtrace all
+```
+
+Or the shorthand:
+
+```
+(lldb) bt all
+```
+
+### Step 3: Classify Thread States
+
+Look at Thread 0 (main thread) — it processes all UI events. If it's blocked, the app is frozen.
+
+**Main thread blocked on synchronous wait:**
+```
+frame #0: libsystem_kernel.dylib`__psynch_mutexwait
+frame #1: libsystem_pthread.dylib`_pthread_mutex_firstfit_lock_wait
+...
+frame #5: MyApp`ViewController.viewDidLoad()
+```
+Translation: Main thread is waiting for a mutex lock. Something else holds it.
+
+**Main thread blocked on dispatch_sync:**
+```
+frame #0: libdispatch.dylib`_dispatch_sync_f_slow
+...
+frame #3: MyApp`DataManager.fetchData()
+```
+Translation: `DispatchQueue.main.sync` called from background → classic deadlock.
+
+**Main thread busy (CPU-bound):**
+```
+frame #0: MyApp`ImageProcessor.processAllImages()
+frame #1: MyApp`ViewController.viewDidLoad()
+```
+Translation: Expensive work on main thread. Move to background.
+
+### Step 4: Check for Deadlocks
+
+If two threads are both waiting on something the other holds:
+
+```
+(lldb) thread list
+```
+
+Look for multiple threads with state `waiting` that reference each other's locks.
+
+### Step 5: Inspect Specific Thread
+
+```
+(lldb) thread select 3
+(lldb) bt
+(lldb) v
+```
+
+Switch to another thread to inspect its state.
+
+**Cross-reference:** For fix patterns once you've identified the hang cause → `/skill axiom-hang-diagnostics`
+
+---
+
+## Playbook 3: Swift Value Inspection
+
+**This is the core value of this skill.** Most developers abandon LLDB because `po` doesn't work reliably with Swift types. Here's what actually works.
+
+### The Four Print Commands
+
+| Command | Full Form | What It Does | Best For |
+|---------|-----------|--------------|----------|
+| `v` | `frame variable` | Reads memory directly, no compilation | Swift structs, enums, locals — **your default** |
+| `p` | `expression` (with formatter) | Compiles expression, shows formatted result | Computed properties, function calls |
+| `po` | `expression --object-description` | Calls `debugDescription` | Classes with `CustomDebugStringConvertible` |
+| `expr` | `expression` | Evaluates arbitrary code | Calling methods, modifying state |
+
+### When to Use Each
+
+**Start with `v`** — it's fastest and most reliable for stored properties:
+
+```
+(lldb) v self.userName
+(lldb) v self.items[0]
+(lldb) v localStruct
+```
+
+`v` works by reading memory directly. It doesn't compile anything, so it can't fail due to expression compilation errors.
+
+**`v` limitation:** It only reads stored properties — computed properties, `lazy var` (before first access), and property wrapper projected values (`$binding`) won't show meaningful values. If a field looks wrong or missing with `v`, try `p` instead.
+
+**Use `p` when `v` can't reach it:**
+
+```
+(lldb) p self.computedProperty
+(lldb) p self.items.count
+(lldb) p someFunction()
+```
+
+`p` compiles and executes the expression. Needed for computed properties and function calls.
+
+**Use `po` for class descriptions:**
+
+```
+(lldb) po myObject
+(lldb) po error
+(lldb) po notification
+```
+
+`po` calls `debugDescription` on the result. Best for objects that have meaningful descriptions (NSError, Notification, etc.).
+
+### The "LLDB Is Broken" Moments
+
+| What You See | Why | Fix |
+|--------------|-----|-----|
+| `` | `po` failed; variable hasn't been populated by optimizer | Use `v` instead |
+| `expression failed to parse, unknown type name` | Swift expression parser can't resolve the type | Try `expr -l objc -- (id)0x12345` for ObjC objects, or use `v` |
+| `` | Compiler optimized it out (Release build) | Rebuild with Debug, per-file `-Onone`, or `register read` as last resort |
+| `error: Couldn't apply expression side effects` | Expression had side effects LLDB couldn't reverse | Try a simpler expression; avoid mutating state |
+| `po` shows memory address instead of value | Object doesn't conform to `CustomDebugStringConvertible` | Use `v` for raw value, or implement the protocol |
+| `cannot find 'self' in scope` | Breakpoint is in a context without `self` (static, closure) | Use `v` with the explicit variable name |
+| `p` shows `$R0 = ...` but `po` crashes | Different compilation paths | Use `p` when it works; `po` adds an extra description step that can fail |
+
+### Inspecting Optionals
+
+```
+(lldb) v optionalValue
+```
+Shows: `(String?) some = "hello"` or `(String?) none`
+
+Don't use `po optionalValue` — it may show just `Optional("hello")` which is less useful.
+
+### Inspecting Collections
+
+```
+(lldb) v myArray
+(lldb) v myArray[2]
+(lldb) v myDict
+```
+
+For large collections, limit output:
+
+```
+(lldb) p Array(myArray.prefix(5))
+```
+
+### Inspecting SwiftUI State
+
+SwiftUI `@State` is backed by stored properties with underscore prefix:
+
+```
+(lldb) v self._isPresented
+(lldb) v self._items
+```
+
+For `@Observable` models:
+
+```
+(lldb) v self.viewModel.propertyName
+```
+
+**Diagnosing "view doesn't update":** If a property changes (confirmed with `v`) but the SwiftUI view doesn't re-render, check which thread the mutation happens on with `bt`. `@Observable` mutations must happen on `@MainActor` for SwiftUI to observe them — mutations on a background actor won't trigger view updates. Use `Self._printChanges()` inside a view body to see which property triggered (or didn't trigger) a re-render:
+
+```
+(lldb) expr Self._printChanges()
+```
+
+For the full observation diagnostic tree → `/skill axiom-swiftui-debugging`
+
+### Inspecting Actors
+
+Actor state is best inspected with `v`, which reads memory directly without isolation concerns:
+
+```
+(lldb) v actor
+```
+
+Shows all stored properties. This works because LLDB pauses the entire process — you can read any memory regardless of actor isolation (which is a compile-time concept).
+
+### Modifying Values at Runtime
+
+```
+(lldb) expr self.debugFlag = true
+(lldb) expr myArray.append("test")
+(lldb) expr self.view.backgroundColor = UIColor.red
+```
+
+Modify values without rebuilding. Useful for testing theories.
+
+### Referencing Previous Results
+
+LLDB assigns result variables (`$R0`, `$R1`, etc.):
+
+```
+(lldb) p someValue
+$R0 = 42
+(lldb) p $R0 + 10
+$R1 = 52
+```
+
+---
+
+## Playbook 4: Breakpoint Strategies
+
+### Source Breakpoints (Basic)
+
+```
+(lldb) breakpoint set -f ViewController.swift -l 42
+(lldb) b ViewController.swift:42
+```
+
+Short form `b` works for simple cases.
+
+### Conditional Breakpoints
+
+Break only when a condition is true:
+
+```
+(lldb) breakpoint set -f MyFile.swift -l 42 -c "index > 100"
+(lldb) breakpoint set -f MyFile.swift -l 42 -c "name == \"test\""
+```
+
+**Iteration-based:** Break after N hits:
+
+```
+(lldb) breakpoint set -f MyFile.swift -l 42 -i 50
+```
+
+Ignores the first 50 hits, then breaks.
+
+### Logpoints (Action + Auto-Continue)
+
+Log without stopping — like a print statement but no rebuild needed:
+
+```
+(lldb) breakpoint set -f MyFile.swift -l 42
+(lldb) breakpoint command add 1
+> v self.value
+> continue
+> DONE
+```
+
+Or in Xcode: Edit breakpoint → Add Action → "Log Message" → use `@self.value@` token syntax → Check "Automatically continue"
+
+### Symbolic Breakpoints
+
+Break on ANY call to a method by name:
+
+```
+(lldb) breakpoint set -n viewDidLoad
+(lldb) breakpoint set -n "MyClass.myMethod"
+```
+
+Break on all ObjC messages to a selector:
+
+```
+(lldb) breakpoint set -S "layoutSubviews"
+```
+
+### Exception Breakpoints
+
+**Swift errors (break on throw):**
+```
+(lldb) breakpoint set -E swift
+```
+
+**Objective-C exceptions (break on throw):**
+```
+(lldb) breakpoint set -E objc
+```
+
+**In Xcode:** Breakpoint Navigator → + → Swift Error Breakpoint / Exception Breakpoint
+
+This is the single most useful breakpoint for crash debugging. It stops at the throw site instead of the catch/crash site.
+
+### Watchpoints
+
+Break when a variable's value changes:
+
+```
+(lldb) watchpoint set variable self.count
+(lldb) watchpoint set variable -w read_write myGlobal
+```
+
+Watchpoints are hardware-backed — limited to ~4 per process but very fast.
+
+### One-Shot Breakpoints
+
+Break once, then auto-delete:
+
+```
+(lldb) breakpoint set -f MyFile.swift -l 42 -o
+```
+
+### Managing Breakpoints
+
+```
+(lldb) breakpoint list
+(lldb) breakpoint disable 3
+(lldb) breakpoint enable 3
+(lldb) breakpoint delete 3
+(lldb) breakpoint delete
+```
+
+---
+
+## Playbook 5: Async/Concurrency Debugging
+
+### Identifying Async Frames
+
+Swift concurrency backtraces are noisy — expect `swift_task_switch`, `_dispatch_call_block_and_release`, and executor internals mixed in with your code. Don't be discouraged by 40+ frames of runtime noise. Focus on frames from YOUR module.
+
+In Swift concurrency backtraces, look for `swift-task` frames:
+
+```
+Thread 3:
+frame #0: MyApp`MyActor.doWork()
+frame #1: swift_task_switch
+frame #2: MyApp`closure #1 in ViewController.loadData()
+```
+
+The `swift_task_switch` frame indicates an async suspension point. Your code frames are the ones prefixed with your module name (`MyApp` above).
+
+### Inspecting Task State
+
+```
+(lldb) thread backtrace all
+```
+
+Look for threads with `swift_task` in their frames. Each represents an active Swift task.
+
+### Actor-Isolated Code
+
+When stopped inside an actor:
+
+```
+(lldb) v self
+```
+
+Shows all actor state. This works because LLDB pauses the entire process — actor isolation is a compile-time concept, not a runtime lock (for default actors).
+
+### Task Group Inspection
+
+When debugging task groups, break inside the group closure and inspect:
+
+```
+(lldb) v
+(lldb) bt
+```
+
+Each child task runs on its own thread. Use `bt all` to see them.
+
+**Cross-reference:** For Swift concurrency patterns and fix strategies → `/skill axiom-swift-concurrency`. For profiling async performance → `/skill axiom-concurrency-profiling`
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Release-Only Crash — LLDB Is Useless in Release"
+
+**Situation:** Crash happens in Release builds but not Debug. Team says "we can't debug it."
+
+**Why this fails:** Release optimizations change timing, memory layout, and can eliminate variables — making the crash non-reproducible in Debug.
+
+**Correct approach:**
+
+1. Build with Debug configuration but Release-like settings:
+ - Optimization Level: `-O` (not `-Onone`)
+ - Still include debug symbols (`DEBUG_INFORMATION_FORMAT = dwarf-with-dsym`)
+2. Enable Address Sanitizer (`-fsanitize=address`) — catches memory errors with 2-3x overhead
+3. Use the crash report to set breakpoints at the crash site
+4. Set exception breakpoints to catch the error before the crash:
+ ```
+ (lldb) breakpoint set -E swift
+ (lldb) breakpoint set -E objc
+ ```
+5. If variable shows ``, reduce optimization for that one file:
+ - Build Settings → Per-file flags → `-Onone` for the specific file
+6. Last resort — read register values directly (variables live in registers before being optimized out):
+ ```
+ (lldb) register read
+ (lldb) register read x0 x1 x2
+ ```
+ On ARM64: `x0` = self, `x1`-`x7` = first 7 arguments. Check `/skill axiom-lldb-ref` Part 1 for details.
+
+### Scenario 2: "Just Add Print Statements"
+
+**Situation:** Developer adds `print()` calls to debug, rebuilds, runs, reads console. Repeat.
+
+**Why this fails:** Each print-debug cycle costs 3-5 minutes (edit → build → run → navigate to state → read output). An LLDB breakpoint costs 30 seconds.
+
+**Correct approach:**
+
+1. Set a breakpoint at the line you'd add a `print()`:
+ ```
+ (lldb) b MyFile.swift:42
+ ```
+2. Add a logpoint for "print-like" behavior without rebuilding:
+ - Edit breakpoint → Add Action → Log Message → Check "Auto continue"
+3. Inspect variables directly: `v self.someValue`
+4. Modify variables at runtime to test theories: `expr self.debugMode = true`
+5. **One breakpoint session replaces 5-10 print-debug cycles.**
+
+**Time comparison** (typical control-flow debugging):
+| Approach | Per investigation | 5 variables |
+|----------|-------------------|-------------|
+| print() statements | 3-5 min (build + run) | 15-25 min |
+| LLDB breakpoint | 30 sec (set + inspect) | 2.5 min |
+
+**Exception:** In tight loops (thousands of hits/sec), logpoints add per-hit overhead. Use `-i` to skip to the iteration you care about, or use a temporary `print()` for that specific loop.
+
+### Scenario 3: "po Doesn't Work So LLDB Is Broken"
+
+**Situation:** Developer types `po myStruct` and gets garbage. Concludes LLDB is broken for Swift. Goes back to print debugging.
+
+**This is the #1 reason developers abandon LLDB.**
+
+**Why `po` fails with Swift structs:** `po` calls `debugDescription` which requires compiling an expression in the debugger context. For Swift structs, this compilation often fails due to missing type metadata, generics, or module resolution issues.
+
+**Correct approach:**
+
+1. Use `v` instead of `po` — reads memory directly, no compilation:
+ ```
+ (lldb) v myStruct
+ (lldb) v myStruct.propertyName
+ ```
+2. Use `p` for computed properties:
+ ```
+ (lldb) p myStruct.computedValue
+ ```
+3. Use `po` only for classes with `CustomDebugStringConvertible`
+4. If `p` also fails, try specifying the language:
+ ```
+ (lldb) expr -l objc -- (id)0x12345
+ ```
+5. If everything fails, `v self` always works inside a method.
+
+---
+
+## Anti-Patterns
+
+| Anti-Pattern | Why It's Wrong | Better Alternative |
+|---|---|---|
+| `po` everything | Fails for Swift structs, enums, optionals | `v` for values, `po` only for classes |
+| Print-debug cycles | 3-5 min per cycle vs 30 sec breakpoint | Breakpoints with logpoint actions |
+| "LLDB doesn't work with Swift" | It does — wrong command choice | `v` is designed for Swift values |
+| Ignoring backtraces | Jumping to guesses instead of reading the trace | `bt` first, then navigate frames |
+| Conditional breakpoints on every hit | Slows execution if condition is expensive | Use `-i` (ignore count) when possible |
+| Debugging optimized (Release) builds | Variables missing, code reordered | Debug configuration, or per-file `-Onone` |
+| Force-continuing past exceptions | Hides the real error | Fix the exception, don't suppress it |
+| No exception breakpoints set | Crashes land in system code, not throw site | Always add Swift Error + ObjC Exception breakpoints |
+
+## Debugging Checklist
+
+Before starting a debug session:
+
+- [ ] Debug build configuration (not Release)
+- [ ] Exception breakpoints enabled (Swift Error + ObjC Exception)
+- [ ] Breakpoint set before suspected problem area
+- [ ] Know which command to use: `v` for values, `p` for computed, `po` for descriptions
+
+During debug session:
+
+- [ ] Read stop reason (`thread info`) before anything else
+- [ ] Get backtrace (`bt`) — find your frame
+- [ ] Navigate to your frame (`frame select N`)
+- [ ] Inspect relevant state (`v self`, `v localVar`)
+- [ ] Understand the cause before writing any fix
+
+After finding the issue:
+
+- [ ] Set conditional breakpoint to catch recurrence
+- [ ] Consider adding assertion/precondition for this case
+- [ ] Remove temporary breakpoints
+
+## Resources
+
+**WWDC**: 2019-429, 2018-412, 2022-110370
+
+**Docs**: /xcode/stepping-through-code-and-inspecting-variables-to-isolate-bugs, /xcode/setting-breakpoints-to-pause-your-running-app, /xcode/diagnosing-memory-thread-and-crash-issues-early
+
+**Skills**: axiom-lldb-ref, axiom-testflight-triage, axiom-hang-diagnostics, axiom-memory-debugging, axiom-swift-concurrency, axiom-concurrency-profiling
diff --git a/.claude/skills/axiom-lldb/agents/openai.yaml b/.claude/skills/axiom-lldb/agents/openai.yaml
new file mode 100644
index 0000000..a04677c
--- /dev/null
+++ b/.claude/skills/axiom-lldb/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "LLDB"
+ short_description: "ANY runtime debugging is needed"
diff --git a/.claude/skills/axiom-localization/.openskills.json b/.claude/skills/axiom-localization/.openskills.json
new file mode 100644
index 0000000..76a0355
--- /dev/null
+++ b/.claude/skills/axiom-localization/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-localization",
+ "installedAt": "2026-04-12T08:06:27.703Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-localization/SKILL.md b/.claude/skills/axiom-localization/SKILL.md
new file mode 100644
index 0000000..3d1fc84
--- /dev/null
+++ b/.claude/skills/axiom-localization/SKILL.md
@@ -0,0 +1,1098 @@
+---
+name: axiom-localization
+description: Use when localizing apps, using String Catalogs, generating type-safe symbols (Xcode 26+), handling plurals, RTL layouts, locale-aware formatting, or migrating from .strings files - comprehensive i18n patterns for Xcode 15-26
+license: MIT
+metadata:
+ version: "1.1.0"
+ last-updated: "2025-12-16"
+---
+
+# Localization & Internationalization
+
+Comprehensive guide to app localization using String Catalogs. Apple Design Award Inclusivity winners always support multiple languages with excellent RTL (Right-to-Left) support.
+
+## Overview
+
+String Catalogs (`.xcstrings`) are Xcode 15's unified format for managing app localization. They replace legacy `.strings` and `.stringsdict` files with a single JSON-based format that's easier to maintain, diff, and integrate with translation workflows.
+
+This skill covers String Catalogs, SwiftUI/UIKit localization APIs, plural handling, RTL support, locale-aware formatting, and migration strategies from legacy formats.
+
+## When to Use This Skill
+
+- Setting up String Catalogs in Xcode 15+
+- Localizing SwiftUI and UIKit apps
+- Handling plural forms correctly (critical for many languages)
+- Supporting RTL languages (Arabic, Hebrew)
+- Formatting dates, numbers, and currencies by locale
+- Migrating from legacy `.strings`/`.stringsdict` files
+- Preparing App Shortcuts and App Intents for localization
+- Debugging missing translations or incorrect plural forms
+
+## System Requirements
+
+- **Xcode 15+** for String Catalogs (`.xcstrings`)
+- **Xcode 26+** for automatic symbol generation, `#bundle` macro, and AI-powered comment generation
+- **iOS 15+** for `LocalizedStringResource`
+- **iOS 16+** for App Shortcuts localization
+- Earlier iOS versions use legacy `.strings` files
+
+---
+
+## Part 1: String Catalogs (WWDC 2023/10155)
+
+### Creating a String Catalog
+
+**Method 1: Xcode Navigator**
+1. File → New → File
+2. Choose "String Catalog"
+3. Name it (e.g., `Localizable.xcstrings`)
+4. Add to target
+
+**Method 2: Automatic Extraction**
+
+Xcode 15 can automatically extract strings from:
+- SwiftUI views (string literals in `Text`, `Label`, `Button`)
+- Swift code (`String(localized:)`)
+- Objective-C (`NSLocalizedString`)
+- C (`CFCopyLocalizedString`)
+- Interface Builder files (`.storyboard`, `.xib`)
+- Info.plist values
+- App Shortcuts phrases
+
+**Build Settings Required**:
+- **"Use Compiler to Extract Swift Strings"** → Yes
+- **"Localization Prefers String Catalogs"** → Yes
+
+### String Catalog Structure
+
+Each entry has:
+- **Key**: Unique identifier (default: the English string)
+- **Default Value**: Fallback if translation missing
+- **Comment**: Context for translators
+- **String Table**: Organization container (default: "Localizable")
+
+**Example `.xcstrings` JSON**:
+```json
+{
+ "sourceLanguage" : "en",
+ "strings" : {
+ "Thanks for shopping with us!" : {
+ "comment" : "Label above checkout button",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Thanks for shopping with us!"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "¡Gracias por comprar con nosotros!"
+ }
+ }
+ }
+ }
+ },
+ "version" : "1.0"
+}
+```
+
+### Translation States
+
+Xcode tracks state for each translation:
+
+- **New** (⚪) - String hasn't been translated yet
+- **Needs Review** (🟡) - Source changed, translation may be outdated
+- **Reviewed** (✅) - Translation approved and current
+- **Stale** (🔴) - String no longer found in source code
+
+**Workflow**:
+1. Developer adds string → **New**
+2. Translator adds translation → **Reviewed**
+3. Developer changes source → **Needs Review**
+4. Translator updates → **Reviewed**
+5. Developer removes code → **Stale**
+
+---
+
+## Part 2: SwiftUI Localization
+
+### LocalizedStringKey (Automatic)
+
+SwiftUI views with `String` parameters automatically support localization:
+
+```swift
+// ✅ Automatically localizable
+Text("Welcome to WWDC!")
+Label("Thanks for shopping with us!", systemImage: "bag")
+Button("Checkout") { }
+
+// Xcode extracts these strings to String Catalog
+```
+
+**How it works**: SwiftUI uses `LocalizedStringKey` internally, which looks up strings in String Catalogs.
+
+### String(localized:) with Comments
+
+For explicit localization in Swift code:
+
+```swift
+// Basic
+let title = String(localized: "Welcome to WWDC!")
+
+// With comment for translators
+let title = String(localized: "Welcome to WWDC!",
+ comment: "Notification banner title")
+
+// With custom table
+let title = String(localized: "Welcome to WWDC!",
+ table: "WWDCNotifications",
+ comment: "Notification banner title")
+
+// With default value (key ≠ English text)
+let title = String(localized: "WWDC_NOTIFICATION_TITLE",
+ defaultValue: "Welcome to WWDC!",
+ comment: "Notification banner title")
+```
+
+**Best practice**: Always include `comment` to give translators context.
+
+### LocalizedStringResource (Deferred Localization)
+
+For passing localizable strings to other functions:
+
+```swift
+import Foundation
+
+struct CardView: View {
+ let title: LocalizedStringResource
+ let subtitle: LocalizedStringResource
+
+ var body: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 10.0)
+ VStack {
+ Text(title) // Resolved at render time
+ Text(subtitle)
+ }
+ .padding()
+ }
+ }
+}
+
+// Usage
+CardView(
+ title: "Recent Purchases",
+ subtitle: "Items you've ordered in the past week."
+)
+```
+
+**Key difference**: `LocalizedStringResource` defers lookup until used, allowing custom views to be fully localizable.
+
+### AttributedString with Markdown
+
+```swift
+// Markdown formatting is preserved across localizations
+let subtitle = AttributedString(localized: "**Bold** and _italic_ text")
+```
+
+---
+
+## Part 3: UIKit & Foundation
+
+### NSLocalizedString Macro
+
+```swift
+// Basic
+let title = NSLocalizedString("Recent Purchases", comment: "Button Title")
+
+// With table
+let title = NSLocalizedString("Recent Purchases",
+ tableName: "Shopping",
+ comment: "Button Title")
+
+// With bundle
+let title = NSLocalizedString("Recent Purchases",
+ tableName: nil,
+ bundle: .main,
+ value: "",
+ comment: "Button Title")
+```
+
+### Bundle.localizedString
+
+```swift
+let customBundle = Bundle(for: MyFramework.self)
+let text = customBundle.localizedString(forKey: "Welcome",
+ value: nil,
+ table: "MyFramework")
+```
+
+### Custom Macros
+
+```objc
+// Objective-C
+#define MyLocalizedString(key, comment) \
+ [myBundle localizedStringForKey:key value:nil table:nil]
+```
+
+### Info.plist Localization
+
+Localize app name, permissions, etc.:
+
+1. Select `Info.plist`
+2. Editor → Add Localization
+3. Create `InfoPlist.strings` for each language:
+
+```
+// InfoPlist.strings (Spanish)
+"CFBundleName" = "Mi Aplicación";
+"NSCameraUsageDescription" = "La app necesita acceso a la cámara para tomar fotos.";
+```
+
+---
+
+## Part 4: Pluralization
+
+Different languages have different plural rules:
+
+- **English**: 2 forms (one, other)
+- **Russian**: 3 forms (one, few, many)
+- **Polish**: 3 forms (one, few, other)
+- **Arabic**: 6 forms (zero, one, two, few, many, other)
+
+### SwiftUI Plural Handling
+
+```swift
+// Xcode automatically creates plural variations
+Text("\(count) items")
+
+// With custom formatting
+Text("\(visitorCount) Recent Visitors")
+```
+
+**In String Catalog**:
+```json
+{
+ "strings" : {
+ "%lld Recent Visitors" : {
+ "localizations" : {
+ "en" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld Recent Visitor"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld Recent Visitors"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+### XLIFF Export Format
+
+When exporting for translation (File → Export Localizations):
+
+**Legacy (stringsdict)**:
+```xml
+
+ %#@recentVisitors@
+
+
+
+ %lld Recent Visitor
+ %lld Visitante Recente
+
+```
+
+**String Catalog (cleaner)**:
+```xml
+
+ %lld Recent Visitor
+ %lld Visitante Recente
+
+
+
+ %lld Recent Visitors
+ %lld Visitantes Recentes
+
+```
+
+### Substitutions with Plural Variables
+
+```swift
+// Multiple variables with different plural forms
+let message = String(localized: "\(songCount) songs on \(albumCount) albums")
+```
+
+Xcode creates variations for **each** variable's plural form:
+- `songCount`: one, other
+- `albumCount`: one, other
+- Total combinations: 2 × 2 = 4 translation entries
+
+---
+
+## Part 5: Device & Width Variations
+
+### Device-Specific Strings
+
+Different text for different platforms:
+
+```swift
+// Same code, different strings per device
+Text("Bird Food Shop")
+```
+
+**String Catalog variations**:
+```json
+{
+ "Bird Food Shop" : {
+ "localizations" : {
+ "en" : {
+ "variations" : {
+ "device" : {
+ "applewatch" : {
+ "stringUnit" : {
+ "value" : "Bird Food"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "value" : "Bird Food Shop"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+**Result**:
+- iPhone/iPad: "Bird Food Shop"
+- Apple Watch: "Bird Food" (shorter for small screen)
+
+### Width Variations
+
+For dynamic type and size classes:
+
+```swift
+Text("Application Settings")
+```
+
+String Catalog can provide shorter text for narrow widths.
+
+---
+
+## Part 6: RTL Support
+
+### Layout Mirroring
+
+SwiftUI automatically mirrors layouts for RTL languages:
+
+```swift
+// ✅ Automatically mirrors for Arabic/Hebrew
+HStack {
+ Image(systemName: "chevron.right")
+ Text("Next")
+}
+
+// iPhone (English): [>] Next
+// iPhone (Arabic): Next [<]
+```
+
+### Leading/Trailing vs Left/Right
+
+**Always use semantic directions**:
+
+```swift
+// ✅ Correct - mirrors automatically
+.padding(.leading, 16)
+.frame(maxWidth: .infinity, alignment: .leading)
+
+// ❌ Wrong - doesn't mirror
+.padding(.left, 16)
+.frame(maxWidth: .infinity, alignment: .left)
+```
+
+### Images and Icons
+
+Mark images that should/shouldn't flip:
+
+```swift
+// ✅ Directional - mirrors for RTL
+Image(systemName: "chevron.forward")
+
+// ✅ Non-directional - never mirrors
+Image(systemName: "star.fill")
+
+// Custom images
+Image("backButton")
+ .flipsForRightToLeftLayoutDirection(true)
+```
+
+### Testing in RTL Mode
+
+**Xcode Scheme**:
+1. Edit Scheme → Run → Options
+2. Application Language: Arabic / Hebrew
+3. OR: App Language → Right-to-Left Pseudolanguage
+
+**Simulator**:
+Settings → General → Language & Region → Preferred Language Order
+
+**SwiftUI Preview**:
+```swift
+struct ContentView_Previews: PreviewProvider {
+ static var previews: some View {
+ ContentView()
+ .environment(\.layoutDirection, .rightToLeft)
+ .environment(\.locale, Locale(identifier: "ar"))
+ }
+}
+```
+
+---
+
+## Part 7: Locale-Aware Formatting
+
+### DateFormatter
+
+```swift
+let formatter = DateFormatter()
+formatter.locale = Locale.current // ✅ Use current locale
+formatter.dateStyle = .long
+formatter.timeStyle = .short
+
+let dateString = formatter.string(from: Date())
+
+// US: "January 15, 2024 at 3:30 PM"
+// France: "15 janvier 2024 à 15:30"
+// Japan: "2024年1月15日 15:30"
+```
+
+**Never hardcode date format strings**:
+```swift
+// ❌ Wrong - breaks in other locales
+formatter.dateFormat = "MM/dd/yyyy"
+
+// ✅ Correct - adapts to locale
+formatter.dateStyle = .short
+```
+
+### NumberFormatter for Currency
+
+```swift
+let formatter = NumberFormatter()
+formatter.locale = Locale.current
+formatter.numberStyle = .currency
+
+let priceString = formatter.string(from: 29.99)
+
+// US: "$29.99"
+// UK: "£29.99"
+// Japan: "¥30" (rounds to integer)
+// France: "29,99 €" (comma decimal, space before symbol)
+```
+
+### MeasurementFormatter
+
+```swift
+let distance = Measurement(value: 100, unit: UnitLength.meters)
+
+let formatter = MeasurementFormatter()
+formatter.locale = Locale.current
+
+let distanceString = formatter.string(from: distance)
+
+// US: "328 ft" (converts to imperial)
+// Metric countries: "100 m"
+```
+
+### Locale-Specific Sorting
+
+```swift
+let names = ["Ångström", "Zebra", "Apple"]
+
+// ✅ Locale-aware sort
+let sorted = names.sorted { (lhs, rhs) in
+ lhs.localizedStandardCompare(rhs) == .orderedAscending
+}
+
+// Sweden: ["Ångström", "Apple", "Zebra"] (Å comes first in Swedish)
+// US: ["Ångström", "Apple", "Zebra"] (Å treated as A)
+```
+
+---
+
+## Part 8: App Shortcuts Localization
+
+### Phrases with Parameters
+
+```swift
+import AppIntents
+
+struct ShowTopDonutsIntent: AppIntent {
+ static var title: LocalizedStringResource = "Show Top Donuts"
+
+ @Parameter(title: "Timeframe")
+ var timeframe: Timeframe
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("\(.applicationName) Trends for \(\.$timeframe)") {
+ \.$timeframe
+ }
+ }
+}
+```
+
+**String Catalog automatically extracts**:
+- Intent title
+- Parameter names
+- Phrase templates with placeholders
+
+**Localized phrases**:
+```
+English: "Food Truck Trends for this week"
+Spanish: "Tendencias de Food Truck para esta semana"
+```
+
+### AppShortcutsProvider Localization
+
+```swift
+struct FoodTruckShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: ShowTopDonutsIntent(),
+ phrases: [
+ "\(.applicationName) Trends for \(\.$timeframe)",
+ "Show trending donuts for \(\.$timeframe) in \(.applicationName)",
+ "Give me trends for \(\.$timeframe) in \(.applicationName)"
+ ]
+ )
+ }
+}
+```
+
+Xcode extracts all 3 phrases into String Catalog for translation.
+
+---
+
+## Part 9: Migration from Legacy
+
+### Converting .strings to .xcstrings
+
+**Automatic migration**:
+1. Select `.strings` file in Navigator
+2. Editor → Convert to String Catalog
+3. Xcode creates `.xcstrings` and preserves translations
+
+**Manual approach**:
+1. Create new String Catalog
+2. Build project (Xcode extracts strings from code)
+3. Import translations via File → Import Localizations (XLIFF)
+4. Delete old `.strings` files
+
+### Converting .stringsdict
+
+**Plural files automatically merge**:
+1. Keep `.strings` and `.stringsdict` together
+2. Convert → Both merge into single `.xcstrings`
+3. Plural variations preserved
+
+### Gradual Migration Strategy
+
+**Phase 1**: New code uses String Catalogs
+- Create `Localizable.xcstrings`
+- Write new code with `String(localized:)`
+- Keep legacy `.strings` files for old code
+
+**Phase 2**: Migrate existing strings
+- Convert one `.strings` table at a time
+- Test translations after each conversion
+- Update code using old `NSLocalizedString` calls
+
+**Phase 3**: Remove legacy files
+- Delete `.strings` and `.stringsdict` files
+- Verify all strings in String Catalog
+- Submit to App Store
+
+**Coexistence**: `.strings` and `.xcstrings` work together - Xcode checks both.
+
+---
+
+## Common Mistakes
+
+### Hardcoded Strings
+
+```swift
+// ❌ Wrong - not localizable
+Text("Welcome")
+let title = "Settings"
+
+// ✅ Correct - localizable
+Text("Welcome") // SwiftUI auto-localizes
+let title = String(localized: "Settings")
+```
+
+### Concatenating Localized Strings
+
+```swift
+// ❌ Wrong - word order varies by language
+let message = String(localized: "You have") + " \(count) " + String(localized: "items")
+
+// ✅ Correct - single localizable string with substitution
+let message = String(localized: "You have \(count) items")
+```
+
+**Why wrong**: Some languages put numbers before nouns, some after.
+
+### Missing Plural Forms
+
+```swift
+// ❌ Wrong - grammatically incorrect for many languages
+Text("\(count) item(s)")
+
+// ✅ Correct - proper plural handling
+Text("\(count) items") // Xcode creates plural variations
+```
+
+### Ignoring RTL
+
+```swift
+// ❌ Wrong - breaks in RTL languages
+.padding(.left, 20)
+HStack {
+ backButton
+ Spacer()
+ title
+}
+
+// ✅ Correct - mirrors automatically
+.padding(.leading, 20)
+HStack {
+ backButton // Appears on right in RTL
+ Spacer()
+ title
+}
+```
+
+### Wrong Date/Number Formats
+
+```swift
+// ❌ Wrong - US-only format
+let formatter = DateFormatter()
+formatter.dateFormat = "MM/dd/yyyy"
+
+// ✅ Correct - adapts to locale
+formatter.dateStyle = .short
+formatter.locale = Locale.current
+```
+
+### Forgetting Comments
+
+```swift
+// ❌ Wrong - translator has no context
+String(localized: "Confirm")
+
+// ✅ Correct - clear context
+String(localized: "Confirm", comment: "Button to confirm delete action")
+```
+
+**Impact**: "Confirm" could mean "verify" or "acknowledge" - context matters for accurate translation.
+
+---
+
+## Troubleshooting
+
+### Strings not appearing in String Catalog
+
+**Cause**: Build settings not enabled
+
+**Solution**:
+1. Build Settings → "Use Compiler to Extract Swift Strings" → Yes
+2. Clean Build Folder (Cmd+Shift+K)
+3. Build project
+
+### Translations not showing in app
+
+**Cause 1**: Language not added to project
+1. Project → Info → Localizations → + button
+2. Add target language
+
+**Cause 2**: String marked as "Stale"
+- Remove stale strings or verify code still uses them
+
+### Plural forms incorrect
+
+**Cause**: Using `String.localizedStringWithFormat` instead of String Catalog
+
+**Solution**: Use String Catalog's automatic plural handling:
+```swift
+// ✅ Correct
+Text("\(count) items")
+
+// ❌ Wrong
+Text(String.localizedStringWithFormat(NSLocalizedString("%d items", comment: ""), count))
+```
+
+### XLIFF export missing strings
+
+**Cause**: "Localization Prefers String Catalogs" not set
+
+**Solution**:
+1. Build Settings → "Localization Prefers String Catalogs" → Yes
+2. Export Localizations again
+
+### Generated symbols not appearing (Xcode 26+)
+
+**Cause 1**: Build setting not enabled
+
+**Solution**:
+1. Build Settings → "Generate String Catalog Symbols" → Yes
+2. Clean Build Folder (Cmd+Shift+K)
+3. Rebuild project
+
+**Cause 2**: String not manually added to catalog
+
+**Solution**: Symbols only generate for manually-added strings (+ button in String Catalog). Auto-extracted strings don't generate symbols.
+
+### #bundle macro not working (Xcode 26+)
+
+**Cause**: Wrong syntax or missing import
+
+**Solution**:
+```swift
+import Foundation // Required for #bundle
+Text("My Collections", bundle: #bundle, comment: "Section title")
+```
+
+Verify you're using `#bundle` not `.module`.
+
+### Refactoring to symbols fails (Xcode 26+)
+
+**Cause 1**: String not in String Catalog
+1. Ensure string exists in `.xcstrings` file
+2. Build project to refresh catalog
+3. Try refactoring again
+
+**Cause 2**: Build setting not enabled
+- Enable "Generate String Catalog Symbols" in Build Settings
+- Clean and rebuild
+
+---
+
+## Part 10: Xcode 26 Localization Enhancements
+
+Xcode 26 introduces type-safe localization with generated symbols, automatic comment generation using on-device AI, and improved Swift Package support with the `#bundle` macro. Based on WWDC 2025 session 225 "Explore localization with Xcode".
+
+### Generated Symbols (Type-Safe Localization)
+
+**The problem**: String-based localization fails silently when typos occur.
+
+```swift
+// ❌ Typo - fails silently at runtime
+Text("App.HomeScren.Title") // Missing 'e' in Screen
+```
+
+**The solution**: Xcode 26 generates type-safe symbols from manually-added strings.
+
+#### How It Works
+
+1. **Add strings manually** to String Catalog using the + button
+2. **Enable build setting**: "Generate String Catalog Symbols" (ON by default in new projects)
+3. **Use symbols** instead of strings
+
+```swift
+// ✅ Type-safe - compiler catches typos
+Text(.appHomeScreenTitle)
+```
+
+#### Symbol Generation Rules
+
+| String Type | Generated Symbol Type | Usage Example |
+|-------------|----------------------|---------------|
+| No placeholders | Static property | `Text(.introductionTitle)` |
+| With placeholders | Function with labeled arguments | `.subtitle(friendsPosts: 42)` |
+
+**Key naming conversion**:
+- `App.HomeScreen.Title` → `.appHomeScreenTitle`
+- Periods removed, camel-cased
+- Available on `LocalizedStringResource`
+
+#### Code Examples
+
+```swift
+// SwiftUI views
+struct ContentView: View {
+ var body: some View {
+ NavigationStack {
+ Text(.introductionTitle)
+ .navigationSubtitle(.subtitle(friendsPosts: 42))
+ }
+ }
+}
+
+// Foundation String
+let message = String(localized: .curatedCollection)
+
+// Custom views with LocalizedStringResource
+struct CollectionDetailEditingView: View {
+ let title: LocalizedStringResource
+
+ init(title: LocalizedStringResource) {
+ self.title = title
+ }
+
+ var body: some View {
+ Text(title)
+ }
+}
+
+CollectionDetailEditingView(title: .editingTitle)
+```
+
+---
+
+### Automatic Comment Generation
+
+Xcode 26 uses an **on-device model** to automatically generate contextual comments for localizable strings.
+
+#### Enabling the Feature
+
+1. Open Xcode Settings → Editing
+2. Enable "automatically generate string catalog comments"
+3. New strings added to code automatically receive generated comments
+
+#### Example
+
+For a button string, Xcode generates:
+
+> "The text label on a button to cancel the deletion of a collection"
+
+This context helps translators understand where and how the string is used.
+
+#### XLIFF Export
+
+Auto-generated comments are marked in exported XLIFF files:
+
+```xml
+
+ Grand Canyon
+ Grand Canyon
+ Suggestion for searching landmarks
+
+```
+
+**Benefits**:
+- Saves developer time writing translator context
+- Provides consistent, clear descriptions
+- Improves translation quality
+
+---
+
+### Swift Package & Framework Localization
+
+#### The Problem
+
+SwiftUI uses the `.main` bundle by default. Swift Packages and frameworks need to reference their own bundle:
+
+```swift
+// ❌ Wrong - uses main bundle, strings not found
+Text("My Collections", comment: "Section title")
+```
+
+#### The Solution: #bundle Macro (NEW in Xcode 26)
+
+The `#bundle` macro automatically references the correct bundle for the current target:
+
+```swift
+// ✅ Correct - automatically uses package/framework bundle
+Text("My Collections", bundle: #bundle, comment: "Section title")
+```
+
+**Key advantages**:
+- Works in main app, frameworks, and Swift Packages
+- Backwards-compatible with older OS versions
+- Eliminates manual `.module` bundle management
+
+#### With Custom Table Names
+
+```swift
+// Main app
+Text("My Collections",
+ tableName: "Discover",
+ comment: "Section title")
+
+// Framework or Swift Package
+Text("My Collections",
+ tableName: "Discover",
+ bundle: #bundle,
+ comment: "Section title")
+```
+
+---
+
+### Custom Table Symbol Access
+
+When using multiple String Catalogs for organization:
+
+#### Default "Localizable" Table
+
+Symbols are directly accessible on `LocalizedStringResource`:
+
+```swift
+Text(.welcomeMessage) // From Localizable.xcstrings
+```
+
+**Note**: Xcode automatically resolves symbols from the default "Localizable" table. Explicit table selection is rarely needed—use it only for debugging or testing specific catalogs.
+
+#### Custom Tables
+
+Symbols are nested in the table namespace:
+
+```swift
+// From Discover.xcstrings
+Text(Discover.featuredCollection)
+
+// From Settings.xcstrings
+Text(Settings.privacyPolicy)
+```
+
+**Organization strategy for large apps**:
+- **Localizable.xcstrings** - Core app strings
+- **FeatureName.xcstrings** - Feature-specific strings (e.g., Onboarding, Settings, Discover)
+- Benefits: Easier to manage, clearer ownership, better XLIFF organization
+
+---
+
+### Two Localization Workflows
+
+Xcode 26 supports two complementary workflows:
+
+#### Workflow 1: String Extraction (Recommended for new projects)
+
+**Process**:
+1. Write strings directly in code
+2. Use SwiftUI views (`Text`, `Button`) and `String(localized:)`
+3. Xcode automatically extracts to String Catalog
+4. Leverage automatic comment generation
+
+**Pros**: Simple initial setup, immediate start
+
+**Cons**: Less control over string organization
+
+```swift
+// ✅ String extraction workflow
+Text("Welcome to WWDC!", comment: "Main welcome message")
+```
+
+#### Workflow 2: Generated Symbols (Recommended as complexity grows)
+
+**Process**:
+1. Manually add strings to String Catalog
+2. Reference via type-safe symbols
+3. Organize into custom tables
+
+**Pros**: Better control, type safety, easier to maintain across frameworks
+
+**Cons**: Requires planning string catalog structure upfront
+
+```swift
+// ✅ Generated symbols workflow
+Text(.welcomeMessage)
+```
+
+| Workflow | Best For | Trade-offs |
+|----------|----------|------------|
+| String Extraction | New projects, simple apps, prototyping | Automatic extraction, less control over organization |
+| Generated Symbols | Large apps, frameworks, multiple teams | Type safety, better organization, requires upfront planning |
+
+---
+
+### Refactoring Between Workflows
+
+Xcode 26 allows converting between workflows without manual rewriting.
+
+#### Converting Strings to Symbols
+
+1. **Right-click** on a string literal in code
+2. Select **"Refactor > Convert Strings to Symbols"**
+3. **Preview** all affected locations
+4. **Customize** symbol names before confirming
+5. **Apply** to entire table or individual strings
+
+**Example**:
+
+```swift
+// Before
+Text("Welcome to WWDC!", comment: "Main welcome message")
+
+// After refactoring
+Text(.welcomeToWWDC)
+```
+
+**Benefits**:
+- Batch conversion of entire String Catalogs
+- Preview changes before applying
+- Maintain localization without code rewrites
+
+---
+
+### Implementation Checklist
+
+After adopting Xcode 26 generated symbols, verify:
+
+**Build Configuration:**
+- [ ] "Generate String Catalog Symbols" build setting enabled
+- [ ] Project builds without "Cannot find 'symbolName' in scope" errors
+- [ ] Clean build succeeds (Cmd+Shift+K, then Cmd+B)
+
+**String Catalog Setup:**
+- [ ] Strings manually added to catalog using + button (not auto-extracted)
+- [ ] Symbol names follow conventions (camelCase, no periods)
+- [ ] Custom tables organized by feature (if using multiple catalogs)
+
+**Swift Package Integration:**
+- [ ] All `Text()` and `String(localized:)` calls in packages use `bundle: #bundle`
+- [ ] Import Foundation added where `#bundle` is used
+- [ ] Tested package builds independently and as dependency
+
+**Refactoring & Migration:**
+- [ ] Tested refactoring tool on sample strings
+- [ ] Preview showed expected changes before applying
+- [ ] Old string-based calls still work during transition period
+
+**Optional Features:**
+- [ ] Automatic comment generation enabled in Xcode Settings → Editing (optional)
+- [ ] Tested AI-generated comments for accuracy
+- [ ] XLIFF export includes auto-generated comments
+
+**Testing:**
+- [ ] Symbols resolve correctly in SwiftUI previews
+- [ ] Localization works across all supported languages
+- [ ] App runs on minimum supported iOS version
+
+---
+
+## Resources
+
+**WWDC**: 2025-225, 2023-10155, 2022-10110
+
+**Docs**: /xcode/localization, /xcode/localizing-and-varying-text-with-a-string-catalog
+
+**Skills**: axiom-app-intents-ref, axiom-hig, axiom-accessibility-diag
diff --git a/.claude/skills/axiom-localization/agents/openai.yaml b/.claude/skills/axiom-localization/agents/openai.yaml
new file mode 100644
index 0000000..3c04f53
--- /dev/null
+++ b/.claude/skills/axiom-localization/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Localization"
+ short_description: "Localizing apps, using String Catalogs, generating type-safe symbols (Xcode 26+), handling plurals, RTL layouts, loca..."
diff --git a/.claude/skills/axiom-mapkit-diag/.openskills.json b/.claude/skills/axiom-mapkit-diag/.openskills.json
new file mode 100644
index 0000000..14b6744
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-mapkit-diag",
+ "installedAt": "2026-04-12T08:06:28.154Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-mapkit-diag/SKILL.md b/.claude/skills/axiom-mapkit-diag/SKILL.md
new file mode 100644
index 0000000..d8038b0
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-diag/SKILL.md
@@ -0,0 +1,462 @@
+---
+name: axiom-mapkit-diag
+description: MapKit troubleshooting — annotations not appearing, region jumping, clustering not working, search failures, overlay rendering issues, user location problems
+license: MIT
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-02-26"
+---
+
+# MapKit Diagnostics
+
+Symptom-based MapKit troubleshooting. Start with the symptom you're seeing, follow the diagnostic path.
+
+## Related Skills
+
+- `axiom-mapkit` — Patterns, decision trees, anti-patterns
+- `axiom-mapkit-ref` — API reference, code examples
+
+---
+
+## Quick Reference
+
+| Symptom | Check First | Common Fix |
+|---|---|---|
+| Annotations not appearing | Coordinate values (lat/lng swapped?) | Verify coordinate, check viewFor delegate |
+| Map region jumps/loops | updateUIView guard | Add region equality check |
+| Slow with many annotations | Annotation count, view reuse | Enable clustering, implement view reuse |
+| Clustering not working | clusteringIdentifier set? | Set same identifier on all views |
+| Overlays not rendering | renderer delegate method | Return correct MKOverlayRenderer subclass |
+| Search returns no results | resultTypes, region bias | Set appropriate resultTypes and region |
+| User location not showing | Authorization status | Request CLLocationManager authorization first |
+| Coordinates appear wrong | lat/lng order | MapKit uses (latitude, longitude) — verify data source |
+
+---
+
+## Symptom 1: Annotations Not Appearing
+
+### Decision Tree
+
+```
+Q1: Are coordinates valid?
+├─ 0,0 or NaN → Data source returning default/empty values
+│ Fix: Validate coordinates before adding annotations
+│ Debug: print("\(annotation.coordinate.latitude), \(annotation.coordinate.longitude)")
+│
+└─ Valid numbers → Check next
+
+Q2: Are lat/lng swapped?
+├─ YES (common with GeoJSON which uses [longitude, latitude]) → Swap values
+│ GeoJSON: [lng, lat] — MapKit: CLLocationCoordinate2D(latitude:, longitude:)
+│ Fix: CLLocationCoordinate2D(latitude: json[1], longitude: json[0])
+│
+└─ NO → Check next
+
+Q3: (MKMapView) Is mapView(_:viewFor:) delegate returning nil for your annotations?
+├─ Not implemented → System uses default pin (should appear)
+├─ Returns nil → System uses default pin (should appear)
+├─ Returns wrong view → Check implementation
+│
+└─ Check delegate is set
+
+Q4: (MKMapView) Is delegate set?
+├─ NO → mapView.delegate = self (or context.coordinator in UIViewRepresentable)
+│ Without delegate: default pins appear. But if viewFor returns nil, check annotation type
+│
+└─ YES → Check next
+
+Q5: (SwiftUI) Are annotations in Map content builder?
+├─ NO → Annotations must be inside Map { ... } content closure
+│ Fix: Map(position: $pos) { Marker("Name", coordinate: coord) }
+│
+└─ YES → Check next
+
+Q6: Is the map region showing the annotation coordinates?
+├─ Map centered elsewhere → Adjust camera/region to include annotation coordinates
+│ Debug: Compare mapView.region with annotation coordinates
+│ Fix: Use .automatic camera position or set region to fit annotations
+│
+└─ Region includes annotations → Check displayPriority
+
+Q7: (MKMapView) Is displayPriority too low?
+├─ .defaultLow → System may hide annotations at certain zoom levels
+│ Fix: view.displayPriority = .required for must-show annotations
+│
+└─ .required → Annotation should appear — file a bug report with minimal repro
+```
+
+---
+
+## Symptom 2: Map Region Jumping / Infinite Loops
+
+### Decision Tree
+
+```
+Q1: (UIViewRepresentable) Is setRegion called in updateUIView without guard?
+├─ YES → Classic infinite loop:
+│ 1. SwiftUI state changes → updateUIView called
+│ 2. updateUIView calls setRegion
+│ 3. setRegion triggers regionDidChangeAnimated delegate
+│ 4. Delegate updates SwiftUI state → back to step 1
+│
+│ Fix: Guard against unnecessary updates
+│ if mapView.region.center.latitude != region.center.latitude
+│ || mapView.region.center.longitude != region.center.longitude {
+│ mapView.setRegion(region, animated: true)
+│ }
+│
+│ Alternative: Use a flag in coordinator
+│ coordinator.isUpdating = true
+│ mapView.setRegion(region, animated: true)
+│ coordinator.isUpdating = false
+│ // In regionDidChangeAnimated: guard !isUpdating
+│
+└─ NO → Check next
+
+Q2: Are multiple state sources fighting over the region?
+├─ YES → Two bindings or state variables controlling the same region
+│ Fix: Single source of truth for camera position
+│ One @State var cameraPosition, not two conflicting values
+│
+└─ NO → Check next
+
+Q3: (SwiftUI) Is MapCameraPosition properly bound?
+├─ Using .constant() or recreating position on each render → Camera resets
+│ Fix: @State private var cameraPosition: MapCameraPosition = .automatic
+│ Use the binding: Map(position: $cameraPosition)
+│
+└─ Properly bound → Check next
+
+Q4: Animation conflict?
+├─ Using animated: true in updateUIView alongside SwiftUI animations → Double animation
+│ Fix: Avoid animated: true in updateUIView, or disable SwiftUI animation for map
+│
+└─ NO → Check next
+
+Q5: Is onMapCameraChange triggering state updates that move the camera?
+├─ YES → Camera change → callback → state change → camera change
+│ Fix: Only update non-camera state in the callback
+│ Don't set cameraPosition inside onMapCameraChange
+│
+└─ NO → Check delegate implementation for unintended state mutations
+```
+
+---
+
+## Symptom 3: Performance Issues
+
+### Decision Tree
+
+```
+Q1: How many annotations?
+├─ > 500 without clustering → Enable clustering
+│ SwiftUI: .mapItemClusteringIdentifier("poi")
+│ MKMapView: view.clusteringIdentifier = "poi"
+│
+├─ > 1000 → Consider visible-region filtering
+│ Only load annotations within mapView.region
+│ Use .onMapCameraChange to fetch when user scrolls
+│
+└─ < 500 → Check next
+
+Q2: (MKMapView) Using dequeueReusableAnnotationView?
+├─ NO → Every annotation creates a new view → memory spike
+│ Fix: Register view class and dequeue in delegate
+│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
+│
+└─ YES → Check next
+
+Q3: Complex custom annotation views?
+├─ YES → Rich SwiftUI views or complex UIViews per annotation
+│ Fix: Pre-render to UIImage for MKAnnotationView.image
+│ Or simplify to MKMarkerAnnotationView with glyph
+│
+└─ NO → Check next
+
+Q4: Overlays with many coordinates?
+├─ YES → Polylines/polygons with 10K+ points
+│ Fix: Simplify geometry (Douglas-Peucker algorithm)
+│ Or render at reduced detail for zoomed-out views
+│
+└─ NO → Check next
+
+Q5: Geocoding in a loop?
+├─ YES → CLGeocoder has rate limit (~1/second)
+│ Fix: Batch geocoding, throttle requests, cache results
+│ Use MKLocalSearch for batch lookups instead of per-item geocoding
+│
+└─ NO → Profile with Instruments → Time Profiler for CPU, Allocations for memory
+```
+
+---
+
+## Symptom 4: Clustering Not Working
+
+### Decision Tree
+
+```
+Q1: Is clusteringIdentifier set on annotation views?
+├─ NO → Clustering requires an identifier on each annotation view
+│ MKMapView: view.clusteringIdentifier = "poi" in viewFor delegate
+│ SwiftUI: .mapItemClusteringIdentifier("poi") on content
+│
+└─ YES → Check next
+
+Q2: Are ALL relevant views using the SAME identifier?
+├─ NO → Different identifiers = different cluster groups
+│ Fix: Use consistent identifier for annotations that should cluster together
+│
+└─ YES → Check next
+
+Q3: (MKMapView) Is mapView(_:clusterAnnotationForMemberAnnotations:) needed?
+├─ Not implemented → System creates default cluster
+│ If you need custom cluster appearance, implement this delegate method
+│
+└─ Implemented → Check return value
+
+Q4: Too few annotations in visible area?
+├─ YES → Clustering only activates when annotations physically overlap
+│ At low zoom (city level), 10 annotations might cluster
+│ At high zoom (street level), same 10 might all be visible individually
+│
+└─ NO → Check next
+
+Q5: (MKMapView) Are annotation views registered?
+├─ NO → Register both individual and cluster view classes
+│ mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
+│
+└─ YES → Verify viewFor delegate handles both MKClusterAnnotation and individual annotations
+```
+
+---
+
+## Symptom 5: Overlays Not Rendering
+
+### Decision Tree
+
+```
+Q1: (MKMapView) Is mapView(_:rendererFor:) delegate method implemented?
+├─ NO → Overlays require a renderer — without this delegate method, nothing renders
+│ Fix: Implement the delegate method, return appropriate renderer subclass
+│
+└─ YES → Check next
+
+Q2: Is the correct renderer subclass returned?
+├─ MKCircle → MKCircleRenderer
+│ MKPolyline → MKPolylineRenderer
+│ MKPolygon → MKPolygonRenderer
+│ MKTileOverlay → MKTileOverlayRenderer
+│ Mismatch → Crash or silent failure
+│
+└─ Correct → Check next
+
+Q3: Is renderer styled?
+├─ No strokeColor/fillColor/lineWidth set → Renderer exists but invisible
+│ Fix: Set at minimum strokeColor and lineWidth
+│ renderer.strokeColor = .systemBlue
+│ renderer.lineWidth = 2
+│
+└─ Styled → Check next
+
+Q4: Overlay level wrong?
+├─ .aboveRoads → Overlay may be behind labels (hard to see)
+│ Try: mapView.addOverlay(overlay, level: .aboveLabels)
+│
+└─ Check overlay coordinates match visible region
+
+Q5: (SwiftUI) Using MapCircle/MapPolyline without styling?
+├─ No .foregroundStyle or .stroke → May render transparent
+│ Fix: MapCircle(center: coord, radius: 500)
+│ .foregroundStyle(.blue.opacity(0.3))
+│ .stroke(.blue, lineWidth: 2)
+│
+└─ Styled → Check coordinates are within visible map region
+```
+
+---
+
+## Symptom 6: Search / Directions Failures
+
+### Decision Tree
+
+```
+Q1: Network available?
+├─ NO → MapKit search requires network connectivity
+│ Fix: Check URLSession connectivity or NWPathMonitor
+│
+└─ YES → Check next
+
+Q2: resultTypes too restrictive?
+├─ Only .physicalFeature but searching for "Starbucks" → No results
+│ Fix: Use .pointOfInterest for businesses, .address for streets
+│ Or combine: [.pointOfInterest, .address]
+│
+└─ Appropriate → Check next
+
+Q3: Region bias missing?
+├─ NO region set → Results may be from anywhere in the world
+│ Fix: request.region = mapView.region (or visible region)
+│ This biases results to what the user can see
+│
+└─ Region set → Check next
+
+Q4: Natural language query format?
+├─ Structured format (lat/lng, codes) → Won't parse
+│ Good: "coffee shops near San Francisco"
+│ Good: "123 Main St"
+│ Bad: "lat:37.7 lng:-122.4 coffee"
+│ Bad: "POI_TYPE=cafe"
+│
+└─ Natural language → Check next
+
+Q5: Rate limited?
+├─ Getting errors after many requests → Apple rate-limits MapKit search
+│ Fix: Throttle searches, use MKLocalSearchCompleter for autocomplete
+│ Don't fire MKLocalSearch on every keystroke
+│
+└─ NO → Check next
+
+Q6: (Directions) Source and destination valid?
+├─ source or destination is nil → Request will fail
+│ Fix: Verify both are valid MKMapItem instances
+│ MKMapItem.forCurrentLocation() requires location authorization
+│
+└─ Both valid → Check transportType availability
+ Transit directions not available in all regions
+ Walking/driving available globally
+```
+
+---
+
+## Symptom 7: User Location Not Showing
+
+### Decision Tree
+
+```
+Q1: What is CLLocationManager.authorizationStatus?
+├─ .notDetermined → Authorization never requested
+│ Fix: Request authorization first, then enable user location
+│ CLServiceSession(authorization: .whenInUse)
+│
+├─ .denied → User denied location access
+│ Fix: Show UI explaining value, link to Settings
+│
+├─ .restricted → Parental controls block access
+│ Fix: Inform user, cannot override
+│
+└─ .authorizedWhenInUse / .authorizedAlways → Check next
+
+Q2: (MKMapView) Is showsUserLocation set to true?
+├─ NO → mapView.showsUserLocation = true
+│
+└─ YES → Check next
+
+Q3: (SwiftUI) Using UserAnnotation() in Map content?
+├─ NO → Add UserAnnotation() inside Map { ... }
+│
+└─ YES → Check next
+
+Q4: Running in Simulator?
+├─ YES, no custom location set → Simulator doesn't have GPS
+│ Fix: Debug menu → Location → Custom Location (or Apple/City Bicycle Ride/etc.)
+│ Xcode: Debug → Simulate Location → pick a location
+│
+└─ Physical device → Check next
+
+Q5: MapKit implicitly requests authorization — was it previously denied?
+├─ MapKit shows no prompt if already denied
+│ Check: Settings → Privacy & Security → Location Services → Your App
+│ If "Never": User must manually re-enable
+│
+└─ Authorized → Check if location services enabled system-wide
+ Settings → Privacy & Security → Location Services → toggle at top
+
+Q6: Location icon appearing but blue dot not on screen?
+├─ User is outside the visible map region
+│ Fix: Use MapCameraPosition.userLocation(fallback: .automatic)
+│ Or add MapUserLocationButton() in .mapControls
+│
+└─ See axiom-core-location-diag for deeper location troubleshooting
+```
+
+---
+
+## Symptom 8: Coordinate System Confusion
+
+Common coordinate mistakes that cause annotations to appear in wrong locations.
+
+### MapKit vs GeoJSON
+
+| System | Order | Example |
+|---|---|---|
+| MapKit (CLLocationCoordinate2D) | latitude, longitude | `CLLocationCoordinate2D(latitude: 37.77, longitude: -122.42)` |
+| GeoJSON | longitude, latitude | `[-122.42, 37.77]` |
+| Google Maps | latitude, longitude | Same as MapKit |
+| PostGIS ST_MakePoint | longitude, latitude | Same as GeoJSON |
+
+**The #1 coordinate bug**: Swapping lat/lng when parsing GeoJSON.
+
+```swift
+// ❌ WRONG: Using GeoJSON order directly
+let coord = CLLocationCoordinate2D(
+ latitude: geoJson[0], // This is longitude!
+ longitude: geoJson[1] // This is latitude!
+)
+
+// ✅ RIGHT: GeoJSON is [lng, lat], MapKit wants (lat, lng)
+let coord = CLLocationCoordinate2D(
+ latitude: geoJson[1],
+ longitude: geoJson[0]
+)
+```
+
+### MKMapPoint vs CLLocationCoordinate2D
+
+- `CLLocationCoordinate2D` — geographic coordinates (lat/lng in degrees)
+- `MKMapPoint` — projected coordinates for flat map rendering
+- Convert: `MKMapPoint(coordinate)` and `coordinate` property on MKMapPoint
+- Never use MKMapPoint x/y as lat/lng — they're completely different number spaces
+
+### Validation
+
+```swift
+func isValidCoordinate(_ coord: CLLocationCoordinate2D) -> Bool {
+ coord.latitude >= -90 && coord.latitude <= 90
+ && coord.longitude >= -180 && coord.longitude <= 180
+ && !coord.latitude.isNaN && !coord.longitude.isNaN
+}
+```
+
+If latitude > 90 or longitude > 180, coordinates are likely swapped or in wrong format.
+
+---
+
+## Console Debugging
+
+### MapKit Logs
+
+```bash
+# View MapKit-related logs
+log stream --predicate 'subsystem == "com.apple.MapKit"' --level debug
+
+# Filter for your app
+log stream --predicate 'process == "YourApp" AND (subsystem == "com.apple.MapKit" OR subsystem == "com.apple.CoreLocation")'
+```
+
+### Common Console Messages
+
+| Message | Meaning |
+|---|---|
+| `No renderer for overlay` | Missing rendererFor delegate method |
+| `Reuse identifier not registered` | Call register before dequeue |
+| `CLLocationManager authorizationStatus is denied` | User denied location |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10043, 2024-10094
+
+**Docs**: /mapkit, /mapkit/mklocalsearch
+
+**Skills**: axiom-mapkit, axiom-mapkit-ref, axiom-core-location-diag
diff --git a/.claude/skills/axiom-mapkit-diag/agents/openai.yaml b/.claude/skills/axiom-mapkit-diag/agents/openai.yaml
new file mode 100644
index 0000000..d5e5eab
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "MapKit Diagnostics"
+ short_description: "MapKit troubleshooting"
diff --git a/.claude/skills/axiom-mapkit-ref/.openskills.json b/.claude/skills/axiom-mapkit-ref/.openskills.json
new file mode 100644
index 0000000..9c07068
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-mapkit-ref",
+ "installedAt": "2026-04-12T08:06:28.375Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-mapkit-ref/SKILL.md b/.claude/skills/axiom-mapkit-ref/SKILL.md
new file mode 100644
index 0000000..b18a43f
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-ref/SKILL.md
@@ -0,0 +1,915 @@
+---
+name: axiom-mapkit-ref
+description: MapKit API reference — SwiftUI Map, MKMapView, Marker, Annotation, MKLocalSearch, MKDirections, Look Around, MKMapSnapshotter, clustering, overlays, GeoToolbox PlaceDescriptor, geocoding
+license: MIT
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-02-26"
+---
+
+# MapKit API Reference
+
+Complete MapKit API reference for iOS development. Covers both SwiftUI Map (iOS 17+) and MKMapView (UIKit).
+
+## Related Skills
+
+- `axiom-mapkit` — Decision trees, anti-patterns, pressure scenarios
+- `axiom-mapkit-diag` — Symptom-based troubleshooting
+
+---
+
+## Part 1: Modern API Overview
+
+| Feature | SwiftUI Map (iOS 17+) | MKMapView |
+|---|---|---|
+| Declaration | `Map(position:) { content }` | `MKMapView()` |
+| Camera control | `MapCameraPosition` binding | `setRegion(_:animated:)` |
+| Annotations | `Marker`, `Annotation` in content | `addAnnotation(_:)` + delegate |
+| Overlays | `MapCircle`, `MapPolyline`, `MapPolygon` | `addOverlay(_:)` + renderer delegate |
+| User location | `UserAnnotation()` | `showsUserLocation = true` |
+| Selection | `.mapSelection($selection)` | delegate `didSelect` |
+| Controls | `.mapControls { }` | `showsCompass`, `showsScale` |
+| Interaction modes | `.mapInteractionModes([])` | delegate methods |
+| Clustering | Built-in via `.mapItemClusteringIdentifier` | `MKClusterAnnotation` |
+
+---
+
+## Part 2: SwiftUI Map API
+
+### Basic Map
+
+```swift
+@State private var cameraPosition: MapCameraPosition = .automatic
+
+Map(position: $cameraPosition) {
+ Marker("Home", coordinate: homeCoord)
+ Annotation("Custom", coordinate: coord) {
+ Image(systemName: "star.fill")
+ .foregroundStyle(.yellow)
+ .padding(4)
+ .background(.blue, in: Circle())
+ }
+ UserAnnotation()
+ MapCircle(center: coord, radius: 500)
+ .foregroundStyle(.blue.opacity(0.3))
+ MapPolyline(coordinates: routeCoords)
+ .stroke(.blue, lineWidth: 3)
+}
+.mapStyle(.standard(elevation: .realistic))
+.mapControls {
+ MapUserLocationButton()
+ MapCompass()
+ MapScaleView()
+}
+```
+
+### MapCameraPosition
+
+Controls where the camera is positioned:
+
+```swift
+// System manages camera to show all content
+.automatic
+
+// Specific region
+.region(MKCoordinateRegion(
+ center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
+ span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
+))
+
+// Specific camera with pitch and heading
+.camera(MapCamera(
+ centerCoordinate: coordinate,
+ distance: 1000, // meters from center
+ heading: 90, // degrees from north
+ pitch: 60 // degrees from vertical (0 = top-down)
+))
+
+// Follow user location
+.userLocation(followsHeading: true, fallback: .automatic)
+
+// Show specific item
+.item(mapItem)
+
+// Show specific rect
+.rect(MKMapRect(...))
+```
+
+#### Programmatic Camera Changes
+
+```swift
+// Animate to new position
+withAnimation {
+ cameraPosition = .region(newRegion)
+}
+
+// Keyframe animation (iOS 17+)
+Map(position: $cameraPosition)
+ .mapCameraKeyframeAnimator(trigger: flyToTrigger) { initialCamera in
+ KeyframeTrack(\.centerCoordinate) {
+ LinearKeyframe(destination, duration: 2.0)
+ }
+ KeyframeTrack(\.distance) {
+ CubicKeyframe(5000, duration: 1.0)
+ CubicKeyframe(1000, duration: 1.0)
+ }
+ }
+```
+
+### Map Selection
+
+```swift
+@State private var selectedItem: MKMapItem?
+
+Map(position: $cameraPosition, selection: $selectedItem) {
+ ForEach(mapItems, id: \.self) { item in
+ Marker(item: item)
+ }
+}
+.onChange(of: selectedItem) { _, newItem in
+ if let newItem {
+ // Handle selection
+ }
+}
+```
+
+### Camera Change Callback
+
+```swift
+Map(position: $cameraPosition) { ... }
+ .onMapCameraChange { context in
+ // context.region — visible MKCoordinateRegion
+ // context.camera — current MapCamera
+ // context.rect — visible MKMapRect
+ fetchAnnotations(in: context.region)
+ }
+ .onMapCameraChange(frequency: .continuous) { context in
+ // Called during gesture (not just at end)
+ }
+```
+
+### Map Styles
+
+```swift
+.mapStyle(.standard) // Default
+.mapStyle(.standard(elevation: .realistic)) // 3D buildings
+.mapStyle(.standard(emphasis: .muted)) // Muted colors
+.mapStyle(.standard(pointsOfInterest: .including([.restaurant, .cafe])))
+.mapStyle(.imagery) // Satellite
+.mapStyle(.imagery(elevation: .realistic)) // 3D satellite
+.mapStyle(.hybrid) // Satellite + labels
+.mapStyle(.hybrid(elevation: .realistic)) // 3D hybrid
+```
+
+### Interaction Modes
+
+```swift
+// Allow all interactions (default)
+.mapInteractionModes(.all)
+
+// Read-only map (no interaction)
+.mapInteractionModes([])
+
+// Pan only, no zoom
+.mapInteractionModes([.pan])
+
+// Pan and zoom, no rotate/pitch
+.mapInteractionModes([.pan, .zoom])
+```
+
+---
+
+## Part 3: Map Content
+
+### Marker
+
+System-styled map marker with callout:
+
+```swift
+// Basic marker
+Marker("Coffee Shop", coordinate: coord)
+
+// With system image
+Marker("Coffee Shop", systemImage: "cup.and.saucer.fill", coordinate: coord)
+
+// With monogram (2 characters max)
+Marker("Coffee Shop", monogram: Text("CS"), coordinate: coord)
+
+// Color
+Marker("Coffee Shop", coordinate: coord)
+ .tint(.brown)
+
+// From MKMapItem
+Marker(item: mapItem)
+```
+
+### Annotation
+
+Fully custom view at a coordinate:
+
+```swift
+Annotation("Custom Pin", coordinate: coord) {
+ VStack {
+ Image(systemName: "mappin.circle.fill")
+ .font(.title)
+ .foregroundStyle(.red)
+ Text("Here")
+ .font(.caption)
+ }
+}
+
+// Anchor point (default is bottom center)
+Annotation("Pin", coordinate: coord, anchor: .center) {
+ Circle()
+ .fill(.blue)
+ .frame(width: 20, height: 20)
+}
+```
+
+### UserAnnotation
+
+Current user location indicator:
+
+```swift
+UserAnnotation()
+
+// Custom appearance
+UserAnnotation(anchor: .center) {
+ Image(systemName: "location.circle.fill")
+ .foregroundStyle(.blue)
+}
+```
+
+### Shape Overlays
+
+```swift
+// Circle
+MapCircle(center: coord, radius: 1000) // radius in meters
+ .foregroundStyle(.blue.opacity(0.2))
+ .stroke(.blue, lineWidth: 2)
+
+// Polygon
+MapPolygon(coordinates: polygonCoords)
+ .foregroundStyle(.green.opacity(0.3))
+ .stroke(.green, lineWidth: 2)
+
+// Polyline
+MapPolyline(coordinates: routeCoords)
+ .stroke(.blue, lineWidth: 4)
+
+// From MKRoute
+MapPolyline(route.polyline)
+ .stroke(.blue, lineWidth: 5)
+```
+
+### Clustering
+
+```swift
+ForEach(locations) { location in
+ Marker(location.name, coordinate: location.coordinate)
+ .tag(location.id)
+}
+.mapItemClusteringIdentifier("locations")
+```
+
+---
+
+## Part 4: MKMapView Lifecycle and Delegates
+
+### Creating MKMapView in SwiftUI
+
+```swift
+struct MapViewWrapper: UIViewRepresentable {
+ @Binding var region: MKCoordinateRegion
+ let annotations: [MKAnnotation]
+
+ func makeUIView(context: Context) -> MKMapView {
+ let mapView = MKMapView()
+ mapView.delegate = context.coordinator
+ mapView.showsUserLocation = true
+ mapView.register(
+ MKMarkerAnnotationView.self,
+ forAnnotationViewWithReuseIdentifier: "marker"
+ )
+ return mapView
+ }
+
+ func updateUIView(_ mapView: MKMapView, context: Context) {
+ // Guard against infinite loops
+ if !regionsAreEqual(mapView.region, region) {
+ mapView.setRegion(region, animated: true)
+ }
+
+ // Diff annotations instead of removing all
+ let current = Set(mapView.annotations.compactMap { $0 as? MyAnnotation })
+ let desired = Set(annotations.compactMap { $0 as? MyAnnotation })
+ let toAdd = desired.subtracting(current)
+ let toRemove = current.subtracting(desired)
+ mapView.addAnnotations(Array(toAdd))
+ mapView.removeAnnotations(Array(toRemove))
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ static func dismantleUIView(_ mapView: MKMapView, coordinator: Coordinator) {
+ mapView.removeAnnotations(mapView.annotations)
+ mapView.removeOverlays(mapView.overlays)
+ }
+}
+```
+
+### Key MKMapViewDelegate Methods
+
+```swift
+class Coordinator: NSObject, MKMapViewDelegate {
+ var parent: MapViewWrapper
+
+ init(_ parent: MapViewWrapper) {
+ self.parent = parent
+ }
+
+ // Annotation view customization
+ func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
+ guard !(annotation is MKUserLocation) else { return nil } // Use default for user
+
+ let view = mapView.dequeueReusableAnnotationView(
+ withIdentifier: "marker",
+ for: annotation
+ ) as! MKMarkerAnnotationView
+ view.markerTintColor = .systemRed
+ view.glyphImage = UIImage(systemName: "mappin")
+ view.clusteringIdentifier = "poi"
+ view.canShowCallout = true
+ return view
+ }
+
+ // Overlay rendering
+ func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
+ if let circle = overlay as? MKCircle {
+ let renderer = MKCircleRenderer(circle: circle)
+ renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2)
+ renderer.strokeColor = .systemBlue
+ renderer.lineWidth = 2
+ return renderer
+ }
+ if let polyline = overlay as? MKPolyline {
+ let renderer = MKPolylineRenderer(polyline: polyline)
+ renderer.strokeColor = .systemBlue
+ renderer.lineWidth = 4
+ return renderer
+ }
+ if let polygon = overlay as? MKPolygon {
+ let renderer = MKPolygonRenderer(polygon: polygon)
+ renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3)
+ renderer.strokeColor = .systemGreen
+ renderer.lineWidth = 2
+ return renderer
+ }
+ return MKOverlayRenderer(overlay: overlay)
+ }
+
+ // Region change tracking
+ func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
+ parent.region = mapView.region
+ }
+
+ // Annotation selection
+ func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
+ // Handle tap
+ }
+
+ // Cluster annotation
+ func mapView(
+ _ mapView: MKMapView,
+ clusterAnnotationForMemberAnnotations memberAnnotations: [MKAnnotation]
+ ) -> MKClusterAnnotation {
+ MKClusterAnnotation(memberAnnotations: memberAnnotations)
+ }
+}
+```
+
+---
+
+## Part 5: Annotation Types and Customization
+
+### MKMarkerAnnotationView (iOS 11+)
+
+Balloon-shaped marker with glyph:
+
+```swift
+let view = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "marker")
+view.markerTintColor = .systemPurple
+view.glyphImage = UIImage(systemName: "star.fill")
+view.glyphText = "A" // Text glyph (overrides image)
+view.displayPriority = .required // Always visible
+view.clusteringIdentifier = "category" // Enable clustering
+view.canShowCallout = true
+view.titleVisibility = .adaptive // Show title based on space
+view.subtitleVisibility = .hidden
+```
+
+### MKAnnotationView
+
+Fully custom annotation view:
+
+```swift
+let view = MKAnnotationView(annotation: annotation, reuseIdentifier: "custom")
+view.image = UIImage(named: "custom-pin")
+view.centerOffset = CGPoint(x: 0, y: -view.image!.size.height / 2)
+view.canShowCallout = true
+view.leftCalloutAccessoryView = UIImageView(image: thumbnail)
+view.rightCalloutAccessoryView = UIButton(type: .detailDisclosure)
+```
+
+### Custom Callout
+
+```swift
+func mapView(
+ _ mapView: MKMapView,
+ annotationView view: MKAnnotationView,
+ calloutAccessoryControlTapped control: UIControl
+) {
+ guard let annotation = view.annotation as? MyAnnotation else { return }
+ // Navigate to detail view
+}
+```
+
+### Annotation View Reuse
+
+Always use `dequeueReusableAnnotationView(withIdentifier:for:)`:
+
+```swift
+// Register in makeUIView (once)
+mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "marker")
+
+// Dequeue in delegate (every time)
+func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
+ let view = mapView.dequeueReusableAnnotationView(withIdentifier: "marker", for: annotation)
+ // Configure...
+ return view
+}
+```
+
+Without reuse: 1000 annotations = 1000 views in memory.
+With reuse: ~20-30 views recycled as user scrolls.
+
+---
+
+## Part 6: MKLocalSearch and MKLocalSearchCompleter
+
+### MKLocalSearchCompleter — Real-Time Autocomplete
+
+```swift
+let completer = MKLocalSearchCompleter()
+completer.delegate = self
+completer.resultTypes = [.pointOfInterest, .address]
+completer.region = visibleMapRegion // Bias results to visible area
+
+// Update on each keystroke
+completer.queryFragment = "coffee"
+
+// Delegate receives results
+func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
+ let results = completer.results // [MKLocalSearchCompletion]
+ for result in results {
+ // result.title — "Starbucks"
+ // result.subtitle — "123 Main St, San Francisco, CA"
+ // result.titleHighlightRanges — Ranges matching query
+ }
+}
+
+func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
+ // Network error, rate limit, etc.
+}
+```
+
+### MKLocalSearch — Full Search
+
+```swift
+// From autocomplete completion
+let request = MKLocalSearch.Request(completion: selectedCompletion)
+
+// From natural language
+let request = MKLocalSearch.Request()
+request.naturalLanguageQuery = "coffee shops"
+request.region = mapRegion // Bias results
+request.resultTypes = .pointOfInterest // Filter type
+request.pointOfInterestFilter = MKPointOfInterestFilter(
+ including: [.cafe, .restaurant]
+)
+
+let search = MKLocalSearch(request: request)
+let response = try await search.start()
+
+for item in response.mapItems {
+ // item.name — "Starbucks"
+ // item.placemark — MKPlacemark with address
+ // item.placemark.coordinate — CLLocationCoordinate2D
+ // item.phoneNumber — optional phone
+ // item.url — optional website
+ // item.pointOfInterestCategory — .cafe, .restaurant, etc.
+}
+```
+
+### Result Types
+
+```swift
+// Filter what kind of results to return
+request.resultTypes = .address // Street addresses only
+request.resultTypes = .pointOfInterest // Businesses, landmarks
+request.resultTypes = .physicalFeature // Mountains, lakes, parks
+request.resultTypes = .query // Suggested search queries (iOS 18+)
+request.resultTypes = [.pointOfInterest, .address] // Multiple types
+```
+
+### Rate Limiting
+
+- `MKLocalSearchCompleter` handles its own throttling — safe to call on every keystroke
+- `MKLocalSearch` — Apple rate-limits these; don't fire more than ~1/second
+- If rate-limited, you'll get an error in the completion handler
+- Reuse `MKLocalSearchCompleter` instances — don't create new ones per query
+
+---
+
+## Part 7: MKDirections and MKRoute
+
+### Calculate Directions
+
+```swift
+let request = MKDirections.Request()
+request.source = MKMapItem.forCurrentLocation()
+request.destination = destinationMapItem
+request.transportType = .automobile
+request.requestsAlternateRoutes = true // Get multiple routes
+
+let directions = MKDirections(request: request)
+let response = try await directions.calculate()
+
+for route in response.routes {
+ route.polyline // MKPolyline — display on map
+ route.expectedTravelTime // TimeInterval in seconds
+ route.distance // CLLocationDistance in meters
+ route.name // "I-280 S" — route name
+ route.advisoryNotices // [String] — warnings
+ route.steps // [MKRoute.Step] — turn-by-turn
+}
+```
+
+### Transport Types
+
+```swift
+.automobile // Driving directions
+.walking // Pedestrian directions
+.transit // Public transit (where available)
+.any // All modes
+```
+
+### ETA Only (Faster)
+
+```swift
+let directions = MKDirections(request: request)
+let eta = try await directions.calculateETA()
+eta.expectedTravelTime // TimeInterval
+eta.distance // CLLocationDistance
+eta.expectedArrivalDate // Date
+eta.expectedDepartureDate // Date
+eta.transportType // MKDirectionsTransportType
+```
+
+### Turn-by-Turn Steps
+
+```swift
+for step in route.steps {
+ step.instructions // "Turn right onto Main St"
+ step.distance // CLLocationDistance in meters
+ step.polyline // MKPolyline for this step's segment
+ step.transportType // May change for transit routes
+ step.notice // Optional advisory
+}
+```
+
+---
+
+## Part 8: Look Around
+
+### Check Availability
+
+```swift
+let request = MKLookAroundSceneRequest(coordinate: coordinate)
+do {
+ let scene = try await request.scene
+ // scene is non-nil — Look Around available at this coordinate
+} catch {
+ // Look Around not available here
+}
+```
+
+### SwiftUI
+
+```swift
+@State private var lookAroundScene: MKLookAroundScene?
+
+LookAroundPreview(scene: $lookAroundScene)
+ .frame(height: 200)
+
+// Load scene
+func loadLookAround(for coordinate: CLLocationCoordinate2D) async {
+ let request = MKLookAroundSceneRequest(coordinate: coordinate)
+ lookAroundScene = try? await request.scene
+}
+```
+
+### UIKit
+
+```swift
+let controller = MKLookAroundViewController(scene: scene)
+// Present modally or embed as child view controller
+```
+
+### Static Snapshot
+
+```swift
+let snapshotter = MKLookAroundSnapshotter(scene: scene, options: .init())
+let snapshot = try await snapshotter.snapshot
+let image = snapshot.image // UIImage
+```
+
+---
+
+## Part 9: Overlays and Renderers
+
+### Adding Overlays (MKMapView)
+
+```swift
+// Circle
+let circle = MKCircle(center: coordinate, radius: 1000)
+mapView.addOverlay(circle)
+
+// Polygon
+let polygon = MKPolygon(coordinates: &coords, count: coords.count)
+mapView.addOverlay(polygon)
+
+// Polyline
+let polyline = MKPolyline(coordinates: &coords, count: coords.count)
+mapView.addOverlay(polyline, level: .aboveRoads)
+
+// Custom tile overlay
+let template = "https://tile.example.com/{z}/{x}/{y}.png"
+let tileOverlay = MKTileOverlay(urlTemplate: template)
+tileOverlay.canReplaceMapContent = true // Hides Apple Maps base layer
+mapView.addOverlay(tileOverlay, level: .aboveLabels)
+```
+
+### Renderer Delegate
+
+```swift
+func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
+ switch overlay {
+ case let circle as MKCircle:
+ let renderer = MKCircleRenderer(circle: circle)
+ renderer.fillColor = UIColor.systemBlue.withAlphaComponent(0.2)
+ renderer.strokeColor = .systemBlue
+ renderer.lineWidth = 2
+ return renderer
+
+ case let polyline as MKPolyline:
+ let renderer = MKPolylineRenderer(polyline: polyline)
+ renderer.strokeColor = .systemBlue
+ renderer.lineWidth = 4
+ return renderer
+
+ case let polygon as MKPolygon:
+ let renderer = MKPolygonRenderer(polygon: polygon)
+ renderer.fillColor = UIColor.systemGreen.withAlphaComponent(0.3)
+ renderer.strokeColor = .systemGreen
+ renderer.lineWidth = 2
+ return renderer
+
+ case let tile as MKTileOverlay:
+ return MKTileOverlayRenderer(tileOverlay: tile)
+
+ default:
+ return MKOverlayRenderer(overlay: overlay)
+ }
+}
+```
+
+### Overlay Levels
+
+```swift
+mapView.addOverlay(overlay, level: .aboveRoads) // Above roads, below labels
+mapView.addOverlay(overlay, level: .aboveLabels) // Above everything
+```
+
+### Gradient Polyline
+
+```swift
+let renderer = MKGradientPolylineRenderer(polyline: polyline)
+renderer.setColors([.green, .yellow, .red], locations: [0.0, 0.5, 1.0])
+renderer.lineWidth = 6
+```
+
+---
+
+## Part 10: Map Snapshots
+
+Generate static map images for sharing, thumbnails, or offline display:
+
+```swift
+let options = MKMapSnapshotter.Options()
+options.region = MKCoordinateRegion(
+ center: coordinate,
+ span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
+)
+options.size = CGSize(width: 300, height: 200)
+options.scale = UIScreen.main.scale // Retina support
+options.mapType = .standard
+options.showsBuildings = true
+options.pointOfInterestFilter = .excludingAll // Clean map
+
+let snapshotter = MKMapSnapshotter(options: options)
+let snapshot = try await snapshotter.start()
+let image = snapshot.image
+
+// Draw custom annotations on snapshot
+UIGraphicsBeginImageContextWithOptions(image.size, true, image.scale)
+image.draw(at: .zero)
+
+let pinImage = UIImage(systemName: "mappin.circle.fill")!
+let point = snapshot.point(for: coordinate)
+pinImage.draw(at: CGPoint(
+ x: point.x - pinImage.size.width / 2,
+ y: point.y - pinImage.size.height
+))
+
+let finalImage = UIGraphicsGetImageFromCurrentImageContext()
+UIGraphicsEndImageContext()
+```
+
+#### Snapshot Coordinate Conversion
+
+```swift
+// Convert coordinate to point in snapshot image
+let point = snapshot.point(for: coordinate)
+
+// Check if coordinate is in snapshot bounds
+let isVisible = CGRect(origin: .zero, size: snapshot.image.size).contains(point)
+```
+
+---
+
+## Part 11: iOS Version Feature Matrix
+
+| Feature | iOS Version |
+|---|---|
+| MKMapView | 3.0+ |
+| MKLocalSearch | 6.1+ |
+| MKDirections | 7.0+ |
+| MKMarkerAnnotationView | 11.0+ |
+| MKMapSnapshotter | 7.0+ |
+| MKLookAroundSceneRequest | 16.0+ |
+| LookAroundPreview (SwiftUI) | 17.0+ |
+| SwiftUI Map (content builder) | 17.0+ |
+| MapCameraPosition | 17.0+ |
+| .mapSelection | 17.0+ |
+| .mapCameraKeyframeAnimator | 17.0+ |
+| .onMapCameraChange | 17.0+ |
+| MapUserLocationButton | 17.0+ |
+| MapCompass | 17.0+ |
+| MapScaleView | 17.0+ |
+| .mapInteractionModes | 17.0+ |
+| MKLocalSearch.ResultType.query | 18.0+ |
+| GeoToolbox / PlaceDescriptor | 26.0+ |
+| MKGeocodingRequest | 26.0+ |
+| MKReverseGeocodingRequest | 26.0+ |
+| MKAddress | 26.0+ |
+
+---
+
+## Part 12: GeoToolbox and Geocoding
+
+### GeoToolbox Framework
+
+`GeoToolbox` provides `PlaceDescriptor` — a standardized representation of physical locations that works across MapKit and third-party mapping services.
+
+```swift
+import GeoToolbox
+
+// From address
+let fountain = PlaceDescriptor(
+ representations: [.address("121-122 James's St \n Dublin 8 \n D08 ET27 \n Ireland")],
+ commonName: "Obelisk Fountain"
+)
+
+// From coordinates
+let tower = PlaceDescriptor(
+ representations: [.coordinate(CLLocationCoordinate2D(latitude: 48.8584, longitude: 2.2945))],
+ commonName: "Eiffel Tower"
+)
+
+// Multiple representations
+let statue = PlaceDescriptor(
+ representations: [
+ .coordinate(CLLocationCoordinate2D(latitude: 40.6892, longitude: -74.0445)),
+ .address("Liberty Island, New York, NY 10004, United States")
+ ],
+ commonName: "Statue of Liberty"
+)
+
+// From MKMapItem
+let descriptor = PlaceDescriptor(item: mapItem) // Returns optional
+```
+
+### PlaceRepresentation
+
+Enum representing a place using common mapping concepts:
+
+| Case | Usage |
+|---|---|
+| `.coordinate(CLLocationCoordinate2D)` | Latitude/longitude |
+| `.address(String)` | Full address string |
+
+Convenience accessors on `PlaceDescriptor`:
+
+```swift
+descriptor.coordinate // CLLocationCoordinate2D?
+descriptor.address // String?
+descriptor.commonName // String?
+```
+
+### SupportingPlaceRepresentation
+
+Proprietary identifiers for places from different mapping services:
+
+```swift
+let place = PlaceDescriptor(
+ representations: [.coordinate(CLLocationCoordinate2D(latitude: 51.5074, longitude: -0.1278))],
+ commonName: "London Eye",
+ supportingRepresentations: [
+ .serviceIdentifiers([
+ "com.apple.maps": "AppleMapsID123",
+ "com.google.maps": "GoogleMapsID456"
+ ])
+ ]
+)
+
+// Retrieve a specific service identifier
+let appleID = place.serviceIdentifier(for: "com.apple.maps")
+```
+
+### MKGeocodingRequest — Forward Geocoding
+
+Convert an address string to map items (address to coordinates):
+
+```swift
+guard let request = MKGeocodingRequest(addressString: "1 Apple Park Way, Cupertino, CA") else {
+ return
+}
+let mapItems = try await request.mapItems
+```
+
+### MKReverseGeocodingRequest — Reverse Geocoding
+
+Convert coordinates to map items (coordinates to address):
+
+```swift
+let location = CLLocation(latitude: 37.3349, longitude: -122.0090)
+guard let request = MKReverseGeocodingRequest(location: location) else {
+ return
+}
+let mapItems = try await request.mapItems
+```
+
+### MKAddress
+
+Structured address type used when creating `MKMapItem` from a `PlaceDescriptor`:
+
+```swift
+if let coordinate = descriptor.coordinate {
+ let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
+ let address = MKAddress()
+ let mapItem = MKMapItem(location: location, address: address)
+}
+```
+
+### Geocoding vs MKLocalSearch
+
+| Need | Use |
+|---|---|
+| Address string to coordinates | `MKGeocodingRequest` |
+| Coordinates to address | `MKReverseGeocodingRequest` |
+| Natural language place search | `MKLocalSearch` |
+| Autocomplete suggestions | `MKLocalSearchCompleter` |
+| Cross-service place identifiers | `PlaceDescriptor` with `SupportingPlaceRepresentation` |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10043, 2024-10094
+
+**Docs**: /mapkit, /mapkit/map, /mapkit/mklocalsearch, /mapkit/mkdirections, /geotoolbox, /geotoolbox/placedescriptor, /mapkit/mkgeocodingrequest, /mapkit/mkreversegeocodingrequest, /mapkit/mkaddress
+
+**Skills**: mapkit, mapkit-diag, core-location-ref
diff --git a/.claude/skills/axiom-mapkit-ref/agents/openai.yaml b/.claude/skills/axiom-mapkit-ref/agents/openai.yaml
new file mode 100644
index 0000000..1d108d5
--- /dev/null
+++ b/.claude/skills/axiom-mapkit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "MapKit Reference"
+ short_description: "MapKit API reference"
diff --git a/.claude/skills/axiom-mapkit/.openskills.json b/.claude/skills/axiom-mapkit/.openskills.json
new file mode 100644
index 0000000..67a5123
--- /dev/null
+++ b/.claude/skills/axiom-mapkit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-mapkit",
+ "installedAt": "2026-04-12T08:06:27.939Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-mapkit/SKILL.md b/.claude/skills/axiom-mapkit/SKILL.md
new file mode 100644
index 0000000..fecee02
--- /dev/null
+++ b/.claude/skills/axiom-mapkit/SKILL.md
@@ -0,0 +1,554 @@
+---
+name: axiom-mapkit
+description: Use when implementing maps, annotations, search, directions, or debugging MapKit display/performance issues - SwiftUI Map, MKMapView, MKLocalSearch, clustering, Look Around
+license: MIT
+metadata:
+ version: "1.0.0"
+ last-updated: "2026-02-26"
+---
+
+# MapKit Patterns
+
+MapKit patterns and anti-patterns for iOS apps. Prevents common mistakes: using MKMapView when SwiftUI Map suffices, annotations in view bodies, setRegion loops, and performance issues with large annotation counts.
+
+## When to Use
+
+- Adding a map to your iOS app
+- Displaying annotations, markers, or custom pins
+- Implementing search (address, POI, autocomplete)
+- Adding directions/routing to a map
+- Debugging map display issues (annotations not showing, region jumping)
+- Optimizing map performance with many annotations
+- Deciding between SwiftUI Map and MKMapView
+
+## Related Skills
+
+- `axiom-mapkit-ref` — Complete API reference
+- `axiom-mapkit-diag` — Symptom-based troubleshooting
+- `axiom-core-location` — Location authorization and monitoring
+
+---
+
+## Part 1: Anti-Patterns (with Time Costs)
+
+| Anti-Pattern | Time Cost | Fix |
+|---|---|---|
+| Using MKMapView when SwiftUI Map suffices | 2-4 hours UIViewRepresentable boilerplate | Use SwiftUI `Map {}` for standard map features (iOS 17+) |
+| Creating annotations in SwiftUI view body | UI freeze with 100+ items, view recreation on every update | Move annotations to model, use `@State` or `@Observable` |
+| No annotation view reuse (MKMapView) | Memory spikes, scroll lag with 500+ annotations | `dequeueReusableAnnotationView(withIdentifier:for:)` |
+| `setRegion` in `updateUIView` without guard | Infinite loop — region change triggers update, update sets region | Guard with `mapView.region != region` or use flag |
+| Ignoring MapCameraPosition (SwiftUI) | Can't programmatically control camera, broken "center on user" | Bind `position` parameter to `@State var cameraPosition` |
+| Synchronous geocoding on main thread | UI freeze for 1-3 seconds per geocode | Use `CLGeocoder().geocodeAddressString` with async/await |
+| Not filtering annotations to visible region | Loading all 10K annotations at once | Use `mapView.annotations(in:)` or fetch by visible region |
+| Ignoring `resultTypes` in MKLocalSearch | Irrelevant results, slow search | Set `.resultTypes = [.pointOfInterest]` or `.address` to filter |
+
+---
+
+## Part 2: Decision Trees
+
+### Decision Tree 1: SwiftUI Map vs MKMapView
+
+```dot
+digraph {
+ "Need map in app?" [shape=diamond];
+ "iOS 17+ target?" [shape=diamond];
+ "Need custom tile overlay?" [shape=diamond];
+ "Need fine-grained delegate control?" [shape=diamond];
+ "Use SwiftUI Map" [shape=box];
+ "Use MKMapView\nvia UIViewRepresentable" [shape=box];
+
+ "Need map in app?" -> "iOS 17+ target?" [label="yes"];
+ "iOS 17+ target?" -> "Need custom tile overlay?" [label="yes"];
+ "iOS 17+ target?" -> "Use MKMapView\nvia UIViewRepresentable" [label="no"];
+ "Need custom tile overlay?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
+ "Need custom tile overlay?" -> "Need fine-grained delegate control?" [label="no"];
+ "Need fine-grained delegate control?" -> "Use MKMapView\nvia UIViewRepresentable" [label="yes"];
+ "Need fine-grained delegate control?" -> "Use SwiftUI Map" [label="no"];
+}
+```
+
+#### When SwiftUI Map is Right (most apps)
+
+- Standard map with markers and annotations
+- Programmatic camera control
+- Built-in user location display
+- Shape overlays (circle, polygon, polyline)
+- Map style selection (standard, imagery, hybrid)
+- Selection handling
+- Clustering
+
+#### When MKMapView is Required
+
+- Custom tile overlays (e.g., OpenStreetMap, custom imagery)
+- Fine-grained delegate control (willBeginLoadingMap, didFinishLoadingMap)
+- Custom annotation view animations beyond SwiftUI
+- Pre-iOS 17 deployment target
+- Advanced overlay rendering with custom MKOverlayRenderer subclasses
+
+### Decision Tree 2: Annotation Strategy by Count
+
+```
+Annotation count?
+├─ < 100 → Use Marker/Annotation directly in Map {} content builder
+│ Simple, declarative, no performance concern
+│
+├─ 100-1000 → Enable clustering
+│ Set .clusteringIdentifier on annotation views
+│ SwiftUI: Marker("", coordinate:).tag(id)
+│ MKMapView: view.clusteringIdentifier = "poi"
+│
+└─ 1000+ → Server-side clustering or visible-region filtering
+ Fetch only annotations within mapView.region
+ Or pre-cluster on server, send cluster centroids
+ MKMapView with view reuse is preferred for very large datasets
+```
+
+#### Visible-Region Filtering (SwiftUI)
+
+Only load annotations within the visible map region. Prevents loading all 10K+ annotations at once:
+
+```swift
+struct MapView: View {
+ @State private var cameraPosition: MapCameraPosition = .automatic
+ @State private var visibleAnnotations: [Location] = []
+
+ let allLocations: [Location] // Full dataset
+
+ var body: some View {
+ Map(position: $cameraPosition) {
+ ForEach(visibleAnnotations) { location in
+ Marker(location.name, coordinate: location.coordinate)
+ }
+ }
+ .onMapCameraChange(frequency: .onEnd) { context in
+ visibleAnnotations = allLocations.filter { location in
+ context.region.contains(location.coordinate)
+ }
+ }
+ }
+}
+
+extension MKCoordinateRegion {
+ func contains(_ coordinate: CLLocationCoordinate2D) -> Bool {
+ let latRange = (center.latitude - span.latitudeDelta / 2)...(center.latitude + span.latitudeDelta / 2)
+ let lngRange = (center.longitude - span.longitudeDelta / 2)...(center.longitude + span.longitudeDelta / 2)
+ return latRange.contains(coordinate.latitude) && lngRange.contains(coordinate.longitude)
+ }
+}
+```
+
+#### Why Clustering Matters
+
+Without clustering at 500 annotations:
+- Map is unreadable (pins overlap completely)
+- Scroll/zoom lag increases with every annotation
+- Memory grows linearly with annotation count
+
+With clustering:
+- User sees meaningful groups with counts
+- Only visible cluster markers rendered
+- Tap to expand reveals individual annotations
+
+### Decision Tree 3: Search and Directions
+
+```
+Search implementation:
+├─ User types search query
+│ └─ MKLocalSearchCompleter (real-time autocomplete)
+│ Configure: resultTypes, region bias
+│ └─ User selects result
+│ └─ MKLocalSearch (full result with MKMapItem)
+│ Use completion.title for MKLocalSearch.Request
+│
+└─ Programmatic search (e.g., "nearest gas station")
+ └─ MKLocalSearch with naturalLanguageQuery
+ Configure: resultTypes, region, pointOfInterestFilter
+
+Directions implementation:
+├─ MKDirections.Request
+│ Set source (MKMapItem.forCurrentLocation()) and destination
+│ Set transportType (.automobile, .walking, .transit)
+│
+└─ MKDirections.calculate()
+ └─ MKRoute
+ ├─ .polyline → Display as MapPolyline or MKPolylineRenderer
+ ├─ .expectedTravelTime → Show ETA
+ ├─ .distance → Show distance
+ └─ .steps → Turn-by-turn instructions
+```
+
+---
+
+## Part 3: Pressure Scenarios
+
+### Scenario 1: "Just Wrap MKMapView in UIViewRepresentable"
+
+**Setup**: Adding a map to a SwiftUI app. Developer is familiar with MKMapView from UIKit projects.
+
+**Pressure**: "I know MKMapView well. SwiftUI Map is new and might be limited."
+
+**Expected with skill**: Check the decision tree. If the app needs standard markers, annotations, camera control, user location, and shape overlays — SwiftUI Map handles all of that. Use it.
+
+**Anti-pattern without skill**: 200+ lines of UIViewRepresentable + Coordinator wrapping MKMapView, manually bridging state, implementing delegate methods for annotation views, fighting updateUIView infinite loops — when 20 lines of `Map {}` with content builder would have worked.
+
+**Time cost**: 2-4 hours of unnecessary boilerplate + ongoing maintenance burden.
+
+**The test**: Can you list a specific feature the app needs that SwiftUI Map cannot provide? If not, use SwiftUI Map.
+
+### Scenario 2: "Add All 10,000 Pins to the Map"
+
+**Setup**: App has a database of 10,000 location data points. Product manager wants users to see all locations on the map.
+
+**Pressure**: "Users need to see ALL locations. Just add them all."
+
+**Expected with skill**: Use clustering + visible region filtering. 10K annotations without clustering is unusable — pins overlap, scrolling lags, memory spikes. Clustering shows meaningful groups. Visible region filtering loads only what's on screen.
+
+**Anti-pattern without skill**: Adding all 10,000 annotations at once. Map becomes an unreadable blob of overlapping pins. Scroll lag makes the app feel broken. Memory usage spikes 200-400MB.
+
+**Implementation path**:
+1. Enable clustering (`.clusteringIdentifier`)
+2. Fetch annotations only within visible region (`.onMapCameraChange` + query)
+3. Server-side pre-clustering for datasets > 5K if possible
+
+### Scenario 3: "Search Isn't Finding Results"
+
+**Setup**: MKLocalSearch returns irrelevant or empty results. Developer considers adding Google Maps SDK.
+
+**Pressure**: "MapKit search is broken. Let me add a third-party SDK."
+
+**Expected with skill**: Check configuration first. MapKit search needs:
+1. `resultTypes` — filter to `.pointOfInterest` or `.address` (default returns everything)
+2. `region` — bias results to the visible map region
+3. Query format — natural language like "coffee shops" works; structured queries don't
+
+**Anti-pattern without skill**: Adding Google Maps SDK (50+ MB binary, API key management, billing setup) when MapKit search works correctly with proper configuration.
+
+**Time cost**: 4-8 hours adding third-party SDK vs 5 minutes configuring MapKit search.
+
+---
+
+## Part 4: Core Location Integration
+
+MapKit and Core Location interact in ways that surprise developers.
+
+### Implicit Authorization
+
+When you set `showsUserLocation = true` on MKMapView or add `UserAnnotation()` in SwiftUI Map, MapKit implicitly requests location authorization if it hasn't been requested yet.
+
+This means:
+- The authorization prompt appears at map display time, not when the developer expects
+- The user sees a prompt with no context about why location is needed
+- If denied, the blue dot silently doesn't appear
+
+#### Recommended Pattern
+
+Request authorization explicitly BEFORE showing the map:
+
+```swift
+// 1. Request authorization with context
+let session = CLServiceSession(authorization: .whenInUse)
+
+// 2. Then show map with user location
+Map {
+ UserAnnotation()
+}
+```
+
+### CLServiceSession (iOS 17+)
+
+For continuous location display on a map, create a `CLServiceSession`:
+
+```swift
+@Observable
+class MapModel {
+ var cameraPosition: MapCameraPosition = .automatic
+ private var locationSession: CLServiceSession?
+
+ func startShowingUserLocation() {
+ locationSession = CLServiceSession(authorization: .whenInUse)
+ }
+
+ func stopShowingUserLocation() {
+ locationSession = nil
+ }
+}
+```
+
+### Cross-Reference
+
+For full authorization decision trees, monitoring patterns, and background location:
+- `axiom-core-location` — Authorization strategy, monitoring approach
+- `axiom-core-location-diag` — "Location not working" troubleshooting
+- `axiom-energy` — Location as battery subsystem
+
+---
+
+## Part 5: SwiftUI Map Quick Start
+
+The most common pattern — a map with markers and user location:
+
+```swift
+struct ContentView: View {
+ @State private var cameraPosition: MapCameraPosition = .automatic
+ @State private var selectedItem: MKMapItem?
+
+ let locations: [Location] // Your model
+
+ var body: some View {
+ Map(position: $cameraPosition, selection: $selectedItem) {
+ UserAnnotation()
+
+ ForEach(locations) { location in
+ Marker(location.name, coordinate: location.coordinate)
+ .tint(location.category.color)
+ }
+ }
+ .mapStyle(.standard(elevation: .realistic))
+ .mapControls {
+ MapUserLocationButton()
+ MapCompass()
+ MapScaleView()
+ }
+ .onChange(of: selectedItem) { _, item in
+ if let item {
+ handleSelection(item)
+ }
+ }
+ }
+}
+```
+
+#### Key Points
+
+- `@State var cameraPosition` — bind for programmatic camera control
+- `selection: $selectedItem` — handle tap on markers
+- `MapCameraPosition.automatic` — system manages initial view
+- `.mapControls {}` — built-in UI for location button, compass, scale
+- `ForEach` in content builder — dynamic annotations from data
+
+---
+
+## Part 6: Search Implementation Pattern
+
+Complete search with autocomplete:
+
+```swift
+@Observable
+class SearchModel {
+ var searchText = ""
+ var completions: [MKLocalSearchCompletion] = []
+ var searchResults: [MKMapItem] = []
+
+ private let completer = MKLocalSearchCompleter()
+ private var completerDelegate: CompleterDelegate?
+
+ init() {
+ completerDelegate = CompleterDelegate { [weak self] results in
+ self?.completions = results
+ }
+ completer.delegate = completerDelegate
+ completer.resultTypes = [.pointOfInterest, .address]
+ }
+
+ func updateSearch(_ text: String) {
+ searchText = text
+ completer.queryFragment = text
+ }
+
+ func search(for completion: MKLocalSearchCompletion) async throws {
+ let request = MKLocalSearch.Request(completion: completion)
+ request.resultTypes = [.pointOfInterest, .address]
+ let search = MKLocalSearch(request: request)
+ let response = try await search.start()
+ searchResults = response.mapItems
+ }
+
+ func search(query: String, in region: MKCoordinateRegion) async throws {
+ let request = MKLocalSearch.Request()
+ request.naturalLanguageQuery = query
+ request.region = region
+ request.resultTypes = .pointOfInterest
+ let search = MKLocalSearch(request: request)
+ let response = try await search.start()
+ searchResults = response.mapItems
+ }
+}
+```
+
+#### MKLocalSearchCompleter Delegate (Required)
+
+```swift
+class CompleterDelegate: NSObject, MKLocalSearchCompleterDelegate {
+ let onUpdate: ([MKLocalSearchCompletion]) -> Void
+
+ init(onUpdate: @escaping ([MKLocalSearchCompletion]) -> Void) {
+ self.onUpdate = onUpdate
+ }
+
+ func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
+ onUpdate(completer.results)
+ }
+
+ func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
+ // Handle error — network issues, rate limiting
+ }
+}
+```
+
+#### Rate Limiting
+
+Apple rate-limits MapKit search. For autocomplete:
+- `MKLocalSearchCompleter` handles its own throttling internally
+- Don't create a new completer per keystroke — reuse one instance
+- Set `queryFragment` on each keystroke; the completer debounces
+
+For `MKLocalSearch`:
+- Don't fire a search on every keystroke — use the completer for autocomplete
+- Fire `MKLocalSearch` only when the user selects a completion or submits
+
+---
+
+## Part 7: Directions Implementation Pattern
+
+```swift
+func calculateDirections(
+ from source: CLLocationCoordinate2D,
+ to destination: MKMapItem,
+ transportType: MKDirectionsTransportType = .automobile
+) async throws -> MKRoute {
+ let request = MKDirections.Request()
+ request.source = MKMapItem(placemark: MKPlacemark(coordinate: source))
+ request.destination = destination
+ request.transportType = transportType
+
+ let directions = MKDirections(request: request)
+ let response = try await directions.calculate()
+
+ guard let route = response.routes.first else {
+ throw MapError.noRouteFound
+ }
+ return route
+}
+```
+
+#### Displaying the Route (SwiftUI)
+
+```swift
+Map(position: $cameraPosition) {
+ if let route {
+ MapPolyline(route.polyline)
+ .stroke(.blue, lineWidth: 5)
+ }
+
+ Marker("Start", coordinate: startCoord)
+ Marker("End", coordinate: endCoord)
+}
+```
+
+#### Displaying the Route (MKMapView)
+
+```swift
+// Add overlay
+mapView.addOverlay(route.polyline, level: .aboveRoads)
+
+// Implement renderer delegate
+func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
+ if let polyline = overlay as? MKPolyline {
+ let renderer = MKPolylineRenderer(polyline: polyline)
+ renderer.strokeColor = .systemBlue
+ renderer.lineWidth = 5
+ return renderer
+ }
+ return MKOverlayRenderer(overlay: overlay)
+}
+```
+
+#### Route Information
+
+```swift
+let route: MKRoute = ...
+let travelTime = route.expectedTravelTime // TimeInterval in seconds
+let distance = route.distance // CLLocationDistance in meters
+let steps = route.steps // [MKRoute.Step]
+
+for step in steps {
+ print("\(step.instructions) — \(step.distance)m")
+ // "Turn right on Main St — 450m"
+}
+```
+
+---
+
+## Part 8: Clustering Pattern
+
+### SwiftUI (iOS 17+)
+
+```swift
+Map(position: $cameraPosition) {
+ ForEach(locations) { location in
+ Marker(location.name, coordinate: location.coordinate)
+ .tag(location.id)
+ }
+ .mapItemClusteringIdentifier("locations")
+}
+```
+
+### MKMapView
+
+```swift
+func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
+ if let cluster = annotation as? MKClusterAnnotation {
+ let view = mapView.dequeueReusableAnnotationView(
+ withIdentifier: "cluster",
+ for: annotation
+ ) as! MKMarkerAnnotationView
+ view.markerTintColor = .systemBlue
+ view.glyphText = "\(cluster.memberAnnotations.count)"
+ return view
+ }
+
+ let view = mapView.dequeueReusableAnnotationView(
+ withIdentifier: "pin",
+ for: annotation
+ ) as! MKMarkerAnnotationView
+ view.clusteringIdentifier = "locations"
+ view.markerTintColor = .systemRed
+ return view
+}
+```
+
+#### Clustering Requirements
+
+1. All annotation views that should cluster MUST share the same `clusteringIdentifier`
+2. Register annotation view classes: `mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: "pin")`
+3. Clustering only activates when annotations physically overlap at the current zoom level
+4. System manages cluster/uncluster animation automatically
+
+---
+
+## Part 9: Pre-Release Checklist
+
+- [ ] Map loads and displays correctly
+- [ ] Annotations appear at correct coordinates (lat/lng not swapped)
+- [ ] Clustering works with 100+ annotations
+- [ ] Search returns relevant results (resultTypes configured)
+- [ ] Camera position controllable programmatically
+- [ ] Memory stable when scrolling/zooming with many annotations
+- [ ] User location shows correctly (authorization handled before display)
+- [ ] Directions render as polyline overlay
+- [ ] Map works in Dark Mode (map styles adapt automatically)
+- [ ] Accessibility: VoiceOver announces map elements
+- [ ] No setRegion/updateUIView infinite loops (if using MKMapView)
+- [ ] MKLocalSearchCompleter reused (not recreated per keystroke)
+- [ ] Annotation views reused via `dequeueReusableAnnotationView` (MKMapView)
+- [ ] Look Around availability checked before displaying (`MKLookAroundSceneRequest`)
+
+---
+
+## Resources
+
+**WWDC**: 2023-10043, 2024-10094
+
+**Docs**: /mapkit, /mapkit/map
+
+**Skills**: mapkit-ref, mapkit-diag, core-location
diff --git a/.claude/skills/axiom-mapkit/agents/openai.yaml b/.claude/skills/axiom-mapkit/agents/openai.yaml
new file mode 100644
index 0000000..e50949b
--- /dev/null
+++ b/.claude/skills/axiom-mapkit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "MapKit"
+ short_description: "Implementing maps, annotations, search, directions, or debugging MapKit display/performance issues"
diff --git a/.claude/skills/axiom-memory-debugging/.openskills.json b/.claude/skills/axiom-memory-debugging/.openskills.json
new file mode 100644
index 0000000..291c69e
--- /dev/null
+++ b/.claude/skills/axiom-memory-debugging/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-memory-debugging",
+ "installedAt": "2026-04-12T08:06:28.590Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-memory-debugging/SKILL.md b/.claude/skills/axiom-memory-debugging/SKILL.md
new file mode 100644
index 0000000..cc56a36
--- /dev/null
+++ b/.claude/skills/axiom-memory-debugging/SKILL.md
@@ -0,0 +1,409 @@
+---
+name: axiom-memory-debugging
+description: Use when you see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so much memory', 'how do I find memory leaks', 'my deinit is never called', 'Instruments shows memory growth', 'app crashes after 10 minutes' - systematic memory leak detection and fixes for iOS/macOS
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Memory Debugging
+
+## Overview
+
+Memory issues manifest as crashes after prolonged use. **Core principle** 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess.
+
+## Example Prompts
+
+- "My app crashes after 10-15 minutes with no error messages"
+- "Memory jumps from 50MB to 200MB+ on a specific action — leak or cache?"
+- "View controllers don't deallocate after dismiss"
+- "Timers/observers causing memory leaks — how to verify?"
+- "App uses 200MB and I don't know if that's normal"
+
+---
+
+## Red Flags — Memory Leak Likely
+
+- Progressive memory growth: 50MB → 100MB → 200MB (not plateauing)
+- App crashes after 10-15 minutes with no error in Xcode console
+- Memory warnings appear repeatedly in device logs
+- View controllers don't deallocate after dismiss (visible in Memory Graph Debugger)
+- Same operation run multiple times causes linear memory growth
+
+**Leak vs normal**: Normal = stays at 100MB. Leak = 50MB → 100MB → 150MB → 200MB → CRASH.
+
+## Mandatory First Steps
+
+**ALWAYS diagnose FIRST** (before reading code):
+
+1. Check device logs for "Memory pressure critical", "Jetsam killed", "Low Memory"
+2. Use Memory Graph Debugger (below) — shows object count growth
+3. Xcode → Product → Profile → Memory. Perform action 5 times, note if memory keeps growing
+
+**What this tells you**: Flat = not a leak. Linear growth = classic leak. Spike then flat = normal cache. Spikes stacking = compound leak.
+
+**Why diagnostics first**: Finding leak with Instruments: 5-15 min. Guessing: 45+ min.
+
+## Detecting Leaks — Step by Step
+
+### Step 1: Memory Graph Debugger (Fastest)
+
+1. Open app in simulator
+2. Debug → Memory Graph Debugger (or toolbar icon)
+3. Look for PURPLE/RED circles with "⚠" badge
+4. Click them → Xcode shows retain cycle chain
+
+### Step 2: Instruments (Detailed Analysis)
+
+1. Product → Profile (Cmd+I) → "Memory" template
+2. Perform action 5-10 times
+3. Memory line goes UP for each action? = Leak confirmed
+
+Key instruments: Heap Allocations (object count), Leaked Objects (direct detection), VM Tracker (by type).
+
+### Step 3: Deallocation Check
+
+```swift
+// Add deinit logging to suspect classes
+class MyViewController: UIViewController {
+ deinit { print("✅ MyViewController deallocated") }
+}
+
+@MainActor
+class ViewModel: ObservableObject {
+ deinit { print("✅ ViewModel deallocated") }
+}
+```
+
+Navigate to view, navigate away. See "✅ deallocated"? Yes = no leak. No = retained somewhere.
+
+## Jetsam (Memory Pressure Termination)
+
+**Jetsam is not a bug** — iOS terminates background apps to free memory. Not a crash (no crash log), but frequent kills hurt UX.
+
+| Termination | Cause | Solution |
+|-------------|-------|----------|
+| **Memory Limit Exceeded** | Your app used too much memory | Reduce peak footprint |
+| **Jetsam** | System needed memory for other apps | Reduce background memory to <50MB |
+
+### Reducing Jetsam Rate
+
+Clear caches on backgrounding:
+
+```swift
+// SwiftUI
+.onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .background {
+ imageCache.clearAll()
+ URLCache.shared.removeAllCachedResponses()
+ }
+}
+```
+
+### State Restoration
+
+Users shouldn't notice jetsam. Use `@SceneStorage` (SwiftUI) or `stateRestorationActivity` (UIKit) to restore navigation position, drafts, and scroll position.
+
+### Monitoring with MetricKit
+
+```swift
+class JetsamMonitor: NSObject, MXMetricManagerSubscriber {
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ guard let exitData = payload.applicationExitMetrics else { continue }
+ let bgData = exitData.backgroundExitData
+ if bgData.cumulativeMemoryPressureExitCount > 0 {
+ // Send to analytics
+ }
+ }
+ }
+}
+```
+
+```
+App memory grows while in USE? → Memory leak (fix retention)
+App killed in BACKGROUND? → Jetsam (reduce bg memory)
+```
+
+## Common Memory Leak Patterns (With Fixes)
+
+### Pattern 1: Timer Leaks (Most Common — 50% of leaks)
+
+**Why `[weak self]` alone doesn't fix timer leaks**: The RunLoop retains scheduled timers. `[weak self]` only prevents the closure from retaining `self` — the Timer object itself continues to exist and fire. You must explicitly `invalidate()` to break the RunLoop's retention.
+
+#### ❌ Leak — Timer never invalidated
+```swift
+progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ self?.updateProgress()
+}
+// Timer never stopped → RunLoop keeps it alive and firing forever
+```
+
+#### ✅ Best fix: Combine (auto-cleanup)
+```swift
+cancellable = Timer.publish(every: 1.0, tolerance: 0.1, on: .main, in: .default)
+ .autoconnect()
+ .sink { [weak self] _ in self?.updateProgress() }
+// No deinit needed — cancellable auto-cleans when released
+```
+
+**Alternative**: Call `timer?.invalidate(); timer = nil` in both the appropriate teardown method (`viewWillDisappear`, stop method, etc.) AND `deinit`.
+
+> For timer crash patterns (EXC_BAD_INSTRUCTION) and RunLoop mode issues, see `axiom-timer-patterns`.
+
+### Pattern 2: Observer/Notification Leaks (25% of leaks)
+
+#### ❌ Leak — No removeObserver
+```swift
+NotificationCenter.default.addObserver(self, selector: #selector(handle),
+ name: AVAudioSession.routeChangeNotification, object: nil)
+// No matching removeObserver → accumulates listeners
+```
+
+#### ✅ Best fix: Combine publisher
+```swift
+NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
+ .sink { [weak self] _ in self?.handleChange() }
+ .store(in: &cancellables) // Auto-cleanup with viewModel
+```
+
+**Alternative**: `NotificationCenter.default.removeObserver(self)` in `deinit`.
+
+### Pattern 3: Closure Capture Leaks (15% of leaks)
+
+#### ❌ Leak — Closure in array captures self
+```swift
+updateCallbacks.append { [self] track in
+ self.refreshUI(with: track) // Strong capture → cycle
+}
+```
+
+#### ✅ Fix: Use [weak self]
+```swift
+updateCallbacks.append { [weak self] track in
+ self?.refreshUI(with: track)
+}
+```
+
+Clear callback arrays in `deinit`. Use `[unowned self]` only when certain self outlives the closure.
+
+### Pattern 4: Strong Reference Cycles
+
+#### ❌ Leak — Mutual strong references
+```swift
+player?.onPlaybackEnd = { [self] in self.playNextTrack() }
+// self → player → closure → self (cycle)
+```
+
+#### ✅ Fix: [weak self] in closure
+```swift
+player?.onPlaybackEnd = { [weak self] in self?.playNextTrack() }
+```
+
+### Pattern 5: View/Layout Callback Leaks
+
+Use the delegation pattern with `AnyObject` protocol (enables weak references) instead of closures that capture view controllers.
+
+### Pattern 6: PhotoKit Image Request Leaks
+
+`PHImageManager.requestImage()` returns a `PHImageRequestID` that must be cancelled. Without cancellation, pending requests queue up and hold memory when scrolling.
+
+```swift
+class PhotoCell: UICollectionViewCell {
+ private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID
+
+ func configure(with asset: PHAsset, imageManager: PHImageManager) {
+ if imageRequestID != PHInvalidImageRequestID {
+ imageManager.cancelImageRequest(imageRequestID)
+ }
+ imageRequestID = imageManager.requestImage(for: asset, targetSize: PHImageManagerMaximumSize,
+ contentMode: .aspectFill, options: nil) { [weak self] image, _ in
+ self?.imageView.image = image
+ }
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ if imageRequestID != PHInvalidImageRequestID {
+ PHImageManager.default().cancelImageRequest(imageRequestID)
+ imageRequestID = PHInvalidImageRequestID
+ }
+ imageView.image = nil
+ }
+}
+```
+
+Similar patterns: `AVAssetImageGenerator` → `cancelAllCGImageGeneration()`, `URLSession.dataTask()` → `cancel()`.
+
+## Systematic Debugging Workflow
+
+### Phase 1: Confirm Leak (5 min)
+
+Profile with Memory template, repeat action 10 times. Flat = not a leak (stop). Steady climb = leak (continue).
+
+### Phase 2: Locate Leak (10-15 min)
+
+Memory Graph Debugger → purple/red circles → click → read retain cycle chain.
+
+Common locations: Timers (50%), Notifications/KVO (25%), Closures in collections (15%), Delegate cycles (10%).
+
+### Phase 3: Fix and Verify (5 min)
+
+Apply fix from patterns above. Add `deinit { print("✅ deallocated") }`. Run Instruments again — memory should stay flat.
+
+### Compound Leaks
+
+Real apps often have 2-3 leaks stacking. Fix the largest first, re-run Instruments, repeat until flat.
+
+## Non-Reproducible / Intermittent Leaks
+
+When Instruments prevents reproduction (Heisenbug) or leaks only happen with specific user data:
+
+**Lightweight diagnostics** (when Instruments can't be attached):
+1. **deinit logging as primary diagnostic** — Add `deinit { print("✅ ClassName deallocated") }` to all suspect classes. Run 20+ sessions. When the leak occurs (e.g., 1 in 5 runs), missing deinit messages reveal which objects are retained.
+2. **Isolate the trigger** — Test each navigation path independently. Rapidly toggle background/foreground if timing-dependent. Narrow to the specific path that leaks.
+3. **MetricKit for field diagnostics** — Monitor peak memory in production via `MXMetricPayload.memoryMetrics.peakMemoryUsage`. Alert when exceeding threshold (e.g., 400MB). This catches leaks that only manifest with real user data volumes.
+
+**Common cause of intermittent leaks**: Notification observers added on lifecycle events (`viewWillAppear`, `applicationDidBecomeActive`) without removing duplicates first. Each re-registration accumulates a listener — timing determines whether the duplicate fires.
+
+**TestFlight verification**: Ship diagnostic build to affected users. Add `os_log` memory milestones. Monitor MetricKit for 24-48 hours after fix deployment.
+
+## Common Mistakes
+
+- **[weak self] without invalidate()** — Timer keeps running, consuming CPU. ALWAYS call `invalidate()` or `cancel()`
+- **Invalidate without nil** — `timer?.invalidate()` stops firing but reference remains. Always follow with `timer = nil`
+- **Local AnyCancellable** — Goes out of scope immediately, subscription dies. Store in `Set` property
+- **deinit with only logging** — Add actual cleanup (invalidate timers, remove observers), not just print statements
+- **Wrong Instruments template** — Memory shows usage. Leaks detects actual leaks. Use both
+
+## Instruments Quick Reference
+
+| Scenario | Tool | What to Look For |
+|----------|------|------------------|
+| Progressive memory growth | Memory | Line steadily climbing = leak |
+| Specific object leaking | Memory Graph | Purple/red circles = leak objects |
+| Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak |
+| Memory by type | VM Tracker | Objects consuming most memory |
+| Cache behavior | Allocations | Objects allocated but not freed |
+
+## CLI Quick Checks (No Instruments)
+
+Xcode ships CLI tools for fast memory diagnostics without opening Instruments. Use these for quick checks during development.
+
+### leaks — Detect Leaks in Running Process
+
+```bash
+# Check running app by name (positional argument, not --process)
+xcrun leaks MyApp
+
+# Check by PID
+xcrun leaks 12345
+
+# Show full stack traces for each leak
+xcrun leaks --fullStacks MyApp
+
+# Analyze a memgraph file (from Xcode's Debug Memory Graph)
+xcrun leaks MyApp.memgraph
+```
+
+**When to use**: Quick leak check without recording an Instruments trace. Run after exercising a suspect code path.
+
+### heap — Inspect Live Heap Allocations
+
+```bash
+# Show heap summary by class (process name is positional)
+xcrun heap MyApp
+
+# Show all instances of a specific class
+xcrun heap --addresses=MyViewController MyApp
+
+# Sort by size (find biggest consumers)
+xcrun heap -s MyApp
+
+# Analyze a memgraph
+xcrun heap MyApp.memgraph
+```
+
+**When to use**: Finding what's consuming memory right now. Answers "how many MyViewController instances exist?" without Instruments.
+
+### vmmap — Virtual Memory Map
+
+```bash
+# Summary view (dirty, clean, swapped)
+xcrun vmmap --summary MyApp.memgraph
+
+# Full memory regions
+xcrun vmmap MyApp.memgraph
+```
+
+**When to use**: Understanding memory composition. Shows dirty pages (your data), clean pages (mapped files), and compressed memory.
+
+### stringdups — Find Duplicate Strings
+
+```bash
+# Find duplicate strings in running process (positional argument)
+xcrun stringdups MyApp
+
+# Analyze a memgraph
+xcrun stringdups MyApp.memgraph
+```
+
+**When to use**: Reducing memory footprint from repeated string allocations. No GUI equivalent.
+
+### malloc_history — Track Allocation Origins
+
+```bash
+# Enable malloc logging first: set MallocStackLogging=1 in scheme env vars
+# Then query a specific address
+xcrun malloc_history
+
+# Show all allocations sorted by size
+xcrun malloc_history -allBySize
+```
+
+**When to use**: Tracing where a leaked object was allocated. Requires `MallocStackLogging=1` environment variable in scheme.
+
+### Quick Diagnosis Workflow
+
+```bash
+# 1. Is there a leak? (30 seconds)
+xcrun leaks MyApp
+
+# 2. What's on the heap? (30 seconds)
+xcrun heap -s MyApp
+
+# 3. Any duplicate strings wasting memory? (30 seconds)
+xcrun stringdups MyApp
+
+# 4. Where is memory allocated? (requires memgraph)
+xcrun vmmap --summary MyApp.memgraph
+```
+
+**Time cost**: 2 minutes for a full CLI memory check vs 10+ minutes launching Instruments.
+
+### xctrace (Headless Instruments)
+
+```bash
+# Record memory trace without GUI
+xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output memory.trace
+
+# Record leak detection
+xcrun xctrace record --instrument 'Leaks' --attach 'MyApp' --time-limit 30s --output leaks.trace
+```
+
+## Real-World Impact
+
+**Before**: 50+ PlayerViewModel instances with uncleared timers → 50MB → 200MB → Crash (13min)
+**After**: Timer properly invalidated → 50MB stable for hours
+
+**Key insight** 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in `deinit` or use reactive patterns that auto-cleanup.
+
+---
+
+## Resources
+
+**WWDC**: 2021-10180, 2020-10078, 2018-416
+
+**Docs**: /xcode/gathering-information-about-memory-use, /metrickit/mxbackgroundexitdata
+
+**Skills**: axiom-performance-profiling, axiom-objc-block-retain-cycles, axiom-metrickit-ref, axiom-lldb (inspect retain cycles interactively)
diff --git a/.claude/skills/axiom-memory-debugging/agents/openai.yaml b/.claude/skills/axiom-memory-debugging/agents/openai.yaml
new file mode 100644
index 0000000..3bee1bb
--- /dev/null
+++ b/.claude/skills/axiom-memory-debugging/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Memory Debugging"
+ short_description: "You see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so muc..."
diff --git a/.claude/skills/axiom-metal-migration-diag/.openskills.json b/.claude/skills/axiom-metal-migration-diag/.openskills.json
new file mode 100644
index 0000000..4e9f000
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-metal-migration-diag",
+ "installedAt": "2026-04-12T08:06:29.030Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-metal-migration-diag/SKILL.md b/.claude/skills/axiom-metal-migration-diag/SKILL.md
new file mode 100644
index 0000000..ff19561
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration-diag/SKILL.md
@@ -0,0 +1,473 @@
+---
+name: axiom-metal-migration-diag
+description: Use when ANY Metal porting issue occurs - black screen, rendering artifacts, shader errors, wrong colors, performance regressions, GPU crashes
+license: MIT
+compatibility: [iOS 12+, macOS 10.14+, tvOS 12+]
+metadata:
+ version: "1.0.0"
+---
+
+# Metal Migration Diagnostics
+
+Systematic diagnosis for common Metal porting issues.
+
+## When to Use This Diagnostic Skill
+
+Use this skill when:
+- Screen is black after porting to Metal
+- Shaders fail to compile in Metal
+- Colors or coordinates are wrong
+- Performance is worse than the original
+- Rendering artifacts appear
+- App crashes during GPU work
+
+## Mandatory First Step: Enable Metal Validation
+
+**Time cost**: 30 seconds setup vs hours of blind debugging
+
+Before ANY debugging, enable Metal validation:
+
+```
+Xcode → Edit Scheme → Run → Diagnostics
+✓ Metal API Validation
+✓ Metal Shader Validation
+✓ GPU Frame Capture (Metal)
+```
+
+Most Metal bugs produce clear validation errors. If you're debugging without validation enabled, **stop and enable it first**.
+
+## Symptom 1: Black Screen
+
+### Decision Tree
+
+```
+Black screen after porting
+│
+├─ Are there Metal validation errors in console?
+│ └─ YES → Fix validation errors first (see below)
+│
+├─ Is the render pass descriptor valid?
+│ ├─ Check: view.currentRenderPassDescriptor != nil
+│ ├─ Check: drawable = view.currentDrawable != nil
+│ └─ FIX: Ensure MTKView.device is set, view is on screen
+│
+├─ Is the pipeline state created?
+│ ├─ Check: makeRenderPipelineState doesn't throw
+│ └─ FIX: Check shader function names match library
+│
+├─ Are draw calls being issued?
+│ ├─ Add: encoder.label = "Main Pass" for frame capture
+│ └─ DEBUG: GPU Frame Capture → verify draw calls appear
+│
+├─ Are resources bound?
+│ ├─ Check: setVertexBuffer, setFragmentTexture called
+│ └─ FIX: Metal requires explicit binding every frame
+│
+├─ Is the vertex data correct?
+│ ├─ DEBUG: GPU Frame Capture → inspect vertex buffer
+│ └─ FIX: Check buffer offsets, vertex count
+│
+├─ Are coordinates in Metal's range?
+│ ├─ Metal NDC: X [-1,1], Y [-1,1], Z [0,1]
+│ ├─ OpenGL NDC: X [-1,1], Y [-1,1], Z [-1,1]
+│ └─ FIX: Adjust projection matrix or vertex shader
+│
+└─ Is clear color set?
+ ├─ Default clear color is (0,0,0,0) — transparent black
+ └─ FIX: Set view.clearColor or renderPassDescriptor.colorAttachments[0].clearColor
+```
+
+### Common Fixes
+
+**Missing Drawable**:
+```swift
+// BAD: Drawing before view is ready
+override func viewDidLoad() {
+ draw() // metalView.currentDrawable is nil
+}
+
+// GOOD: Wait for delegate callback
+func draw(in view: MTKView) {
+ guard let drawable = view.currentDrawable else { return }
+ // Safe to draw
+}
+```
+
+**Wrong Function Names**:
+```swift
+// BAD: Function name doesn't match .metal file
+descriptor.vertexFunction = library.makeFunction(name: "vertexMain")
+// .metal file has: vertex VertexOut vertexShader(...)
+
+// GOOD: Names must match exactly
+descriptor.vertexFunction = library.makeFunction(name: "vertexShader")
+```
+
+**Missing Resource Binding**:
+```swift
+// BAD: Assumed state persists like OpenGL
+encoder.setRenderPipelineState(pso)
+encoder.drawPrimitives(...) // No buffers bound!
+
+// GOOD: Bind everything explicitly
+encoder.setRenderPipelineState(pso)
+encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+encoder.setVertexBytes(&uniforms, length: uniformsSize, index: 1)
+encoder.setFragmentTexture(texture, index: 0)
+encoder.drawPrimitives(...)
+```
+
+**Time cost**: GPU Frame Capture diagnosis: 5-10 min. Guessing without tools: 1-4 hours.
+
+## Symptom 2: Shader Compilation Errors
+
+### Decision Tree
+
+```
+Shader fails to compile
+│
+├─ "Use of undeclared identifier"
+│ ├─ Check: #include
+│ ├─ Check: using namespace metal;
+│ └─ FIX: Standard functions need metal_stdlib
+│
+├─ "No matching function for call to 'texture'"
+│ └─ GLSL texture() → MSL tex.sample(sampler, uv)
+│ FIX: Texture sampling is a method, needs sampler
+│
+├─ "Invalid type 'vec4'"
+│ └─ GLSL vec4 → MSL float4
+│ FIX: See type mapping table in metal-migration-ref
+│
+├─ "No matching constructor"
+│ ├─ GLSL: vec4(vec3, float) works
+│ ├─ MSL: float4(float3, float) works
+│ └─ Check: Argument types match exactly
+│
+├─ "Attribute index out of range"
+│ ├─ Check: [[attribute(N)]] matches vertex descriptor
+│ └─ FIX: vertexDescriptor.attributes[N] must be configured
+│
+├─ "Buffer binding index out of range"
+│ ├─ Check: [[buffer(N)]] where N < 31
+│ └─ FIX: Metal has max 31 buffer bindings per stage
+│
+└─ "Cannot convert value of type"
+ ├─ MSL is stricter than GLSL about implicit conversions
+ └─ FIX: Add explicit casts: float(intValue), int(floatValue)
+```
+
+### Common Conversions
+
+```metal
+// GLSL
+vec4 color = texture(sampler2D, uv);
+
+// MSL — texture and sampler are separate
+float4 color = tex.sample(samp, uv);
+
+// GLSL — mod() for floats
+float x = mod(y, z);
+
+// MSL — fmod() for floats
+float x = fmod(y, z);
+
+// GLSL — atan(y, x)
+float angle = atan(y, x);
+
+// MSL — atan2(y, x)
+float angle = atan2(y, x);
+
+// GLSL — inversesqrt
+float invSqrt = inversesqrt(x);
+
+// MSL — rsqrt
+float invSqrt = rsqrt(x);
+```
+
+**Time cost**: With conversion table: 2-5 min per shader. Without: 15-30 min per shader.
+
+## Symptom 3: Wrong Colors or Coordinates
+
+### Decision Tree
+
+```
+Rendering looks wrong
+│
+├─ Image is upside down
+│ ├─ Cause: Metal Y-axis is opposite OpenGL
+│ ├─ FIX (vertex shader): pos.y = -pos.y
+│ ├─ FIX (texture load): MTKTextureLoader .origin: .bottomLeft
+│ └─ FIX (UV): uv.y = 1.0 - uv.y in fragment shader
+│
+├─ Image is mirrored
+│ ├─ Cause: Winding order or cull mode wrong
+│ ├─ FIX: encoder.setFrontFacing(.counterClockwise)
+│ └─ FIX: encoder.setCullMode(.back) or .none to test
+│
+├─ Colors are swapped (red/blue)
+│ ├─ Cause: Pixel format mismatch
+│ ├─ Check: .bgra8Unorm vs .rgba8Unorm
+│ └─ FIX: Match texture pixel format to data format
+│
+├─ Colors are washed out / too bright
+│ ├─ Cause: sRGB vs linear color space
+│ ├─ Check: Using .bgra8Unorm_srgb for sRGB textures?
+│ └─ FIX: Use _srgb format variants for gamma-correct rendering
+│
+├─ Depth fighting / z-fighting
+│ ├─ Cause: NDC Z range difference
+│ ├─ OpenGL: Z in [-1, 1]
+│ ├─ Metal: Z in [0, 1]
+│ └─ FIX: Adjust projection matrix for Metal's Z range
+│
+├─ Objects clipped incorrectly
+│ ├─ Cause: Near/far plane or viewport
+│ ├─ Check: Viewport size matches drawable size
+│ └─ FIX: encoder.setViewport(MTLViewport(...))
+│
+└─ Transparency wrong
+ ├─ Cause: Blend state not configured
+ ├─ FIX: pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true
+ └─ FIX: Set sourceRGBBlendFactor, destinationRGBBlendFactor
+```
+
+### Coordinate System Fix
+
+```swift
+// Fix projection matrix for Metal's Z range [0, 1]
+func metalPerspectiveProjection(fovY: Float, aspect: Float, near: Float, far: Float) -> simd_float4x4 {
+ let yScale = 1.0 / tan(fovY * 0.5)
+ let xScale = yScale / aspect
+ let zRange = far - near
+
+ return simd_float4x4(rows: [
+ SIMD4(xScale, 0, 0, 0),
+ SIMD4(0, yScale, 0, 0),
+ SIMD4(0, 0, far / zRange, 1), // Metal: [0, 1]
+ SIMD4(0, 0, -near * far / zRange, 0)
+ ])
+}
+```
+
+**Time cost**: With GPU Frame Capture texture inspection: 5-10 min. Without: 1-2 hours.
+
+## Symptom 4: Performance Regression
+
+### Decision Tree
+
+```
+Performance worse than OpenGL
+│
+├─ Enabling validation?
+│ └─ Validation adds ~30% overhead
+│ FIX: Disable for release builds, keep for debug
+│
+├─ Creating resources every frame?
+│ ├─ BAD: device.makeBuffer() in draw()
+│ └─ FIX: Create buffers once, reuse with triple buffering
+│
+├─ Creating pipeline state every frame?
+│ ├─ BAD: makeRenderPipelineState() in draw()
+│ └─ FIX: Create PSO once at init, store as property
+│
+├─ Too many draw calls?
+│ ├─ DEBUG: GPU Frame Capture → count draw calls
+│ └─ FIX: Batch geometry, use instancing, indirect draws
+│
+├─ GPU-CPU sync stalls?
+│ ├─ DEBUG: Metal System Trace → look for stalls
+│ ├─ Cause: waitUntilCompleted() blocks CPU
+│ └─ FIX: Triple buffering with semaphore
+│
+├─ Inefficient buffer updates?
+│ ├─ BAD: Recreating buffer to update
+│ └─ FIX: buffer.contents().copyMemory() for dynamic data
+│
+├─ Wrong storage mode?
+│ ├─ .shared: Good for small dynamic data
+│ ├─ .private: Good for static GPU-only data
+│ └─ FIX: Use .private for geometry that doesn't change
+│
+└─ Missing Metal-specific optimizations?
+ ├─ Argument buffers reduce binding overhead
+ ├─ Indirect draws reduce CPU work
+ └─ See WWDC sessions on Metal optimization
+```
+
+### Triple Buffering Pattern
+
+```swift
+class TripleBufferedRenderer {
+ static let maxInflightFrames = 3
+
+ let inflightSemaphore = DispatchSemaphore(value: maxInflightFrames)
+ var uniformBuffers: [MTLBuffer] = []
+ var currentBufferIndex = 0
+
+ init(device: MTLDevice) {
+ for _ in 0..10 sec on iOS)
+│ ├─ Check: Infinite loop in shader?
+│ └─ FIX: Add early exit conditions, reduce work
+│
+├─ "-[MTLDebugRenderCommandEncoder validateDrawCallWithArray:...]"
+│ ├─ Cause: Validation caught misuse
+│ └─ FIX: Read the validation message — it tells you exactly what's wrong
+│
+├─ "Fragment shader writes to non-existent render target"
+│ ├─ Cause: Shader returns color but no color attachment
+│ └─ FIX: Configure colorAttachments[0].pixelFormat
+│
+├─ Crash in shader (SIGABRT)
+│ ├─ Cause: Out-of-bounds buffer access
+│ ├─ DEBUG: Enable shader validation
+│ └─ FIX: Check array bounds, buffer sizes
+│
+└─ Device disconnected / GPU restart
+ ├─ Cause: Severe GPU hang
+ ├─ Check: Infinite loop or massive overdraw
+ └─ FIX: Simplify shader, reduce draw complexity
+```
+
+### Resource Lifetime Fix
+
+```swift
+// BAD: Buffer released before GPU finishes
+func draw(in view: MTKView) {
+ let buffer = device.makeBuffer(...) // Created here
+ encoder.setVertexBuffer(buffer, ...)
+ commandBuffer.commit()
+ // buffer released at end of scope — GPU still using it!
+}
+
+// GOOD: Keep reference until completion
+class Renderer {
+ var currentBuffer: MTLBuffer? // Strong reference
+
+ func draw(in view: MTKView) {
+ currentBuffer = device.makeBuffer(...)
+ encoder.setVertexBuffer(currentBuffer!, ...)
+ commandBuffer.addCompletedHandler { [weak self] _ in
+ // Safe to release now
+ self?.currentBuffer = nil
+ }
+ commandBuffer.commit()
+ }
+}
+```
+
+## Debugging Tools Quick Reference
+
+### GPU Frame Capture
+
+```
+Xcode → Debug → Capture GPU Frame (Cmd+Opt+Shift+G)
+```
+
+**Use for**:
+- Inspecting buffer contents
+- Viewing intermediate textures
+- Checking draw call sequence
+- Debugging shader variable values
+- Understanding why something isn't rendering
+
+### Metal System Trace (Instruments)
+
+```
+Instruments → Metal System Trace template
+```
+
+**Use for**:
+- GPU/CPU timeline analysis
+- Finding synchronization stalls
+- Measuring encoder/buffer overhead
+- Identifying bottlenecks
+
+### Shader Debugger
+
+```
+GPU Frame Capture → Select draw call → Debug button
+```
+
+**Use for**:
+- Step through shader execution
+- Inspect variable values per pixel/vertex
+- Find logic errors in shaders
+
+### Validation Messages
+
+Most validation messages include:
+- What went wrong
+- Which resource/state
+- What the expected value was
+
+**Always read the full message** — it usually tells you exactly how to fix the problem.
+
+## Diagnostic Checklist
+
+When something doesn't work:
+
+- [ ] **Metal validation enabled?** (Most bugs produce validation errors)
+- [ ] **GPU Frame Capture available?** (Visual debugging is fastest)
+- [ ] **Console error messages?** (Read them fully)
+- [ ] **Resources bound?** (Metal requires explicit binding)
+- [ ] **Coordinates correct?** (Y-flip, NDC Z range)
+- [ ] **Pipeline state created successfully?** (Check for throw)
+- [ ] **Drawable available?** (View must be on screen)
+
+## Resources
+
+**WWDC**: 2019-00611, 2020-10602, 2020-10603
+
+**Docs**: /metal/debugging-metal-applications, /metal/gpu-capture
+
+**Skills**: axiom-metal-migration, axiom-metal-migration-ref
+
+---
+
+**Last Updated**: 2025-12-29
+**Platforms**: iOS 12+, macOS 10.14+, tvOS 12+
+**Status**: Comprehensive Metal porting diagnostics
diff --git a/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml b/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml
new file mode 100644
index 0000000..21e4d56
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Metal Migration Diagnostics"
+ short_description: "ANY Metal porting issue occurs"
diff --git a/.claude/skills/axiom-metal-migration-ref/.openskills.json b/.claude/skills/axiom-metal-migration-ref/.openskills.json
new file mode 100644
index 0000000..6979061
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-metal-migration-ref",
+ "installedAt": "2026-04-12T08:06:29.229Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-metal-migration-ref/SKILL.md b/.claude/skills/axiom-metal-migration-ref/SKILL.md
new file mode 100644
index 0000000..97d7389
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration-ref/SKILL.md
@@ -0,0 +1,649 @@
+---
+name: axiom-metal-migration-ref
+description: Use when converting shaders or looking up API equivalents - GLSL to MSL, HLSL to MSL, GL/DirectX to Metal mappings, MTKView setup code
+license: MIT
+compatibility: [iOS 12+, macOS 10.14+, tvOS 12+]
+metadata:
+ version: "1.0.0"
+---
+
+# Metal Migration Reference
+
+Complete reference for converting OpenGL/DirectX code to Metal.
+
+## When to Use This Reference
+
+Use this reference when:
+- Converting GLSL shaders to Metal Shading Language (MSL)
+- Converting HLSL shaders to MSL
+- Looking up GL/D3D API equivalents in Metal
+- Setting up MTKView or CAMetalLayer
+- Building render pipelines
+- Using Metal Shader Converter for DirectX
+
+## Part 1: GLSL to MSL Conversion
+
+### Type Mappings
+
+| GLSL | MSL | Notes |
+|------|-----|-------|
+| `void` | `void` | |
+| `bool` | `bool` | |
+| `int` | `int` | 32-bit signed |
+| `uint` | `uint` | 32-bit unsigned |
+| `float` | `float` | 32-bit |
+| `double` | N/A | Use `float` (no 64-bit float in MSL) |
+| `vec2` | `float2` | |
+| `vec3` | `float3` | |
+| `vec4` | `float4` | |
+| `ivec2` | `int2` | |
+| `ivec3` | `int3` | |
+| `ivec4` | `int4` | |
+| `uvec2` | `uint2` | |
+| `uvec3` | `uint3` | |
+| `uvec4` | `uint4` | |
+| `bvec2` | `bool2` | |
+| `bvec3` | `bool3` | |
+| `bvec4` | `bool4` | |
+| `mat2` | `float2x2` | |
+| `mat3` | `float3x3` | |
+| `mat4` | `float4x4` | |
+| `mat2x3` | `float2x3` | Columns x Rows |
+| `mat3x4` | `float3x4` | |
+| `sampler2D` | `texture2d` + `sampler` | Separate in MSL |
+| `sampler3D` | `texture3d` + `sampler` | |
+| `samplerCube` | `texturecube` + `sampler` | |
+| `sampler2DArray` | `texture2d_array` + `sampler` | |
+| `sampler2DShadow` | `depth2d` + `sampler` | |
+
+### Built-in Variable Mappings
+
+| GLSL | MSL | Stage |
+|------|-----|-------|
+| `gl_Position` | Return `[[position]]` | Vertex |
+| `gl_PointSize` | Return `[[point_size]]` | Vertex |
+| `gl_VertexID` | `[[vertex_id]]` parameter | Vertex |
+| `gl_InstanceID` | `[[instance_id]]` parameter | Vertex |
+| `gl_FragCoord` | `[[position]]` parameter | Fragment |
+| `gl_FrontFacing` | `[[front_facing]]` parameter | Fragment |
+| `gl_PointCoord` | `[[point_coord]]` parameter | Fragment |
+| `gl_FragDepth` | Return `[[depth(any)]]` | Fragment |
+| `gl_SampleID` | `[[sample_id]]` parameter | Fragment |
+| `gl_SamplePosition` | `[[sample_position]]` parameter | Fragment |
+
+### Function Mappings
+
+| GLSL | MSL | Notes |
+|------|-----|-------|
+| `texture(sampler, uv)` | `tex.sample(sampler, uv)` | Method on texture |
+| `textureLod(sampler, uv, lod)` | `tex.sample(sampler, uv, level(lod))` | |
+| `textureGrad(sampler, uv, ddx, ddy)` | `tex.sample(sampler, uv, gradient2d(ddx, ddy))` | |
+| `texelFetch(sampler, coord, lod)` | `tex.read(coord, lod)` | Integer coords |
+| `textureSize(sampler, lod)` | `tex.get_width(lod)`, `tex.get_height(lod)` | Separate calls |
+| `dFdx(v)` | `dfdx(v)` | |
+| `dFdy(v)` | `dfdy(v)` | |
+| `fwidth(v)` | `fwidth(v)` | Same |
+| `mix(a, b, t)` | `mix(a, b, t)` | Same |
+| `clamp(v, lo, hi)` | `clamp(v, lo, hi)` | Same |
+| `smoothstep(e0, e1, x)` | `smoothstep(e0, e1, x)` | Same |
+| `step(edge, x)` | `step(edge, x)` | Same |
+| `mod(x, y)` | `fmod(x, y)` | Different name |
+| `fract(x)` | `fract(x)` | Same |
+| `inversesqrt(x)` | `rsqrt(x)` | Different name |
+| `atan(y, x)` | `atan2(y, x)` | Different name |
+
+### Shader Structure Conversion
+
+**GLSL Vertex Shader**:
+```glsl
+#version 300 es
+precision highp float;
+
+layout(location = 0) in vec3 aPosition;
+layout(location = 1) in vec2 aTexCoord;
+
+uniform mat4 uModelViewProjection;
+
+out vec2 vTexCoord;
+
+void main() {
+ gl_Position = uModelViewProjection * vec4(aPosition, 1.0);
+ vTexCoord = aTexCoord;
+}
+```
+
+**MSL Vertex Shader**:
+```metal
+#include
+using namespace metal;
+
+struct VertexIn {
+ float3 position [[attribute(0)]];
+ float2 texCoord [[attribute(1)]];
+};
+
+struct VertexOut {
+ float4 position [[position]];
+ float2 texCoord;
+};
+
+struct Uniforms {
+ float4x4 modelViewProjection;
+};
+
+vertex VertexOut vertexShader(
+ VertexIn in [[stage_in]],
+ constant Uniforms& uniforms [[buffer(1)]]
+) {
+ VertexOut out;
+ out.position = uniforms.modelViewProjection * float4(in.position, 1.0);
+ out.texCoord = in.texCoord;
+ return out;
+}
+```
+
+**GLSL Fragment Shader**:
+```glsl
+#version 300 es
+precision highp float;
+
+in vec2 vTexCoord;
+uniform sampler2D uTexture;
+
+out vec4 fragColor;
+
+void main() {
+ fragColor = texture(uTexture, vTexCoord);
+}
+```
+
+**MSL Fragment Shader**:
+```metal
+fragment float4 fragmentShader(
+ VertexOut in [[stage_in]],
+ texture2d tex [[texture(0)]],
+ sampler samp [[sampler(0)]]
+) {
+ return tex.sample(samp, in.texCoord);
+}
+```
+
+### Precision Qualifiers
+
+GLSL precision qualifiers have no direct MSL equivalent — MSL uses explicit types:
+
+| GLSL | MSL Equivalent |
+|------|----------------|
+| `lowp float` | `half` (16-bit) |
+| `mediump float` | `half` (16-bit) |
+| `highp float` | `float` (32-bit) |
+| `lowp int` | `short` (16-bit) |
+| `mediump int` | `short` (16-bit) |
+| `highp int` | `int` (32-bit) |
+
+### Buffer Alignment (Critical)
+
+**GLSL/C assumes**:
+- `vec3`: 12 bytes, any alignment
+- `vec4`: 16 bytes
+
+**MSL requires**:
+- `float3`: 12 bytes storage, **16-byte aligned**
+- `float4`: 16 bytes storage, 16-byte aligned
+
+**Solution**: Use `simd` types in Swift for CPU-GPU shared structs:
+
+```swift
+import simd
+
+struct Uniforms {
+ var modelViewProjection: simd_float4x4 // Correct alignment
+ var cameraPosition: simd_float3 // 16-byte aligned
+ var padding: Float = 0 // Explicit padding if needed
+}
+```
+
+Or use packed types in MSL (slower):
+```metal
+struct VertexPacked {
+ packed_float3 position; // 12 bytes, no padding
+ packed_float2 texCoord; // 8 bytes
+};
+```
+
+## Part 2: HLSL to MSL Conversion
+
+### Type Mappings
+
+| HLSL | MSL | Notes |
+|------|-----|-------|
+| `float` | `float` | |
+| `float2` | `float2` | |
+| `float3` | `float3` | |
+| `float4` | `float4` | |
+| `half` | `half` | |
+| `int` | `int` | |
+| `uint` | `uint` | |
+| `bool` | `bool` | |
+| `float2x2` | `float2x2` | |
+| `float3x3` | `float3x3` | |
+| `float4x4` | `float4x4` | |
+| `Texture2D` | `texture2d` | |
+| `Texture3D` | `texture3d` | |
+| `TextureCube` | `texturecube` | |
+| `SamplerState` | `sampler` | |
+| `RWTexture2D` | `texture2d` | |
+| `RWBuffer` | `device float* [[buffer(n)]]` | |
+| `StructuredBuffer` | `constant T* [[buffer(n)]]` | |
+| `RWStructuredBuffer` | `device T* [[buffer(n)]]` | |
+
+### Semantic Mappings
+
+| HLSL Semantic | MSL Attribute |
+|---------------|---------------|
+| `SV_Position` | `[[position]]` |
+| `SV_Target0` | Return value / `[[color(0)]]` |
+| `SV_Target1` | `[[color(1)]]` |
+| `SV_Depth` | `[[depth(any)]]` |
+| `SV_VertexID` | `[[vertex_id]]` |
+| `SV_InstanceID` | `[[instance_id]]` |
+| `SV_IsFrontFace` | `[[front_facing]]` |
+| `SV_SampleIndex` | `[[sample_id]]` |
+| `SV_PrimitiveID` | `[[primitive_id]]` |
+| `SV_DispatchThreadID` | `[[thread_position_in_grid]]` |
+| `SV_GroupThreadID` | `[[thread_position_in_threadgroup]]` |
+| `SV_GroupID` | `[[threadgroup_position_in_grid]]` |
+| `SV_GroupIndex` | `[[thread_index_in_threadgroup]]` |
+
+### Function Mappings
+
+| HLSL | MSL | Notes |
+|------|-----|-------|
+| `tex.Sample(samp, uv)` | `tex.sample(samp, uv)` | Lowercase |
+| `tex.SampleLevel(samp, uv, lod)` | `tex.sample(samp, uv, level(lod))` | |
+| `tex.SampleGrad(samp, uv, ddx, ddy)` | `tex.sample(samp, uv, gradient2d(ddx, ddy))` | |
+| `tex.Load(coord)` | `tex.read(coord.xy, coord.z)` | Split coord |
+| `mul(a, b)` | `a * b` | Operator |
+| `saturate(x)` | `saturate(x)` | Same |
+| `lerp(a, b, t)` | `mix(a, b, t)` | Different name |
+| `frac(x)` | `fract(x)` | Different name |
+| `ddx(v)` | `dfdx(v)` | Different name |
+| `ddy(v)` | `dfdy(v)` | Different name |
+| `clip(x)` | `if (x < 0) discard_fragment()` | Manual |
+| `discard` | `discard_fragment()` | Function call |
+
+### Metal Shader Converter (DirectX → Metal)
+
+Apple's official tool for converting DXIL (compiled HLSL) to Metal libraries.
+
+**Requirements**:
+- macOS 13+ with Xcode 15+
+- OR Windows 10+ with VS 2019+
+- Target devices: Argument Buffers Tier 2 (macOS 14+, iOS 17+)
+
+**Workflow**:
+
+```bash
+# Step 1: Compile HLSL to DXIL using DXC
+dxc -T vs_6_0 -E MainVS -Fo vertex.dxil shader.hlsl
+dxc -T ps_6_0 -E MainPS -Fo fragment.dxil shader.hlsl
+
+# Step 2: Convert DXIL to Metal library
+metal-shaderconverter vertex.dxil -o vertex.metallib
+metal-shaderconverter fragment.dxil -o fragment.metallib
+
+# Step 3: Load in Swift
+let vertexLib = try device.makeLibrary(URL: vertexURL)
+let fragmentLib = try device.makeLibrary(URL: fragmentURL)
+```
+
+**Key Options**:
+
+| Option | Purpose |
+|--------|---------|
+| `-o ` | Output metallib path |
+| `--minimum-gpu-family` | Target GPU family |
+| `--minimum-os-build-version` | Minimum OS version |
+| `--vertex-stage-in` | Separate vertex fetch function |
+| `-dualSourceBlending` | Enable dual-source blending |
+
+**Supported Shader Models**: SM 6.0 - 6.6 (with limitations on 6.6 features)
+
+## Part 3: OpenGL API to Metal API
+
+### View/Context Setup
+
+| OpenGL | Metal |
+|--------|-------|
+| `NSOpenGLView` | `MTKView` |
+| `GLKView` | `MTKView` |
+| `EAGLContext` | `MTLDevice` + `MTLCommandQueue` |
+| `CGLContextObj` | `MTLDevice` |
+
+### Resource Creation
+
+| OpenGL | Metal |
+|--------|-------|
+| `glGenBuffers` + `glBufferData` | `device.makeBuffer(bytes:length:options:)` |
+| `glGenTextures` + `glTexImage2D` | `device.makeTexture(descriptor:)` + `texture.replace(region:...)` |
+| `glGenFramebuffers` | `MTLRenderPassDescriptor` |
+| `glGenVertexArrays` | `MTLVertexDescriptor` |
+| `glCreateShader` + `glCompileShader` | Build-time compilation → `MTLLibrary` |
+| `glCreateProgram` + `glLinkProgram` | `MTLRenderPipelineDescriptor` → `MTLRenderPipelineState` |
+
+### State Management
+
+| OpenGL | Metal |
+|--------|-------|
+| `glEnable(GL_DEPTH_TEST)` | `MTLDepthStencilDescriptor` → `MTLDepthStencilState` |
+| `glDepthFunc(GL_LESS)` | `descriptor.depthCompareFunction = .less` |
+| `glEnable(GL_BLEND)` | `pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true` |
+| `glBlendFunc` | `sourceRGBBlendFactor`, `destinationRGBBlendFactor` |
+| `glCullFace` | `encoder.setCullMode(.back)` |
+| `glFrontFace` | `encoder.setFrontFacing(.counterClockwise)` |
+| `glViewport` | `encoder.setViewport(MTLViewport(...))` |
+| `glScissor` | `encoder.setScissorRect(MTLScissorRect(...))` |
+
+### Draw Commands
+
+| OpenGL | Metal |
+|--------|-------|
+| `glDrawArrays(mode, first, count)` | `encoder.drawPrimitives(type:vertexStart:vertexCount:)` |
+| `glDrawElements(mode, count, type, indices)` | `encoder.drawIndexedPrimitives(type:indexCount:indexType:indexBuffer:indexBufferOffset:)` |
+| `glDrawArraysInstanced` | `encoder.drawPrimitives(type:vertexStart:vertexCount:instanceCount:)` |
+| `glDrawElementsInstanced` | `encoder.drawIndexedPrimitives(...instanceCount:)` |
+
+### Primitive Types
+
+| OpenGL | Metal |
+|--------|-------|
+| `GL_POINTS` | `.point` |
+| `GL_LINES` | `.line` |
+| `GL_LINE_STRIP` | `.lineStrip` |
+| `GL_TRIANGLES` | `.triangle` |
+| `GL_TRIANGLE_STRIP` | `.triangleStrip` |
+| `GL_TRIANGLE_FAN` | N/A (decompose to triangles) |
+
+## Part 4: Complete Setup Examples
+
+### MTKView Setup (Recommended)
+
+```swift
+import MetalKit
+
+class GameViewController: UIViewController {
+ var metalView: MTKView!
+ var renderer: Renderer!
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ // Create Metal view
+ guard let device = MTLCreateSystemDefaultDevice() else {
+ fatalError("Metal not supported")
+ }
+
+ metalView = MTKView(frame: view.bounds, device: device)
+ metalView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ metalView.colorPixelFormat = .bgra8Unorm
+ metalView.depthStencilPixelFormat = .depth32Float
+ metalView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+ metalView.preferredFramesPerSecond = 60
+ view.addSubview(metalView)
+
+ // Create renderer
+ renderer = Renderer(metalView: metalView)
+ metalView.delegate = renderer
+ }
+}
+
+class Renderer: NSObject, MTKViewDelegate {
+ let device: MTLDevice
+ let commandQueue: MTLCommandQueue
+ var pipelineState: MTLRenderPipelineState!
+ var depthState: MTLDepthStencilState!
+ var vertexBuffer: MTLBuffer!
+
+ init(metalView: MTKView) {
+ device = metalView.device!
+ commandQueue = device.makeCommandQueue()!
+ super.init()
+
+ buildPipeline(metalView: metalView)
+ buildDepthStencil()
+ buildBuffers()
+ }
+
+ private func buildPipeline(metalView: MTKView) {
+ let library = device.makeDefaultLibrary()!
+
+ let descriptor = MTLRenderPipelineDescriptor()
+ descriptor.vertexFunction = library.makeFunction(name: "vertexShader")
+ descriptor.fragmentFunction = library.makeFunction(name: "fragmentShader")
+ descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
+ descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat
+
+ // Vertex descriptor (matches shader's VertexIn struct)
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float2
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ descriptor.vertexDescriptor = vertexDescriptor
+
+ pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
+ }
+
+ private func buildDepthStencil() {
+ let descriptor = MTLDepthStencilDescriptor()
+ descriptor.depthCompareFunction = .less
+ descriptor.isDepthWriteEnabled = true
+ depthState = device.makeDepthStencilState(descriptor: descriptor)
+ }
+
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
+ // Handle resize
+ }
+
+ func draw(in view: MTKView) {
+ guard let drawable = view.currentDrawable,
+ let descriptor = view.currentRenderPassDescriptor,
+ let commandBuffer = commandQueue.makeCommandBuffer(),
+ let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
+ return
+ }
+
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthState)
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+ encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexCount)
+ encoder.endEncoding()
+
+ commandBuffer.present(drawable)
+ commandBuffer.commit()
+ }
+}
+```
+
+### CAMetalLayer Setup (Custom Control)
+
+```swift
+import Metal
+import QuartzCore
+
+class MetalLayerView: UIView {
+ var metalLayer: CAMetalLayer!
+ var device: MTLDevice!
+ var commandQueue: MTLCommandQueue!
+ var displayLink: CADisplayLink?
+
+ override class var layerClass: AnyClass { CAMetalLayer.self }
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setup()
+ }
+
+ private func setup() {
+ device = MTLCreateSystemDefaultDevice()!
+ commandQueue = device.makeCommandQueue()!
+
+ metalLayer = layer as? CAMetalLayer
+ metalLayer.device = device
+ metalLayer.pixelFormat = .bgra8Unorm
+ metalLayer.framebufferOnly = true
+
+ displayLink = CADisplayLink(target: self, selector: #selector(render))
+ displayLink?.add(to: .main, forMode: .common)
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ metalLayer.drawableSize = CGSize(
+ width: bounds.width * contentScaleFactor,
+ height: bounds.height * contentScaleFactor
+ )
+ }
+
+ @objc func render() {
+ guard let drawable = metalLayer.nextDrawable(),
+ let commandBuffer = commandQueue.makeCommandBuffer() else {
+ return
+ }
+
+ let descriptor = MTLRenderPassDescriptor()
+ descriptor.colorAttachments[0].texture = drawable.texture
+ descriptor.colorAttachments[0].loadAction = .clear
+ descriptor.colorAttachments[0].storeAction = .store
+ descriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
+ return
+ }
+
+ // Draw commands here
+ encoder.endEncoding()
+
+ commandBuffer.present(drawable)
+ commandBuffer.commit()
+ }
+}
+```
+
+### Compute Shader Setup
+
+```swift
+class ComputeProcessor {
+ let device: MTLDevice
+ let commandQueue: MTLCommandQueue
+ var computePipeline: MTLComputePipelineState!
+
+ init() {
+ device = MTLCreateSystemDefaultDevice()!
+ commandQueue = device.makeCommandQueue()!
+
+ let library = device.makeDefaultLibrary()!
+ let function = library.makeFunction(name: "computeKernel")!
+ computePipeline = try! device.makeComputePipelineState(function: function)
+ }
+
+ func process(input: MTLBuffer, output: MTLBuffer, count: Int) {
+ let commandBuffer = commandQueue.makeCommandBuffer()!
+ let encoder = commandBuffer.makeComputeCommandEncoder()!
+
+ encoder.setComputePipelineState(computePipeline)
+ encoder.setBuffer(input, offset: 0, index: 0)
+ encoder.setBuffer(output, offset: 0, index: 1)
+
+ let threadGroupSize = MTLSize(width: 256, height: 1, depth: 1)
+ let threadGroups = MTLSize(
+ width: (count + 255) / 256,
+ height: 1,
+ depth: 1
+ )
+
+ encoder.dispatchThreadgroups(threadGroups, threadsPerThreadgroup: threadGroupSize)
+ encoder.endEncoding()
+
+ commandBuffer.commit()
+ commandBuffer.waitUntilCompleted()
+ }
+}
+```
+
+```metal
+// Compute shader
+kernel void computeKernel(
+ device float* input [[buffer(0)]],
+ device float* output [[buffer(1)]],
+ uint id [[thread_position_in_grid]]
+) {
+ output[id] = input[id] * 2.0;
+}
+```
+
+## Part 5: Storage Modes & Synchronization
+
+### Buffer Storage Modes
+
+| Mode | CPU Access | GPU Access | Use Case |
+|------|------------|------------|----------|
+| `.shared` | Read/Write | Read/Write | Small dynamic data, uniforms |
+| `.private` | None | Read/Write | Static assets, render targets |
+| `.managed` (macOS) | Read/Write | Read/Write | Large buffers with partial updates |
+
+```swift
+// Shared: CPU and GPU both access (iOS typical)
+let uniformBuffer = device.makeBuffer(length: size, options: .storageModeShared)
+
+// Private: GPU only (best for static geometry)
+let vertexBuffer = device.makeBuffer(bytes: vertices, length: size, options: .storageModePrivate)
+
+// Managed: Explicit sync (macOS)
+#if os(macOS)
+let buffer = device.makeBuffer(length: size, options: .storageModeManaged)
+// After CPU write:
+buffer.didModifyRange(0..` | Different preamble |
+
+**Example conversion:**
+
+```glsl
+// GLSL vertex shader
+#version 300 es
+uniform mat4 u_mvp;
+in vec3 a_position;
+in vec2 a_texCoord;
+out vec2 v_texCoord;
+
+void main() {
+ v_texCoord = a_texCoord;
+ gl_Position = u_mvp * vec4(a_position, 1.0);
+}
+```
+
+```metal
+// Equivalent MSL vertex shader
+#include
+using namespace metal;
+
+struct VertexIn {
+ float3 position [[attribute(0)]];
+ float2 texCoord [[attribute(1)]];
+};
+
+struct VertexOut {
+ float4 position [[position]];
+ float2 texCoord;
+};
+
+struct Uniforms {
+ float4x4 mvp;
+};
+
+vertex VertexOut vertexShader(VertexIn in [[stage_in]],
+ constant Uniforms &uniforms [[buffer(1)]]) {
+ VertexOut out;
+ out.texCoord = in.texCoord;
+ out.position = uniforms.mvp * float4(in.position, 1.0);
+ return out;
+}
+```
+
+**Key differences to watch:**
+- GLSL globals → MSL function parameters with `[[attribute]]` qualifiers
+- Implicit uniform binding → explicit `[[buffer(N)]]` indices
+- `sampler2D` combines texture+sampler → Metal separates `texture2d` and `sampler`
+- GLSL preprocessor → Metal uses C++ `#include` and `using namespace metal`
+
+### Core Architecture Differences
+
+| Concept | OpenGL | Metal |
+|---------|--------|-------|
+| State model | Implicit, mutable | Explicit, immutable PSO |
+| Validation | At draw time | At PSO creation |
+| Shader compilation | Runtime (JIT) | Build time (AOT) |
+| Command submission | Implicit | Explicit command buffers |
+| Resource binding | Global state | Per-encoder binding |
+| Synchronization | Driver-managed | App-managed |
+
+### MTKView Setup (Native Metal)
+
+```swift
+import MetalKit
+
+class MetalRenderer: NSObject, MTKViewDelegate {
+ let device: MTLDevice
+ let commandQueue: MTLCommandQueue
+ var pipelineState: MTLRenderPipelineState!
+
+ init?(metalView: MTKView) {
+ guard let device = MTLCreateSystemDefaultDevice(),
+ let queue = device.makeCommandQueue() else {
+ return nil
+ }
+ self.device = device
+ self.commandQueue = queue
+
+ metalView.device = device
+ metalView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+ metalView.depthStencilPixelFormat = .depth32Float
+
+ super.init()
+ metalView.delegate = self
+
+ buildPipeline(metalView: metalView)
+ }
+
+ private func buildPipeline(metalView: MTKView) {
+ let library = device.makeDefaultLibrary()!
+ let vertexFunction = library.makeFunction(name: "vertexShader")
+ let fragmentFunction = library.makeFunction(name: "fragmentShader")
+
+ let descriptor = MTLRenderPipelineDescriptor()
+ descriptor.vertexFunction = vertexFunction
+ descriptor.fragmentFunction = fragmentFunction
+ descriptor.colorAttachments[0].pixelFormat = metalView.colorPixelFormat
+ descriptor.depthAttachmentPixelFormat = metalView.depthStencilPixelFormat
+
+ // Pre-validated at creation, not at draw time
+ pipelineState = try! device.makeRenderPipelineState(descriptor: descriptor)
+ }
+
+ func draw(in view: MTKView) {
+ guard let drawable = view.currentDrawable,
+ let descriptor = view.currentRenderPassDescriptor,
+ let commandBuffer = commandQueue.makeCommandBuffer(),
+ let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
+ return
+ }
+
+ encoder.setRenderPipelineState(pipelineState)
+ // Bind resources explicitly - nothing persists between draws
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+ encoder.setFragmentTexture(texture, index: 0)
+ encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: vertexCount)
+ encoder.endEncoding()
+
+ commandBuffer.present(drawable)
+ commandBuffer.commit()
+ }
+}
+```
+
+## Common Migration Anti-Patterns
+
+### Anti-Pattern 1: Keeping GL State Machine Mentality
+
+❌ **BAD** — Thinking in GL's implicit state:
+```swift
+// GL mental model: "set state, then draw"
+glBindTexture(GL_TEXTURE_2D, texture)
+glBindBuffer(GL_ARRAY_BUFFER, vbo)
+glUseProgram(program)
+glDrawArrays(GL_TRIANGLES, 0, vertexCount)
+// State persists until changed — can draw again without rebinding
+```
+
+✅ **GOOD** — Metal's explicit model:
+```swift
+// Metal: encode everything explicitly per draw
+let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: rpd)!
+encoder.setRenderPipelineState(pipelineState) // Always set
+encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) // Always bind
+encoder.setFragmentTexture(texture, index: 0) // Always bind
+encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: count)
+encoder.endEncoding()
+// Nothing persists — next encoder starts fresh
+```
+
+**Time cost**: 30-60 min debugging "why did my texture disappear" vs 2 min understanding the model upfront.
+
+### Anti-Pattern 2: Ignoring Coordinate System Differences
+
+❌ **BAD** — Assuming GL coordinates work in Metal:
+```
+OpenGL:
+- Origin: bottom-left
+- Y-axis: up
+- NDC Z range: [-1, 1]
+- Texture origin: bottom-left
+
+Metal:
+- Origin: top-left
+- Y-axis: down
+- NDC Z range: [0, 1]
+- Texture origin: top-left
+```
+
+✅ **GOOD** — Explicit coordinate handling:
+```metal
+// Option 1: Flip Y in vertex shader
+vertex float4 vertexShader(VertexIn in [[stage_in]]) {
+ float4 pos = uniforms.mvp * float4(in.position, 1.0);
+ pos.y = -pos.y; // Flip Y for Metal's coordinate system
+ return pos;
+}
+
+// Option 2: Flip texture coordinates in fragment shader
+fragment float4 fragmentShader(VertexOut in [[stage_in]],
+ texture2d tex [[texture(0)]],
+ sampler samp [[sampler(0)]]) {
+ float2 uv = in.texCoord;
+ uv.y = 1.0 - uv.y; // Flip V for Metal's texture origin
+ return tex.sample(samp, uv);
+}
+```
+
+```swift
+// Option 3: Use MTKTextureLoader with origin option
+let options: [MTKTextureLoader.Option: Any] = [
+ .origin: MTKTextureLoader.Origin.bottomLeft // Match GL convention
+]
+let texture = try textureLoader.newTexture(URL: url, options: options)
+```
+
+**Time cost**: 2-4 hours debugging "upside down" or "mirrored" rendering vs 5 min reading this pattern.
+
+### Anti-Pattern 3: No Validation Layer During Development
+
+❌ **BAD** — Disabling validation for "performance":
+```swift
+// No validation — API misuse silently corrupts or crashes later
+```
+
+✅ **GOOD** — Always enable during development:
+```
+In Xcode: Edit Scheme → Run → Diagnostics
+✓ Metal API Validation
+✓ Metal Shader Validation
+✓ GPU Frame Capture (Metal)
+```
+
+**Time cost**: Hours debugging silent corruption vs immediate error messages with call stacks.
+
+### Anti-Pattern 4: Single Buffer Without Synchronization
+
+❌ **BAD** — CPU and GPU fight over same buffer:
+```swift
+// Frame N: CPU writes to buffer
+// Frame N: GPU reads from buffer
+// Frame N+1: CPU writes again — RACE CONDITION
+buffer.contents().copyMemory(from: data, byteCount: size)
+```
+
+✅ **GOOD** — Triple buffering with semaphore:
+```swift
+class TripleBufferedRenderer {
+ let inflightSemaphore = DispatchSemaphore(value: 3)
+ var buffers: [MTLBuffer] = []
+ var bufferIndex = 0
+
+ func draw(in view: MTKView) {
+ // Wait for a buffer to become available
+ inflightSemaphore.wait()
+
+ let buffer = buffers[bufferIndex]
+ // Safe to write — GPU finished with this buffer
+ buffer.contents().copyMemory(from: data, byteCount: size)
+
+ let commandBuffer = commandQueue.makeCommandBuffer()!
+ commandBuffer.addCompletedHandler { [weak self] _ in
+ self?.inflightSemaphore.signal() // Release buffer
+ }
+
+ // ... encode and commit
+
+ bufferIndex = (bufferIndex + 1) % 3
+ }
+}
+```
+
+**Time cost**: Hours debugging intermittent visual glitches vs 15 min implementing triple buffering.
+
+## Pressure Scenarios
+
+### Scenario 1: "Just Ship with MetalANGLE"
+
+**Situation**: Deadline in 2 weeks. MetalANGLE demo works. PM says ship it.
+
+**Pressure**: "We can optimize later. Users won't notice 20% overhead."
+
+**Why this fails**:
+- Translation overhead compounds with complex scenes (visualizers, games)
+- No compute shader support limits future features
+- Technical debt grows — team learns MetalANGLE quirks, not Metal
+- Apple deprecation risk (OpenGL ES deprecated since iOS 12)
+- Battery/thermal complaints from users
+
+**Response template**:
+> "MetalANGLE is viable for the demo milestone. For production, I recommend a 3-week buffer to implement native Metal for the render loop. This recovers the 20-30% overhead and eliminates deprecation risk. Can we scope the MVP to fewer visual effects to hit the deadline with native Metal?"
+
+### Scenario 2: "Port All Shaders This Sprint"
+
+**Situation**: 50 GLSL shaders. Sprint is 2 weeks. Manager wants all converted.
+
+**Pressure**: "They're just text files. How hard can shader conversion be?"
+
+**Why this fails**:
+- GLSL → MSL isn't 1:1 (precision qualifiers, built-ins, sampling)
+- Each shader needs visual validation, not just compilation
+- Complex shaders need performance profiling
+- Bugs compound — broken shader A masks broken shader B
+
+**Response template**:
+> "Shader conversion requires visual validation, not just compilation. I can convert 10-15 shaders/week with confidence. For 50 shaders: (1) Prioritize by usage — convert the 10 most-used first, (2) Automate mappings — type conversions, boilerplate, (3) Parallel validation — run GL and Metal side-by-side. Realistic timeline: 4-5 weeks for full conversion with quality."
+
+### Scenario 3: "We Don't Need GPU Frame Capture"
+
+**Situation**: Developer says "I'll just use print statements to debug shaders."
+
+**Pressure**: "GPU tools are overkill. I know what I'm doing."
+
+**Why this fails**:
+- Print statements don't work in shaders
+- Visual bugs require seeing intermediate render targets
+- Performance issues require GPU timeline analysis
+- Metal validation errors need call stack context
+
+**Response template**:
+> "GPU Frame Capture is the only way to inspect shader variables, see intermediate textures, and understand GPU timing. It takes 30 seconds to capture a frame. Without it, shader debugging is 10x slower — you're guessing instead of observing."
+
+## Pre-Migration Checklist
+
+Before starting any port:
+
+- [ ] **Inventory shaders**: Count GLSL/HLSL files, complexity (LOC, features used)
+- [ ] **Identify extensions**: Which GL extensions does the code use? Metal equivalents?
+- [ ] **Audit state management**: How stateful is the renderer? Global state count?
+- [ ] **Check compute usage**: Any GL compute shaders? GPGPU? (MetalANGLE won't help)
+- [ ] **Profile baseline**: FPS, frame time, memory, thermal on reference platform
+- [ ] **Define success criteria**: Target FPS, memory budget, thermal envelope
+- [ ] **Set up A/B testing**: Can you run GL and Metal side-by-side for validation?
+- [ ] **Enable validation**: Metal API Validation, Shader Validation, Frame Capture
+
+## Post-Migration Checklist
+
+After completing the port:
+
+- [ ] **Visual parity**: Side-by-side screenshots match reference
+- [ ] **Performance parity or better**: Frame time ≤ GL baseline
+- [ ] **No validation errors**: Clean run with Metal validation enabled
+- [ ] **Thermal acceptable**: Device doesn't throttle during normal use
+- [ ] **Memory stable**: No leaks over extended use
+- [ ] **All code paths tested**: Edge cases, error states, resize/rotate
+
+## Resources
+
+**WWDC**: 2016-00602, 2018-00604, 2019-00611
+
+**Docs**: /metal/migrating-opengl-code-to-metal, /metal/shader-converter
+
+**Tools**: MetalANGLE, MoltenVK
+
+**Skills**: axiom-metal-migration-ref, axiom-metal-migration-diag
+
+---
+
+**Last Updated**: 2025-12-29
+**Platforms**: iOS 12+, macOS 10.14+, tvOS 12+
+**Status**: Production-ready Metal migration patterns
diff --git a/.claude/skills/axiom-metal-migration/agents/openai.yaml b/.claude/skills/axiom-metal-migration/agents/openai.yaml
new file mode 100644
index 0000000..f7d590a
--- /dev/null
+++ b/.claude/skills/axiom-metal-migration/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Metal Migration"
+ short_description: "Porting OpenGL/DirectX to Metal"
diff --git a/.claude/skills/axiom-metrickit-ref/.openskills.json b/.claude/skills/axiom-metrickit-ref/.openskills.json
new file mode 100644
index 0000000..5767803
--- /dev/null
+++ b/.claude/skills/axiom-metrickit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-metrickit-ref",
+ "installedAt": "2026-04-12T08:06:29.424Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-metrickit-ref/SKILL.md b/.claude/skills/axiom-metrickit-ref/SKILL.md
new file mode 100644
index 0000000..1d69868
--- /dev/null
+++ b/.claude/skills/axiom-metrickit-ref/SKILL.md
@@ -0,0 +1,676 @@
+---
+name: axiom-metrickit-ref
+description: MetricKit API reference for field diagnostics - MXMetricPayload, MXDiagnosticPayload, MXCallStackTree parsing, crash and hang collection
+license: MIT
+---
+
+# MetricKit API Reference
+
+Complete API reference for collecting field performance metrics and diagnostics using MetricKit.
+
+## Overview
+
+MetricKit provides aggregated, on-device performance and diagnostic data from users who opt into sharing analytics. Data is delivered daily (or on-demand in development).
+
+## When to Use This Reference
+
+Use this reference when:
+- Setting up MetricKit subscriber in your app
+- Parsing MXMetricPayload or MXDiagnosticPayload
+- Symbolicating MXCallStackTree crash data
+- Understanding background exit reasons (jetsam, watchdog)
+- Integrating MetricKit with existing crash reporters
+
+For hang diagnosis workflows, see `axiom-hang-diagnostics`.
+For general profiling with Instruments, see `axiom-performance-profiling`.
+For memory debugging including jetsam, see `axiom-memory-debugging`.
+
+## Common Gotchas
+
+1. **24-hour delay** — MetricKit data arrives once daily; it's not real-time debugging
+2. **Call stacks require symbolication** — MXCallStackTree frames are unsymbolicated; keep dSYMs
+3. **Opt-in only** — Only users who enable "Share with App Developers" contribute data
+4. **Aggregated, not individual** — You get counts and averages, not per-user traces
+5. **Simulator doesn't work** — MetricKit only collects on physical devices
+
+**iOS Version Support**:
+| Feature | iOS Version |
+|---------|-------------|
+| Basic metrics (battery, CPU, memory) | iOS 13+ |
+| Diagnostic payloads | iOS 14+ |
+| Hang diagnostics | iOS 14+ |
+| Launch diagnostics | iOS 16+ |
+| Immediate delivery in dev | iOS 15+ |
+
+## Part 1: Setup
+
+### Basic Integration
+
+```swift
+import MetricKit
+
+class AppMetricsSubscriber: NSObject, MXMetricManagerSubscriber {
+
+ override init() {
+ super.init()
+ MXMetricManager.shared.add(self)
+ }
+
+ deinit {
+ MXMetricManager.shared.remove(self)
+ }
+
+ // MARK: - MXMetricManagerSubscriber
+
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ processMetrics(payload)
+ }
+ }
+
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
+ for payload in payloads {
+ processDiagnostics(payload)
+ }
+ }
+}
+```
+
+### Registration Timing
+
+Register subscriber early in app lifecycle:
+
+```swift
+@main
+struct MyApp: App {
+ @StateObject private var metricsSubscriber = AppMetricsSubscriber()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
+```
+
+Or in AppDelegate:
+
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ metricsSubscriber = AppMetricsSubscriber()
+ return true
+}
+```
+
+### Development Testing
+
+In iOS 15+, trigger immediate delivery via Debug menu:
+
+**Xcode > Debug > Simulate MetricKit Payloads**
+
+Or programmatically (debug builds only):
+
+```swift
+#if DEBUG
+// Payloads delivered immediately in development
+// No special code needed - just run and wait
+#endif
+```
+
+## Part 2: MXMetricPayload
+
+`MXMetricPayload` contains aggregated performance metrics from the past 24 hours.
+
+### Payload Structure
+
+```swift
+func processMetrics(_ payload: MXMetricPayload) {
+ // Time range for this payload
+ let start = payload.timeStampBegin
+ let end = payload.timeStampEnd
+
+ // App version that generated this data
+ let version = payload.metaData?.applicationBuildVersion
+
+ // Access specific metric categories
+ if let cpuMetrics = payload.cpuMetrics {
+ processCPU(cpuMetrics)
+ }
+
+ if let memoryMetrics = payload.memoryMetrics {
+ processMemory(memoryMetrics)
+ }
+
+ if let launchMetrics = payload.applicationLaunchMetrics {
+ processLaunches(launchMetrics)
+ }
+
+ // ... other categories
+}
+```
+
+### CPU Metrics (MXCPUMetric)
+
+```swift
+func processCPU(_ metrics: MXCPUMetric) {
+ // Cumulative CPU time
+ let cpuTime = metrics.cumulativeCPUTime // Measurement
+
+ // iOS 14+: CPU instruction count
+ if #available(iOS 14.0, *) {
+ let instructions = metrics.cumulativeCPUInstructions // Measurement
+ }
+}
+```
+
+### Memory Metrics (MXMemoryMetric)
+
+```swift
+func processMemory(_ metrics: MXMemoryMetric) {
+ // Peak memory usage
+ let peakMemory = metrics.peakMemoryUsage // Measurement
+
+ // Average suspended memory
+ let avgSuspended = metrics.averageSuspendedMemory // MXAverage
+}
+```
+
+### Launch Metrics (MXAppLaunchMetric)
+
+```swift
+func processLaunches(_ metrics: MXAppLaunchMetric) {
+ // First draw (cold launch) histogram
+ let firstDrawHistogram = metrics.histogrammedTimeToFirstDraw
+
+ // Resume time histogram
+ let resumeHistogram = metrics.histogrammedApplicationResumeTime
+
+ // Optimized time to first draw (iOS 15.2+)
+ if #available(iOS 15.2, *) {
+ let optimizedLaunch = metrics.histogrammedOptimizedTimeToFirstDraw
+ }
+
+ // Parse histogram buckets
+ for bucket in firstDrawHistogram.bucketEnumerator {
+ if let bucket = bucket as? MXHistogramBucket {
+ let start = bucket.bucketStart // e.g., 0ms
+ let end = bucket.bucketEnd // e.g., 100ms
+ let count = bucket.bucketCount // Number of launches in this range
+ }
+ }
+}
+```
+
+### Application Exit Metrics (MXAppExitMetric) — iOS 14+
+
+```swift
+@available(iOS 14.0, *)
+func processExits(_ metrics: MXAppExitMetric) {
+ let fg = metrics.foregroundExitData
+ let bg = metrics.backgroundExitData
+
+ // Foreground (onscreen) exits
+ let fgNormal = fg.cumulativeNormalAppExitCount
+ let fgWatchdog = fg.cumulativeAppWatchdogExitCount
+ let fgMemoryLimit = fg.cumulativeMemoryResourceLimitExitCount
+ let fgMemoryPressure = fg.cumulativeMemoryPressureExitCount
+ let fgBadAccess = fg.cumulativeBadAccessExitCount
+ let fgIllegalInstruction = fg.cumulativeIllegalInstructionExitCount
+ let fgAbnormal = fg.cumulativeAbnormalExitCount
+
+ // Background exits
+ let bgSuspended = bg.cumulativeSuspendedWithLockedFileExitCount
+ let bgTaskTimeout = bg.cumulativeBackgroundTaskAssertionTimeoutExitCount
+ let bgCPULimit = bg.cumulativeCPUResourceLimitExitCount
+}
+```
+
+### Scroll Hitch Metrics (MXAnimationMetric) — iOS 14+
+
+```swift
+@available(iOS 14.0, *)
+func processHitches(_ metrics: MXAnimationMetric) {
+ // Scroll hitch rate (hitches per scroll)
+ let scrollHitchRate = metrics.scrollHitchTimeRatio // Double (0.0 - 1.0)
+}
+```
+
+### Disk I/O Metrics (MXDiskIOMetric)
+
+```swift
+func processDiskIO(_ metrics: MXDiskIOMetric) {
+ let logicalWrites = metrics.cumulativeLogicalWrites // Measurement
+}
+```
+
+### Network Metrics (MXNetworkTransferMetric)
+
+```swift
+func processNetwork(_ metrics: MXNetworkTransferMetric) {
+ let cellUpload = metrics.cumulativeCellularUpload
+ let cellDownload = metrics.cumulativeCellularDownload
+ let wifiUpload = metrics.cumulativeWifiUpload
+ let wifiDownload = metrics.cumulativeWifiDownload
+}
+```
+
+### Signpost Metrics (MXSignpostMetric)
+
+Track custom operations with signposts:
+
+```swift
+// In your code: emit signposts
+import os.signpost
+
+let log = MXMetricManager.makeLogHandle(category: "ImageProcessing")
+
+func processImage(_ image: UIImage) {
+ mxSignpost(.begin, log: log, name: "ProcessImage")
+ // ... do work ...
+ mxSignpost(.end, log: log, name: "ProcessImage")
+}
+
+// In metrics subscriber: read signpost data
+func processSignposts(_ metrics: MXSignpostMetric) {
+ let name = metrics.signpostName
+ let category = metrics.signpostCategory
+
+ // Histogram of durations
+ let histogram = metrics.signpostIntervalData.histogrammedSignpostDurations
+
+ // Total count
+ let count = metrics.totalCount
+}
+```
+
+### Exporting Payload as JSON
+
+```swift
+func exportPayload(_ payload: MXMetricPayload) {
+ // JSON representation for upload to analytics
+ let jsonData = payload.jsonRepresentation()
+
+ // Or as Dictionary
+ if let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] {
+ uploadToAnalytics(json)
+ }
+}
+```
+
+## Part 3: MXDiagnosticPayload — iOS 14+
+
+`MXDiagnosticPayload` contains diagnostic reports for crashes, hangs, disk write exceptions, and CPU exceptions.
+
+### Payload Structure
+
+```swift
+@available(iOS 14.0, *)
+func processDiagnostics(_ payload: MXDiagnosticPayload) {
+ // Crash diagnostics
+ if let crashes = payload.crashDiagnostics {
+ for crash in crashes {
+ processCrash(crash)
+ }
+ }
+
+ // Hang diagnostics
+ if let hangs = payload.hangDiagnostics {
+ for hang in hangs {
+ processHang(hang)
+ }
+ }
+
+ // Disk write exceptions
+ if let diskWrites = payload.diskWriteExceptionDiagnostics {
+ for diskWrite in diskWrites {
+ processDiskWriteException(diskWrite)
+ }
+ }
+
+ // CPU exceptions
+ if let cpuExceptions = payload.cpuExceptionDiagnostics {
+ for cpuException in cpuExceptions {
+ processCPUException(cpuException)
+ }
+ }
+}
+```
+
+### MXCrashDiagnostic
+
+```swift
+@available(iOS 14.0, *)
+func processCrash(_ diagnostic: MXCrashDiagnostic) {
+ // Call stack tree (needs symbolication)
+ let callStackTree = diagnostic.callStackTree
+
+ // Crash metadata
+ let signal = diagnostic.signal // e.g., SIGSEGV
+ let exceptionType = diagnostic.exceptionType // e.g., EXC_BAD_ACCESS
+ let exceptionCode = diagnostic.exceptionCode
+ let terminationReason = diagnostic.terminationReason
+
+ // Virtual memory info
+ let virtualMemoryRegionInfo = diagnostic.virtualMemoryRegionInfo
+
+ // Unique identifier for grouping similar crashes
+ // (not available - use call stack signature)
+}
+```
+
+### MXHangDiagnostic
+
+```swift
+@available(iOS 14.0, *)
+func processHang(_ diagnostic: MXHangDiagnostic) {
+ // How long the hang lasted
+ let duration = diagnostic.hangDuration // Measurement
+
+ // Call stack when hang occurred
+ let callStackTree = diagnostic.callStackTree
+}
+```
+
+### MXDiskWriteExceptionDiagnostic
+
+```swift
+@available(iOS 14.0, *)
+func processDiskWriteException(_ diagnostic: MXDiskWriteExceptionDiagnostic) {
+ // Total bytes written that triggered exception
+ let totalWrites = diagnostic.totalWritesCaused // Measurement
+
+ // Call stack of writes
+ let callStackTree = diagnostic.callStackTree
+}
+```
+
+### MXCPUExceptionDiagnostic
+
+```swift
+@available(iOS 14.0, *)
+func processCPUException(_ diagnostic: MXCPUExceptionDiagnostic) {
+ // Total CPU time that triggered exception
+ let totalCPUTime = diagnostic.totalCPUTime // Measurement
+
+ // Total sampled time
+ let totalSampledTime = diagnostic.totalSampledTime
+
+ // Call stack of CPU-intensive code
+ let callStackTree = diagnostic.callStackTree
+}
+```
+
+## Part 4: MXCallStackTree
+
+`MXCallStackTree` contains stack frames from diagnostics. Frames are NOT symbolicated—you must symbolicate using your dSYM.
+
+### Structure
+
+```swift
+@available(iOS 14.0, *)
+func parseCallStackTree(_ tree: MXCallStackTree) {
+ // JSON representation
+ let jsonData = tree.jsonRepresentation()
+
+ // Parse the JSON
+ guard let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
+ let callStacks = json["callStacks"] as? [[String: Any]] else {
+ return
+ }
+
+ for callStack in callStacks {
+ guard let threadAttributed = callStack["threadAttributed"] as? Bool,
+ let frames = callStack["callStackRootFrames"] as? [[String: Any]] else {
+ continue
+ }
+
+ // threadAttributed = true means this thread caused the issue
+ if threadAttributed {
+ parseFrames(frames)
+ }
+ }
+}
+
+func parseFrames(_ frames: [[String: Any]]) {
+ for frame in frames {
+ // Binary image UUID (match to dSYM)
+ let binaryUUID = frame["binaryUUID"] as? String
+
+ // Address offset within binary
+ let offsetIntoBinaryTextSegment = frame["offsetIntoBinaryTextSegment"] as? Int
+
+ // Binary name (e.g., "MyApp", "UIKitCore")
+ let binaryName = frame["binaryName"] as? String
+
+ // Address (for symbolication)
+ let address = frame["address"] as? Int
+
+ // Sample count (how many times this frame appeared)
+ let sampleCount = frame["sampleCount"] as? Int
+
+ // Sub-frames (tree structure)
+ let subFrames = frame["subFrames"] as? [[String: Any]]
+ }
+}
+```
+
+### JSON Structure Example
+
+```json
+{
+ "callStacks": [
+ {
+ "threadAttributed": true,
+ "callStackRootFrames": [
+ {
+ "binaryUUID": "A1B2C3D4-E5F6-7890-ABCD-EF1234567890",
+ "offsetIntoBinaryTextSegment": 123456,
+ "binaryName": "MyApp",
+ "address": 4384712345,
+ "sampleCount": 10,
+ "subFrames": [
+ {
+ "binaryUUID": "F1E2D3C4-B5A6-7890-1234-567890ABCDEF",
+ "offsetIntoBinaryTextSegment": 78901,
+ "binaryName": "UIKitCore",
+ "address": 7234567890,
+ "sampleCount": 10
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
+```
+
+### Symbolication
+
+MetricKit call stacks are **unsymbolicated**. To symbolicate:
+
+1. **Keep your dSYM files** for every App Store build
+2. **Match UUID** from `binaryUUID` to your dSYM
+3. **Use atos** to symbolicate:
+
+```bash
+# Find dSYM for binary UUID
+mdfind "com_apple_xcode_dsym_uuids == A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
+
+# Symbolicate address
+atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x105234567
+```
+
+Or use a crash reporting service that handles symbolication (Crashlytics, Sentry, etc.).
+
+## Part 5: MXBackgroundExitData
+
+Track why your app was terminated in the background:
+
+```swift
+@available(iOS 14.0, *)
+func analyzeBackgroundExits(_ data: MXBackgroundExitData) {
+ // Normal exits (user closed, system reclaimed)
+ let normal = data.cumulativeNormalAppExitCount
+
+ // Memory issues
+ let memoryLimit = data.cumulativeMemoryResourceLimitExitCount // Exceeded memory limit
+ let memoryPressure = data.cumulativeMemoryPressureExitCount // Jetsam
+
+ // Crashes
+ let badAccess = data.cumulativeBadAccessExitCount // SIGSEGV
+ let illegalInstruction = data.cumulativeIllegalInstructionExitCount // SIGILL
+ let abnormal = data.cumulativeAbnormalExitCount // Other crashes
+
+ // System terminations
+ let watchdog = data.cumulativeAppWatchdogExitCount // Timeout during transition
+ let taskTimeout = data.cumulativeBackgroundTaskAssertionTimeoutExitCount // Background task timeout
+ let cpuLimit = data.cumulativeCPUResourceLimitExitCount // Exceeded CPU quota
+ let lockedFile = data.cumulativeSuspendedWithLockedFileExitCount // File lock held
+}
+```
+
+### Exit Type Interpretation
+
+| Exit Type | Meaning | Action |
+|-----------|---------|--------|
+| `normalAppExitCount` | Clean exit | None (expected) |
+| `memoryResourceLimitExitCount` | Used too much memory | Reduce footprint |
+| `memoryPressureExitCount` | Jetsam (system reclaimed) | Reduce background memory to <50MB |
+| `badAccessExitCount` | SIGSEGV crash | Check null pointers, invalid memory |
+| `illegalInstructionExitCount` | SIGILL crash | Check invalid function pointers |
+| `abnormalExitCount` | Other crash | Check crash diagnostics |
+| `appWatchdogExitCount` | Hung during transition | Reduce launch/background work |
+| `backgroundTaskAssertionTimeoutExitCount` | Didn't end background task | Call `endBackgroundTask` properly |
+| `cpuResourceLimitExitCount` | Too much background CPU | Move to BGProcessingTask |
+| `suspendedWithLockedFileExitCount` | Held file lock while suspended | Release locks before suspend |
+
+## Part 6: Integration Patterns
+
+### Upload to Analytics Service
+
+```swift
+class MetricsUploader {
+ func upload(_ payload: MXMetricPayload) {
+ let jsonData = payload.jsonRepresentation()
+
+ var request = URLRequest(url: analyticsEndpoint)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = jsonData
+
+ URLSession.shared.dataTask(with: request) { _, response, error in
+ if let error = error {
+ // Queue for retry
+ self.queueForRetry(jsonData)
+ }
+ }.resume()
+ }
+}
+```
+
+### Combine with Crash Reporter
+
+```swift
+class HybridCrashReporter: MXMetricManagerSubscriber {
+ let crashlytics: Crashlytics // or Sentry, etc.
+
+ func didReceive(_ payloads: [MXDiagnosticPayload]) {
+ for payload in payloads {
+ // MetricKit captures crashes that traditional reporters might miss
+ // (e.g., watchdog kills, memory pressure exits)
+
+ if let crashes = payload.crashDiagnostics {
+ for crash in crashes {
+ crashlytics.recordException(
+ name: crash.exceptionType?.description ?? "Unknown",
+ reason: crash.terminationReason ?? "MetricKit crash",
+ callStack: parseCallStack(crash.callStackTree)
+ )
+ }
+ }
+ }
+ }
+}
+```
+
+### Alert on Regressions
+
+```swift
+class MetricsMonitor: MXMetricManagerSubscriber {
+ let thresholds = MetricThresholds(
+ launchTime: 2.0, // seconds
+ hangRate: 0.01, // 1% of sessions
+ memoryPeak: 200 // MB
+ )
+
+ func didReceive(_ payloads: [MXMetricPayload]) {
+ for payload in payloads {
+ checkThresholds(payload)
+ }
+ }
+
+ private func checkThresholds(_ payload: MXMetricPayload) {
+ // Check launch time
+ if let launches = payload.applicationLaunchMetrics {
+ let p50 = calculateP50(launches.histogrammedTimeToFirstDraw)
+ if p50 > thresholds.launchTime {
+ sendAlert("Launch time regression: \(p50)s > \(thresholds.launchTime)s")
+ }
+ }
+
+ // Check memory
+ if let memory = payload.memoryMetrics {
+ let peakMB = memory.peakMemoryUsage.converted(to: .megabytes).value
+ if peakMB > Double(thresholds.memoryPeak) {
+ sendAlert("Memory peak regression: \(peakMB)MB > \(thresholds.memoryPeak)MB")
+ }
+ }
+ }
+}
+```
+
+## Part 7: Best Practices
+
+### Do
+
+- **Register subscriber early** — In `application(_:didFinishLaunchingWithOptions:)` or App init
+- **Keep dSYM files** — Required for symbolicating call stacks
+- **Upload payloads to server** — Local processing loses data on uninstall
+- **Set up alerting** — Detect regressions before users report them
+- **Test with simulated payloads** — Xcode Debug menu in iOS 15+
+
+### Don't
+
+- **Don't rely solely on MetricKit** — 24-hour delay, requires user opt-in
+- **Don't ignore background exits** — Jetsam and task timeouts affect UX
+- **Don't skip symbolication** — Raw addresses are unusable
+- **Don't process on main thread** — Payload processing can be expensive
+
+### Privacy Considerations
+
+- MetricKit data is **aggregated and anonymized**
+- Data only from users who **opted into sharing analytics**
+- No personally identifiable information
+- Safe to upload to your servers
+
+## Part 8: MetricKit vs Xcode Organizer
+
+| Feature | MetricKit | Xcode Organizer |
+|---------|-----------|-----------------|
+| **Data source** | Devices running your app | App Store Connect aggregation |
+| **Delivery** | Daily to your subscriber | On-demand in Xcode |
+| **Customization** | Full access to raw data | Predefined views |
+| **Symbolication** | You must symbolicate | Pre-symbolicated |
+| **Historical data** | Only when subscriber active | Last 16 versions |
+| **Requires code** | Yes | No |
+
+**Use both**: Organizer for quick overview, MetricKit for custom analytics and alerting.
+
+## Resources
+
+**WWDC**: 2019-417, 2020-10081, 2021-10087
+
+**Docs**: /metrickit, /metrickit/mxmetricmanager, /metrickit/mxdiagnosticpayload
+
+**Skills**: axiom-hang-diagnostics, axiom-performance-profiling, axiom-testflight-triage
diff --git a/.claude/skills/axiom-metrickit-ref/agents/openai.yaml b/.claude/skills/axiom-metrickit-ref/agents/openai.yaml
new file mode 100644
index 0000000..da68480
--- /dev/null
+++ b/.claude/skills/axiom-metrickit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "MetricKit Reference"
+ short_description: "MetricKit API reference for field diagnostics"
diff --git a/.claude/skills/axiom-modernize/.openskills.json b/.claude/skills/axiom-modernize/.openskills.json
new file mode 100644
index 0000000..c896854
--- /dev/null
+++ b/.claude/skills/axiom-modernize/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-modernize",
+ "installedAt": "2026-04-12T08:06:29.425Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-modernize/SKILL.md b/.claude/skills/axiom-modernize/SKILL.md
new file mode 100644
index 0000000..cd982db
--- /dev/null
+++ b/.claude/skills/axiom-modernize/SKILL.md
@@ -0,0 +1,546 @@
+---
+name: axiom-modernize
+description: Use when the user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @StateObject to @State, or adopt modern SwiftUI APIs.
+license: MIT
+disable-model-invocation: true
+---
+# Modernization Helper Agent
+
+You are an expert at migrating iOS apps to modern iOS 17/18+ patterns.
+
+## Your Mission
+
+Scan the codebase for legacy patterns and provide migration paths:
+- `ObservableObject` → `@Observable`
+- `@StateObject` → `@State` with Observable
+- `@ObservedObject` → Direct property or `@Bindable`
+- `@EnvironmentObject` → `@Environment`
+- Legacy SwiftUI modifiers → Modern equivalents
+- Completion handlers → async/await
+
+Report findings with:
+- File:line references
+- Priority (HIGH/MEDIUM/LOW based on benefit)
+- Migration code examples
+- Breaking change warnings
+
+## Files to Scan
+
+**Swift files**: `**/*.swift`
+Skip: `*Tests.swift`, `*Previews.swift`, `*/Pods/*`, `*/Carthage/*`, `*/.build/*`, `*/DerivedData/*`, `*/scratch/*`, `*/docs/*`, `*/.claude/*`, `*/.claude-plugin/*`
+
+## Modernization Patterns (iOS 17+ / iOS 18+)
+
+### Pattern 1: ObservableObject → @Observable (HIGH)
+
+**Why migrate**: Better performance (view updates only when accessed properties change), simpler syntax, no `@Published` needed
+
+**Requirement**: iOS 17+
+
+**Detection**:
+```
+Grep: class.*ObservableObject
+Grep: : ObservableObject
+Grep: @Published
+```
+
+```swift
+// ❌ LEGACY (iOS 14-16)
+class ContentViewModel: ObservableObject {
+ @Published var items: [Item] = []
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+}
+
+// ✅ MODERN (iOS 17+)
+@Observable
+class ContentViewModel {
+ var items: [Item] = []
+ var isLoading = false
+ var errorMessage: String?
+
+ // Use @ObservationIgnored for non-observed properties
+ @ObservationIgnored
+ var internalCache: [String: Any] = [:]
+}
+```
+
+**Migration steps**:
+1. Replace `: ObservableObject` with `@Observable` macro
+2. Remove all `@Published` property wrappers
+3. Add `@ObservationIgnored` to properties that shouldn't trigger updates
+4. Update consuming views (see patterns below)
+
+### Pattern 2: @StateObject → @State (HIGH)
+
+**Why migrate**: Simpler, consistent with value types, works with @Observable
+
+**Requirement**: iOS 17+ with @Observable model
+
+**Detection**:
+```
+Grep: @StateObject
+```
+
+```swift
+// ❌ LEGACY
+struct ContentView: View {
+ @StateObject private var viewModel = ContentViewModel()
+
+ var body: some View { ... }
+}
+
+// ✅ MODERN (with @Observable model)
+struct ContentView: View {
+ @State private var viewModel = ContentViewModel()
+
+ var body: some View { ... }
+}
+```
+
+**Note**: Only migrate after the model uses `@Observable`. If model still uses `ObservableObject`, keep `@StateObject`.
+
+### Pattern 3: @ObservedObject → Direct Property or @Bindable (HIGH)
+
+**Why migrate**: Simpler code, explicit binding when needed
+
+**Requirement**: iOS 17+ with @Observable model
+
+**Detection**:
+```
+Grep: @ObservedObject
+```
+
+```swift
+// ❌ LEGACY
+struct ItemView: View {
+ @ObservedObject var item: ItemModel
+
+ var body: some View {
+ Text(item.name)
+ }
+}
+
+// ✅ MODERN - Direct property (read-only access)
+struct ItemView: View {
+ var item: ItemModel // No wrapper needed!
+
+ var body: some View {
+ Text(item.name)
+ }
+}
+
+// ✅ MODERN - @Bindable (for two-way binding)
+struct ItemEditorView: View {
+ @Bindable var item: ItemModel
+
+ var body: some View {
+ TextField("Name", text: $item.name) // Binding works
+ }
+}
+```
+
+**Decision tree**:
+- Need binding (`$item.property`)? → Use `@Bindable`
+- Just reading properties? → Use plain property (no wrapper)
+
+### Pattern 4: @EnvironmentObject → @Environment (HIGH)
+
+**Why migrate**: Type-safe, works with @Observable
+
+**Requirement**: iOS 17+ with @Observable model
+
+**Detection**:
+```
+Grep: @EnvironmentObject
+Grep: \.environmentObject\(
+```
+
+```swift
+// ❌ LEGACY - Setting
+ContentView()
+ .environmentObject(settings)
+
+// ❌ LEGACY - Reading
+struct SettingsView: View {
+ @EnvironmentObject var settings: AppSettings
+
+ var body: some View { ... }
+}
+
+// ✅ MODERN - Setting
+ContentView()
+ .environment(settings)
+
+// ✅ MODERN - Reading
+struct SettingsView: View {
+ @Environment(AppSettings.self) var settings
+
+ var body: some View { ... }
+}
+
+// ✅ MODERN - With binding
+struct SettingsEditorView: View {
+ @Environment(AppSettings.self) var settings
+
+ var body: some View {
+ @Bindable var settings = settings
+ Toggle("Dark Mode", isOn: $settings.darkMode)
+ }
+}
+```
+
+### Pattern 5: onChange(of:perform:) → onChange(of:initial:_:) (MEDIUM)
+
+**Why migrate**: Deprecated modifier, new API has `initial` parameter
+
+**Requirement**: iOS 17+
+
+**Detection**:
+```
+Grep: \.onChange\(of:.*perform:
+```
+
+```swift
+// ❌ DEPRECATED
+.onChange(of: searchText) { newValue in
+ performSearch(newValue)
+}
+
+// ✅ MODERN (iOS 17+)
+.onChange(of: searchText) { oldValue, newValue in
+ performSearch(newValue)
+}
+
+// ✅ With initial execution
+.onChange(of: searchText, initial: true) { oldValue, newValue in
+ performSearch(newValue)
+}
+```
+
+### Pattern 6: Completion Handlers → async/await (MEDIUM)
+
+**Why migrate**: Cleaner code, better error handling, structured concurrency
+
+**Requirement**: iOS 15+ (widely adopted in iOS 17+)
+
+**Detection**:
+```
+Grep: completion:\s*@escaping
+Grep: completionHandler:
+Grep: DispatchQueue\.main\.async
+```
+
+```swift
+// ❌ LEGACY
+func fetchUser(id: String, completion: @escaping (Result) -> Void) {
+ URLSession.shared.dataTask(with: url) { data, response, error in
+ DispatchQueue.main.async {
+ if let error = error {
+ completion(.failure(error))
+ return
+ }
+ // Parse and return
+ completion(.success(user))
+ }
+ }.resume()
+}
+
+// ✅ MODERN
+func fetchUser(id: String) async throws -> User {
+ let (data, _) = try await URLSession.shared.data(from: url)
+ return try JSONDecoder().decode(User.self, from: data)
+}
+```
+
+### Pattern 7: withAnimation Closures → Animation Parameter (LOW)
+
+**Why migrate**: Cleaner API, avoids closure
+
+**Requirement**: iOS 17+
+
+**Detection**:
+```
+Grep: withAnimation.*\{
+```
+
+```swift
+// ❌ LEGACY
+withAnimation(.spring()) {
+ isExpanded.toggle()
+}
+
+// ✅ MODERN (simple cases)
+isExpanded.toggle()
+// Apply animation to view:
+.animation(.spring(), value: isExpanded)
+
+// Or use new binding animation:
+$isExpanded.animation(.spring()).wrappedValue.toggle()
+```
+
+### Pattern 8: Swift Language Modernization (LOW)
+
+**Why migrate**: Clearer, more efficient, modern Swift idioms
+
+**Detection**:
+```
+Grep: Date\(\)
+Grep: CGFloat
+Grep: replacingOccurrences
+Grep: DateFormatter\(\)
+Grep: \.filter\(.*\)\.count
+Grep: Task\.sleep\(nanoseconds:
+```
+
+**Reference**: See `axiom-swift-modern` skill for the full modern API replacement table.
+
+Report matches as LOW priority unless they appear in hot paths (then MEDIUM).
+
+## Audit Process
+
+### Step 1: Find Swift Files
+
+```
+Glob: **/*.swift
+```
+
+### Step 2: Detect Legacy Patterns
+
+**ObservableObject**:
+```
+Grep: ObservableObject
+Grep: @Published
+```
+
+**Property Wrappers**:
+```
+Grep: @StateObject|@ObservedObject|@EnvironmentObject
+```
+
+**Deprecated Modifiers**:
+```
+Grep: onChange\(of:.*perform:
+```
+
+**Completion Handlers**:
+```
+Grep: completion:\s*@escaping
+Grep: completionHandler:
+```
+
+### Step 3: Categorize by Priority
+
+**HIGH Priority** (significant benefits):
+- ObservableObject → @Observable
+- Property wrapper migrations
+
+**MEDIUM Priority** (code quality):
+- Deprecated modifiers
+- async/await adoption
+
+**LOW Priority** (minor improvements):
+- Animation syntax
+- Minor API updates
+
+## Output Format
+
+```markdown
+# Modernization Analysis Results
+
+## Summary
+- **HIGH Priority**: [count] (Significant performance/maintainability gains)
+- **MEDIUM Priority**: [count] (Deprecated APIs, code quality)
+- **LOW Priority**: [count] (Minor improvements)
+
+## Minimum Deployment Target Impact
+- Current patterns support: iOS 14+
+- After full modernization: iOS 17+
+
+## HIGH Priority Migrations
+
+### ObservableObject → @Observable
+
+**Files affected**: 5
+**Estimated effort**: 2-3 hours
+
+#### Models to Migrate
+
+1. `Models/ContentViewModel.swift:12`
+ ```swift
+ // Current
+ class ContentViewModel: ObservableObject {
+ @Published var items: [Item] = []
+ @Published var isLoading = false
+ }
+
+ // Migrated
+ @Observable
+ class ContentViewModel {
+ var items: [Item] = []
+ var isLoading = false
+ }
+ ```
+
+2. `Models/UserSettings.swift:8`
+ [Similar migration...]
+
+#### Views to Update After Model Migration
+
+| File | Change |
+|------|--------|
+| `Views/ContentView.swift:15` | `@StateObject` → `@State` |
+| `Views/ItemList.swift:23` | `@ObservedObject` → plain property |
+| `Views/SettingsView.swift:8` | `@EnvironmentObject` → `@Environment` |
+
+### @EnvironmentObject → @Environment
+
+- `Views/RootView.swift:45`
+ ```swift
+ // Current
+ .environmentObject(settings)
+
+ // Migrated
+ .environment(settings)
+ ```
+
+- `Views/SettingsView.swift:12`
+ ```swift
+ // Current
+ @EnvironmentObject var settings: AppSettings
+
+ // Migrated
+ @Environment(AppSettings.self) var settings
+ ```
+
+## MEDIUM Priority Migrations
+
+### Deprecated onChange Modifier
+
+- `Views/SearchView.swift:34`
+ ```swift
+ // Deprecated
+ .onChange(of: query) { newValue in
+ search(newValue)
+ }
+
+ // Modern
+ .onChange(of: query) { oldValue, newValue in
+ search(newValue)
+ }
+ ```
+
+### async/await Opportunities
+
+- `Services/NetworkService.swift` - 3 completion handler methods
+ - `fetchUser(completion:)` → `fetchUser() async throws`
+ - `fetchItems(completion:)` → `fetchItems() async throws`
+ - `uploadData(completion:)` → `uploadData() async throws`
+
+## Migration Order
+
+1. **First**: Migrate models to `@Observable`
+ - All `ObservableObject` → `@Observable`
+ - Remove all `@Published`
+
+2. **Second**: Update view property wrappers
+ - `@StateObject` → `@State` (for owned models)
+ - `@ObservedObject` → plain or `@Bindable`
+ - `@EnvironmentObject` → `@Environment`
+
+3. **Third**: Update view modifiers
+ - `.environmentObject()` → `.environment()`
+ - Deprecated `onChange` syntax
+
+4. **Fourth**: Adopt async/await (optional, but recommended)
+
+## Breaking Changes Warning
+
+⚠️ **Deployment Target**: Full migration requires iOS 17+
+
+If you need to support iOS 16 or earlier:
+- Keep `ObservableObject` for those models
+- Use conditional compilation:
+ ```swift
+ #if os(iOS) && swift(>=5.9)
+ @Observable
+ class ViewModel { ... }
+ #else
+ class ViewModel: ObservableObject { ... }
+ #endif
+ ```
+
+## Verification
+
+After migration:
+1. Build and fix any compiler errors
+2. Test view updates (properties should still trigger UI refresh)
+3. Test bindings (TextField, Toggle still work)
+4. Test environment injection
+```
+
+## When No Migration Needed
+
+```markdown
+# Modernization Analysis Results
+
+## Summary
+Codebase is already using modern patterns!
+
+## Verified
+- ✅ Using `@Observable` macro
+- ✅ Using `@State` with Observable models
+- ✅ Using `@Environment` for shared state
+- ✅ No deprecated modifiers detected
+
+## Optional Improvements
+- Consider adopting iOS 18+ features when available
+- Review remaining completion handlers for async/await conversion
+```
+
+## Decision Flowchart
+
+```
+Is model a class with published properties?
+├─ YES: Does it conform to ObservableObject?
+│ ├─ YES: Target iOS 17+?
+│ │ ├─ YES → Migrate to @Observable
+│ │ └─ NO → Keep ObservableObject
+│ └─ NO: Already modern or not observable
+└─ NO: Check if it's a struct (usually fine)
+
+Is view using @StateObject?
+├─ YES: Is the model @Observable?
+│ ├─ YES → Change to @State
+│ └─ NO → Keep @StateObject until model migrated
+└─ NO: Check other wrappers
+
+Is view using @ObservedObject?
+├─ YES: Is the model @Observable?
+│ ├─ YES: Need binding?
+│ │ ├─ YES → Use @Bindable
+│ │ └─ NO → Remove wrapper, use plain property
+│ └─ NO → Keep @ObservedObject
+└─ NO: Already modern
+
+Is view using @EnvironmentObject?
+├─ YES: Is the model @Observable?
+│ ├─ YES → Change to @Environment(Type.self)
+│ └─ NO → Keep @EnvironmentObject
+└─ NO: Already modern
+```
+
+## False Positives to Avoid
+
+**Not issues**:
+- Third-party SDK types using ObservableObject
+- Models that intentionally support iOS 14-16
+- Combine publishers (not the same as @Published)
+- Already migrated code using @Observable
+- Apple protocol families unrelated to Observation — classes conforming to `AppIntent`, `EntityQuery`, `AppEntity`, `WidgetConfiguration`, `TimelineProvider`, or other App Intents / WidgetKit protocols are NOT `ObservableObject` and should not be flagged for `@Observable` migration
+
+**Check before reporting**:
+- Verify file is in your project, not dependencies
+- Check deployment target constraints
+- Confirm model is actually used in SwiftUI views
+- Confirm the class actually conforms to `ObservableObject` — do not flag classes just because they are classes
diff --git a/.claude/skills/axiom-modernize/agents/openai.yaml b/.claude/skills/axiom-modernize/agents/openai.yaml
new file mode 100644
index 0000000..1477e33
--- /dev/null
+++ b/.claude/skills/axiom-modernize/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Modernize"
+ short_description: "The user wants to modernize iOS code to iOS 17/18 patterns, migrate from ObservableObject to @Observable, update @Sta..."
diff --git a/.claude/skills/axiom-network-framework-ref/.openskills.json b/.claude/skills/axiom-network-framework-ref/.openskills.json
new file mode 100644
index 0000000..2aad087
--- /dev/null
+++ b/.claude/skills/axiom-network-framework-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-network-framework-ref",
+ "installedAt": "2026-04-12T08:06:29.616Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-network-framework-ref/SKILL.md b/.claude/skills/axiom-network-framework-ref/SKILL.md
new file mode 100644
index 0000000..2499307
--- /dev/null
+++ b/.claude/skills/axiom-network-framework-ref/SKILL.md
@@ -0,0 +1,1413 @@
+---
+name: axiom-network-framework-ref
+description: Reference — Comprehensive Network.framework guide covering NetworkConnection (iOS 26+), NWConnection (iOS 12-25), TLV framing, Coder protocol, NetworkListener, NetworkBrowser, Wi-Fi Aware discovery, and migration strategies
+license: MIT
+compatibility: iOS 12+ (NWConnection), iOS 26+ (NetworkConnection)
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-02"
+---
+
+# Network.framework API Reference
+
+## Overview
+
+Network.framework is Apple's modern networking API that replaces Berkeley sockets, providing smart connection establishment, user-space networking, built-in TLS support, and seamless mobility. Introduced in iOS 12 (2018) with NWConnection and evolved in iOS 26 (2025) with NetworkConnection for structured concurrency.
+
+#### Evolution timeline
+- **2018 (iOS 12)** NWConnection with completion handlers, deprecates CFSocket/NSStream/SCNetworkReachability
+- **2019 (iOS 13)** User-space networking (30% CPU reduction), TLS 1.3 default
+- **2025 (iOS 26)** NetworkConnection with async/await, TLV framing built-in, Coder protocol, Wi-Fi Aware discovery
+
+#### Key capabilities
+- **Smart connection establishment** Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation (PAC), VPN detection, WiFi Assist fallback
+- **User-space networking** ~30% lower CPU usage vs sockets, memory-mapped regions, reduced context switches
+- **Built-in security** TLS 1.3 by default, DTLS for UDP, certificate pinning support
+- **Mobility** Automatic network transition handling (WiFi ↔ cellular), viability notifications, Multipath TCP
+- **Performance** ECN (Explicit Congestion Notification), service class marking, TCP Fast Open, UDP batching
+
+#### When to use vs URLSession
+- **URLSession** HTTP, HTTPS, WebSocket, simple TCP/TLS streams → Use URLSession (optimized for these)
+- **Network.framework** UDP, custom protocols, low-level control, peer-to-peer, gaming, streaming → Use Network.framework
+
+#### Related Skills
+- Use `axiom-networking` for anti-patterns, common patterns, pressure scenarios
+- Use `axiom-networking-diag` for systematic troubleshooting of connection failures
+
+---
+
+## When to Use This Skill
+
+Use this skill when:
+- **Planning migration** from BSD sockets, CFSocket, NSStream, or SCNetworkReachability
+- **Understanding API differences** between NWConnection (iOS 12-25) and NetworkConnection (iOS 26+)
+- **Implementing all 12 WWDC 2025 examples** (TLS connection, TLV framing, Coder protocol, NetworkListener, Wi-Fi Aware)
+- **Choosing protocols** (TCP, UDP, TLS, QUIC) for your use case
+- **Peer-to-peer discovery** setup with NetworkBrowser and Wi-Fi Aware
+- **Optimizing performance** with user-space networking, batching, pacing
+- **Migrating** from completion handlers to async/await (NWConnection → NetworkConnection)
+
+---
+
+## API Evolution
+
+### Timeline
+
+| Year | iOS Version | Key Features |
+|------|-------------|--------------|
+| 2018 | iOS 12 | NWConnection, NWListener, NWBrowser introduced |
+| 2019 | iOS 13 | User-space networking (30% CPU reduction), TLS 1.3 default |
+| 2021 | iOS 15 | WebSocket support in URLSession |
+| 2025 | iOS 26 | NetworkConnection (async/await), TLV framing, Coder protocol, Wi-Fi Aware |
+
+### NWConnection (iOS 12-25) vs NetworkConnection (iOS 26+)
+
+| Feature | NWConnection (iOS 12-25) | NetworkConnection (iOS 26+) |
+|---------|-------------------------|----------------------------|
+| **Async model** | Completion handlers | async/await structured concurrency |
+| **State updates** | `stateUpdateHandler` callback | `states` AsyncSequence |
+| **Send** | `send(content:completion:)` callback | `try await send(content)` suspending |
+| **Receive** | `receive(minimumIncompleteLength:maximumLength:completion:)` | `try await receive(exactly:)` suspending |
+| **Framing** | Manual or custom NWFramer | TLV built-in (`TLV { TLS() }`) |
+| **Codable** | Manual JSON encode/decode | Coder protocol (`Coder(MyType.self, using: .json)`) |
+| **Memory** | Requires `[weak self]` in all closures | No `[weak self]` needed (Task cancellation automatic) |
+| **Error handling** | Check error in completion | `throws` with natural propagation |
+| **State machine** | Callbacks on state changes | `for await state in connection.states` |
+| **Discovery** | NWBrowser (Bonjour only) | NetworkBrowser (Bonjour + Wi-Fi Aware) |
+
+#### Recommendation
+- New apps targeting iOS 26+: Use NetworkConnection (cleaner, safer)
+- Apps supporting iOS 12-25: Use NWConnection (backward compatible)
+- Migration: Both APIs coexist, migrate incrementally
+
+---
+
+## NetworkConnection (iOS 26+) Complete Reference
+
+### 4.1 Creating Connections
+
+NetworkConnection uses declarative protocol stack composition.
+
+#### Example 1: Basic TLS Connection (WWDC 4:04)
+
+```swift
+import Network
+
+// Basic connection with TLS (TCP and IP inferred)
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ TLS()
+}
+
+// Send and receive with async/await
+public func sendAndReceiveWithTLS() async throws {
+ let outgoingData = Data("Hello, world!".utf8)
+ try await connection.send(outgoingData)
+
+ let incomingData = try await connection.receive(exactly: 98).content
+ print("Received data: \(incomingData)")
+}
+```
+
+#### Key points
+- `TLS()` infers `TCP()` and `IP()` automatically
+- No explicit connection.start() needed (happens on first send/receive)
+- Async/await eliminates callback nesting
+
+#### Example 2: Custom IP Options (WWDC 4:41)
+
+```swift
+// Customize IP fragmentation
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ TLS {
+ TCP {
+ IP()
+ .fragmentationEnabled(false) // Disable IP fragmentation
+ }
+ }
+}
+```
+
+#### When to customize IP
+- `.fragmentationEnabled(false)` — For protocols that handle fragmentation themselves (QUIC)
+- `.ipVersion(.v6)` — Force IPv6 only (testing)
+
+#### Example 3: Custom Parameters (WWDC 5:07)
+
+```swift
+// Constrained paths (low data mode) + custom IP
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029),
+ using: .parameters {
+ TLS {
+ TCP {
+ IP()
+ .fragmentationEnabled(false)
+ }
+ }
+ }
+ .constrainedPathsProhibited(true) // Don't use cellular in low data mode
+)
+```
+
+#### Common parameters
+- `.constrainedPathsProhibited(true)` — Respect low data mode
+- `.expensivePathsProhibited(true)` — Don't use cellular/hotspot
+- `.multipathServiceType(.handover)` — Enable Multipath TCP
+
+#### Endpoint Types
+
+```swift
+// Host + Port
+.hostPort(host: "example.com", port: 443)
+
+// Service (Bonjour)
+.service(name: "MyPrinter", type: "_ipp._tcp", domain: "local.", interface: nil)
+
+// Unix domain socket
+.unix(path: "/tmp/my.sock")
+```
+
+#### Protocol Stack Composition
+
+```swift
+// TLS over TCP (most common)
+TLS()
+
+// QUIC (TLS + UDP, multiplexed streams)
+QUIC()
+
+// UDP (datagrams)
+UDP()
+
+// TCP (stream, no encryption)
+TCP()
+
+// WebSocket over TLS
+WebSocket {
+ TLS()
+}
+
+// Custom framing
+TLV {
+ TLS()
+}
+```
+
+---
+
+### 4.2 State Machine
+
+NetworkConnection transitions through these states:
+
+```
+setup
+ ↓
+preparing (DNS, TCP handshake, TLS handshake)
+ ↓
+┌─ waiting (no network, retrying)
+│ ↓
+└→ ready (can send/receive)
+ ↓
+ failed (error) or cancelled
+```
+
+#### Monitoring States
+
+```swift
+// Option 1: Async sequence (monitor in background)
+Task {
+ for await state in connection.states {
+ switch state {
+ case .preparing:
+ print("Connecting...")
+ case .waiting(let error):
+ print("Waiting for network: \(error)")
+ case .ready:
+ print("Connected!")
+ case .failed(let error):
+ print("Failed: \(error)")
+ case .cancelled:
+ print("Cancelled")
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+#### Key states
+- **.preparing** DNS lookup, TCP SYN, TLS handshake
+- **.waiting** No network available, framework retries automatically
+- **.ready** Connection established, can send/receive
+- **.failed** Unrecoverable error (server refused, TLS failed, timeout)
+- **.cancelled** Task cancelled or connection.cancel() called
+
+---
+
+### 4.3 Send/Receive Patterns
+
+#### Send: Basic
+
+```swift
+let data = Data("Hello".utf8)
+try await connection.send(data)
+```
+
+#### Receive: Exact Byte Count (WWDC 7:30)
+
+```swift
+// Receive exactly 98 bytes
+let incomingData = try await connection.receive(exactly: 98).content
+print("Received \(incomingData.count) bytes")
+```
+
+#### Receive: Variable Length (WWDC 8:29)
+
+```swift
+// Read UInt32 length prefix, then read that many bytes
+let remaining32 = try await connection.receive(as: UInt32.self).content
+guard var remaining = Int(exactly: remaining32) else { throw MyError.invalidLength }
+
+while remaining > 0 {
+ let chunk = try await connection.receive(atLeast: 1, atMost: remaining).content
+ remaining -= chunk.count
+ // Process chunk...
+}
+```
+
+#### receive() variants
+- `receive(exactly: n)` — Wait for exactly n bytes
+- `receive(atLeast: min, atMost: max)` — Get between min and max bytes
+- `receive(as: UInt32.self)` — Read fixed-size type (network byte order)
+
+---
+
+### 4.4 TLV Framing (iOS 26+)
+
+**TLV (Type-Length-Value)** solves message boundary problem on stream protocols (TCP/TLS).
+
+#### Format
+- Type: UInt32 (message identifier)
+- Length: UInt32 (message size, automatic)
+- Value: Message bytes
+
+#### Example: GameMessage with TLV (WWDC 11:06, 11:24, 11:53)
+
+```swift
+import Network
+
+// Define message types
+enum GameMessage: Int {
+ case selectedCharacter = 0
+ case move = 1
+}
+
+struct GameCharacter: Codable {
+ let character: String
+}
+
+struct GameMove: Codable {
+ let row: Int
+ let column: Int
+}
+
+// Connection with TLV framing
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ TLV {
+ TLS()
+ }
+}
+
+// Send typed message
+public func sendWithTLV() async throws {
+ let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨"))
+ try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue)
+}
+
+// Receive typed message
+public func receiveWithTLV() async throws {
+ let (incomingData, metadata) = try await connection.receive()
+
+ switch GameMessage(rawValue: metadata.type) {
+ case .selectedCharacter:
+ let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData)
+ print("Character selected: \(character)")
+ case .move:
+ let move = try JSONDecoder().decode(GameMove.self, from: incomingData)
+ print("Move: row=\(move.row), column=\(move.column)")
+ case .none:
+ print("Unknown message type: \(metadata.type)")
+ }
+}
+```
+
+#### Benefits
+- Message boundaries preserved (send 3 messages → receive exactly 3)
+- Type-safe message handling (enum-based routing)
+- Minimal overhead (8 bytes per message: type + length)
+
+#### When to use
+- Mixed message types (chat + presence + typing)
+- Existing protocols using TLV
+- Need message boundaries without heavy framing
+
+---
+
+### 4.5 Coder Protocol (iOS 26+)
+
+**Coder** eliminates manual JSON encoding/decoding boilerplate.
+
+#### Example: GameMessage with Coder (WWDC 12:50, 13:13, 13:53)
+
+```swift
+import Network
+
+// Define message types as Codable enum
+enum GameMessage: Codable {
+ case selectedCharacter(String)
+ case move(row: Int, column: Int)
+}
+
+// Connection with Coder
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ Coder(GameMessage.self, using: .json) {
+ TLS()
+ }
+}
+
+// Send Codable directly (no encoding needed!)
+public func sendWithCoder() async throws {
+ let selectedCharacter: GameMessage = .selectedCharacter("🐨")
+ try await connection.send(selectedCharacter)
+}
+
+// Receive Codable directly (no decoding needed!)
+public func receiveWithCoder() async throws {
+ let gameMessage = try await connection.receive().content // Returns GameMessage!
+
+ switch gameMessage {
+ case .selectedCharacter(let character):
+ print("Character selected: \(character)")
+ case .move(let row, let column):
+ print("Move: (\(row), \(column))")
+ }
+}
+```
+
+#### Supported formats
+- `.json` — JSON encoding (human-readable, widely compatible)
+- `.propertyList` — Property list (faster, smaller)
+
+#### Benefits
+- No JSON boilerplate (~50 lines → ~10 lines)
+- Type-safe (compiler catches message structure changes)
+- Automatic framing (handles message boundaries)
+
+#### When to use
+- App-to-app communication (you control both ends)
+- Prototyping (fastest time to working code)
+- Type-safe protocols
+
+#### When NOT to use
+- Interoperating with non-Swift servers
+- Need custom wire format
+- Performance-critical (prefer manual encoding for control)
+
+---
+
+### 4.6 NetworkListener (iOS 26+)
+
+Listen for incoming connections with automatic subtask management.
+
+#### Example: Listening for Connections (WWDC 15:16)
+
+```swift
+import Network
+
+// Listener with Coder protocol
+public func listenForIncomingConnections() async throws {
+ try await NetworkListener {
+ Coder(GameMessage.self, using: .json) {
+ TLS()
+ }
+ }.run { connection in
+ // Each connection gets its own subtask
+ for try await (gameMessage, _) in connection.messages {
+ switch gameMessage {
+ case .selectedCharacter(let character):
+ print("Player chose: \(character)")
+ case .move(let row, let column):
+ print("Player moved: (\(row), \(column))")
+ }
+ }
+ }
+}
+```
+
+#### Key features
+- Automatic subtask per connection (no manual Task management)
+- Structured concurrency (all subtasks cancelled when listener exits)
+- `connection.messages` async sequence for receiving
+
+#### Listener configuration
+
+```swift
+// Specify port
+NetworkListener(port: 1029) { TLS() }
+
+// Let system choose port
+NetworkListener { TLS() }
+
+// Bonjour advertising
+NetworkListener(service: .init(name: "MyApp", type: "_myapp._tcp")) { TLS() }
+```
+
+---
+
+### 4.7 NetworkBrowser & Wi-Fi Aware (iOS 26+)
+
+Discover endpoints on local network or nearby devices.
+
+#### Example: Wi-Fi Aware Discovery (WWDC 17:39)
+
+```swift
+import Network
+import WiFiAware
+
+// Browse for nearby paired Wi-Fi Aware devices
+public func findNearbyDevice() async throws {
+ let endpoint = try await NetworkBrowser(
+ for: .wifiAware(.connecting(to: .allPairedDevices, from: .ticTacToeService))
+ ).run { endpoints in
+ .finish(endpoints.first!) // Use first discovered device
+ }
+
+ // Make connection to the discovered endpoint
+ let connection = NetworkConnection(to: endpoint) {
+ Coder(GameMessage.self, using: .json) {
+ TLS()
+ }
+ }
+}
+```
+
+#### Wi-Fi Aware features
+- Peer-to-peer without infrastructure (no WiFi router needed)
+- Automatic discovery of paired devices
+- Low latency, axiom-high throughput
+- iOS 26+ only
+
+#### Browse descriptors
+
+```swift
+// Bonjour
+.bonjour(type: "_http._tcp", domain: "local")
+
+// Wi-Fi Aware (all paired devices)
+.wifiAware(.connecting(to: .allPairedDevices, from: .myService))
+
+// Wi-Fi Aware (specific device)
+.wifiAware(.connecting(to: .pairedDevice(identifier: deviceID), from: .myService))
+```
+
+---
+
+## NWConnection (iOS 12-25) Complete Reference
+
+### 5.1 Creating Connections
+
+NWConnection uses completion handlers (pre-async/await).
+
+#### Basic TLS Connection (WWDC 2018 lines 133-166)
+
+```swift
+import Network
+
+// Create connection
+let connection = NWConnection(
+ host: NWEndpoint.Host("mail.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 993),
+ using: .tls // TCP inferred
+)
+
+// Handle connection state changes
+connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ print("Connection established")
+ self?.sendData()
+
+ case .waiting(let error):
+ print("Waiting for network: \(error)")
+ // Show "Waiting..." UI, don't fail immediately
+
+ case .failed(let error):
+ print("Connection failed: \(error)")
+
+ case .cancelled:
+ print("Connection cancelled")
+
+ default:
+ break
+ }
+}
+
+// Start connection
+connection.start(queue: .main)
+```
+
+**Critical** Always use `[weak self]` in stateUpdateHandler to prevent retain cycles.
+
+#### Custom Parameters
+
+```swift
+// Create custom parameters
+let parameters = NWParameters.tls
+
+// Prohibit expensive networks
+parameters.prohibitExpensivePaths = true // Don't use cellular/hotspot
+
+// Prohibit constrained networks
+parameters.prohibitConstrainedPaths = true // Respect low data mode
+
+// Require IPv6
+parameters.requiredInterfaceType = .wifi
+parameters.ipOptions.version = .v6
+
+let connection = NWConnection(host: "example.com", port: 443, using: parameters)
+```
+
+---
+
+### 5.2 State Handling
+
+NWConnection state machine (same as NetworkConnection):
+
+```
+setup → preparing → waiting/ready → failed/cancelled
+```
+
+#### State handling best practices
+
+```swift
+connection.stateUpdateHandler = { [weak self] state in
+ guard let self = self else { return }
+
+ switch state {
+ case .preparing:
+ // DNS lookup, TCP SYN, TLS handshake in progress
+ self.updateUI(.connecting)
+
+ case .waiting(let error):
+ // Network unavailable or blocked
+ // DON'T fail immediately, framework retries automatically
+ print("Waiting: \(error.localizedDescription)")
+ self.updateUI(.waiting)
+
+ case .ready:
+ // Connection established, can send/receive
+ self.updateUI(.connected)
+ self.startSending()
+
+ case .failed(let error):
+ // Unrecoverable error after all retry attempts
+ print("Failed: \(error.localizedDescription)")
+ self.updateUI(.failed)
+
+ case .cancelled:
+ // connection.cancel() called
+ self.updateUI(.disconnected)
+
+ default:
+ break
+ }
+}
+```
+
+---
+
+### 5.3 Send/Receive with Callbacks
+
+#### Send with Pacing (WWDC 2018 lines 320-341)
+
+```swift
+// Send with contentProcessed callback for pacing
+func sendData() {
+ let data = Data("Hello, world!".utf8)
+
+ connection.send(content: data, completion: .contentProcessed { [weak self] error in
+ if let error = error {
+ print("Send error: \(error)")
+ return
+ }
+
+ // contentProcessed = network stack consumed data
+ // NOW send next chunk (pacing)
+ self?.sendNextData()
+ })
+}
+```
+
+**contentProcessed callback** Invoked when network stack consumes your data (equivalent to when blocking socket call would return). Use this for pacing to avoid buffering excessive data.
+
+#### Receive with Exact Byte Count
+
+```swift
+// Receive exactly 10 bytes
+connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
+ if let error = error {
+ print("Receive error: \(error)")
+ return
+ }
+
+ if let data = data {
+ print("Received \(data.count) bytes")
+ // Process data...
+
+ // Continue receiving
+ self?.receiveMore()
+ }
+}
+```
+
+#### Receive parameters
+- `minimumIncompleteLength`: Minimum bytes before callback (1 = return any data)
+- `maximumLength`: Maximum bytes per callback
+- For "exactly n bytes": Set both to n
+
+---
+
+### 5.4 UDP Batching (WWDC 2018 lines 343-347)
+
+#### Batch sending for 30% CPU reduction.
+
+```swift
+// UDP connection
+let connection = NWConnection(
+ host: NWEndpoint.Host("game-server.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 9000),
+ using: .udp
+)
+
+connection.start(queue: .main)
+
+// Batch multiple datagrams
+func sendVideoFrames(_ frames: [Data]) {
+ connection.batch {
+ for frame in frames {
+ connection.send(content: frame, completion: .contentProcessed { error in
+ if let error = error {
+ print("Send error: \(error)")
+ }
+ })
+ }
+ }
+ // All sends batched into ~1 syscall
+ // Result: 30% lower CPU usage vs individual sends
+}
+```
+
+**Without batch** 100 datagrams = 100 syscalls = high CPU
+**With batch** 100 datagrams = ~1 syscall = 30% lower CPU (measured with Instruments)
+
+---
+
+### 5.5 NWListener (WWDC 2018 lines 233-293)
+
+Accept incoming connections.
+
+```swift
+import Network
+
+// Create listener on port 1029
+let listener = try NWListener(using: .tcp, on: 1029)
+
+// Advertise Bonjour service
+listener.service = NWListener.Service(name: "MyApp", type: "_myapp._tcp")
+
+// Handle service registration
+listener.serviceRegistrationUpdateHandler = { update in
+ switch update {
+ case .add(let endpoint):
+ if case .service(let name, let type, let domain, _) = endpoint {
+ print("Advertising: \(name).\(type)\(domain)")
+ }
+ default:
+ break
+ }
+}
+
+// Handle new connections
+listener.newConnectionHandler = { [weak self] newConnection in
+ print("New connection from: \(newConnection.endpoint)")
+
+ newConnection.stateUpdateHandler = { state in
+ if case .ready = state {
+ print("Client connected")
+ self?.handleClient(newConnection)
+ }
+ }
+
+ newConnection.start(queue: .main)
+}
+
+// Handle listener state
+listener.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ print("Listener ready on port \(listener.port ?? 0)")
+ case .failed(let error):
+ print("Listener failed: \(error)")
+ default:
+ break
+ }
+}
+
+// Start listening
+listener.start(queue: .main)
+```
+
+---
+
+### 5.6 NWBrowser (Bonjour Discovery)
+
+Discover services on local network.
+
+```swift
+import Network
+
+// Browse for Bonjour services
+let browser = NWBrowser(
+ for: .bonjour(type: "_http._tcp", domain: nil),
+ using: .tcp
+)
+
+// Handle discovered services
+browser.browseResultsChangedHandler = { results, changes in
+ for result in results {
+ switch result.endpoint {
+ case .service(let name, let type, let domain, _):
+ print("Found service: \(name).\(type)\(domain)")
+
+ // Connect to this service
+ let connection = NWConnection(to: result.endpoint, using: .tcp)
+ connection.start(queue: .main)
+
+ default:
+ break
+ }
+ }
+}
+
+// Handle browser state
+browser.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ print("Browser ready")
+ case .failed(let error):
+ print("Browser failed: \(error)")
+ default:
+ break
+ }
+}
+
+// Start browsing
+browser.start(queue: .main)
+```
+
+---
+
+## Mobility & Network Transitions
+
+### Connection Viability (WWDC 2018 lines 453-463)
+
+Viability = connection can send/receive data (has valid route).
+
+```swift
+connection.viabilityUpdateHandler = { isViable in
+ if isViable {
+ print("✅ Connection viable (can send/receive)")
+ } else {
+ print("⚠️ Connection not viable (no route)")
+ // Don't tear down immediately, may recover
+ // Show UI: "Connection interrupted"
+ }
+}
+```
+
+#### When viability changes
+- Walk into elevator (WiFi signal lost) → not viable
+- Walk out of elevator (WiFi returns) → viable again
+- Switch WiFi → cellular → not viable briefly → viable on cellular
+
+**Best practice** Don't tear down connection on viability loss. Framework will recover when network returns.
+
+### Better Path Available (WWDC 2018 lines 464-477)
+
+Better path = alternative network with better characteristics.
+
+```swift
+connection.betterPathUpdateHandler = { betterPathAvailable in
+ if betterPathAvailable {
+ print("📶 Better path available (e.g., WiFi while on cellular)")
+ // Consider migrating to new connection
+ self.migrateToNewConnection()
+ }
+}
+```
+
+#### Scenarios
+- Connected on cellular, walk into building with WiFi → better path available
+- Connected on WiFi, WiFi quality degrades, cellular available → better path available
+
+#### Migration pattern
+
+```swift
+func migrateToNewConnection() {
+ // Create new connection
+ let newConnection = NWConnection(host: host, port: port, using: parameters)
+
+ newConnection.stateUpdateHandler = { [weak self] state in
+ if case .ready = state {
+ // New connection ready, switch over
+ self?.currentConnection?.cancel()
+ self?.currentConnection = newConnection
+ }
+ }
+
+ newConnection.start(queue: .main)
+
+ // Keep old connection until new one ready
+}
+```
+
+### Multipath TCP (WWDC 2018 lines 480-487)
+
+Automatically migrate between networks without application intervention.
+
+```swift
+let parameters = NWParameters.tcp
+parameters.multipathServiceType = .handover // Seamless network transition
+
+let connection = NWConnection(host: "example.com", port: 443, using: parameters)
+```
+
+#### Multipath TCP modes
+- `.handover` — Seamless handoff between networks (WiFi ↔ cellular)
+- `.interactive` — Use multiple paths simultaneously (lowest latency)
+- `.aggregate` — Use multiple paths simultaneously (highest throughput)
+
+#### Benefits
+- Automatic network transition (no viability handlers needed)
+- No connection interruption when switching networks
+- Fallback to single-path if MPTCP unavailable
+
+### NWPathMonitor (WWDC 2018 lines 489-496)
+
+Monitor network state changes (replaces SCNetworkReachability).
+
+```swift
+import Network
+
+let monitor = NWPathMonitor()
+
+monitor.pathUpdateHandler = { path in
+ if path.status == .satisfied {
+ print("✅ Network available")
+
+ // Check interface types
+ if path.usesInterfaceType(.wifi) {
+ print("Using WiFi")
+ } else if path.usesInterfaceType(.cellular) {
+ print("Using cellular")
+ }
+
+ // Check if expensive
+ if path.isExpensive {
+ print("⚠️ Expensive path (cellular/hotspot)")
+ }
+
+ } else {
+ print("❌ No network")
+ }
+}
+
+monitor.start(queue: .main)
+```
+
+#### Use cases
+- Show "No network" UI when path.status == .unsatisfied
+- Disable high-bandwidth features when path.isExpensive
+- Adjust quality based on interface type
+
+#### When to use
+- Global network state monitoring
+- When "waiting for connectivity" isn't enough
+- Need to know available interfaces before connecting
+
+#### When NOT to use
+- Checking before connecting (use waiting state instead)
+- Per-connection monitoring (use viability handlers instead)
+
+---
+
+## Security Configuration
+
+### TLS Version
+
+```swift
+// iOS 13+ requires TLS 1.2+ by default
+let tlsOptions = NWProtocolTLS.Options()
+
+// Allow TLS 1.2 and 1.3
+tlsOptions.minimumTLSProtocolVersion = .TLSv12
+
+// Require TLS 1.3 only
+tlsOptions.minimumTLSProtocolVersion = .TLSv13
+
+let parameters = NWParameters(tls: tlsOptions)
+let connection = NWConnection(host: "example.com", port: 443, using: parameters)
+```
+
+### Certificate Pinning
+
+```swift
+// Production-grade certificate pinning
+let tlsOptions = NWProtocolTLS.Options()
+
+sec_protocol_options_set_verify_block(
+ tlsOptions.securityProtocolOptions,
+ { (metadata, trust, complete) in
+ // Get server certificate
+ let serverCert = sec_protocol_metadata_copy_peer_public_key(metadata)
+
+ // Compare with pinned certificate
+ let pinnedCertData = Data(/* your pinned cert */)
+ let serverCertData = SecCertificateCopyData(serverCert) as Data
+
+ if serverCertData == pinnedCertData {
+ complete(true) // Accept
+ } else {
+ complete(false) // Reject (prevents MITM attacks)
+ }
+ },
+ .main
+)
+
+let parameters = NWParameters(tls: tlsOptions)
+```
+
+### Certificate Pinning + Corporate Proxies
+
+Corporate networks often use TLS inspection proxies that present their own certificates. Strict pinning breaks these environments.
+
+**Strategy**: Pin against the public key (SPKI) rather than the full certificate, and provide a configuration escape hatch:
+
+```swift
+sec_protocol_options_set_verify_block(
+ tlsOptions.securityProtocolOptions,
+ { (metadata, trust, complete) in
+ // 1. Check if system trusts the certificate chain (handles corporate CAs)
+ let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
+ SecTrustEvaluateAsyncWithError(secTrust, .main) { _, result, _ in
+ guard result else { complete(false); return }
+
+ // 2. If pinning enabled, also verify public key
+ if PinningConfig.isEnabled {
+ let serverKey = SecTrustCopyKey(secTrust)
+ let matches = pinnedKeys.contains { $0 == serverKey }
+ complete(matches)
+ } else {
+ complete(true) // System trust only (enterprise mode)
+ }
+ }
+ },
+ .main
+)
+```
+
+**Rules**:
+- Always validate system trust first (`SecTrustEvaluateAsyncWithError`) — this respects enterprise-installed root CAs
+- Use public key pinning over certificate pinning (survives cert rotation)
+- Provide a managed configuration (MDM profile or app config) to disable pinning in enterprise environments
+- Pin at least 2 keys (current + backup) to survive rotation
+
+### Cipher Suites
+
+```swift
+let tlsOptions = NWProtocolTLS.Options()
+
+// Specify allowed cipher suites
+tlsOptions.tlsCipherSuites = [
+ tls_ciphersuite_t(rawValue: 0x1301), // TLS_AES_128_GCM_SHA256
+ tls_ciphersuite_t(rawValue: 0x1302), // TLS_AES_256_GCM_SHA384
+]
+
+// iOS defaults to secure modern ciphers, only customize if required
+```
+
+---
+
+## Performance Optimization
+
+### User-Space Networking (WWDC 2018 lines 409-441)
+
+**Automatic on iOS/tvOS.** Network.framework moves TCP/UDP stack into your app process.
+
+#### Benefits
+- ~30% lower CPU usage (measured with Instruments)
+- No kernel→userspace copy (memory-mapped regions)
+- Reduced context switches
+
+#### Legacy vs User-Space
+
+| Traditional Sockets | User-Space Networking |
+|---------------------|----------------------|
+| Packet → driver → kernel → copy → userspace | Packet → driver → memory-mapped region → userspace (no copy) |
+| 100 datagrams = 100 syscalls | 100 datagrams = ~1 syscall (with batching) |
+| ~30% higher CPU | Baseline CPU |
+
+**WWDC demo** Live UDP video streaming showed 30% CPU difference (sockets vs Network.framework).
+
+### ECN for UDP (WWDC 2018 lines 365-378)
+
+Explicit Congestion Notification for smooth UDP transmission.
+
+```swift
+// Create IP metadata with ECN
+let ipMetadata = NWProtocolIP.Metadata()
+ipMetadata.ecnFlag = .congestionEncountered // Or .ect0, .ect1
+
+// Attach to send context
+let context = NWConnection.ContentContext(
+ identifier: "video_frame",
+ metadata: [ipMetadata]
+)
+
+connection.send(content: data, contentContext: context, completion: .contentProcessed { _ in })
+```
+
+#### ECN flags
+- `.ect0` / `.ect1` — ECN-capable transport
+- `.congestionEncountered` — Congestion notification received
+
+**Benefits** Network can signal congestion without dropping packets.
+
+### Service Class (WWDC 2018 lines 379-388)
+
+Mark traffic priority.
+
+```swift
+// Connection-wide service class
+let parameters = NWParameters.tcp
+parameters.serviceClass = .background // Low priority
+
+let connection = NWConnection(host: "example.com", port: 443, using: parameters)
+
+// Per-packet service class (UDP)
+let ipMetadata = NWProtocolIP.Metadata()
+ipMetadata.serviceClass = .realTimeInteractive // High priority (voice)
+
+let context = NWConnection.ContentContext(identifier: "voip", metadata: [ipMetadata])
+connection.send(content: audioData, contentContext: context, completion: .contentProcessed { _ in })
+```
+
+#### Service classes
+- `.background` — Low priority (large downloads, sync)
+- `.default` — Normal priority
+- `.responsiveData` — Interactive data (API calls)
+- `.realTimeInteractive` — Time-sensitive (voice, gaming)
+
+### TCP Fast Open (WWDC 2018 lines 389-406)
+
+Send initial data in TCP SYN packet (saves round trip).
+
+```swift
+let parameters = NWParameters.tcp
+parameters.allowFastOpen = true
+
+let connection = NWConnection(host: "example.com", port: 443, using: parameters)
+
+// Send initial data BEFORE calling start()
+let initialData = Data("GET / HTTP/1.1\r\n".utf8)
+connection.send(
+ content: initialData,
+ contentContext: .defaultMessage,
+ isComplete: false,
+ completion: .idempotent // Data is safe to replay
+)
+
+// Now start connection (initial data sent in SYN)
+connection.start(queue: .main)
+```
+
+**Benefits** Reduces connection establishment time by 1 RTT.
+**Requirements** Data must be idempotent (safe to replay if SYN retransmitted).
+
+---
+
+## Migration Strategies
+
+### From BSD Sockets to NWConnection
+
+| BSD Sockets | NWConnection | Notes |
+|-------------|--------------|-------|
+| `socket() + connect()` | `NWConnection(host:port:using:) + start()` | Non-blocking by default |
+| `send() / sendto()` | `connection.send(content:completion:)` | Async callback |
+| `recv() / recvfrom()` | `connection.receive(min:max:completion:)` | Async callback |
+| `bind() + listen()` | `NWListener(using:on:)` | Automatic port binding |
+| `accept()` | `listener.newConnectionHandler` | Callback per connection |
+| `getaddrinfo()` | Use `NWEndpoint.Host(hostname)` | DNS automatic |
+| `SCNetworkReachability` | `connection.stateUpdateHandler` waiting state | No race conditions |
+| `setsockopt()` | `NWParameters` | Type-safe options |
+
+#### Migration example
+
+#### Before (blocking sockets)
+```c
+int sock = socket(AF_INET, SOCK_STREAM, 0);
+connect(sock, &addr, addrlen); // BLOCKS
+send(sock, data, len, 0);
+```
+
+#### After (NWConnection)
+```swift
+let connection = NWConnection(host: "example.com", port: 443, using: .tls)
+connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ connection.send(content: data, completion: .contentProcessed { _ in })
+ }
+}
+connection.start(queue: .main)
+```
+
+### From URLSession StreamTask to NetworkConnection
+
+#### When to migrate
+- Need UDP (StreamTask only supports TCP)
+- Need custom protocols
+- Need low-level control
+
+#### When to STAY with URLSession
+- HTTP/HTTPS (URLSession optimized for this)
+- WebSocket support
+- Built-in caching, cookies
+
+#### Migration example
+
+#### Before (URLSession StreamTask)
+```swift
+let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443)
+task.resume()
+task.write(Data("Hello".utf8), timeout: 10) { _ in }
+```
+
+#### After (NetworkConnection iOS 26+)
+```swift
+let connection = NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() }
+try await connection.send(Data("Hello".utf8))
+```
+
+### From NWConnection to NetworkConnection
+
+#### Benefits of migration
+- Async/await (no callback nesting)
+- No `[weak self]` needed
+- TLV framing built-in
+- Coder protocol for Codable types
+
+#### Migration mapping
+
+| NWConnection | NetworkConnection |
+|--------------|-------------------|
+| `connection.stateUpdateHandler = { }` | `for await state in connection.states { }` |
+| `connection.send(content:completion:)` | `try await connection.send(content)` |
+| `connection.receive(min:max:completion:)` | `try await connection.receive(exactly:)` |
+| Manual JSON | `Coder(MyType.self, using: .json)` |
+| Custom framer | `TLV { TLS() }` |
+| `[weak self]` everywhere | No `[weak self]` needed |
+
+#### Migration example
+
+#### Before (NWConnection)
+```swift
+connection.stateUpdateHandler = { [weak self] state in
+ if case .ready = state {
+ self?.sendData()
+ }
+}
+
+func sendData() {
+ connection.send(content: data, completion: .contentProcessed { [weak self] error in
+ self?.receiveData()
+ })
+}
+```
+
+#### After (NetworkConnection)
+```swift
+Task {
+ for await state in connection.states {
+ if case .ready = state {
+ try await connection.send(data)
+ let received = try await connection.receive(exactly: 10).content
+ }
+ }
+}
+```
+
+---
+
+## Testing Checklist
+
+Before shipping networking code:
+
+### Device Testing
+- [ ] Tested on real device (not just simulator)
+- [ ] Tested on multiple iOS versions (12, 15, 26)
+- [ ] Tested on iPhone and iPad (different network characteristics)
+
+### Network Conditions
+- [ ] WiFi (home network)
+- [ ] Cellular (disable WiFi)
+- [ ] Airplane Mode → WiFi (test waiting state)
+- [ ] WiFi → cellular transition (walk out of building)
+- [ ] Cellular → WiFi transition (walk into building)
+- [ ] Weak signal (basement, elevator)
+- [ ] Network Link Conditioner (100ms latency, 3% packet loss)
+
+### Network Types
+- [ ] IPv4-only network
+- [ ] IPv6-only network (some cellular carriers)
+- [ ] Dual-stack (IPv4 + IPv6)
+- [ ] Corporate VPN active
+- [ ] Personal hotspot (expensive path)
+
+### Performance
+- [ ] Connection establishment < 500ms (check logs)
+- [ ] Using batch for UDP (verify with Instruments)
+- [ ] Using contentProcessed for pacing (check send timing)
+- [ ] Profiled with Instruments Network template
+- [ ] CPU usage acceptable (< 10% for networking)
+- [ ] Memory stable (no leaks, check [weak self])
+
+### Error Handling
+- [ ] Handling .waiting state (show "Waiting..." UI)
+- [ ] Handling .failed state (specific error messages)
+- [ ] TLS handshake errors logged
+- [ ] Timeout handling (don't wait forever)
+- [ ] User-facing errors actionable ("Check network" not "POSIX 61")
+
+### iOS 26+ Features (if using NetworkConnection)
+- [ ] Using TLV framing if need message boundaries
+- [ ] Using Coder protocol if sending Codable types
+- [ ] Using NetworkListener instead of NWListener
+- [ ] Using NetworkBrowser for Wi-Fi Aware if peer-to-peer
+
+---
+
+## API Quick Reference
+
+### NetworkConnection (iOS 26+)
+
+```swift
+// Create connection
+NetworkConnection(to: .hostPort(host: "example.com", port: 443)) { TLS() }
+
+// Send
+try await connection.send(data)
+
+// Receive
+try await connection.receive(exactly: n).content
+
+// States
+for await state in connection.states { }
+
+// TLV framing
+NetworkConnection(to: endpoint) { TLV { TLS() } }
+
+// Coder protocol
+NetworkConnection(to: endpoint) { Coder(MyType.self, using: .json) { TLS() } }
+
+// Listener
+NetworkListener { TLS() }.run { connection in }
+
+// Browser
+NetworkBrowser(for: .wifiAware(...)).run { endpoints in }
+```
+
+### NWConnection (iOS 12-25)
+
+```swift
+// Create connection
+let connection = NWConnection(host: "example.com", port: 443, using: .tls)
+
+// State handler
+connection.stateUpdateHandler = { [weak self] state in }
+
+// Start
+connection.start(queue: .main)
+
+// Send
+connection.send(content: data, completion: .contentProcessed { [weak self] error in })
+
+// Receive
+connection.receive(minimumIncompleteLength: min, maximumLength: max) { [weak self] data, context, isComplete, error in }
+
+// Viability
+connection.viabilityUpdateHandler = { isViable in }
+
+// Better path
+connection.betterPathUpdateHandler = { betterPathAvailable in }
+
+// Cancel
+connection.cancel()
+```
+
+### NWListener (iOS 12-25)
+
+```swift
+let listener = try NWListener(using: .tcp, on: 1029)
+listener.newConnectionHandler = { newConnection in }
+listener.start(queue: .main)
+```
+
+### NWBrowser (iOS 12-25)
+
+```swift
+let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: .tcp)
+browser.browseResultsChangedHandler = { results, changes in }
+browser.start(queue: .main)
+```
+
+### NWPathMonitor
+
+```swift
+let monitor = NWPathMonitor()
+monitor.pathUpdateHandler = { path in }
+monitor.start(queue: .main)
+```
+
+---
+
+## Resources
+
+**WWDC**: 2018-715, 2025-250
+
+**Docs**: /network, /network/nwconnection, /network/networkconnection
+
+**Skills**: axiom-networking, axiom-networking-diag
+
+---
+
+**Last Updated** 2025-12-02
+**Status** Production-ready reference from WWDC 2018 and WWDC 2025
+**Coverage** NWConnection (iOS 12-25), NetworkConnection (iOS 26+), all 12 WWDC 2025 code examples
diff --git a/.claude/skills/axiom-network-framework-ref/agents/openai.yaml b/.claude/skills/axiom-network-framework-ref/agents/openai.yaml
new file mode 100644
index 0000000..ef7e24a
--- /dev/null
+++ b/.claude/skills/axiom-network-framework-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Network Framework Reference"
+ short_description: "Reference — Comprehensive Network.framework guide covering NetworkConnection (iOS 26+), NWConnection (iOS 12-25), TLV..."
diff --git a/.claude/skills/axiom-networking-diag/.openskills.json b/.claude/skills/axiom-networking-diag/.openskills.json
new file mode 100644
index 0000000..f1779e5
--- /dev/null
+++ b/.claude/skills/axiom-networking-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-networking-diag",
+ "installedAt": "2026-04-12T08:06:30.010Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-networking-diag/SKILL.md b/.claude/skills/axiom-networking-diag/SKILL.md
new file mode 100644
index 0000000..0eb2c73
--- /dev/null
+++ b/.claude/skills/axiom-networking-diag/SKILL.md
@@ -0,0 +1,1068 @@
+---
+name: axiom-networking-diag
+description: Use when debugging connection timeouts, TLS handshake failures, data not arriving, connection drops, performance issues, or proxy/VPN interference - systematic Network.framework diagnostics with production crisis defense
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Network.framework Diagnostics
+
+## Overview
+
+**Core principle** 85% of networking problems stem from misunderstanding connection states, not handling network transitions, or improper error handling—not Network.framework defects.
+
+Network.framework is battle-tested in every iOS app (powers URLSession internally), handles trillions of requests daily, and provides smart connection establishment with Happy Eyeballs, proxy evaluation, and WiFi Assist. If your connection is failing, timing out, or behaving unexpectedly, the issue is almost always in how you're using the framework, not the framework itself.
+
+This skill provides systematic diagnostics to identify root causes in minutes, not hours.
+
+## Red Flags — Suspect Networking Issue
+
+If you see ANY of these, suspect a networking misconfiguration, not framework breakage:
+
+- Connection times out after 60 seconds with no clear error
+- TLS handshake fails with "certificate invalid" on some networks
+- Data sent but never arrives at receiver
+- Connection drops when switching WiFi to cellular
+- Works perfectly on WiFi but fails 100% of time on cellular
+- Works in simulator but fails on real device
+- Connection succeeds on your network but fails for users
+
+- ❌ **FORBIDDEN** "Network.framework is broken, we should rewrite with sockets"
+ - Network.framework powers URLSession, used in every iOS app
+ - Handles edge cases you'll spend months discovering with sockets
+ - Apple engineers have 10+ years of production debugging baked into framework
+ - Switching to sockets will expose you to 100+ edge cases
+
+**Critical distinction** Simulator uses macOS networking stack (not iOS), hides cellular-specific issues (IPv6-only networks), and doesn't simulate network transitions. **MANDATORY: Test on real device with real network conditions.**
+
+## Mandatory First Steps
+
+**ALWAYS run these commands FIRST** (before changing code):
+
+```swift
+// 1. Enable Network.framework logging
+// Add to Xcode scheme: Product → Scheme → Edit Scheme → Arguments
+// -NWLoggingEnabled 1
+// -NWConnectionLoggingEnabled 1
+
+// 2. Check connection state history
+connection.stateUpdateHandler = { state in
+ print("\(Date()): Connection state: \(state)")
+ // Log every state transition with timestamp
+}
+
+// 3. Check TLS configuration
+// If using custom TLS parameters:
+print("TLS version: \(tlsParameters.minimumTLSProtocolVersion)")
+print("Cipher suites: \(tlsParameters.tlsCipherSuites ?? [])")
+
+// 4. Test with packet capture (Charles Proxy or Wireshark)
+// On device: Settings → WiFi → (i) → Configure Proxy → Manual
+// Charles: Help → SSL Proxying → Install Charles Root Certificate on iOS
+
+// 5. Test on different networks
+// - WiFi
+// - Cellular (disable WiFi)
+// - Airplane Mode → WiFi (test waiting state)
+// - VPN active
+// - IPv6-only (some cellular carriers)
+```
+
+#### What this tells you
+
+| Observation | Diagnosis | Next Step |
+|-------------|-----------|-----------|
+| Stuck in .preparing > 5 seconds | DNS failure or network down | Pattern 1a |
+| Moves to .waiting immediately | No connectivity (Airplane Mode, no signal) | Pattern 1b |
+| .failed with POSIX error 61 | Connection refused (server not listening) | Pattern 1c |
+| .failed with POSIX error 50 | Network down (interface disabled) | Pattern 1d |
+| .ready then immediate .failed | TLS handshake failure | Pattern 2b |
+| .ready, send succeeds, no data arrives | Framing problem or receiver not processing | Pattern 3a |
+| Works WiFi, fails cellular | IPv6-only network (hardcoded IPv4) | Pattern 5a |
+| Works without VPN, fails with VPN | Proxy interference or DNS override | Pattern 5b |
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, identify ONE of these:
+
+1. If stuck in .preparing AND network is available → DNS failure (check nslookup)
+2. If .waiting immediately AND Airplane Mode is off → Interface-specific issue (cellular blocked)
+3. If .failed POSIX 61 → Server issue (check server logs)
+4. If .failed with TLS error -9806 → Certificate validation (check with openssl)
+5. If .ready but data not arriving → Framing or receiver issue (enable packet capture)
+
+#### If diagnostics are contradictory or unclear
+- STOP. Do NOT proceed to patterns yet
+- Add timestamp logging to every send/receive call
+- Enable packet capture (Charles/Wireshark)
+- Test on different device to isolate hardware vs software issue
+
+## Decision Tree
+
+Use this to reach the correct diagnostic pattern in 2 minutes:
+
+```
+Network problem?
+├─ Connection never reaches .ready?
+│ ├─ Stuck in .preparing for >5 seconds?
+│ │ ├─ DNS lookup timing out? → Pattern 1a (DNS Failure)
+│ │ ├─ Network available but can't reach host? → Pattern 1c (Connection Refused)
+│ │ └─ First connection slow, subsequent fast? → Pattern 1e (DNS Caching)
+│ │
+│ ├─ Moves to .waiting immediately?
+│ │ ├─ Airplane Mode or no signal? → Pattern 1b (No Connectivity)
+│ │ ├─ Cellular blocked by parameters? → Pattern 1b (Interface Restrictions)
+│ │ └─ VPN connecting? → Wait and retry
+│ │
+│ ├─ .failed with POSIX error 61?
+│ │ └─ → Pattern 1c (Connection Refused)
+│ │
+│ └─ .failed with POSIX error 50?
+│ └─ → Pattern 1d (Network Down)
+│
+├─ Connection reaches .ready, then fails?
+│ ├─ Fails immediately after .ready?
+│ │ ├─ TLS error -9806? → Pattern 2b (Certificate Validation)
+│ │ ├─ TLS error -9801? → Pattern 2b (Protocol Version)
+│ │ └─ POSIX error 54? → Pattern 2d (Connection Reset)
+│ │
+│ ├─ Fails after network change (WiFi → cellular)?
+│ │ ├─ No viabilityUpdateHandler? → Pattern 2a (Viability Not Handled)
+│ │ ├─ Didn't detect better path? → Pattern 2a (Better Path)
+│ │ └─ IPv6 → IPv4 transition? → Pattern 5a (Dual Stack)
+│ │
+│ ├─ Fails after timeout?
+│ │ └─ → Pattern 2c (Receiver Not Responding)
+│ │
+│ └─ Random disconnects?
+│ └─ → Pattern 2d (Network Instability)
+│
+├─ Data not arriving?
+│ ├─ Send succeeds, receive never returns?
+│ │ ├─ No message framing? → Pattern 3a (Framing Problem)
+│ │ ├─ Wrong byte count? → Pattern 3b (Min/Max Bytes)
+│ │ └─ Receiver not calling receive()? → Check receiver code
+│ │
+│ ├─ Partial data arrives?
+│ │ ├─ receive(exactly:) too large? → Pattern 3b (Chunking)
+│ │ ├─ Sender closing too early? → Check sender lifecycle
+│ │ └─ Buffer overflow? → Pattern 3b (Buffer Management)
+│ │
+│ ├─ Data corrupted?
+│ │ ├─ TLS disabled? → Pattern 3c (No Encryption)
+│ │ ├─ Binary vs text encoding? → Check ContentType
+│ │ └─ Byte order (endianness)? → Use network byte order
+│ │
+│ └─ Works sometimes, fails intermittently?
+│ └─ → Pattern 3d (Race Condition)
+│
+├─ Performance degrading?
+│ ├─ Latency increasing over time?
+│ │ ├─ TCP congestion? → Pattern 4a (Congestion Control)
+│ │ ├─ No contentProcessed pacing? → Pattern 4a (Buffering)
+│ │ └─ Server overloaded? → Check server metrics
+│ │
+│ ├─ Throughput decreasing?
+│ │ ├─ Network transition WiFi → cellular? → Pattern 4b (Bandwidth Change)
+│ │ ├─ Packet loss increasing? → Pattern 4b (Network Quality)
+│ │ └─ Multiple streams competing? → Pattern 4b (Prioritization)
+│ │
+│ ├─ High CPU usage?
+│ │ ├─ Not using batch for UDP? → Pattern 4c (Batching)
+│ │ ├─ Too many small sends? → Pattern 4c (Coalescing)
+│ │ └─ Using sockets instead of Network.framework? → Migrate (30% CPU savings)
+│ │
+│ └─ Memory growing?
+│ ├─ Not releasing connections? → Pattern 4d (Connection Leaks)
+│ ├─ Not cancelling on deinit? → Pattern 4d (Lifecycle)
+│ └─ Missing [weak self]? → Pattern 4d (Retain Cycles)
+│
+└─ Works on WiFi, fails on cellular/VPN?
+ ├─ IPv6-only cellular network?
+ │ ├─ Hardcoded IPv4 address? → Pattern 5a (IPv4 Literal)
+ │ ├─ getaddrinfo with AF_INET only? → Pattern 5a (Address Family)
+ │ └─ Works on some carriers, not others? → Pattern 5a (Regional IPv6)
+ │
+ ├─ Corporate VPN active?
+ │ ├─ Proxy configuration failing? → Pattern 5b (PAC)
+ │ ├─ DNS override blocking hostname? → Pattern 5b (DNS)
+ │ └─ Certificate pinning failing? → Pattern 5b (TLS in VPN)
+ │
+ ├─ Port blocked by firewall?
+ │ ├─ Non-standard port? → Pattern 5c (Firewall)
+ │ ├─ Outbound only? → Pattern 5c (NATing)
+ │ └─ Works on port 443, not 8080? → Pattern 5c (Port Scanning)
+ │
+ ├─ Peer-to-peer connection failing?
+ │ ├─ NAT traversal issue? → Pattern 5d (STUN/TURN)
+ │ ├─ Symmetric NAT? → Pattern 5d (NAT Type)
+ │ └─ Local network only? → Pattern 5d (Bonjour/mDNS)
+ │
+ └─ URLSession fails but NWConnection works?
+ ├─ HTTP URL blocked? → Pattern 6a (ATS HTTP Block)
+ ├─ "SSL error" on HTTPS? → Pattern 6b (ATS TLS Version)
+ └─ Works on older iOS? → Pattern 6a/6b (ATS enforcement)
+```
+
+## Pattern Selection Rules (MANDATORY)
+
+Before proceeding to a pattern:
+
+1. **Connection never reaching .ready** → Start with Pattern 1 (DNS, connectivity, refused)
+2. **TLS error codes** → Jump directly to Pattern 2b (Certificate validation)
+3. **Data not arriving** → Enable packet capture FIRST, then Pattern 3
+4. **Network-specific (works WiFi, fails cellular)** → Test on that exact network, Pattern 5
+5. **Performance degradation** → Profile with Instruments Network template, Pattern 4
+
+#### Apply ONE pattern at a time
+- Implement the fix from one pattern
+- Test thoroughly
+- Only if issue persists, try next pattern
+- DO NOT apply multiple patterns simultaneously (can't isolate cause)
+
+#### FORBIDDEN
+- Guessing at solutions without diagnostics
+- Changing multiple things at once
+- Assuming "just needs more timeout"
+- Disabling TLS "temporarily"
+- Switching to sockets to "avoid framework issues"
+
+## Diagnostic Patterns
+
+### Pattern 1a: DNS Resolution Failure
+
+**Time cost** 10-15 minutes
+
+#### Symptom
+- Connection stuck in .preparing for >5 seconds
+- Eventually fails or times out
+- Works with IP address but not hostname
+- Works on one network, fails on another
+
+#### Diagnosis
+```swift
+// Enable DNS logging
+// -NWLoggingEnabled 1
+
+// Check DNS resolution manually
+// Terminal: nslookup example.com
+// Terminal: dig example.com
+
+// Logs show:
+// "DNS lookup timed out"
+// "getaddrinfo failed: 8 (nodename nor servname provided)"
+```
+
+#### Common causes
+1. DNS server unreachable (corporate network blocks external DNS)
+2. Hostname typo or doesn't exist
+3. DNS caching stale entry (rare, but happens)
+4. VPN blocking DNS resolution
+
+#### Fix
+
+```swift
+// ❌ WRONG — Adding timeout doesn't fix DNS
+/*
+let parameters = NWParameters.tls
+parameters.expiredDNSBehavior = .allow // Doesn't help if DNS never resolves
+*/
+
+// ✅ CORRECT — Verify hostname, test DNS manually
+// 1. Test DNS manually:
+// $ nslookup your-hostname.com
+// If this fails, DNS is the problem (not your code)
+
+// 2. If DNS works manually but not in app:
+// Check if VPN or enterprise config blocking app DNS
+
+// 3. If hostname doesn't exist:
+let connection = NWConnection(
+ host: NWEndpoint.Host("correct-hostname.com"), // Fix typo
+ port: 443,
+ using: .tls
+)
+
+// 4. If DNS caching issue (rare):
+// Restart device to clear DNS cache
+// Or use IP address temporarily while investigating DNS server issue
+```
+
+#### Verification
+- Run `nslookup your-hostname.com` — should return IP in <1 second
+- Test on cellular (different DNS servers) — should work
+- Check corporate network DNS configuration
+
+#### Prevention
+- Use well-known hostnames (don't rely on internal DNS)
+- Test on multiple networks during development
+- Don't hardcode IPs (if DNS fails, you need to fix DNS, not bypass it)
+
+---
+
+### Pattern 2b: TLS Certificate Validation Failure
+
+**Time cost** 15-20 minutes
+
+#### Symptom
+- Connection reaches .ready briefly, then .failed immediately
+- Error: `-9806` (kSSLPeerCertInvalid)
+- Error: `-9807` (kSSLPeerCertExpired)
+- Error: `-9801` (kSSLProtocol)
+- Works on some servers, fails on others
+
+#### Diagnosis
+```bash
+# Test TLS manually with openssl
+openssl s_client -connect example.com:443 -showcerts
+
+# Check certificate details
+openssl s_client -connect example.com:443 | openssl x509 -noout -dates
+# notBefore: Jan 1 00:00:00 2024 GMT
+# notAfter: Dec 31 23:59:59 2024 GMT ← Check if expired
+
+# Check certificate chain
+openssl s_client -connect example.com:443 -showcerts | grep "CN="
+# Should show: Subject CN=example.com, Issuer CN=Trusted CA
+```
+
+#### Common causes
+1. Self-signed certificate (dev/staging servers)
+2. Expired certificate
+3. Certificate hostname mismatch (cert for "example.com" but connecting to "www.example.com")
+4. Missing intermediate CA certificate
+5. TLS 1.0/1.1 (iOS 13+ requires TLS 1.2+)
+
+#### Fix
+
+#### For production servers with invalid certs
+```swift
+// ❌ WRONG — Never disable certificate validation in production
+/*
+let tlsOptions = NWProtocolTLS.Options()
+sec_protocol_options_set_verify_block(tlsOptions.securityProtocolOptions, { ... }, .main)
+// This disables validation → security vulnerability
+*/
+
+// ✅ CORRECT — Fix the certificate on server
+// 1. Renew expired certificate (Let's Encrypt, DigiCert, etc.)
+// 2. Ensure hostname matches (CN=example.com or SAN includes example.com)
+// 3. Include intermediate CA certificates on server
+// 4. Test with: openssl s_client -connect example.com:443
+```
+
+#### For development servers (temporary)
+```swift
+// ⚠️ ONLY for development/staging
+#if DEBUG
+let tlsOptions = NWProtocolTLS.Options()
+
+sec_protocol_options_set_verify_block(
+ tlsOptions.securityProtocolOptions,
+ { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in
+ // Trust any certificate (DEV ONLY)
+ sec_protocol_verify_complete(true)
+ },
+ .main
+)
+
+let parameters = NWParameters(tls: tlsOptions)
+let connection = NWConnection(host: "dev-server.example.com", port: 443, using: parameters)
+#endif
+```
+
+#### For certificate pinning
+```swift
+// Production-grade certificate pinning
+let tlsOptions = NWProtocolTLS.Options()
+
+sec_protocol_options_set_verify_block(
+ tlsOptions.securityProtocolOptions,
+ { (metadata, trust, complete) in
+ let trust = sec_protocol_metadata_copy_peer_public_key(metadata)
+ // Compare trust with pinned certificate
+ let pinnedCertificateData = Data(/* your cert */)
+ let serverCertificateData = SecCertificateCopyData(trust) as Data
+
+ if serverCertificateData == pinnedCertificateData {
+ complete(true)
+ } else {
+ complete(false) // Reject non-pinned certificates
+ }
+ },
+ .main
+)
+```
+
+#### Verification
+- `openssl s_client -connect example.com:443` shows `Verify return code: 0 (ok)`
+- Certificate expiration > 30 days in future
+- Certificate CN matches hostname
+- Test on real iOS device (not just simulator)
+
+---
+
+### Pattern 3a: Message Framing Problem
+
+**Time cost** 20-30 minutes
+
+#### Symptom
+- connection.send() succeeds with no error
+- connection.receive() never returns data
+- Or receive() returns partial data
+- Packet capture shows bytes on wire, but app doesn't process them
+
+#### Diagnosis
+```swift
+// Enable detailed logging
+connection.send(content: data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Send error: \(error)")
+ } else {
+ print("✅ Sent \(data.count) bytes at \(Date())")
+ }
+})
+
+connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, context, isComplete, error in
+ if let error = error {
+ print("Receive error: \(error)")
+ } else if let data = data {
+ print("✅ Received \(data.count) bytes at \(Date())")
+ }
+}
+
+// Use Charles Proxy or Wireshark to verify bytes on wire
+```
+
+**Common cause** Stream protocols (TCP/TLS) don't preserve message boundaries.
+
+#### Example
+```swift
+// Sender sends 3 messages:
+send("Hello") // 5 bytes
+send("World") // 5 bytes
+send("!") // 1 byte
+
+// Receiver might get:
+receive() → "HelloWorld!" // All 11 bytes at once
+// Or:
+receive() → "Hel" // 3 bytes
+receive() → "loWorld!" // 8 bytes
+
+// Message boundaries lost!
+```
+
+#### Fix
+
+#### Solution 1: Use TLV Framing (iOS 26+)
+```swift
+// NetworkConnection with TLV
+let connection = NetworkConnection(
+ to: .hostPort(host: "example.com", port: 1029)
+) {
+ TLV {
+ TLS()
+ }
+}
+
+// Send typed messages
+enum MessageType: Int {
+ case chat = 1
+ case ping = 2
+}
+
+let chatData = Data("Hello".utf8)
+try await connection.send(chatData, type: MessageType.chat.rawValue)
+
+// Receive typed messages
+let (data, metadata) = try await connection.receive()
+if metadata.type == MessageType.chat.rawValue {
+ print("Chat message: \(String(data: data, encoding: .utf8)!)")
+}
+```
+
+#### Solution 2: Manual Length Prefix (iOS 12-25)
+```swift
+// Sender: Prefix message with UInt32 length
+func sendMessage(_ message: Data) {
+ var length = UInt32(message.count).bigEndian
+ let lengthData = Data(bytes: &length, count: 4)
+
+ connection.send(content: lengthData, completion: .contentProcessed { _ in
+ connection.send(content: message, completion: .contentProcessed { _ in
+ print("Sent message with length prefix")
+ })
+ })
+}
+
+// Receiver: Read length, then read message
+func receiveMessage() {
+ // 1. Read 4-byte length
+ connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { lengthData, _, _, error in
+ guard let lengthData = lengthData else { return }
+
+ let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
+
+ // 2. Read message of exact length
+ connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { messageData, _, _, error in
+ guard let messageData = messageData else { return }
+ print("Received complete message: \(messageData.count) bytes")
+ }
+ }
+}
+```
+
+#### Verification
+- Send 10 messages, verify receiver gets exactly 10 messages
+- Send messages of varying sizes (1 byte, 1000 bytes, 64KB)
+- Test with packet loss simulation (Network Link Conditioner)
+
+---
+
+### Pattern 4a: TCP Congestion and Buffering
+
+**Time cost** 15-25 minutes
+
+#### Symptom
+- First few sends fast, then increasingly slow
+- Latency grows from 50ms → 500ms → 2000ms over time
+- Memory usage growing (buffering unsent data)
+- User reports app "feels sluggish" after 5 minutes
+
+#### Diagnosis
+```swift
+// Monitor send completion time
+let sendStart = Date()
+connection.send(content: data, completion: .contentProcessed { error in
+ let elapsed = Date().timeIntervalSince(sendStart)
+ print("Send completed in \(elapsed)s") // Should be < 0.1s normally
+ // If > 1s, TCP congestion or receiver not draining fast enough
+})
+
+// Profile with Instruments
+// Xcode → Product → Profile → Network template
+// Check "Bytes Sent" vs "Time" graph
+// Should be smooth line, not stepped/stalled
+```
+
+#### Common causes
+1. Sender sending faster than receiver can process (back pressure)
+2. Network congestion (packet loss, retransmits)
+3. No pacing with contentProcessed callback
+4. Sending on connection that lost viability
+
+#### Fix
+
+```swift
+// ❌ WRONG — Sending without pacing
+/*
+for frame in videoFrames {
+ connection.send(content: frame, completion: .contentProcessed { _ in })
+ // Buffers all frames immediately → memory spike → congestion
+}
+*/
+
+// ✅ CORRECT — Pace with contentProcessed callback
+func sendFrameWithPacing() {
+ guard let nextFrame = getNextFrame() else { return }
+
+ connection.send(content: nextFrame, completion: .contentProcessed { [weak self] error in
+ if let error = error {
+ print("Send error: \(error)")
+ return
+ }
+
+ // contentProcessed = network stack consumed frame
+ // NOW send next frame (pacing)
+ self?.sendFrameWithPacing()
+ })
+}
+
+// Start pacing
+sendFrameWithPacing()
+```
+
+#### Alternative: Async/await (iOS 26+)
+```swift
+// NetworkConnection with natural back pressure
+func sendFrames() async throws {
+ for frame in videoFrames {
+ try await connection.send(frame)
+ // Suspends automatically if network can't keep up
+ // Built-in back pressure, no manual pacing needed
+ }
+}
+```
+
+#### Verification
+- Send 1000 messages, monitor memory usage (should stay flat)
+- Monitor send completion time (should stay < 100ms)
+- Test with Network Link Conditioner (100ms latency, 3% packet loss)
+
+---
+
+### Pattern 5a: IPv6-Only Cellular Network (Hardcoded IPv4)
+
+**Time cost** 10-15 minutes
+
+#### Symptom
+- Works perfectly on WiFi (dual-stack IPv4/IPv6)
+- Fails 100% of time on cellular (IPv6-only)
+- Works on some carriers (T-Mobile), fails on others (Verizon)
+- Logs show "Host unreachable" or POSIX error 65 (EHOSTUNREACH)
+
+#### Diagnosis
+```bash
+# Check if hostname has IPv6
+dig AAAA example.com
+
+# Check if device is on IPv6-only network
+# Settings → WiFi/Cellular → (i) → IP Address
+# If starts with "2001:" or "fe80:" → IPv6
+# If "192.168" or "10." → IPv4
+
+# Test with IPv6-only simulator
+# Xcode → Devices → (device) → Use as Development Target
+# Settings → Developer → Networking → DNS64/NAT64
+```
+
+#### Common causes
+1. Hardcoded IPv4 address ("192.168.1.1")
+2. getaddrinfo with AF_INET only (filters out IPv6)
+3. Server has no IPv6 address (AAAA record)
+4. Not using Connect by Name (manual DNS)
+
+#### Fix
+
+```swift
+// ❌ WRONG — Hardcoded IPv4
+/*
+let host = "192.168.1.100" // Fails on IPv6-only cellular
+*/
+
+// ❌ WRONG — Forcing IPv4
+/*
+let parameters = NWParameters.tcp
+parameters.requiredInterfaceType = .wifi
+parameters.ipOptions.version = .v4 // Fails on IPv6-only
+*/
+
+// ✅ CORRECT — Use hostname, let framework handle IPv4/IPv6
+let connection = NWConnection(
+ host: NWEndpoint.Host("example.com"), // Hostname, not IP
+ port: 443,
+ using: .tls
+)
+// Framework automatically:
+// 1. Resolves both A (IPv4) and AAAA (IPv6) records
+// 2. Tries IPv6 first (if available)
+// 3. Falls back to IPv4 (Happy Eyeballs)
+// 4. Works on any network (IPv4, IPv6, dual-stack)
+```
+
+#### Verification
+- Test on real device with cellular (disable WiFi)
+- Test with multiple carriers (Verizon, AT&T, T-Mobile)
+- Enable DNS64/NAT64 in developer settings
+- Run `dig AAAA your-hostname.com` to verify IPv6 record exists
+
+---
+
+## Production Crisis Scenario
+
+### Context: iOS Update Causes 15% Connection Failures
+
+#### Situation
+- Your company releases iOS app update (v4.2) on Monday morning
+- By noon, Customer Support reports surge in "app doesn't work" tickets
+- Analytics show 15% of users experiencing connection failures (10,000+ users)
+- CEO sends Slack message: "What's going on? How fast can we fix this?"
+- Engineering manager asks for ETA
+- You're the networking engineer
+
+#### Pressure signals
+- 🚨 **Production outage** 10K+ users affected, revenue impact, negative App Store reviews incoming
+- ⏰ **Time pressure** "Need fix ASAP, trending on Twitter"
+- 👔 **Executive visibility** CEO personally asking for updates
+- 📊 **Public image** App Store rating dropping from 4.8 → 4.1 in 3 hours
+- 💸 **Financial impact** E-commerce app, each minute costs $5K in lost sales
+
+#### Rationalization traps (DO NOT fall into these)
+
+1. *"Just roll back to v4.1"*
+ - Tempting but takes 1-2 hours for app review, another 24 hours for users to update
+ - Doesn't find root cause (might happen again)
+ - Loses v4.2 features you worked on for weeks
+
+2. *"Disable TLS temporarily to narrow it down"*
+ - Security vulnerability, will cause App Store rejection
+ - Doesn't solve actual problem (masks symptoms)
+ - When would you re-enable? (spoiler: never, because fixing it "later" never happens)
+
+3. *"It works on my device, must be user error"*
+ - Arrogance, not diagnosis
+ - 10K users having same "error"? That's not user error.
+
+4. *"Let's add retry logic and more timeouts"*
+ - Doesn't address root cause
+ - Makes problem worse (more retries = more load on failing path)
+
+#### MANDATORY Diagnostic Protocol
+
+You have 1 hour to provide CEO with:
+1. Root cause
+2. Fix timeline
+3. Mitigation plan
+
+#### Step 1: Establish Baseline (5 minutes)
+
+```swift
+// Check what changed in v4.2
+git diff v4.1 v4.2 -- NetworkClient.swift
+
+// Most likely culprits:
+// - TLS configuration changed
+// - Added certificate pinning
+// - Changed connection parameters
+// - Updated hostname
+```
+
+#### Step 2: Reproduce in Production Environment (10 minutes)
+
+```swift
+// Check failure pattern:
+// - Random 15%? Or specific user segment?
+// - Specific iOS version? (check analytics)
+// - Specific network? (WiFi vs cellular)
+
+// Enable logging on production builds (emergency flag):
+#if PRODUCTION
+if UserDefaults.standard.bool(forKey: "EnableNetworkLogging") {
+ // -NWLoggingEnabled 1
+}
+#endif
+
+// Ask Customer Support to enable for affected users
+// Check logs for specific error code
+```
+
+#### Step 3: Check Recent Code Changes (5 minutes)
+
+```swift
+// Found in git diff:
+// v4.1:
+let parameters = NWParameters.tls
+
+// v4.2:
+let tlsOptions = NWProtocolTLS.Options()
+tlsOptions.minimumTLSProtocolVersion = .TLSv13 // ← SMOKING GUN
+let parameters = NWParameters(tls: tlsOptions)
+```
+
+**Root Cause Identified** Some users' backend infrastructure (load balancers, proxy servers) don't support TLS 1.3. v4.1 negotiated TLS 1.2, v4.2 requires TLS 1.3 → connection fails.
+
+#### Step 4: Apply Targeted Fix (15 minutes)
+
+```swift
+// Fix: Support both TLS 1.2 and TLS 1.3
+let tlsOptions = NWProtocolTLS.Options()
+tlsOptions.minimumTLSProtocolVersion = .TLSv12 // ✅ Support older infrastructure
+// TLS 1.3 will still be used where supported (automatic negotiation)
+let parameters = NWParameters(tls: tlsOptions)
+```
+
+#### Step 5: Deploy Hotfix (20 minutes)
+
+```bash
+# Build hotfix v4.2.1
+# Test on affected user's network (critical!)
+# Submit to App Store with expedited review request
+# Explain: "Production outage affecting 15% of users"
+```
+
+#### Professional Communication Templates
+
+#### To CEO (15 minutes after crisis starts)
+
+```
+Found root cause: v4.2 requires TLS 1.3, but 15% of users on older infrastructure
+(enterprise proxies, older load balancers) that only support TLS 1.2.
+
+Fix: Change minimum TLS version to 1.2 (backward compatible, 1.3 still used when available).
+
+ETA: Hotfix v4.2.1 in App Store in 1 hour (expedited review).
+Full rollout to users: 24 hours.
+
+Mitigation now: Telling affected users to update immediately when available.
+```
+
+#### To Engineering Manager
+
+```
+Root cause: TLS version requirement changed in v4.2 (TLS 1.3 only).
+15% of users behind infrastructure that doesn't support TLS 1.3.
+
+Technical fix: Set tlsOptions.minimumTLSProtocolVersion = .TLSv12
+This allows backward compatibility while still using TLS 1.3 where supported.
+
+Testing: Verified fix on user's network (enterprise VPN with old proxy).
+Deployment: Hotfix build in progress, ETA 30 minutes to submit.
+
+Prevention: Add TLS compatibility testing to pre-release checklist.
+```
+
+#### To Customer Support
+
+```
+Update: We've identified the issue and have a fix deploying within 1 hour.
+
+Affected users: Those on enterprise networks or older ISP infrastructure.
+Workaround: None (network level issue).
+
+Expected resolution: v4.2.1 will be available in App Store in 1 hour.
+Ask users to update immediately.
+
+Updates: I'll notify you every 30 minutes.
+```
+
+#### Time Saved
+
+| Approach | Time to Resolution | User Impact |
+|----------|-------------------|-------------|
+| ❌ Panic rollback | 1-2 hours app review + 24 hours user updates = 26 hours | 10K users down for 26 hours |
+| ❌ "Add more retries" | Unknown (doesn't fix root cause) | Permanent 15% failure rate |
+| ❌ "Works for me" | Days of debugging wrong thing | Frustrated users, bad reviews |
+| ✅ Systematic diagnosis | 30 min diagnosis + 20 min fix + 1 hour review = 2 hours | 10K users down for 2 hours |
+
+#### Lessons Learned
+
+1. **Test on diverse networks** Don't just test on your WiFi. Test on cellular, VPN, enterprise networks.
+2. **Monitor TLS compatibility** If you change TLS config, verify backend supports it.
+3. **Gradual rollout** Use phased rollout (10% → 50% → 100%) to catch issues early.
+4. **Emergency logging** Have a way to enable detailed logging in production for diagnosis.
+5. **Communication cadence** Update stakeholders every 30 minutes, even if just "still investigating."
+
+---
+
+## Quick Reference Table
+
+| Symptom | Likely Cause | First Check | Pattern | Fix Time |
+|---------|--------------|-------------|---------|----------|
+| Stuck in .preparing | DNS failure | `nslookup hostname` | 1a | 10-15 min |
+| .waiting immediately | No connectivity | Airplane Mode? | 1b | 5 min |
+| .failed POSIX 61 | Connection refused | Server listening? | 1c | 5-10 min |
+| .failed POSIX 50 | Network down | Check interface | 1d | 5 min |
+| TLS error -9806 | Certificate invalid | `openssl s_client` | 2b | 15-20 min |
+| Data not received | Framing problem | Packet capture | 3a | 20-30 min |
+| Partial data | Min/max bytes wrong | Check receive() params | 3b | 10 min |
+| Latency increasing | TCP congestion | contentProcessed pacing | 4a | 15-25 min |
+| High CPU | No batching | Use connection.batch | 4c | 10 min |
+| Memory growing | Connection leaks | Check [weak self] | 4d | 10-15 min |
+| Works WiFi, fails cellular | IPv6-only network | `dig AAAA hostname` | 5a | 10-15 min |
+| Works without VPN, fails with VPN | Proxy interference | Test PAC file | 5b | 20-30 min |
+| Port blocked | Firewall | Try 443 vs 8080 | 5c | 10 min |
+| HTTP URL blocked silently | ATS enforcement | Check Info.plist | 6a | 5-10 min |
+| "An SSL error has occurred" | ATS TLS requirements | Check server TLS version | 6b | 10-15 min |
+
+---
+
+## Pattern 6: App Transport Security (ATS) Failures
+
+**Time cost** 5-15 minutes
+
+ATS enforces HTTPS for all connections by default (iOS 9+). ATS failures are silent — connections fail with generic errors, no ATS-specific message in console.
+
+### Pattern 6a: HTTP Blocked by ATS
+
+#### Symptom
+- URLSession request fails with `NSURLErrorSecureConnectionFailed` (-1200) or `NSURLErrorAppTransportSecurityRequiresSecureConnection` (-1022)
+- Network.framework connection works but URLSession doesn't
+- Works in older iOS versions, fails in newer ones
+- No clear error message — just "connection failed"
+
+#### Diagnosis
+
+```bash
+# Check if ATS is blocking the connection
+nscurl --ats-diagnostics https://yourserver.com
+# Shows exactly which ATS policy the server fails
+```
+
+```swift
+// In console, look for:
+// "App Transport Security has blocked a cleartext HTTP (http://) resource load"
+// This only appears if OS-level logging is enabled
+```
+
+#### Fix — Allow Specific HTTP Domain (Preferred)
+
+```xml
+
+NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ api.legacy-server.com
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+
+
+
+```
+
+**Do NOT use `NSAllowsArbitraryLoads`** — disables ATS entirely. App Store Review flags this and may reject. Use domain-specific exceptions.
+
+### Pattern 6b: ATS TLS Version Requirements
+
+#### Symptom
+- HTTPS connection fails with "SSL error" despite valid certificate
+- Server uses TLS 1.0 or 1.1 (ATS requires TLS 1.2+)
+- `nscurl --ats-diagnostics` shows TLS version failure
+
+#### Diagnosis
+
+```bash
+# Check server's TLS version
+openssl s_client -connect yourserver.com:443 -tls1_2
+# If this fails but -tls1 succeeds → server doesn't support TLS 1.2
+```
+
+#### Fix — Upgrade Server (Preferred) or Add Exception
+
+```xml
+
+NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ legacy-api.example.com
+
+ NSExceptionMinimumTLSVersion
+ TLSv1.0
+
+
+
+```
+
+**Better fix**: Upgrade the server to TLS 1.2+. ATS exceptions for TLS downgrade trigger App Store Review scrutiny.
+
+### ATS vs Network.framework Distinction
+
+ATS applies to **URLSession** and **WKWebView** connections. **Network.framework** (NWConnection/NetworkConnection) is NOT subject to ATS — it handles TLS configuration directly via `tlsOptions`. If URLSession fails but NWConnection succeeds for the same server, ATS is almost certainly the cause.
+
+---
+
+## Common Mistakes
+
+### Mistake 1: Not Enabling Logging Before Debugging
+
+**Problem** Trying to debug networking issues without seeing framework's internal state.
+
+**Why it fails** You're guessing what's happening. Logs show exact state transitions, error codes, timing.
+
+#### Fix
+```swift
+// Add to Xcode scheme BEFORE debugging:
+// -NWLoggingEnabled 1
+// -NWConnectionLoggingEnabled 1
+
+// Or programmatically:
+#if DEBUG
+ProcessInfo.processInfo.environment["NW_LOGGING_ENABLED"] = "1"
+#endif
+```
+
+### Mistake 2: Testing Only on WiFi
+
+**Problem** WiFi and cellular have different characteristics (IPv6-only, proxy configs, packet loss).
+
+**Why it fails** 40% of connection failures are network-specific. If you only test WiFi, you miss cellular issues.
+
+#### Fix
+- Test on real device with WiFi OFF
+- Test on multiple carriers (Verizon, AT&T, T-Mobile have different configs)
+- Test with VPN active (enterprise users)
+- Use Network Link Conditioner (Xcode → Devices)
+
+### Mistake 3: Ignoring POSIX Error Codes
+
+**Problem** Seeing `.failed(let error)` and just showing generic "Connection failed" to user.
+
+**Why it fails** Different error codes require different fixes. POSIX 61 = server issue, POSIX 50 = client network issue.
+
+#### Fix
+```swift
+if case .failed(let error) = state {
+ let posixError = (error as NSError).code
+ switch posixError {
+ case 61: // ECONNREFUSED
+ print("Server not listening, check server logs")
+ case 50: // ENETDOWN
+ print("Network interface down, check WiFi/cellular")
+ case 60: // ETIMEDOUT
+ print("Connection timeout, check firewall/DNS")
+ default:
+ print("Connection failed: \(error)")
+ }
+}
+```
+
+### Mistake 4: Not Testing State Transitions
+
+**Problem** Testing only happy path (.preparing → .ready). Not testing .waiting, network changes, failures.
+
+**Why it fails** Real users experience network transitions (WiFi → cellular), Airplane Mode, weak signal.
+
+#### Fix
+```swift
+// Test with Network Link Conditioner:
+// 1. 100% Loss — verify .waiting state shows "Waiting for network"
+// 2. WiFi → None → WiFi — verify automatic reconnection
+// 3. 3% packet loss — verify performance graceful degradation
+```
+
+### Mistake 5: Assuming Simulator = Device
+
+**Problem** Testing only in simulator. Simulator uses macOS networking (different from iOS), no cellular.
+
+**Why it fails** Simulator hides IPv6-only issues, doesn't simulate network transitions, has different DNS.
+
+#### Fix
+- ALWAYS test on real device before shipping
+- Test with Airplane Mode toggle (simulate network transitions)
+- Test with cellular only (disable WiFi)
+
+---
+
+## Cross-References
+
+### For Preventive Patterns
+
+**networking skill** — Discipline-enforcing anti-patterns:
+- Red Flags: SCNetworkReachability, blocking sockets, hardcoded IPs
+- Pattern 1a: NetworkConnection with TLS (correct implementation)
+- Pattern 2a: NWConnection with proper state handling
+- Pressure Scenarios: How to handle deadline pressure without cutting corners
+
+### For API Reference
+
+**network-framework-ref skill** — Complete API documentation:
+- NetworkConnection (iOS 26+): All 12 WWDC 2025 examples
+- NWConnection (iOS 12-25): Complete API with examples
+- TLV framing, Coder protocol, NetworkListener, NetworkBrowser
+- Migration strategies from sockets, URLSession, NWConnection
+
+### For Related Issues
+
+**swift-concurrency skill** — If using async/await:
+- Pattern 3: Weak self in Task closures (similar memory leak prevention)
+- @MainActor usage for connection state updates
+- Task cancellation when connection fails
+
+---
+
+**Last Updated** 2025-12-02
+**Status** Production-ready diagnostics from WWDC 2018/2025
+**Tested** Diagnostic patterns validated against real production issues
diff --git a/.claude/skills/axiom-networking-diag/agents/openai.yaml b/.claude/skills/axiom-networking-diag/agents/openai.yaml
new file mode 100644
index 0000000..64fd500
--- /dev/null
+++ b/.claude/skills/axiom-networking-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Networking Diagnostics"
+ short_description: "Debugging connection timeouts, TLS handshake failures, data not arriving, connection drops, performance issues, or pr..."
diff --git a/.claude/skills/axiom-networking-legacy/.openskills.json b/.claude/skills/axiom-networking-legacy/.openskills.json
new file mode 100644
index 0000000..c289ccf
--- /dev/null
+++ b/.claude/skills/axiom-networking-legacy/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-networking-legacy",
+ "installedAt": "2026-04-12T08:06:30.207Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-networking-legacy/SKILL.md b/.claude/skills/axiom-networking-legacy/SKILL.md
new file mode 100644
index 0000000..777664b
--- /dev/null
+++ b/.claude/skills/axiom-networking-legacy/SKILL.md
@@ -0,0 +1,373 @@
+---
+name: axiom-networking-legacy
+description: This skill should be used when working with NWConnection patterns for iOS 12-25, supporting apps that can't use async/await yet, or maintaining backward compatibility with completion handler networking.
+license: MIT
+---
+
+# Legacy iOS 12-25 NWConnection Patterns
+
+These patterns use NWConnection with completion handlers for apps supporting iOS 12-25. If your app targets iOS 26+, use NetworkConnection with async/await instead (see axiom-network-framework-ref skill).
+
+## Pattern 2a: NWConnection with TLS (iOS 12-25)
+
+**Use when** Supporting iOS 12-25, need TLS encryption, can't use async/await yet
+
+**Time cost** 10-15 minutes
+
+### GOOD: NWConnection with Completion Handlers
+
+```swift
+import Network
+
+// Create connection with TLS
+let connection = NWConnection(
+ host: NWEndpoint.Host("mail.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 993),
+ using: .tls // TCP inferred
+)
+
+// Handle connection state changes
+connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ print("Connection established")
+ self?.sendInitialData()
+ case .waiting(let error):
+ print("Waiting for network: \(error)")
+ // Show "Waiting..." UI, don't fail immediately
+ case .failed(let error):
+ print("Connection failed: \(error)")
+ case .cancelled:
+ print("Connection cancelled")
+ default:
+ break
+ }
+}
+
+// Start connection
+connection.start(queue: .main)
+
+// Send data with pacing
+func sendData() {
+ let data = Data("Hello, world!".utf8)
+ connection.send(content: data, completion: .contentProcessed { [weak self] error in
+ if let error = error {
+ print("Send error: \(error)")
+ return
+ }
+ // contentProcessed callback = network stack consumed data
+ // This is when you should send next chunk (pacing)
+ self?.sendNextChunk()
+ })
+}
+
+// Receive exact byte count
+func receiveData() {
+ connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
+ if let error = error {
+ print("Receive error: \(error)")
+ return
+ }
+
+ if let data = data {
+ print("Received \(data.count) bytes")
+ // Process data...
+ self?.receiveData() // Continue receiving
+ }
+ }
+}
+```
+
+### Key differences from NetworkConnection
+- Must use `[weak self]` in all completion handlers to prevent retain cycles
+- stateUpdateHandler receives state, not async sequence
+- send/receive use completion callbacks, not async/await
+
+### When to use
+- Supporting iOS 12-15 (70% of devices as of 2024)
+- Codebases not yet using async/await
+- Libraries needing backward compatibility
+
+### Migration to NetworkConnection (iOS 26+)
+- stateUpdateHandler -> connection.states async sequence
+- Completion handlers -> try await calls
+- [weak self] -> No longer needed (async/await handles cancellation)
+
+## Pattern 2b: NWConnection UDP Batch (iOS 12-25)
+
+**Use when** Supporting iOS 12-25, sending multiple UDP datagrams efficiently, need ~30% CPU reduction
+
+**Time cost** 10-15 minutes
+
+**Background** Traditional UDP sockets send one datagram per syscall. If you're sending 100 small packets, that's 100 context switches. Batching reduces this to ~1 syscall.
+
+### BAD: Individual UDP Sends (High CPU)
+```swift
+// WRONG — 100 context switches for 100 packets
+for frame in videoFrames {
+ sendto(socket, frame.bytes, frame.count, 0, &addr, addrlen)
+ // Each send = context switch to kernel
+}
+```
+
+### GOOD: Batched UDP Sends (30% Lower CPU)
+
+```swift
+import Network
+
+// UDP connection
+let connection = NWConnection(
+ host: NWEndpoint.Host("stream-server.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 9000),
+ using: .udp
+)
+
+connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ print("Ready to send UDP")
+ }
+}
+
+connection.start(queue: .main)
+
+// Batch sending for efficiency
+func sendVideoFrames(_ frames: [Data]) {
+ connection.batch {
+ for frame in frames {
+ connection.send(content: frame, completion: .contentProcessed { error in
+ if let error = error {
+ print("Send error: \(error)")
+ }
+ })
+ }
+ }
+ // All sends batched into ~1 syscall
+ // 30% lower CPU usage vs individual sends
+}
+
+// Receive UDP datagrams
+func receiveFrames() {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in
+ if let error = error {
+ print("Receive error: \(error)")
+ return
+ }
+
+ if let data = data {
+ // Process video frame
+ self?.displayFrame(data)
+ self?.receiveFrames() // Continue receiving
+ }
+ }
+}
+```
+
+### Performance characteristics
+- **Without batch** 100 datagrams = 100 syscalls = 100 context switches
+- **With batch** 100 datagrams = ~1 syscall = 1 context switch
+- **Result** ~30% lower CPU usage (measured with Instruments)
+
+### When to use
+- Real-time video/audio streaming
+- Gaming with frequent updates (player position)
+- High-frequency sensor data (IoT)
+
+**WWDC 2018 demo** Live video streaming showed 30% lower CPU on receiver with user-space networking + batching
+
+## Pattern 2c: NWListener (iOS 12-25)
+
+**Use when** Need to accept incoming connections, building servers or peer-to-peer apps, supporting iOS 12-25
+
+**Time cost** 20-25 minutes
+
+### BAD: Manual Socket Listening
+```swift
+// WRONG — Manual socket management
+let sock = socket(AF_INET, SOCK_STREAM, 0)
+bind(sock, &addr, addrlen)
+listen(sock, 5)
+while true {
+ let client = accept(sock, nil, nil) // Blocks thread
+ // Handle client...
+}
+```
+
+### GOOD: NWListener with Automatic Connection Handling
+
+```swift
+import Network
+
+// Create listener with default parameters
+let listener = try NWListener(using: .tcp, on: 1029)
+
+// Advertise Bonjour service
+listener.service = NWListener.Service(name: "MyApp", type: "_myservice._tcp")
+
+// Handle service registration updates
+listener.serviceRegistrationUpdateHandler = { update in
+ switch update {
+ case .add(let endpoint):
+ if case .service(let name, let type, let domain, _) = endpoint {
+ print("Advertising as: \(name).\(type)\(domain)")
+ }
+ default:
+ break
+ }
+}
+
+// Handle incoming connections
+listener.newConnectionHandler = { [weak self] newConnection in
+ print("New connection from: \(newConnection.endpoint)")
+
+ // Configure connection
+ newConnection.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ print("Client connected")
+ self?.handleClient(newConnection)
+ case .failed(let error):
+ print("Client connection failed: \(error)")
+ default:
+ break
+ }
+ }
+
+ // Start handling this connection
+ newConnection.start(queue: .main)
+}
+
+// Handle listener state
+listener.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ print("Listener ready on port \(listener.port ?? 0)")
+ case .failed(let error):
+ print("Listener failed: \(error)")
+ default:
+ break
+ }
+}
+
+// Start listening
+listener.start(queue: .main)
+
+// Handle client data
+func handleClient(_ connection: NWConnection) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in
+ if let error = error {
+ print("Receive error: \(error)")
+ return
+ }
+
+ if let data = data {
+ print("Received \(data.count) bytes")
+
+ // Echo back
+ connection.send(content: data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Send error: \(error)")
+ }
+ })
+
+ self?.handleClient(connection) // Continue receiving
+ }
+ }
+}
+```
+
+### When to use
+- Peer-to-peer apps (file sharing, messaging)
+- Local network services
+- Development/testing servers
+
+### Bonjour advertising
+- Automatic service discovery on local network
+- No hardcoded IPs needed
+- Works with NWBrowser for discovery
+
+### Security considerations
+- Use TLS parameters for encryption: `NWListener(using: .tls, on: port)`
+- Validate client connections before processing data
+- Set connection limits to prevent DoS
+
+## Pattern 2d: Network Discovery (iOS 12-25)
+
+**Use when** Discovering services on local network (Bonjour), building peer-to-peer apps, supporting iOS 12-25
+
+**Time cost** 25-30 minutes
+
+### BAD: Hardcoded IP Addresses
+```swift
+// WRONG — Brittle, requires manual configuration
+let connection = NWConnection(host: "192.168.1.100", port: 9000, using: .tcp)
+// What if IP changes? What if multiple devices?
+```
+
+### GOOD: NWBrowser for Service Discovery
+
+```swift
+import Network
+
+// Browse for services on local network
+let browser = NWBrowser(for: .bonjour(type: "_myservice._tcp", domain: nil), using: .tcp)
+
+// Handle discovered services
+browser.browseResultsChangedHandler = { results, changes in
+ for result in results {
+ switch result.endpoint {
+ case .service(let name, let type, let domain, _):
+ print("Found service: \(name).\(type)\(domain)")
+ // Connect to this service
+ self.connectToService(result.endpoint)
+ default:
+ break
+ }
+ }
+}
+
+// Handle browser state
+browser.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ print("Browser ready")
+ case .failed(let error):
+ print("Browser failed: \(error)")
+ default:
+ break
+ }
+}
+
+// Start browsing
+browser.start(queue: .main)
+
+// Connect to discovered service
+func connectToService(_ endpoint: NWEndpoint) {
+ let connection = NWConnection(to: endpoint, using: .tcp)
+
+ connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ print("Connected to service")
+ }
+ }
+
+ connection.start(queue: .main)
+}
+```
+
+### When to use
+- Peer-to-peer discovery (AirDrop-like features)
+- Local network printers, media servers
+- Development/testing (find test servers automatically)
+
+### Performance characteristics
+- mDNS-based (multicast DNS, no central server)
+- Near-instant discovery on same subnet
+- Automatic updates when services appear/disappear
+
+### iOS 26+ alternative
+- Use NetworkBrowser with Wi-Fi Aware for peer-to-peer without infrastructure
+- See Pattern 1d in axiom-network-framework-ref skill
+
+## Resources
+
+**Skills**: axiom-networking, axiom-network-framework-ref, axiom-networking-migration
diff --git a/.claude/skills/axiom-networking-legacy/agents/openai.yaml b/.claude/skills/axiom-networking-legacy/agents/openai.yaml
new file mode 100644
index 0000000..13819e0
--- /dev/null
+++ b/.claude/skills/axiom-networking-legacy/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Networking Legacy"
+ short_description: "This skill should be used when working with NWConnection patterns for iOS 12-25, supporting apps that can't use async..."
diff --git a/.claude/skills/axiom-networking-migration/.openskills.json b/.claude/skills/axiom-networking-migration/.openskills.json
new file mode 100644
index 0000000..1171aed
--- /dev/null
+++ b/.claude/skills/axiom-networking-migration/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-networking-migration",
+ "installedAt": "2026-04-12T08:06:30.409Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-networking-migration/SKILL.md b/.claude/skills/axiom-networking-migration/SKILL.md
new file mode 100644
index 0000000..88f7428
--- /dev/null
+++ b/.claude/skills/axiom-networking-migration/SKILL.md
@@ -0,0 +1,278 @@
+---
+name: axiom-networking-migration
+description: Network framework migration guides. Use when migrating from BSD sockets to NWConnection, NWConnection to NetworkConnection (iOS 26+), or URLSession StreamTask to NetworkConnection.
+license: MIT
+---
+
+# Network Framework Migration Guides
+
+## Do I Need to Migrate?
+
+```
+What networking API are you using?
+
+├─ URLSession for HTTP/HTTPS REST APIs?
+│ └─ Stay with URLSession — it's the RIGHT tool for HTTP
+│ URLSession handles caching, cookies, auth challenges,
+│ HTTP/2/3, and is heavily optimized for web APIs.
+│ Network.framework is for custom protocols, NOT HTTP.
+│
+├─ BSD Sockets (socket, connect, send, recv)?
+│ └─ Migrate to NWConnection (iOS 12+)
+│ → See Migration 1 below
+│
+├─ NWConnection / NWListener?
+│ ├─ Need async/await? → Migrate to NetworkConnection (iOS 26+)
+│ │ → See Migration 2 below
+│ └─ Callback-based code working fine? → Stay (not deprecated)
+│
+├─ URLSession StreamTask for TCP/TLS?
+│ └─ Need UDP or custom protocols? → NetworkConnection
+│ Need just TCP/TLS for HTTP? → Stay with URLSession
+│ → See Migration 3 below
+│
+├─ SCNetworkReachability?
+│ └─ DEPRECATED — Replace with NWPathMonitor (iOS 12+)
+│ let monitor = NWPathMonitor()
+│ monitor.pathUpdateHandler = { path in
+│ print(path.status == .satisfied ? "Online" : "Offline")
+│ }
+│
+└─ CFSocket / NSStream?
+ └─ DEPRECATED — Replace with NWConnection (iOS 12+)
+ → See Migration 1 below
+```
+
+## Migration 1: From BSD Sockets to NWConnection
+
+### Migration mapping
+
+| BSD Sockets | NWConnection | Notes |
+|-------------|--------------|-------|
+| `socket() + connect()` | `NWConnection(host:port:using:) + start()` | Non-blocking by default |
+| `send() / sendto()` | `connection.send(content:completion:)` | Async, returns immediately |
+| `recv() / recvfrom()` | `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | Async, returns immediately |
+| `bind() + listen()` | `NWListener(using:on:)` | Automatic port binding |
+| `accept()` | `listener.newConnectionHandler` | Callback for each connection |
+| `getaddrinfo()` | Let NWConnection handle DNS | Smart resolution with racing |
+| `SCNetworkReachability` | `connection.stateUpdateHandler` waiting state | No race conditions |
+| `setsockopt()` | `NWParameters` configuration | Type-safe options |
+
+### Example migration
+
+#### Before (BSD Sockets)
+```c
+// BEFORE — Blocking, manual DNS, error-prone
+var hints = addrinfo()
+hints.ai_family = AF_INET
+hints.ai_socktype = SOCK_STREAM
+
+var results: UnsafeMutablePointer?
+getaddrinfo("example.com", "443", &hints, &results)
+
+let sock = socket(results.pointee.ai_family, results.pointee.ai_socktype, 0)
+connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // BLOCKS
+
+let data = "Hello".data(using: .utf8)!
+data.withUnsafeBytes { ptr in
+ send(sock, ptr.baseAddress, data.count, 0)
+}
+```
+
+#### After (NWConnection)
+```swift
+// AFTER — Non-blocking, automatic DNS, type-safe
+let connection = NWConnection(
+ host: NWEndpoint.Host("example.com"),
+ port: NWEndpoint.Port(integerLiteral: 443),
+ using: .tls
+)
+
+connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ let data = Data("Hello".utf8)
+ connection.send(content: data, completion: .contentProcessed { error in
+ if let error = error {
+ print("Send failed: \(error)")
+ }
+ })
+ }
+}
+
+connection.start(queue: .main)
+```
+
+### Benefits
+- 20 lines → 10 lines
+- No manual DNS, no blocking, no unsafe pointers
+- Automatic Happy Eyeballs, proxy support, WiFi Assist
+
+---
+
+## Migration 2: From NWConnection to NetworkConnection (iOS 26+)
+
+### Why migrate
+- Async/await eliminates callback hell
+- TLV framing and Coder protocol built-in
+- No [weak self] needed (async/await handles cancellation)
+- State monitoring via async sequences
+
+### Migration mapping
+
+| NWConnection (iOS 12-25) | NetworkConnection (iOS 26+) | Notes |
+|-------------------------|----------------------------|-------|
+| `connection.stateUpdateHandler = { state in }` | `for await state in connection.states { }` | Async sequence |
+| `connection.send(content:completion:)` | `try await connection.send(content)` | Suspending function |
+| `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | `try await connection.receive(exactly:)` | Suspending function |
+| Manual JSON encode/decode | `Coder(MyType.self, using: .json)` | Built-in Codable support |
+| Custom framer | `TLV { TLS() }` | Built-in Type-Length-Value |
+| `[weak self]` everywhere | No `[weak self]` needed | Task cancellation automatic |
+
+### Example migration
+
+#### Before (NWConnection)
+```swift
+// BEFORE — Completion handlers, manual memory management
+let connection = NWConnection(host: "example.com", port: 443, using: .tls)
+
+connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ self?.sendData()
+ case .waiting(let error):
+ print("Waiting: \(error)")
+ case .failed(let error):
+ print("Failed: \(error)")
+ default:
+ break
+ }
+}
+
+connection.start(queue: .main)
+
+func sendData() {
+ let data = Data("Hello".utf8)
+ connection.send(content: data, completion: .contentProcessed { [weak self] error in
+ if let error = error {
+ print("Send error: \(error)")
+ return
+ }
+ self?.receiveData()
+ })
+}
+
+func receiveData() {
+ connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in
+ if let error = error {
+ print("Receive error: \(error)")
+ return
+ }
+ if let data = data {
+ print("Received: \(data)")
+ }
+ }
+}
+```
+
+#### After (NetworkConnection)
+```swift
+// AFTER — Async/await, automatic memory management
+let connection = NetworkConnection(
+ to: .hostPort(host: "example.com", port: 443)
+) {
+ TLS()
+}
+
+// Monitor states in background task
+Task {
+ for await state in connection.states {
+ switch state {
+ case .preparing:
+ print("Connecting...")
+ case .ready:
+ print("Ready")
+ case .waiting(let error):
+ print("Waiting: \(error)")
+ case .failed(let error):
+ print("Failed: \(error)")
+ default:
+ break
+ }
+ }
+}
+
+// Send and receive with async/await
+func sendAndReceive() async throws {
+ let data = Data("Hello".utf8)
+ try await connection.send(data)
+
+ let received = try await connection.receive(exactly: 10).content
+ print("Received: \(received)")
+}
+```
+
+### Benefits
+- 30 lines → 15 lines
+- No callback nesting, no [weak self]
+- Errors propagate naturally with throws
+- Automatic cancellation on Task exit
+
+---
+
+## Migration 3: From URLSession StreamTask to NetworkConnection
+
+### When to migrate
+- Need UDP (StreamTask only supports TCP)
+- Need custom protocols beyond TCP/TLS
+- Need low-level control (packet pacing, ECN, service class)
+
+### When to STAY with URLSession
+- Doing HTTP/HTTPS (URLSession optimized for this)
+- Need WebSocket support
+- Need built-in caching, cookie handling
+
+### Example migration
+
+#### Before (URLSession StreamTask)
+```swift
+// BEFORE — URLSession for TCP/TLS stream
+let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443)
+
+task.resume()
+
+task.write(Data("Hello".utf8), timeout: 10) { error in
+ if let error = error {
+ print("Write error: \(error)")
+ }
+}
+
+task.readData(ofMinLength: 10, maxLength: 10, timeout: 10) { data, atEOF, error in
+ if let error = error {
+ print("Read error: \(error)")
+ return
+ }
+ if let data = data {
+ print("Received: \(data)")
+ }
+}
+```
+
+#### After (NetworkConnection)
+```swift
+// AFTER — NetworkConnection for TCP/TLS
+let connection = NetworkConnection(
+ to: .hostPort(host: "example.com", port: 443)
+) {
+ TLS()
+}
+
+func sendAndReceive() async throws {
+ try await connection.send(Data("Hello".utf8))
+ let data = try await connection.receive(exactly: 10).content
+ print("Received: \(data)")
+}
+```
+
+## Resources
+
+**Skills**: axiom-ios-networking, axiom-networking-legacy
diff --git a/.claude/skills/axiom-networking-migration/agents/openai.yaml b/.claude/skills/axiom-networking-migration/agents/openai.yaml
new file mode 100644
index 0000000..8dd64cb
--- /dev/null
+++ b/.claude/skills/axiom-networking-migration/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Networking Migration"
+ short_description: "Network framework migration guides"
diff --git a/.claude/skills/axiom-networking/.openskills.json b/.claude/skills/axiom-networking/.openskills.json
new file mode 100644
index 0000000..cf79344
--- /dev/null
+++ b/.claude/skills/axiom-networking/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-networking",
+ "installedAt": "2026-04-12T08:06:29.814Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-networking/SKILL.md b/.claude/skills/axiom-networking/SKILL.md
new file mode 100644
index 0000000..97c9f4f
--- /dev/null
+++ b/.claude/skills/axiom-networking/SKILL.md
@@ -0,0 +1,979 @@
+---
+name: axiom-networking
+description: Use when implementing Network.framework connections, debugging connection failures, migrating from sockets/URLSession streams, or adopting structured concurrency networking patterns - prevents deprecated API usage, reachability anti-patterns, and thread-safety violations with iOS 12-26+ APIs
+license: MIT
+compatibility: iOS 12+ (NWConnection), iOS 26+ (NetworkConnection)
+metadata:
+ version: "1.0.0"
+ last-updated: "2025-12-02"
+---
+
+# Network.framework Networking
+
+## When to Use This Skill
+
+Use when:
+- Implementing UDP/TCP connections for gaming, streaming, or messaging apps
+- Migrating from BSD sockets, CFSocket, NSStream, or SCNetworkReachability
+- Debugging connection timeouts or TLS handshake failures
+- Supporting network transitions (WiFi ↔ cellular) gracefully
+- Adopting structured concurrency networking patterns (iOS 26+)
+- Implementing custom protocols over TLS/QUIC
+- Requesting code review of networking implementation before shipping
+
+#### Related Skills
+- Use `axiom-networking-diag` for systematic troubleshooting of connection failures, timeouts, and performance issues
+- Use `axiom-network-framework-ref` for comprehensive API reference with all WWDC examples
+
+## Example Prompts
+
+#### 1. "How do I migrate from SCNetworkReachability? My app checks connectivity before connecting."
+#### 2. "My connection times out after 60 seconds. How do I debug this?"
+#### 3. "Should I use NWConnection or NetworkConnection? What's the difference?"
+
+---
+
+## Red Flags — Anti-Patterns to Prevent
+
+If you're doing ANY of these, STOP and use the patterns in this skill:
+
+### ❌ CRITICAL — Never Do These
+
+#### 1. Using SCNetworkReachability to check connectivity before connecting
+```swift
+// ❌ WRONG — Race condition
+if SCNetworkReachabilityGetFlags(reachability, &flags) {
+ connection.start() // Network may change between check and start
+}
+```
+**Why this fails** Network state changes between reachability check and connect(). You miss Network.framework's smart connection establishment (Happy Eyeballs, proxy handling, WiFi Assist). Apple deprecated this API in 2018.
+
+#### 2. Blocking socket operations on main thread
+```swift
+// ❌ WRONG — Guaranteed ANR (Application Not Responding)
+let socket = socket(AF_INET, SOCK_STREAM, 0)
+connect(socket, &addr, addrlen) // Blocks main thread
+```
+**Why this fails** Main thread hang → frozen UI → App Store rejection for responsiveness. Even "quick" connects take 200-500ms.
+
+#### 3. Manual DNS resolution with getaddrinfo
+```swift
+// ❌ WRONG — Misses Happy Eyeballs, proxies, VPN
+var hints = addrinfo(...)
+getaddrinfo("example.com", "443", &hints, &results)
+// Now manually try each address...
+```
+**Why this fails** You reimplement 10+ years of Apple's connection logic poorly. Misses IPv4/IPv6 racing, proxy evaluation, VPN detection.
+
+#### 4. Hardcoded IP addresses instead of hostnames
+```swift
+// ❌ WRONG — Breaks proxy/VPN compatibility
+let host = "192.168.1.1" // or any IP literal
+```
+**Why this fails** Proxy auto-configuration (PAC) needs hostname to evaluate rules. VPNs can't route properly. DNS-based load balancing broken.
+
+#### 5. Ignoring waiting state — not handling lack of connectivity
+```swift
+// ❌ WRONG — Poor UX
+connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ // Handle ready
+ }
+ // Missing: .waiting case
+}
+```
+**Why this fails** User sees "Connection failed" in Airplane Mode instead of "Waiting for network." No automatic retry when WiFi returns.
+
+#### 6. Not using [weak self] in NWConnection completion handlers
+```swift
+// ❌ WRONG — Memory leak
+connection.send(content: data, completion: .contentProcessed { error in
+ self.handleSend(error) // Retain cycle: connection → handler → self → connection
+})
+```
+**Why this fails** Connection retains completion handler, handler captures self strongly, self retains connection → memory leak.
+
+#### 7. Mixing async/await and completion handlers in NetworkConnection (iOS 26+)
+```swift
+// ❌ WRONG — Structured concurrency violation
+Task {
+ let connection = NetworkConnection(...)
+ connection.send(data) // async/await
+ connection.stateUpdateHandler = { ... } // completion handler — don't mix
+}
+```
+**Why this fails** NetworkConnection designed for pure async/await. Mixing paradigms creates difficult error propagation and cancellation issues.
+
+#### 8. Not supporting network transitions
+```swift
+// ❌ WRONG — Connection fails on WiFi → cellular transition
+// No viabilityUpdateHandler, no betterPathUpdateHandler
+// User walks out of building → connection dies
+```
+**Why this fails** Modern apps must handle network changes gracefully. 40% of connection failures happen during network transitions.
+
+---
+
+## Mandatory First Steps
+
+**ALWAYS complete these steps** before writing any networking code:
+
+```swift
+// Step 1: Identify your use case
+// Record: "UDP gaming" vs "TLS messaging" vs "Custom protocol over QUIC"
+// Ask: What data am I sending? Real-time? Reliable delivery needed?
+
+// Step 2: Check if URLSession is sufficient
+// URLSession handles: HTTP, HTTPS, WebSocket, TCP/TLS streams (via StreamTask)
+// Network.framework handles: UDP, custom protocols, low-level control, peer-to-peer
+
+// If HTTP/HTTPS/WebSocket → STOP, use URLSession instead
+// Example:
+URLSession.shared.dataTask(with: url) { ... } // ✅ Correct for HTTP
+
+// Step 3: Choose API version based on deployment target
+if #available(iOS 26, *) {
+ // Use NetworkConnection (structured concurrency, async/await)
+ // TLV framing built-in, Coder protocol for Codable types
+} else {
+ // Use NWConnection (completion handlers)
+ // Manual framing or custom framers
+}
+
+// Step 4: Verify you're NOT using deprecated APIs
+// Search your codebase for these:
+// - SCNetworkReachability → Use connection waiting state
+// - CFSocket → Use NWConnection
+// - NSStream, CFStream → Use NWConnection
+// - NSNetService → Use NWBrowser or NetworkBrowser
+// - getaddrinfo → Let Network.framework handle DNS
+
+// To search:
+// grep -rn "SCNetworkReachability\|CFSocket\|NSStream\|getaddrinfo" .
+```
+
+#### What this tells you
+- If HTTP/HTTPS: Use URLSession, not Network.framework
+- If iOS 26+ deployment: Use NetworkConnection with async/await
+- If iOS 12-25 support needed: Use NWConnection with completion handlers
+- If any deprecated API found: Must migrate before shipping (App Store review concern)
+
+---
+
+## Decision Tree
+
+Use this to select the correct pattern in 2 minutes:
+
+```
+Need networking?
+├─ HTTP, HTTPS, or WebSocket?
+│ └─ YES → Use URLSession (NOT Network.framework)
+│ ✅ URLSession.shared.dataTask(with: url)
+│ ✅ URLSession.webSocketTask(with: url)
+│ ✅ URLSession.streamTask(withHostName:port:) for TCP/TLS
+│
+├─ iOS 26+ and can use structured concurrency?
+│ └─ YES → NetworkConnection path (async/await)
+│ ├─ TCP with TLS security?
+│ │ └─ Pattern 1a: NetworkConnection + TLS
+│ │ Time: 10-15 minutes
+│ │
+│ ├─ UDP for gaming/streaming?
+│ │ └─ Pattern 1b: NetworkConnection + UDP
+│ │ Time: 10-15 minutes
+│ │
+│ ├─ Need message boundaries (framing)?
+│ │ └─ Pattern 1c: TLV Framing
+│ │ Type-Length-Value for mixed message types
+│ │ Time: 15-20 minutes
+│ │
+│ └─ Send/receive Codable objects directly?
+│ └─ Pattern 1d: Coder Protocol
+│ No manual JSON encoding needed
+│ Time: 10-15 minutes
+│
+└─ iOS 12-25 or need completion handlers?
+ └─ YES → NWConnection path (callbacks)
+ ├─ TCP with TLS security?
+ │ └─ Pattern 2a: NWConnection + TLS
+ │ stateUpdateHandler, completion-based send/receive
+ │ Time: 15-20 minutes
+ │
+ ├─ UDP streaming with batching?
+ │ └─ Pattern 2b: NWConnection + UDP Batch
+ │ connection.batch for 30% CPU reduction
+ │ Time: 10-15 minutes
+ │
+ ├─ Listening for incoming connections?
+ │ └─ Pattern 2c: NWListener
+ │ Accept inbound connections, newConnectionHandler
+ │ Time: 20-25 minutes
+ │
+ └─ Network discovery (Bonjour)?
+ └─ Pattern 2d: NWBrowser
+ Discover services on local network
+ Time: 25-30 minutes
+```
+
+#### Quick selection guide
+- Gaming (low latency, some loss OK) → UDP patterns (1b or 2b)
+- Messaging (reliable, ordered) → TLS patterns (1a or 2a)
+- Mixed message types → TLV or Coder (1c or 1d)
+- Peer-to-peer → Discovery patterns (2d) + incoming (2c)
+
+---
+
+## Common Patterns
+
+### Pattern 1a: NetworkConnection with TLS (iOS 26+)
+
+**Use when** iOS 26+ deployment, need reliable TCP with TLS security, want async/await
+
+**Time cost** 10-15 minutes
+
+#### ❌ BAD: Manual DNS, Blocking Socket
+```swift
+// WRONG — Don't do this
+var hints = addrinfo(...)
+getaddrinfo("www.example.com", "1029", &hints, &results)
+let sock = socket(AF_INET, SOCK_STREAM, 0)
+connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // Blocks!
+```
+
+#### ✅ GOOD: NetworkConnection with Declarative Stack
+
+```swift
+import Network
+
+// Basic connection with TLS
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ TLS() // TCP and IP inferred automatically
+}
+
+// Send and receive with async/await
+public func sendAndReceiveWithTLS() async throws {
+ let outgoingData = Data("Hello, world!".utf8)
+ try await connection.send(outgoingData)
+
+ let incomingData = try await connection.receive(exactly: 98).content
+ print("Received data: \(incomingData)")
+}
+
+// Optional: Monitor connection state for UI updates
+Task {
+ for await state in connection.states {
+ switch state {
+ case .preparing:
+ print("Establishing connection...")
+ case .ready:
+ print("Connected!")
+ case .waiting(let error):
+ print("Waiting for network: \(error)")
+ case .failed(let error):
+ print("Connection failed: \(error)")
+ case .cancelled:
+ print("Connection cancelled")
+ @unknown default:
+ break
+ }
+ }
+}
+```
+
+#### Custom parameters for low data mode
+
+```swift
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029),
+ using: .parameters {
+ TLS {
+ TCP {
+ IP()
+ .fragmentationEnabled(false)
+ }
+ }
+ }
+ .constrainedPathsProhibited(true) // Don't use cellular in low data mode
+)
+```
+
+#### When to use
+- Secure messaging, email protocols (IMAP, SMTP)
+- Custom protocols requiring encryption
+- APIs using non-HTTP protocols
+
+#### Performance characteristics
+- Smart connection establishment: Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation, VPN detection
+- TLS 1.3 by default (faster handshake)
+- User-space networking: ~30% lower CPU usage vs sockets
+
+#### Debugging
+- Enable logging: `-NWLoggingEnabled 1 -NWConnectionLoggingEnabled 1`
+- Check connection.states async sequence for state transitions
+- Test on real device with Airplane Mode toggle
+
+---
+
+### Pattern 1b: NetworkConnection UDP (iOS 26+)
+
+**Use when** iOS 26+ deployment, need UDP datagrams for gaming or real-time streaming, want async/await
+
+**Time cost** 10-15 minutes
+
+#### ❌ BAD: Blocking UDP Socket
+```swift
+// WRONG — Don't do this
+let sock = socket(AF_INET, SOCK_DGRAM, 0)
+let sent = sendto(sock, buffer, length, 0, &addr, addrlen)
+// Blocks, no batching, axiom-high CPU overhead
+```
+
+#### ✅ GOOD: NetworkConnection with UDP
+
+```swift
+import Network
+
+// UDP connection for real-time data
+let connection = NetworkConnection(
+ to: .hostPort(host: "game-server.example.com", port: 9000)
+) {
+ UDP()
+}
+
+// Send game state update
+public func sendGameUpdate() async throws {
+ let gameState = Data("player_position:100,50".utf8)
+ try await connection.send(gameState)
+}
+
+// Receive game updates
+public func receiveGameUpdates() async throws {
+ while true {
+ let (data, _) = try await connection.receive()
+ processGameState(data)
+ }
+}
+
+// Batch multiple datagrams for efficiency (30% CPU reduction)
+public func sendMultipleUpdates(_ updates: [Data]) async throws {
+ for update in updates {
+ try await connection.send(update)
+ }
+}
+```
+
+#### When to use
+- Real-time gaming (player position, game state)
+- Live streaming (video/audio frames where some loss is acceptable)
+- IoT telemetry (sensor data)
+
+#### Performance characteristics
+- User-space networking: ~30% lower CPU vs sockets
+- Batching multiple sends reduces context switches
+- ECN (Explicit Congestion Notification) enabled automatically
+
+#### Debugging
+- Use Instruments Network template to profile datagram throughput
+- Check for packet loss with receive timeouts
+- Test on cellular network (higher latency/loss)
+
+---
+
+### Pattern 1c: TLV Framing (iOS 26+)
+
+**Use when** Need message boundaries on stream protocols (TCP/TLS), have mixed message types, want type-safe message handling
+
+**Time cost** 15-20 minutes
+
+**Background** Stream protocols (TCP/TLS) don't preserve message boundaries. If you send 3 chunks, receiver might get them 1 byte at a time, or all at once. TLV (Type-Length-Value) solves this by encoding each message with its type and length.
+
+#### ❌ BAD: Manual Length Prefix Parsing
+```swift
+// WRONG — Error-prone, boilerplate-heavy
+let lengthData = try await connection.receive(exactly: 4).content
+let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self) }
+let messageData = try await connection.receive(exactly: Int(length)).content
+// Now decode manually...
+```
+
+#### ✅ GOOD: TLV Framing with Type Safety
+
+```swift
+import Network
+
+// Define your message types
+enum GameMessage: Int {
+ case selectedCharacter = 0
+ case move = 1
+}
+
+struct GameCharacter: Codable {
+ let character: String
+}
+
+struct GameMove: Codable {
+ let row: Int
+ let column: Int
+}
+
+// Connection with TLV framing
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ TLV {
+ TLS()
+ }
+}
+
+// Send typed messages
+public func sendWithTLV() async throws {
+ let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨"))
+ try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue)
+}
+
+// Receive typed messages
+public func receiveWithTLV() async throws {
+ let (incomingData, metadata) = try await connection.receive()
+
+ switch GameMessage(rawValue: metadata.type) {
+ case .selectedCharacter:
+ let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData)
+ print("Character selected: \(character)")
+ case .move:
+ let move = try JSONDecoder().decode(GameMove.self, from: incomingData)
+ print("Move: row=\(move.row), column=\(move.column)")
+ case .none:
+ print("Unknown message type: \(metadata.type)")
+ }
+}
+```
+
+#### When to use
+- Mixed message types in same connection (chat + presence + typing indicators)
+- Existing protocols using TLV (many custom protocols)
+- Need message boundaries without heavy framing overhead
+
+#### How it works
+- Type: UInt32 message identifier (your enum raw value)
+- Length: UInt32 message size (automatic)
+- Value: Actual message bytes
+
+#### Performance characteristics
+- Minimal overhead: 8 bytes per message (type + length)
+- No manual parsing: Framework handles framing
+- Type-safe: Compiler catches message type errors
+
+---
+
+### Pattern 1d: Coder Protocol (iOS 26+)
+
+**Use when** Sending/receiving Codable types, want to eliminate JSON boilerplate, need type-safe message handling
+
+**Time cost** 10-15 minutes
+
+**Background** Most apps manually encode Codable types to JSON, send bytes, receive bytes, decode JSON. Coder protocol eliminates this boilerplate by handling serialization automatically.
+
+#### ❌ BAD: Manual JSON Encoding/Decoding
+```swift
+// WRONG — Boilerplate-heavy, error-prone
+let encoder = JSONEncoder()
+let data = try encoder.encode(message)
+try await connection.send(data)
+
+let receivedData = try await connection.receive().content
+let decoder = JSONDecoder()
+let message = try decoder.decode(GameMessage.self, from: receivedData)
+```
+
+#### ✅ GOOD: Coder Protocol for Direct Codable Send/Receive
+
+```swift
+import Network
+
+// Define message types as Codable enum
+enum GameMessage: Codable {
+ case selectedCharacter(String)
+ case move(row: Int, column: Int)
+}
+
+// Connection with Coder protocol
+let connection = NetworkConnection(
+ to: .hostPort(host: "www.example.com", port: 1029)
+) {
+ Coder(GameMessage.self, using: .json) {
+ TLS()
+ }
+}
+
+// Send Codable types directly
+public func sendWithCoder() async throws {
+ let selectedCharacter: GameMessage = .selectedCharacter("🐨")
+ try await connection.send(selectedCharacter) // No encoding needed!
+}
+
+// Receive Codable types directly
+public func receiveWithCoder() async throws {
+ let gameMessage = try await connection.receive().content // Returns GameMessage!
+
+ switch gameMessage {
+ case .selectedCharacter(let character):
+ print("Character selected: \(character)")
+ case .move(let row, let column):
+ print("Move: (\(row), \(column))")
+ }
+}
+```
+
+#### Supported formats
+- `.json` — JSON encoding (most common, human-readable)
+- `.propertyList` — Property list encoding (smaller, faster)
+
+#### When to use
+- App-to-app communication (you control both ends)
+- Prototyping (fastest time to working code)
+- Type-safe protocols (compiler catches message structure changes)
+
+#### When NOT to use
+- Interoperating with non-Swift servers
+- Need custom wire format
+- Performance-critical (prefer TLV with manual encoding for control)
+
+#### Benefits
+- No JSON boilerplate: ~50 lines → ~10 lines
+- Type-safe: Compiler catches message structure changes
+- Automatic framing: Handles message boundaries
+
+---
+
+## Legacy iOS 12-25 Patterns
+
+For apps supporting iOS 12-25 that can't use async/await yet, invoke `/skill axiom-networking-legacy`:
+- Pattern 2a: NWConnection with TLS (completion handlers)
+- Pattern 2b: NWConnection UDP Batch (30% CPU reduction)
+- Pattern 2c: NWListener (accepting connections, Bonjour)
+- Pattern 2d: Network Discovery (NWBrowser for service discovery)
+
+
+## Pressure Scenarios
+
+### Scenario 1: Reachability Race Condition Under App Store Deadline
+
+#### Context
+
+You're 3 days from App Store submission. QA reports connection failures on cellular networks (15% failure rate). Your PM reviews the code and suggests: "Just add a reachability check before connecting. If there's no network, show an error immediately instead of timing out."
+
+#### Pressure signals
+- ⏰ **Deadline pressure** "App Store deadline is Friday. We need this fixed by EOD Wednesday."
+- 👔 **Authority pressure** PM (non-technical) suggesting specific implementation
+- 💸 **Sunk cost** Already spent 2 hours debugging connection logs, found nothing obvious
+- 📊 **Customer impact** "15% of users affected, mostly on cellular"
+
+#### Rationalization trap
+
+*"SCNetworkReachability is Apple's API, it must be correct. I've seen it in Stack Overflow answers with 500+ upvotes. Adding a quick reachability check will fix the issue today, and I can refactor it properly after launch. The deadline is more important than perfect code right now."*
+
+#### Why this fails
+
+1. **Race condition** Network state changes between reachability check and connection start. You check "WiFi available" at 10:00:00.000, but WiFi disconnects at 10:00:00.050, then you call connection.start() at 10:00:00.100. Connection fails, but reachability said it was available.
+
+2. **Misses smart connection establishment** Network.framework tries multiple strategies (IPv4, IPv6, proxies, WiFi Assist fallback to cellular). SCNetworkReachability gives you "yes/no" but doesn't tell you which strategy will work.
+
+3. **Deprecated API** Apple explicitly deprecated SCNetworkReachability in WWDC 2018. App Store Review may flag this as using legacy APIs.
+
+4. **Doesn't solve actual problem** 15% cellular failures likely caused by not handling waiting state, not by absence of reachability check.
+
+#### MANDATORY response
+
+```swift
+// ❌ NEVER check reachability before connecting
+/*
+if SCNetworkReachabilityGetFlags(reachability, &flags) {
+ if flags.contains(.reachable) {
+ connection.start()
+ } else {
+ showError("No network") // RACE CONDITION
+ }
+}
+*/
+
+// ✅ ALWAYS let Network.framework handle waiting state
+let connection = NWConnection(
+ host: NWEndpoint.Host("api.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 443),
+ using: .tls
+)
+
+connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .preparing:
+ // Show: "Connecting..."
+ self?.showStatus("Connecting...")
+
+ case .ready:
+ // Connection established
+ self?.hideStatus()
+ self?.sendRequest()
+
+ case .waiting(let error):
+ // CRITICAL: Don't fail here, show "Waiting for network"
+ // Network.framework will automatically retry when network returns
+ print("Waiting for network: \(error)")
+ self?.showStatus("Waiting for network...")
+ // User walks out of elevator → WiFi returns → automatic retry
+
+ case .failed(let error):
+ // Only fail after framework exhausts all options
+ // (tried IPv4, IPv6, proxies, WiFi Assist, waited for network)
+ print("Connection failed: \(error)")
+ self?.showError("Connection failed. Please check your network.")
+
+ case .cancelled:
+ self?.hideStatus()
+
+ @unknown default:
+ break
+ }
+}
+
+connection.start(queue: .main)
+```
+
+#### Professional push-back template
+
+*"I understand the deadline pressure. However, adding SCNetworkReachability will create a race condition that will make the 15% failure rate worse, not better. Apple deprecated this API in 2018 specifically because it causes these issues.*
+
+*The correct fix is to handle the waiting state properly, which Network.framework provides. This will actually solve the cellular failures because the framework will automatically retry when network becomes available (e.g., user walks out of elevator, WiFi returns).*
+
+*Implementation time: 15 minutes to add waiting state handler vs 2-4 hours debugging reachability race conditions. The waiting state approach is both faster AND more reliable."*
+
+#### Time saved
+- **Reachability approach** 30 min to implement + 2-4 hours debugging race conditions + potential App Store rejection = 3-5 hours total
+- **Waiting state approach** 15 minutes to implement + 0 hours debugging = 15 minutes total
+- **Savings** 2.5-4.5 hours + avoiding App Store review issues
+
+#### Actual root cause of 15% cellular failures
+
+Likely missing waiting state handler. When user is in area with weak cellular, connection moves to waiting state. Without handler, app shows "Connection failed" instead of "Waiting for network," so user force-quits and reports "doesn't work on cellular."
+
+---
+
+### Scenario 2: Blocking Socket Call Causing Main Thread Hang
+
+#### Context
+
+Your app has 1-star reviews: "App freezes for 5-10 seconds randomly." After investigation, you find a "quick" socket connect() call on the main thread. Your tech lead says: "This is a legacy code path from 2015. It only connects to localhost (127.0.0.1), so it should be instant. The real fix is a 3-week refactor to move all networking to a background queue, but we don't have time. Just leave it for now."
+
+#### Pressure signals
+- ⏰ **Time pressure** "3-week refactor, we're in feature freeze for 2.0 launch"
+- 💸 **Sunk cost** "This code has worked for 8 years, why change it now?"
+- 🎯 **Scope pressure** "It's just localhost, not a real network call"
+- 📊 **Low frequency** "Only 2% of users see this freeze"
+
+#### Rationalization trap
+
+*"Connecting to localhost is basically instant. The freeze must be caused by something else. Besides, refactoring this legacy code is risky—what if I break something? Better to leave working code alone and focus on the new features for 2.0."*
+
+#### Why this fails
+
+1. **Even localhost can block** If the app has many threads, the kernel may schedule other work before returning from connect(). Even 50-100ms is visible to users as a stutter.
+
+2. **ANR (Application Not Responding)** iOS watchdog will terminate your app if main thread blocks for >5 seconds. This explains "random" crashes.
+
+3. **Localhost isn't always available** If VPN is active, localhost routing can be delayed. If device is under memory pressure, kernel scheduling is slower.
+
+4. **Guaranteed App Store rejection** Apple's App Store Review Guidelines explicitly check for main thread blocking. This will fail App Review's performance tests.
+
+#### MANDATORY response
+
+```swift
+// ❌ NEVER call blocking socket APIs on main thread
+/*
+let sock = socket(AF_INET, SOCK_STREAM, 0)
+connect(sock, &addr, addrlen) // BLOCKS MAIN THREAD → ANR
+*/
+
+// ✅ ALWAYS use async connection, even for localhost
+func connectToLocalhost() {
+ let connection = NWConnection(
+ host: "127.0.0.1",
+ port: 8080,
+ using: .tcp
+ )
+
+ connection.stateUpdateHandler = { [weak self] state in
+ switch state {
+ case .ready:
+ print("Connected to localhost")
+ self?.sendRequest(on: connection)
+ case .failed(let error):
+ print("Localhost connection failed: \(error)")
+ default:
+ break
+ }
+ }
+
+ // Non-blocking, returns immediately
+ connection.start(queue: .main)
+}
+```
+
+#### Alternative: If you must keep legacy socket code (not recommended)
+
+```swift
+// Move blocking call to background queue (minimum viable fix)
+DispatchQueue.global(qos: .userInitiated).async {
+ let sock = socket(AF_INET, SOCK_STREAM, 0)
+ connect(sock, &addr, addrlen) // Still blocks, but not main thread
+
+ DispatchQueue.main.async {
+ // Update UI after connection
+ }
+}
+```
+
+#### Professional push-back template
+
+*"I understand this code has been stable for 8 years. However, Apple's App Store Review now runs automated performance tests that will fail apps with main thread blocking. This will block our 2.0 release.*
+
+*The fix doesn't require a 3-week refactor. I can wrap the existing socket code in a background queue dispatch in 30 minutes. Or, I can replace it with NWConnection (non-blocking) in 45 minutes, which also eliminates the socket management code entirely.*
+
+*Neither approach requires touching other parts of the codebase. We can ship 2.0 on schedule AND fix the ANR crashes."*
+
+#### Time saved
+- **Leave it alone** 0 hours upfront + 4-8 hours when App Review rejects + user churn from 1-star reviews
+- **Background queue fix** 30 minutes = main thread safe
+- **NWConnection fix** 45 minutes = main thread safe + eliminates socket management
+- **Savings** 3-7 hours + avoiding App Store rejection
+
+---
+
+### Scenario 3: Design Review Pressure — "Use WebSockets for Everything"
+
+#### Context
+
+Your team is building a multiplayer game with real-time player positions (20 updates/second). In architecture review, the senior architect says: "All our other apps use WebSockets for networking. We should use WebSockets here too for consistency. It's production-proven, and the backend team already knows how to deploy WebSocket servers."
+
+#### Pressure signals
+- 👔 **Authority pressure** Senior architect with 15 years experience
+- 🏢 **Org consistency** "All other apps use WebSockets"
+- 💼 **Backend expertise** "Backend team doesn't know UDP"
+- 📊 **Proven technology** "WebSockets are battle-tested"
+
+#### Rationalization trap
+
+*"The architect has way more experience than me. If WebSockets work for the other apps, they'll work here too. UDP sounds complicated and risky. Better to stick with proven technology than introduce something new that might break in production."*
+
+#### Why this fails for real-time gaming
+
+1. **Head-of-line blocking** WebSockets use TCP. If one packet is lost, TCP blocks ALL subsequent packets until retransmission succeeds. In a game, this means old player position (frame 100) blocks new position (frame 120), causing stutter.
+
+2. **Latency overhead** TCP requires 3-way handshake (SYN, SYN-ACK, ACK) before sending data. For 20 updates/second, this overhead adds 50-150ms latency.
+
+3. **Unnecessary reliability** Game position updates don't need guaranteed delivery. If frame 100 is lost, frame 101 (5ms later) makes it obsolete. TCP retransmits frame 100, wasting bandwidth.
+
+4. **Connection establishment** WebSockets require HTTP upgrade handshake (4 round trips) before data transfer. UDP starts sending immediately.
+
+#### MANDATORY response
+
+```swift
+// ❌ WRONG for real-time gaming
+/*
+let webSocket = URLSession.shared.webSocketTask(with: url)
+webSocket.resume()
+webSocket.send(.data(positionUpdate)) { error in
+ // TCP guarantees delivery but blocks on loss
+ // Old position blocks new position → stutter
+}
+*/
+
+// ✅ CORRECT for real-time gaming
+let connection = NWConnection(
+ host: NWEndpoint.Host("game-server.example.com"),
+ port: NWEndpoint.Port(integerLiteral: 9000),
+ using: .udp
+)
+
+connection.stateUpdateHandler = { state in
+ if case .ready = state {
+ print("Ready to send game updates")
+ }
+}
+
+connection.start(queue: .main)
+
+// Send player position updates (20/second)
+func sendPosition(_ position: PlayerPosition) {
+ let data = encodePosition(position)
+ connection.send(content: data, completion: .contentProcessed { error in
+ // Fire and forget, no blocking
+ // If this frame is lost, next frame (50ms later) makes it obsolete
+ })
+}
+```
+
+#### Technical comparison table
+
+| Aspect | WebSocket (TCP) | UDP |
+|--------|----------------|-----|
+| Latency (typical) | 50-150ms | 10-30ms |
+| Head-of-line blocking | Yes (old data blocks new) | No |
+| Connection setup | 4 round trips (HTTP upgrade) | 0 round trips |
+| Packet loss handling | Blocks until retransmit | Continues with next packet |
+| Bandwidth (20 updates/sec) | ~40 KB/s | ~20 KB/s |
+| Best for | Chat, API calls | Gaming, streaming |
+
+#### Professional push-back template
+
+*"I appreciate the concern about consistency and proven technology. WebSockets are excellent for our other apps because they're doing chat, notifications, and API calls—use cases where guaranteed delivery matters.*
+
+*However, real-time gaming has different requirements. Let me explain with a concrete example:*
+
+*Player moves from position A to B to C (3 updates in 150ms). With WebSockets:*
+*- Frame A sent*
+*- Frame A packet lost*
+*- Frame B sent, but TCP blocks it (waiting for Frame A retransmit)*
+*- Frame C sent, also blocked*
+*- Frame A retransmits, arrives 200ms later*
+*- Frames B and C finally delivered*
+*- Result: 200ms of frozen player position, then sudden jump to C*
+
+*With UDP:*
+*- Frame A sent and lost*
+*- Frame B sent and delivered (50ms later)*
+*- Frame C sent and delivered (50ms later)*
+*- Result: Smooth position updates, no freeze*
+
+*The backend team doesn't need to learn UDP from scratch—they can use the same Network.framework on server-side Swift (Vapor, Hummingbird). Implementation time is the same.*
+
+*I'm happy to do a proof-of-concept this week showing latency comparison. We can measure both approaches with real data."*
+
+#### When WebSockets ARE correct
+- Chat applications (message delivery must be reliable)
+- Turn-based games (moves must arrive in order)
+- API calls over persistent connection
+- Live notifications/updates
+
+#### Time saved
+- **WebSocket approach** 2 days implementation + 1-2 weeks debugging stutter/lag issues + potential rewrite = 3-4 weeks
+- **UDP approach** 2 days implementation + smooth gameplay = 2 days
+- **Savings** 2-3 weeks + better user experience
+
+---
+
+## Migration Guides
+
+For detailed migration guides from legacy networking APIs, invoke `/skill axiom-networking-migration`:
+- Migration 1: BSD Sockets → NWConnection
+- Migration 2: NWConnection → NetworkConnection (iOS 26+)
+- Migration 3: URLSession StreamTask → NetworkConnection
+
+
+## Checklist
+
+Before shipping networking code, verify:
+
+### Deprecated API Check
+- [ ] Not using SCNetworkReachability anywhere in codebase
+- [ ] Not using CFSocket, NSSocket, or BSD sockets directly
+- [ ] Not using NSStream or CFStream
+- [ ] Not using NSNetService (use NWBrowser instead)
+- [ ] Not calling getaddrinfo for manual DNS resolution
+
+### Connection Configuration
+- [ ] Using hostname, NOT hardcoded IP address
+- [ ] TLS enabled for sensitive data (passwords, tokens, user content)
+- [ ] Handling waiting state with user feedback ("Waiting for network...")
+- [ ] Not checking reachability before calling connection.start()
+
+### Memory Management
+- [ ] Using [weak self] in all NWConnection completion handlers
+- [ ] Or using NetworkConnection (iOS 26+) with async/await (no [weak self] needed)
+- [ ] Calling connection.cancel() when done to free resources
+
+### Network Transitions
+- [ ] Supporting network changes (WiFi → cellular, or vice versa)
+- [ ] Using viabilityUpdateHandler or betterPathUpdateHandler (NWConnection)
+- [ ] Or monitoring connection.states async sequence (NetworkConnection)
+- [ ] NOT tearing down connection immediately on viability change
+
+### Testing on Real Devices
+- [ ] Tested on real device (not just simulator)
+- [ ] Tested WiFi → cellular transition (walk out of building)
+- [ ] Tested Airplane Mode toggle (enable → disable)
+- [ ] Tested on IPv6-only network (some cellular carriers)
+- [ ] Tested with corporate VPN active
+- [ ] Tested with low signal (basement, elevator)
+
+### Performance
+- [ ] Using connection.batch for multiple UDP datagrams (30% CPU reduction)
+- [ ] Using contentProcessed completion for send pacing (not sleep())
+- [ ] Profiled with Instruments Network template
+- [ ] Connection establishment < 500ms (check with logging)
+
+### Error Handling
+- [ ] Handling .failed state with specific error
+- [ ] Timeout handling (don't wait forever in .preparing)
+- [ ] TLS handshake errors logged for debugging
+- [ ] User-facing errors are actionable ("Check network" not "POSIX error 61")
+
+### iOS 26+ Features (if using NetworkConnection)
+- [ ] Using TLV framing if need message boundaries
+- [ ] Using Coder protocol if sending Codable types
+- [ ] Using NetworkListener instead of NWListener
+- [ ] Using NetworkBrowser with Wi-Fi Aware for peer-to-peer
+
+---
+
+## Real-World Impact
+
+### User-Space Networking: 30% CPU Reduction
+
+**WWDC 2018 Demo** Live UDP video streaming comparison:
+- **BSD sockets** ~30% higher CPU usage on receiver
+- **Network.framework** ~30% lower CPU usage
+
+**Why** Traditional sockets copy data kernel → userspace. Network.framework uses memory-mapped regions (no copy) and reduces context switches from 100 syscalls → ~1 syscall (with batching).
+
+#### Impact for your app
+- Lower battery drain (30% less CPU = longer battery life)
+- Smoother gameplay (more CPU for rendering)
+- Cooler device (less thermal throttling)
+
+### Smart Connection Establishment: 50% Faster
+
+#### Traditional approach
+1. Call getaddrinfo (100-300ms DNS lookup)
+2. Try first IPv6 address, wait 5 seconds for timeout
+3. Try IPv4 address, finally connects
+
+#### Network.framework (Happy Eyeballs)
+1. Start DNS lookup in background
+2. As soon as first address arrives, try connecting
+3. Start second connection attempt 50ms later
+4. Use whichever connects first
+
+**Result** 50% faster connection establishment in dual-stack environments (measured by Apple)
+
+### Proper State Handling: 10x Crash Reduction
+
+**Customer report** App crash rate dropped from 5% → 0.5% after implementing waiting state handler.
+
+**Before** App showed "Connection failed" when no network, users force-quit app → crash report.
+
+**After** App showed "Waiting for network" and automatically retried when WiFi returned → users saw seamless reconnection.
+
+---
+
+## Resources
+
+**WWDC**: 2018-715, 2025-250
+
+**Skills**: axiom-networking-diag, axiom-network-framework-ref
+
+---
+
+**Last Updated** 2025-12-02
+**Status** Production-ready patterns from WWDC 2018 and WWDC 2025
+**Tested** Patterns validated against Apple documentation and WWDC transcripts
diff --git a/.claude/skills/axiom-networking/agents/openai.yaml b/.claude/skills/axiom-networking/agents/openai.yaml
new file mode 100644
index 0000000..78444ff
--- /dev/null
+++ b/.claude/skills/axiom-networking/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Networking"
+ short_description: "Implementing Network.framework connections, debugging connection failures, migrating from sockets/URLSession streams,..."
diff --git a/.claude/skills/axiom-now-playing-carplay/.openskills.json b/.claude/skills/axiom-now-playing-carplay/.openskills.json
new file mode 100644
index 0000000..949efe3
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-carplay/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-now-playing-carplay",
+ "installedAt": "2026-04-12T08:06:30.803Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-now-playing-carplay/SKILL.md b/.claude/skills/axiom-now-playing-carplay/SKILL.md
new file mode 100644
index 0000000..b2c7f3c
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-carplay/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: axiom-now-playing-carplay
+description: CarPlay Now Playing integration patterns. Use when implementing CarPlay audio controls, CPNowPlayingTemplate customization, or debugging CarPlay-specific issues.
+license: MIT
+---
+
+# CarPlay Integration
+
+**Time cost**: 15-20 minutes (if MPNowPlayingInfoCenter already working)
+
+## Key Insight
+
+**CarPlay uses the SAME MPNowPlayingInfoCenter and MPRemoteCommandCenter as Lock Screen and Control Center.** If your Now Playing integration works on iOS, it automatically works in CarPlay with zero additional code.
+
+## What CarPlay Reads
+
+| iOS Component | CarPlay Display |
+|---------------|-----------------|
+| `MPNowPlayingInfoCenter.nowPlayingInfo` | CPNowPlayingTemplate metadata (title, artist, artwork) |
+| `MPRemoteCommandCenter` handlers | CPNowPlayingTemplate button responses |
+| Artwork from `nowPlayingInfo` | Album art in CarPlay UI |
+
+No CarPlay-specific metadata needed. Your existing code works.
+
+## CPNowPlayingTemplate Customization (iOS 14+)
+
+For custom playback controls beyond standard play/pause/skip:
+
+```swift
+import CarPlay
+
+@MainActor
+class SceneDelegate: UIResponder, UIWindowSceneDelegate, CPTemplateApplicationSceneDelegate {
+
+ func templateApplicationScene(
+ _ templateApplicationScene: CPTemplateApplicationScene,
+ didConnect interfaceController: CPInterfaceController
+ ) {
+ // ✅ Configure CPNowPlayingTemplate at connection time (not when pushed)
+ let nowPlayingTemplate = CPNowPlayingTemplate.shared
+
+ // Enable Album/Artist browsing (shows button that navigates to album/artist view in your app)
+ nowPlayingTemplate.isAlbumArtistButtonEnabled = true
+
+ // Enable Up Next queue (shows button that displays upcoming tracks)
+ nowPlayingTemplate.isUpNextButtonEnabled = true
+
+ // Add custom buttons (iOS 14+)
+ setupCustomButtons(for: nowPlayingTemplate)
+ }
+
+ private func setupCustomButtons(for template: CPNowPlayingTemplate) {
+ var buttons: [CPNowPlayingButton] = []
+
+ // Playback rate button
+ let rateButton = CPNowPlayingPlaybackRateButton { [weak self] button in
+ self?.cyclePlaybackRate()
+ }
+ buttons.append(rateButton)
+
+ // Shuffle button
+ let shuffleButton = CPNowPlayingShuffleButton { [weak self] button in
+ self?.toggleShuffle()
+ }
+ buttons.append(shuffleButton)
+
+ // Repeat button
+ let repeatButton = CPNowPlayingRepeatButton { [weak self] button in
+ self?.cycleRepeatMode()
+ }
+ buttons.append(repeatButton)
+
+ // Update template with custom buttons
+ template.updateNowPlayingButtons(buttons)
+ }
+}
+```
+
+## Entitlement Requirement
+
+CarPlay requires an entitlement in your Xcode project:
+
+**Info.plist:**
+```xml
+UIBackgroundModes
+
+ audio
+
+```
+
+**Entitlements file:**
+```xml
+com.apple.developer.carplay-audio
+
+```
+
+Without the entitlement, CarPlay won't show your app at all.
+
+## CarPlay-Specific Gotchas
+
+| Issue | Cause | Fix | Time |
+|-------|-------|-----|------|
+| CarPlay doesn't show app | Missing entitlement | Add `com.apple.developer.carplay-audio` | 5 min |
+| Now Playing blank in CarPlay | MPNowPlayingInfoCenter not set | Same fix as Lock Screen (Pattern 1) | 10 min |
+| Custom buttons don't appear | Configured after push | Configure at `templateApplicationScene(_:didConnect:)` | 5 min |
+| Buttons work on device, not CarPlay simulator | Debugger interference | Test without debugger attached | 1 min |
+| Album art missing | Same as iOS issue | Fix MPMediaItemArtwork (Pattern 3) | 15 min |
+
+## Testing CarPlay
+
+**Simulator (Xcode 12+):**
+1. I/O → External Displays → CarPlay
+2. Tap CarPlay display
+3. Find your app in Audio section
+4. **Important**: Run without debugger for reliable testing (debugger can interfere with CarPlay audio session activation)
+
+**Real Vehicle:**
+Requires entitlement approval from Apple (automatic for apps with `UIBackgroundModes` audio; no manual request needed).
+
+## Verification
+
+- [ ] App appears in CarPlay Audio section
+- [ ] Now Playing shows correct metadata
+- [ ] Album artwork displays
+- [ ] Play/pause/skip buttons respond
+- [ ] Custom buttons (if any) appear and work
+- [ ] Tested both with and without debugger
+
+## Resources
+
+**Skills**: axiom-now-playing, axiom-now-playing-musickit
diff --git a/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml b/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml
new file mode 100644
index 0000000..5fb40af
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-carplay/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Now Playing Carplay"
+ short_description: "CarPlay Now Playing integration patterns"
diff --git a/.claude/skills/axiom-now-playing-musickit/.openskills.json b/.claude/skills/axiom-now-playing-musickit/.openskills.json
new file mode 100644
index 0000000..4ffe9d6
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-musickit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-now-playing-musickit",
+ "installedAt": "2026-04-12T08:06:31.004Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-now-playing-musickit/SKILL.md b/.claude/skills/axiom-now-playing-musickit/SKILL.md
new file mode 100644
index 0000000..093dff6
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-musickit/SKILL.md
@@ -0,0 +1,334 @@
+---
+name: axiom-now-playing-musickit
+description: MusicKit Now Playing integration patterns. Use when playing Apple Music content with ApplicationMusicPlayer and understanding automatic vs manual Now Playing info updates.
+license: MIT
+---
+
+# MusicKit Integration (Apple Music)
+
+**Time cost**: 5-10 minutes
+
+## Key Insight
+
+**MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter.** You don't need to manually update Now Playing info when playing Apple Music content.
+
+## What's Automatic
+
+When using `ApplicationMusicPlayer`:
+- Track title, artist, album
+- Artwork (Apple's album art)
+- Duration and elapsed time
+- Playback rate (playing/paused state)
+
+The system handles all MPNowPlayingInfoCenter updates for you.
+
+## What's NOT Automatic
+
+- Custom metadata (chapter markers, custom artist notes)
+- Remote command customization beyond standard controls
+- Mixing MusicKit content with your own content
+
+---
+
+## Subscription and Authorization
+
+### Check Music Authorization
+
+```swift
+import MusicKit
+
+func requestMusicAccess() async -> Bool {
+ let status = await MusicAuthorization.request()
+ return status == .authorized
+}
+
+// Check current status without prompting
+let currentStatus = MusicAuthorization.currentStatus
+// .authorized, .denied, .notDetermined, .restricted
+```
+
+### Check Apple Music Subscription
+
+```swift
+func checkSubscription() async -> Bool {
+ do {
+ let subscription = try await MusicSubscription.current
+ return subscription.canPlayCatalogContent
+ } catch {
+ return false
+ }
+}
+
+// Observe subscription changes
+func observeSubscription() {
+ Task {
+ for await subscription in MusicSubscription.subscriptionUpdates {
+ if subscription.canPlayCatalogContent {
+ // Full Apple Music access
+ } else if subscription.canBecomeSubscriber {
+ // Show subscription offer
+ showSubscriptionOffer()
+ }
+ }
+ }
+}
+```
+
+### Subscription Offer Sheet
+
+```swift
+import MusicKit
+import StoreKit
+
+// Present Apple Music subscription offer
+MusicSubscriptionOffer.Options(
+ messageIdentifier: .playMusic,
+ itemID: song.id
+)
+
+// In SwiftUI
+.musicSubscriptionOffer(isPresented: $showOffer, options: offerOptions)
+```
+
+### Graceful Fallback Without Subscription
+
+```swift
+@MainActor
+class MusicPlayer: ObservableObject {
+ @Published var canPlay = false
+
+ func handlePlayRequest(song: Song) async {
+ let authorized = await requestMusicAccess()
+ guard authorized else {
+ showAuthorizationDeniedAlert()
+ return
+ }
+
+ do {
+ let subscription = try await MusicSubscription.current
+ if subscription.canPlayCatalogContent {
+ // Full playback
+ try await play(song: song)
+ } else {
+ // Preview only (30-second clips)
+ if let previewURL = song.previewAssets?.first?.url {
+ playPreview(url: previewURL)
+ }
+ }
+ } catch {
+ handleError(error)
+ }
+ }
+}
+```
+
+---
+
+## Playback
+
+### Basic Playback
+
+```swift
+import MusicKit
+
+@MainActor
+class MusicKitPlayer {
+ private let player = ApplicationMusicPlayer.shared
+
+ func play(song: Song) async throws {
+ // ✅ Just play - MPNowPlayingInfoCenter updates automatically
+ player.queue = [song]
+ try await player.play()
+
+ // ❌ DO NOT manually set nowPlayingInfo here
+ // MPNowPlayingInfoCenter.default().nowPlayingInfo = [...] // WRONG!
+ }
+
+ func pause() {
+ player.pause()
+ }
+
+ func stop() {
+ player.stop()
+ }
+}
+```
+
+### Observing Playback State
+
+```swift
+@MainActor
+class PlayerViewModel: ObservableObject {
+ private let player = ApplicationMusicPlayer.shared
+ @Published var isPlaying = false
+ @Published var currentEntry: ApplicationMusicPlayer.Queue.Entry?
+ @Published var playbackTime: TimeInterval = 0
+
+ func observeState() {
+ // Observe playback status
+ Task {
+ for await state in player.state.objectWillChange.values {
+ isPlaying = player.state.playbackStatus == .playing
+ }
+ }
+
+ // Observe current entry (track changes)
+ Task {
+ for await queue in player.queue.objectWillChange.values {
+ currentEntry = player.queue.currentEntry
+ }
+ }
+ }
+}
+```
+
+---
+
+## Queue Management
+
+### Setting the Queue
+
+```swift
+let player = ApplicationMusicPlayer.shared
+
+// Single song
+player.queue = [song]
+
+// Album
+player.queue = ApplicationMusicPlayer.Queue(album: album)
+
+// Playlist
+player.queue = ApplicationMusicPlayer.Queue(playlist: playlist)
+
+// Multiple items
+player.queue = ApplicationMusicPlayer.Queue(for: [song1, song2, song3])
+
+// Start at specific item
+player.queue = ApplicationMusicPlayer.Queue(for: songs, startingAt: songs[2])
+```
+
+### Queue Operations
+
+```swift
+// Skip to next
+try await player.skipToNextEntry()
+
+// Skip to previous
+try await player.skipToPreviousEntry()
+
+// Restart current track
+player.restartCurrentEntry()
+
+// Append to queue
+try await player.queue.insert(song, position: .afterCurrentEntry)
+try await player.queue.insert(song, position: .tail) // End of queue
+
+// Shuffle and repeat
+player.state.shuffleMode = .songs // .off, .songs
+player.state.repeatMode = .all // .none, .one, .all
+```
+
+### Observing Queue Changes
+
+```swift
+// Current track info
+if let entry = player.queue.currentEntry {
+ let title = entry.title
+ let subtitle = entry.subtitle // Artist name
+ let artwork = entry.artwork // Artwork for display
+
+ // Get full Song object if needed
+ if case .song(let song) = entry.item {
+ let albumTitle = song.albumTitle
+ }
+}
+```
+
+---
+
+## Hybrid Apps (Own Content + Apple Music)
+
+If your app plays both Apple Music and your own content:
+
+```swift
+import MusicKit
+
+@MainActor
+class HybridPlayer {
+ private let musicKitPlayer = ApplicationMusicPlayer.shared
+ private var avPlayer: AVPlayer?
+ private var currentSource: ContentSource = .none
+
+ enum ContentSource {
+ case none
+ case appleMusic // MusicKit handles Now Playing
+ case ownContent // We handle Now Playing
+ }
+
+ func playAppleMusicSong(_ song: Song) async throws {
+ // Switch to MusicKit
+ avPlayer?.pause()
+ currentSource = .appleMusic
+
+ musicKitPlayer.queue = [song]
+ try await musicKitPlayer.play()
+ // ✅ MusicKit handles Now Playing automatically
+ }
+
+ func playOwnContent(_ url: URL) {
+ // Switch to AVPlayer
+ musicKitPlayer.pause()
+ currentSource = .ownContent
+
+ avPlayer = AVPlayer(url: url)
+ avPlayer?.play()
+
+ // ✅ Manually update Now Playing (see axiom-now-playing)
+ updateNowPlayingForOwnContent()
+ }
+
+ private func updateNowPlayingForOwnContent() {
+ var nowPlayingInfo = [String: Any]()
+ nowPlayingInfo[MPMediaItemPropertyTitle] = "My Track"
+ // ... rest of manual setup
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+ }
+}
+```
+
+---
+
+## Common Mistake
+
+```swift
+// ❌ WRONG - Overwrites MusicKit's automatic Now Playing data
+func playAppleMusicSong(_ song: Song) async throws {
+ try await ApplicationMusicPlayer.shared.play()
+
+ // ❌ This clears MusicKit's Now Playing info!
+ var nowPlayingInfo = [String: Any]()
+ nowPlayingInfo[MPMediaItemPropertyTitle] = song.title
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+}
+
+// ✅ CORRECT - Let MusicKit handle it
+func playAppleMusicSong(_ song: Song) async throws {
+ try await ApplicationMusicPlayer.shared.play()
+ // That's it! MusicKit publishes Now Playing automatically.
+}
+```
+
+## When to Use Manual Updates with MusicKit
+
+Only override MPNowPlayingInfoCenter if:
+- You're mixing in additional metadata (e.g., podcast chapter markers)
+- You're displaying custom content alongside Apple Music
+- You have a specific reason to replace MusicKit's automatic behavior
+
+**Default**: Let MusicKit manage Now Playing automatically.
+
+## Resources
+
+**Docs**: /musickit, /musickit/applicationmusicplayer, /musickit/musicsubscription
+
+**Skills**: axiom-now-playing, axiom-now-playing-carplay
diff --git a/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml b/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml
new file mode 100644
index 0000000..6cf43f9
--- /dev/null
+++ b/.claude/skills/axiom-now-playing-musickit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Now Playing Musickit"
+ short_description: "MusicKit Now Playing integration patterns"
diff --git a/.claude/skills/axiom-now-playing/.openskills.json b/.claude/skills/axiom-now-playing/.openskills.json
new file mode 100644
index 0000000..450cb02
--- /dev/null
+++ b/.claude/skills/axiom-now-playing/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-now-playing",
+ "installedAt": "2026-04-12T08:06:30.607Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-now-playing/SKILL.md b/.claude/skills/axiom-now-playing/SKILL.md
new file mode 100644
index 0000000..b362d7e
--- /dev/null
+++ b/.claude/skills/axiom-now-playing/SKILL.md
@@ -0,0 +1,1001 @@
+---
+name: axiom-now-playing
+description: Use when Now Playing metadata doesn't appear on Lock Screen/Control Center, remote commands (play/pause/skip) don't respond, artwork is missing/wrong/flickering, or playback state is out of sync - provides systematic diagnosis, correct patterns, and professional push-back for audio/video apps on iOS 18+
+license: MIT
+compatibility: iOS 18+, iPadOS 18+, CarPlay (iOS 14+)
+metadata:
+ version: "1.1.0"
+---
+
+# Now Playing Integration Guide
+
+**Purpose**: Prevent the 4 most common Now Playing issues on iOS 18+: info not appearing, commands not working, artwork problems, and state sync issues
+
+**Swift Version**: Swift 6.0+
+**iOS Version**: iOS 18+
+**Xcode**: Xcode 16+
+
+## Core Philosophy
+
+> "Now Playing eligibility requires THREE things working together: AVAudioSession activation, remote command handlers, and metadata publishing. Missing ANY of these silently breaks the entire system. 90% of Now Playing issues stem from incorrect activation order or missing command handlers, not API bugs."
+
+**Key Insight from WWDC 2022/110338**: Apps must meet two system heuristics:
+1. Register handlers for at least one remote command
+2. Configure AVAudioSession with a non-mixable category
+
+## When to Use This Skill
+
+✅ **Use this skill when**:
+- Now Playing info doesn't appear on Lock Screen or Control Center
+- Play/pause/skip buttons are grayed out or don't respond
+- Album artwork is missing, wrong, or flickers between images
+- Control Center shows "Playing" when app is paused, or vice versa
+- Apple Music or other apps "steal" Now Playing status
+- Implementing Now Playing for the first time
+- Debugging Now Playing issues in existing implementation
+- Integrating CarPlay Now Playing (covered in Pattern 6)
+- Working with MusicKit/Apple Music content (covered in Pattern 7)
+
+### iOS 26 Note
+
+iOS 26 introduces **Liquid Glass visual design** for Lock Screen and Control Center Now Playing widgets. This is **automatic system behavior** — no code changes required. The patterns in this skill remain valid for iOS 26.
+
+❌ **Do NOT use this skill for**:
+- Background audio configuration details (see AVFoundation skill)
+
+## Related Skills
+
+- **swift-concurrency** - For @MainActor patterns, weak self in closures, async artwork loading
+- **memory-debugging** - For retain cycles in command handlers
+- **avfoundation-ref** - For AVAudioSession configuration details
+
+---
+
+## Red Flags / Anti-Patterns
+
+**If you see ANY of these, suspect Now Playing misconfiguration:**
+
+- Info appears briefly then disappears (AVAudioSession deactivated)
+- Commands work in simulator but not on device (simulator has different audio stack)
+- Artwork shows placeholder then updates (race condition, not necessarily wrong)
+- Artwork never appears (format/size issue or MPMediaItemArtwork block returning nil)
+- Play/pause state incorrect after backgrounding (not updating on playback rate changes)
+- Another app "steals" Now Playing (didn't meet eligibility requirements)
+- `playbackState` property doesn't update (iOS doesn't have `playbackState`, macOS only!)
+
+**FORBIDDEN Assumptions:**
+- "Just set nowPlayingInfo and it works" - Must have AVAudioSession + command handlers
+- "playbackState controls Control Center" - iOS ignores playbackState, uses playbackRate
+- "Artwork just needs an image" - Needs proper MPMediaItemArtwork with size handler
+- "Commands enable themselves" - Must add target AND set isEnabled = true
+- "Update elapsed time every second" - System infers from rate, causes jitter
+
+## Mandatory First Steps (Pre-Diagnosis)
+
+Run this code to understand current state before debugging:
+
+```swift
+// 1. Verify AVAudioSession configuration
+let session = AVAudioSession.sharedInstance()
+print("Category: \(session.category.rawValue)")
+print("Mode: \(session.mode.rawValue)")
+print("Options: \(session.categoryOptions)")
+print("Is active: \(try? session.setActive(true))")
+// Must be: .playback category, NOT .mixWithOthers option
+
+// 2. Verify background mode
+// Info.plist must have: UIBackgroundModes = ["audio"]
+
+// 3. Check command handlers are registered
+let commandCenter = MPRemoteCommandCenter.shared()
+print("Play enabled: \(commandCenter.playCommand.isEnabled)")
+print("Pause enabled: \(commandCenter.pauseCommand.isEnabled)")
+// Must have at least one command with target AND isEnabled = true
+
+// 4. Check nowPlayingInfo dictionary
+if let info = MPNowPlayingInfoCenter.default().nowPlayingInfo {
+ print("Title: \(info[MPMediaItemPropertyTitle] ?? "nil")")
+ print("Artwork: \(info[MPMediaItemPropertyArtwork] != nil)")
+ print("Duration: \(info[MPMediaItemPropertyPlaybackDuration] ?? "nil")")
+ print("Elapsed: \(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] ?? "nil")")
+ print("Rate: \(info[MPNowPlayingInfoPropertyPlaybackRate] ?? "nil")")
+} else {
+ print("No nowPlayingInfo set!")
+}
+```
+
+**What this tells you:**
+
+| Observation | Diagnosis | Pattern |
+|-------------|-----------|---------|
+| Category is .ambient or has .mixWithOthers | Won't become Now Playing app | Pattern 1 |
+| No commands have targets | System ignores app | Pattern 2 |
+| Commands have targets but isEnabled = false | UI grayed out | Pattern 2 |
+| Artwork is nil | MPMediaItemArtwork block returning nil | Pattern 3 |
+| playbackRate is 0.0 when playing | Control Center shows paused | Pattern 4 |
+| Background mode "audio" not in Info.plist | Info disappears on lock | Pattern 1 |
+
+## Decision Tree
+
+```
+Now Playing not working?
+├─ Info never appears at all?
+│ ├─ AVAudioSession category .ambient or .mixWithOthers?
+│ │ └─ Pattern 1a (Wrong Category)
+│ ├─ No remote command handlers registered?
+│ │ └─ Pattern 2a (Missing Handlers)
+│ ├─ Background mode "audio" not in Info.plist?
+│ │ └─ Pattern 1b (Background Mode)
+│ └─ AVAudioSession.setActive(true) never called?
+│ └─ Pattern 1c (Not Activated)
+│
+├─ Info appears briefly, then disappears?
+│ ├─ On lock screen specifically?
+│ │ ├─ AVAudioSession deactivated too early?
+│ │ │ └─ Pattern 1d (Early Deactivation)
+│ │ └─ App suspended (no background mode)?
+│ │ └─ Pattern 1b (Background Mode)
+│ └─ When switching apps?
+│ └─ Another app claiming Now Playing → Pattern 5
+│
+├─ Commands not responding?
+│ ├─ Buttons grayed out (disabled)?
+│ │ └─ command.isEnabled = false → Pattern 2b
+│ ├─ Buttons visible but no response?
+│ │ ├─ Handler not returning .success?
+│ │ │ └─ Pattern 2c (Handler Return)
+│ │ └─ Using wrong command center (session vs shared)?
+│ │ └─ Pattern 2d (Command Center)
+│ └─ Skip forward/backward not showing?
+│ └─ preferredIntervals not set → Pattern 2e
+│
+├─ Artwork problems?
+│ ├─ Never appears?
+│ │ ├─ MPMediaItemArtwork block returning nil?
+│ │ │ └─ Pattern 3a (Artwork Block)
+│ │ └─ Image format/size invalid?
+│ │ └─ Pattern 3b (Image Format)
+│ ├─ Wrong artwork showing?
+│ │ └─ Race condition between sources → Pattern 3c
+│ └─ Artwork flickering?
+│ └─ Multiple updates in rapid succession → Pattern 3d
+│
+├─ State sync issues?
+│ ├─ Shows "Playing" when paused?
+│ │ └─ playbackRate not updated → Pattern 4a
+│ ├─ Progress bar stuck or jumping?
+│ │ └─ elapsedTime not updated at right moments → Pattern 4b
+│ └─ Duration wrong?
+│ └─ Not setting playbackDuration → Pattern 4c
+│
+├─ CarPlay specific issues?
+│ ├─ App doesn't appear in CarPlay at all?
+│ │ └─ Missing entitlement → Pattern 6 (Add com.apple.developer.carplay-audio)
+│ ├─ Now Playing blank in CarPlay but works on iOS?
+│ │ └─ Same root cause as iOS → Check Patterns 1-4
+│ ├─ Custom buttons don't appear in CarPlay?
+│ │ └─ Wrong configuration timing → Pattern 6 (Configure at templateApplicationScene)
+│ └─ Works on device but not CarPlay simulator?
+│ └─ Debugger interference → Pattern 6 (Run without debugger)
+│
+└─ Using MusicKit (ApplicationMusicPlayer)?
+ ├─ Now Playing shows wrong info?
+ │ └─ Overwriting automatic data → Pattern 7 (Don't set nowPlayingInfo manually)
+ └─ Mixing MusicKit + own content?
+ └─ Hybrid approach needed → Pattern 7 (Switch between players)
+```
+
+---
+
+## Pattern 1: AVAudioSession Configuration (Info Not Appearing)
+
+**Time cost**: 10-15 minutes
+
+### Symptom
+- Now Playing info never appears on Lock Screen
+- Info appears briefly then disappears on lock
+- Works in foreground, disappears in background
+
+### BAD Code
+
+```swift
+// ❌ WRONG — Category allows mixing, won't become Now Playing app
+class PlayerService {
+ func setupAudioSession() throws {
+ try AVAudioSession.sharedInstance().setCategory(
+ .playback,
+ options: .mixWithOthers // ❌ Mixable = not eligible for Now Playing
+ )
+ // Never called setActive() // ❌ Session not activated
+ }
+
+ func play() {
+ player.play()
+ updateNowPlaying() // ❌ Won't appear - session not active
+ }
+}
+```
+
+### GOOD Code
+
+```swift
+// ✅ CORRECT — Non-mixable category, activated before playback
+class PlayerService {
+ func setupAudioSession() throws {
+ try AVAudioSession.sharedInstance().setCategory(
+ .playback,
+ mode: .default,
+ options: [] // ✅ No .mixWithOthers = eligible for Now Playing
+ )
+ }
+
+ func play() async throws {
+ // ✅ Activate BEFORE starting playback
+ try AVAudioSession.sharedInstance().setActive(true)
+
+ player.play()
+ updateNowPlaying() // ✅ Now appears correctly
+ }
+
+ func stop() async throws {
+ player.pause()
+
+ // ✅ Deactivate AFTER stopping, with notify option
+ try AVAudioSession.sharedInstance().setActive(
+ false,
+ options: .notifyOthersOnDeactivation
+ )
+ }
+}
+```
+
+### Info.plist Requirement
+
+```xml
+UIBackgroundModes
+
+ audio
+
+```
+
+### Verification
+- Lock screen shows Now Playing controls
+- Info persists when app backgrounded
+- Survives app switch (unless another app plays)
+
+---
+
+## Pattern 2: Remote Command Registration (Commands Not Working)
+
+**Time cost**: 15-20 minutes
+
+### Symptom
+- Play/pause buttons grayed out
+- Buttons visible but tapping does nothing
+- Skip buttons don't appear
+- Commands work once then stop
+
+### BAD Code
+
+```swift
+// ❌ WRONG — Missing targets and isEnabled
+class PlayerService {
+ func setupCommands() {
+ let commandCenter = MPRemoteCommandCenter.shared()
+
+ // ❌ Added target but forgot isEnabled
+ commandCenter.playCommand.addTarget { _ in
+ self.player.play()
+ return .success
+ }
+ // playCommand.isEnabled defaults to false!
+
+ // ❌ Never added pause handler
+
+ // ❌ skipForward without preferredIntervals
+ commandCenter.skipForwardCommand.addTarget { _ in
+ return .success
+ }
+ }
+}
+```
+
+### GOOD Code
+
+```swift
+// ✅ CORRECT — Targets registered, enabled, with proper configuration
+@MainActor
+class PlayerService {
+ private var commandTargets: [Any] = [] // Keep strong references
+
+ func setupCommands() {
+ let commandCenter = MPRemoteCommandCenter.shared()
+
+ // ✅ Play command - add target AND enable
+ let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
+ self?.player.play()
+ self?.updateNowPlayingPlaybackState(isPlaying: true)
+ return .success
+ }
+ commandCenter.playCommand.isEnabled = true
+ commandTargets.append(playTarget)
+
+ // ✅ Pause command
+ let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
+ self?.player.pause()
+ self?.updateNowPlayingPlaybackState(isPlaying: false)
+ return .success
+ }
+ commandCenter.pauseCommand.isEnabled = true
+ commandTargets.append(pauseTarget)
+
+ // ✅ Skip forward - set preferredIntervals BEFORE adding target
+ commandCenter.skipForwardCommand.preferredIntervals = [15.0]
+ let skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] event in
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
+ return .commandFailed
+ }
+ self?.skip(by: skipEvent.interval)
+ return .success
+ }
+ commandCenter.skipForwardCommand.isEnabled = true
+ commandTargets.append(skipForwardTarget)
+
+ // ✅ Skip backward
+ commandCenter.skipBackwardCommand.preferredIntervals = [15.0]
+ let skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] event in
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent else {
+ return .commandFailed
+ }
+ self?.skip(by: -skipEvent.interval)
+ return .success
+ }
+ commandCenter.skipBackwardCommand.isEnabled = true
+ commandTargets.append(skipBackwardTarget)
+ }
+
+ func teardownCommands() {
+ let commandCenter = MPRemoteCommandCenter.shared()
+ commandCenter.playCommand.removeTarget(nil)
+ commandCenter.pauseCommand.removeTarget(nil)
+ commandCenter.skipForwardCommand.removeTarget(nil)
+ commandCenter.skipBackwardCommand.removeTarget(nil)
+ commandTargets.removeAll()
+ }
+
+ deinit {
+ teardownCommands()
+ }
+}
+```
+
+### Verification
+- Buttons not grayed out in Control Center
+- Tapping play/pause actually plays/pauses
+- Skip buttons show with correct interval (15s)
+
+---
+
+## Pattern 3: Artwork Configuration (Artwork Problems)
+
+**Time cost**: 15-25 minutes
+
+### Symptom
+- Artwork never appears (generic placeholder)
+- Wrong artwork for current track
+- Artwork flickers between images
+- Artwork appears then disappears
+
+### BAD Code
+
+```swift
+// ❌ WRONG — MPMediaItemArtwork block can return nil, no size handling
+func updateNowPlaying() {
+ var nowPlayingInfo = [String: Any]()
+ nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
+
+ // ❌ Storing UIImage directly (doesn't work)
+ nowPlayingInfo[MPMediaItemPropertyArtwork] = image
+
+ // ❌ Or: Block that ignores requested size
+ let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in
+ return self.cachedImage // ❌ May be nil, ignores requested size
+ }
+
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+}
+
+// ❌ WRONG — Multiple rapid updates cause flickering
+func loadArtwork(from url: URL) {
+ // Request 1
+ loadImage(url) { image in
+ self.updateNowPlayingArtwork(image) // Update 1
+ }
+ // Request 2 (cached) returns faster
+ loadCachedImage(url) { image in
+ self.updateNowPlayingArtwork(image) // Update 2 - flicker!
+ }
+}
+```
+
+### GOOD Code
+
+```swift
+// ✅ CORRECT — Proper MPMediaItemArtwork with value capture (Swift 6 compliant)
+@MainActor
+class NowPlayingService {
+ private var currentArtworkURL: URL?
+
+ func updateNowPlayingArtwork(_ image: UIImage, for trackURL: URL) {
+ // ✅ Prevent race conditions - only update if still current track
+ guard trackURL == currentArtworkURL else { return }
+
+ // ✅ Create MPMediaItemArtwork with VALUE CAPTURE (not stored property)
+ // This is Swift 6 strict concurrency compliant — UIImage is immutable
+ // and safe to capture across isolation domains
+ let artwork = MPMediaItemArtwork(boundsSize: image.size) { [image] requestedSize in
+ // ✅ System calls this block from any thread
+ // Captured value avoids "Main actor-isolated property" error
+ return image
+ }
+
+ // ✅ Update only artwork key, preserve other values
+ var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
+ nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+ }
+
+ // ✅ Single entry point with priority: embedded > cached > remote
+ func loadArtwork(for track: Track) async {
+ currentArtworkURL = track.artworkURL
+
+ // Priority 1: Embedded in file (immediate, no flicker)
+ if let embedded = await extractEmbeddedArtwork(track.fileURL) {
+ updateNowPlayingArtwork(embedded, for: track.artworkURL)
+ return
+ }
+
+ // Priority 2: Already cached (fast)
+ if let cached = await loadFromCache(track.artworkURL) {
+ updateNowPlayingArtwork(cached, for: track.artworkURL)
+ return
+ }
+
+ // Priority 3: Remote (slow, but don't flicker)
+ // ✅ Set placeholder first, then update once with real image
+ if let remote = await downloadImage(track.artworkURL) {
+ updateNowPlayingArtwork(remote, for: track.artworkURL)
+ }
+ }
+}
+```
+
+**Why value capture, not `nonisolated(unsafe)`**: The closure passed to `MPMediaItemArtwork` may be called by the system from any thread. Under Swift 6 strict concurrency, accessing `@MainActor`-isolated stored properties from this closure would cause a compile error. Capturing the image value directly is cleaner than using `nonisolated(unsafe)` because UIImage is immutable and thread-safe for reads.
+
+### Artwork Size Guidelines
+- Lock Screen: 300x300 points (600x600 @2x, 900x900 @3x)
+- Control Center: Various sizes
+- **Best practice**: Provide image at least 600x600 pixels
+
+### Verification
+- Artwork appears on Lock Screen
+- Correct artwork for current track
+- No flickering when track changes
+- Artwork persists after backgrounding
+
+---
+
+## Pattern 4: Playback State Synchronization (State Sync Issues)
+
+**Time cost**: 10-20 minutes
+
+### Symptom
+- Control Center shows "Playing" when actually paused
+- Progress bar doesn't move or jumps unexpectedly
+- Duration shows wrong value
+- Scrubbing doesn't work correctly
+
+### BAD Code
+
+```swift
+// ❌ WRONG — Using playbackState (macOS only, ignored on iOS)
+func updatePlaybackState(isPlaying: Bool) {
+ MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
+ // ❌ iOS ignores this property! Only macOS uses it.
+}
+
+// ❌ WRONG — Updating elapsed time on a timer (causes drift)
+Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
+ var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
+ info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.player.currentTime().seconds
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = info
+ // ❌ Every second creates jitter, system already infers from timestamp
+}
+
+// ❌ WRONG — Partial dictionary updates cause race conditions
+func updateTitle() {
+ var info = [String: Any]()
+ info[MPMediaItemPropertyTitle] = track.title
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = info
+ // ❌ Cleared all other values (artwork, duration, etc.)!
+}
+```
+
+### GOOD Code
+
+```swift
+// ✅ CORRECT — Use playbackRate for iOS, update at key moments only
+@MainActor
+class NowPlayingService {
+
+ // ✅ Update when playback STARTS
+ func playbackStarted(track: Track, player: AVPlayer) {
+ var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
+
+ // ✅ Core metadata
+ nowPlayingInfo[MPMediaItemPropertyTitle] = track.title
+ nowPlayingInfo[MPMediaItemPropertyArtist] = track.artist
+ nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = track.album
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
+
+ // ✅ Playback state via RATE (not playbackState property)
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 // Playing
+
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+ }
+
+ // ✅ Update when playback PAUSES
+ func playbackPaused(player: AVPlayer) {
+ var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
+
+ // ✅ Update elapsed time AND rate together
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 // Paused
+
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+ }
+
+ // ✅ Update when user SEEKS
+ func userSeeked(to time: CMTime, player: AVPlayer) {
+ var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
+
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time.seconds
+ // ✅ Keep current rate (don't change playing/paused state)
+
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+ }
+
+ // ✅ Update when track CHANGES
+ func trackChanged(to newTrack: Track, player: AVPlayer) {
+ // ✅ Full refresh of all metadata
+ var nowPlayingInfo = [String: Any]()
+
+ nowPlayingInfo[MPMediaItemPropertyTitle] = newTrack.title
+ nowPlayingInfo[MPMediaItemPropertyArtist] = newTrack.artist
+ nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = newTrack.album
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem?.duration.seconds ?? 0
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
+
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
+
+ // Then load artwork asynchronously
+ Task {
+ await loadArtwork(for: newTrack)
+ }
+ }
+}
+```
+
+### When to Update Now Playing Info
+
+| Event | What to Update |
+|-------|---------------|
+| Playback starts | All metadata + elapsed=current + rate=1.0 |
+| Playback pauses | elapsed=current + rate=0.0 |
+| User seeks | elapsed=newPosition (keep rate) |
+| Track changes | All metadata (new track) |
+| Playback rate changes (2x, 0.5x) | rate=newRate |
+
+### DO NOT Update
+- On a timer (system infers from elapsed + rate + timestamp)
+- Elapsed time continuously (causes jitter)
+- Partial dictionaries (loses other values)
+
+---
+
+## Pattern 5: MPNowPlayingSession (iOS 16+ Recommended Approach)
+
+**Time cost**: 20-30 minutes
+
+### When to Use MPNowPlayingSession
+- iOS 16+ (available since iOS 16, previously tvOS only)
+- Using AVPlayer for playback
+- Want automatic publishing of playback state
+- Multiple players (Picture-in-Picture scenarios)
+
+### BAD Code (Manual Approach - More Error-Prone)
+
+```swift
+// ❌ Manual updates are error-prone, easy to miss state changes
+class OldStylePlayer {
+ func play() {
+ player.play()
+ // Must remember to:
+ updateNowPlayingElapsed()
+ updateNowPlayingRate()
+ // Easy to forget one...
+ }
+}
+```
+
+### GOOD Code (MPNowPlayingSession)
+
+```swift
+// ✅ CORRECT — MPNowPlayingSession handles automatic publishing
+@MainActor
+class ModernPlayerService {
+ private var player: AVPlayer
+ private var session: MPNowPlayingSession?
+
+ init() {
+ player = AVPlayer()
+ setupSession()
+ }
+
+ func setupSession() {
+ // ✅ Create session with player
+ session = MPNowPlayingSession(players: [player])
+
+ // ✅ Enable automatic publishing of:
+ // - Duration
+ // - Elapsed time
+ // - Playback state (rate)
+ // - Playback progress
+ session?.automaticallyPublishNowPlayingInfo = true
+
+ // ✅ Register commands on SESSION's command center (not shared)
+ session?.remoteCommandCenter.playCommand.addTarget { [weak self] _ in
+ self?.player.play()
+ return .success
+ }
+ session?.remoteCommandCenter.playCommand.isEnabled = true
+
+ session?.remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in
+ self?.player.pause()
+ return .success
+ }
+ session?.remoteCommandCenter.pauseCommand.isEnabled = true
+
+ // ✅ Try to become active Now Playing session
+ session?.becomeActiveIfPossible { success in
+ print("Became active Now Playing: \(success)")
+ }
+ }
+
+ func play(track: Track) async {
+ let item = AVPlayerItem(url: track.url)
+
+ // ✅ Set static metadata on player item (title, artwork)
+ item.nowPlayingInfo = [
+ MPMediaItemPropertyTitle: track.title,
+ MPMediaItemPropertyArtist: track.artist,
+ MPMediaItemPropertyArtwork: await createArtwork(for: track)
+ ]
+
+ player.replaceCurrentItem(with: item)
+ player.play()
+ // ✅ No need to manually update elapsed time, rate, duration
+ // MPNowPlayingSession publishes automatically!
+ }
+}
+```
+
+### Multiple Sessions (Picture-in-Picture)
+
+```swift
+class MultiPlayerService {
+ var mainSession: MPNowPlayingSession
+ var pipSession: MPNowPlayingSession
+
+ func pipDidExpand() {
+ // ✅ Promote PiP session when it expands to full screen
+ pipSession.becomeActiveIfPossible { success in
+ // PiP now controls Lock Screen, Control Center
+ }
+ }
+
+ func pipDidMinimize() {
+ // ✅ Demote back to main session
+ mainSession.becomeActiveIfPossible { success in
+ // Main player now controls Lock Screen, Control Center
+ }
+ }
+}
+```
+
+### Critical Gotcha
+
+**When using MPNowPlayingSession**: Use `session.remoteCommandCenter`, NOT `MPRemoteCommandCenter.shared()`
+
+```swift
+// ❌ WRONG
+let commandCenter = MPRemoteCommandCenter.shared()
+commandCenter.playCommand.addTarget { _ in }
+
+// ✅ CORRECT
+session.remoteCommandCenter.playCommand.addTarget { _ in }
+```
+
+---
+
+## Pattern 6: CarPlay Integration
+
+For CarPlay-specific integration patterns, invoke `/skill axiom-now-playing-carplay`.
+
+**Key insight**: CarPlay uses the SAME MPNowPlayingInfoCenter and MPRemoteCommandCenter as iOS. If your Now Playing works on iOS, it works in CarPlay with zero additional code.
+
+---
+
+## Pattern 7: MusicKit Integration (Apple Music)
+
+For MusicKit-specific integration patterns and hybrid app examples, invoke `/skill axiom-now-playing-musickit`.
+
+**Key insight**: MusicKit's ApplicationMusicPlayer automatically publishes to MPNowPlayingInfoCenter. You don't need to manually update Now Playing info when playing Apple Music content.
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: Apple Music Keeps Taking Over (24-Hour Launch Deadline)
+
+#### Situation
+- App launching tomorrow
+- QA reports: "Now Playing works, but when user opens Apple Music then returns to our app, our controls disappear"
+- Product manager: "This is a blocker, users will think our app is broken"
+- You're 2 hours from code freeze
+
+#### Rationalization Traps (DO NOT)
+1. *"Just tell users not to use Apple Music"* - Unacceptable UX, will get 1-star reviews
+2. *"Force our app to always be Now Playing"* - Impossible, system controls eligibility
+3. *"File a bug with Apple"* - Won't help before launch
+
+#### Root Cause
+Your app loses eligibility because:
+- Using `.mixWithOthers` option (allows other apps to play simultaneously)
+- Not calling `becomeActiveIfPossible()` when returning to foreground
+- AVAudioSession deactivated when backgrounded
+
+#### Systematic Fix (30 minutes)
+
+```swift
+// 1. Remove mixWithOthers
+try AVAudioSession.sharedInstance().setCategory(.playback, options: [])
+
+// 2. Reactivate when returning to foreground
+NotificationCenter.default.addObserver(
+ forName: UIApplication.willEnterForegroundNotification,
+ object: nil,
+ queue: .main
+) { [weak self] _ in
+ guard self?.isPlaying == true else { return }
+
+ do {
+ try AVAudioSession.sharedInstance().setActive(true)
+ self?.session?.becomeActiveIfPossible { _ in }
+ } catch {
+ print("Failed to reactivate audio session: \(error)")
+ }
+}
+
+// 3. Handle interruptions (phone call, Siri)
+NotificationCenter.default.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: nil,
+ queue: .main
+) { [weak self] notification in
+ guard let info = notification.userInfo,
+ let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
+ return
+ }
+
+ if type == .ended {
+ // ✅ Reactivate after interruption
+ try? AVAudioSession.sharedInstance().setActive(true)
+ self?.session?.becomeActiveIfPossible { _ in }
+ }
+}
+```
+
+#### Communication Template
+
+```
+To PM: Found root cause - our audio session config allowed Apple Music to take over.
+Fix implemented: 3 changes to audio session handling.
+Testing: Verified fix with Apple Music, Spotify, phone calls.
+ETA: 20 more minutes for full regression test.
+
+To QA: Please test this flow:
+1. Play audio in our app
+2. Open Apple Music, play a song
+3. Return to our app, tap play
+4. Lock screen should show OUR controls
+```
+
+#### Time Saved
+- 2-3 hours of debugging speculation
+- Launch delay avoided
+- QA confidence restored
+
+---
+
+### Scenario 2: Artwork Flickers Every Track Change
+
+#### Situation
+- User feedback: "Album art keeps flashing when songs change"
+- Analytics show 3-4 artwork updates per track change
+- Designer: "This looks unprofessional"
+
+#### Root Cause
+Multiple artwork sources racing:
+1. Cache check (async)
+2. Remote URL fetch (async)
+3. Embedded artwork extraction (async)
+
+All three complete at different times, each updating Now Playing
+
+#### Fix (20 minutes)
+
+```swift
+// ✅ Single-source-of-truth with cancellation
+private var artworkTask: Task?
+
+func loadArtwork(for track: Track) {
+ // Cancel previous artwork load
+ artworkTask?.cancel()
+
+ artworkTask = Task { @MainActor in
+ // Clear previous artwork immediately (optional)
+ // updateNowPlayingArtwork(nil)
+
+ // Wait for best available artwork
+ let artwork = await loadBestArtwork(for: track)
+
+ // Check if still current track
+ guard !Task.isCancelled else { return }
+
+ // Single update
+ updateNowPlayingArtwork(artwork, for: track.artworkURL)
+ }
+}
+
+private func loadBestArtwork(for track: Track) async -> UIImage? {
+ // Priority order: embedded > cached > remote
+ if let embedded = await extractEmbeddedArtwork(track) {
+ return embedded
+ }
+ if let cached = await loadFromCache(track.artworkURL) {
+ return cached
+ }
+ return await downloadImage(track.artworkURL)
+}
+```
+
+#### Communication Template
+
+```
+To Designer: Fixed artwork flicker - reduced from 3-4 updates to 1 per track.
+Root cause: Multiple async sources racing to update artwork.
+Solution: Task cancellation + priority order (embedded > cached > remote).
+Testing: Verified with 10 track changes, zero flicker.
+```
+
+#### Time Saved
+- 1-2 hours investigating image caching
+- Designer approval unblocked
+- Professional UX restored
+
+---
+
+## Common Gotchas
+
+| Symptom | Cause | Solution | Time to Fix |
+|---------|-------|----------|-------------|
+| Info never appears | Missing background mode | Add `audio` to UIBackgroundModes in Info.plist | 2 min |
+| Info never appears | AVAudioSession not activated | Call `setActive(true)` before playback | 5 min |
+| Info never appears | No command handlers | Add target to at least one command | 10 min |
+| Info never appears | Using `.mixWithOthers` | Remove .mixWithOthers option | 5 min |
+| Commands grayed out | `isEnabled = false` | Set `command.isEnabled = true` after adding target | 5 min |
+| Commands don't respond | Handler returns wrong status | Return `.success` from handler | 5 min |
+| Commands don't respond | Using shared command center with MPNowPlayingSession | Use `session.remoteCommandCenter` instead | 10 min |
+| Skip buttons missing | No preferredIntervals | Set `skipCommand.preferredIntervals = [15.0]` | 5 min |
+| Artwork never appears | MPMediaItemArtwork block returns nil | Ensure image is loaded before creating artwork | 15 min |
+| Artwork flickers | Multiple rapid updates | Single source of truth with cancellation | 20 min |
+| Wrong play/pause state | Using `playbackState` property | Use `playbackRate` (1.0 = playing, 0.0 = paused) | 10 min |
+| Progress bar stuck | Not updating on seek | Update `elapsedPlaybackTime` after seek completes | 10 min |
+| Progress bar jumps | Updating elapsed on timer | Don't update on timer; system infers from rate | 10 min |
+| Loses Now Playing to other apps | Session not reactivated on foreground | Call `becomeActiveIfPossible()` on foreground | 15 min |
+| `playbackState` doesn't work | iOS-only app | `playbackState` is macOS only; use `playbackRate` on iOS | 10 min |
+| Siri skip ignores preferredIntervals | Hardcoded interval in handler | Use `event.interval` from MPSkipIntervalCommandEvent | 5 min |
+| **CarPlay**: App doesn't appear | Missing entitlement | Add `com.apple.developer.carplay-audio` to entitlements | 5 min |
+| **CarPlay**: Custom buttons don't appear | Configured at wrong time | Configure at `templateApplicationScene(_:didConnect:)` | 5 min |
+| **CarPlay**: Works on device, not simulator | Debugger attached | Run without debugger for reliable testing | 1 min |
+| **MusicKit**: Now Playing wrong | Overwriting automatic data | Don't set `nowPlayingInfo` when using ApplicationMusicPlayer | 5 min |
+
+---
+
+## Expert Checklist
+
+### Before Implementing Now Playing
+- [ ] Added `audio` to UIBackgroundModes in Info.plist
+- [ ] AVAudioSession category is `.playback` without `.mixWithOthers`
+- [ ] Decided: Manual (MPNowPlayingInfoCenter) or Automatic (MPNowPlayingSession)?
+
+### AVAudioSession Setup
+- [ ] `setCategory(.playback)` called at app launch
+- [ ] `setActive(true)` called before playback starts
+- [ ] `setActive(false, options: .notifyOthersOnDeactivation)` on stop
+- [ ] Interruption notification handled (reactivate after phone call)
+- [ ] Foreground notification handled (reactivate after background)
+
+### Remote Commands
+- [ ] At least one command has target registered
+- [ ] All registered commands have `isEnabled = true`
+- [ ] Skip commands have `preferredIntervals` set
+- [ ] Handlers return `.success` on success
+- [ ] Using correct command center (session's vs shared)
+- [ ] Command targets stored to prevent deallocation
+- [ ] Commands removed in deinit
+
+### Now Playing Info
+- [ ] Title set (`MPMediaItemPropertyTitle`)
+- [ ] Duration set (`MPMediaItemPropertyPlaybackDuration`)
+- [ ] Elapsed time set at play/pause/seek (`MPNowPlayingInfoPropertyElapsedPlaybackTime`)
+- [ ] Playback rate set (`MPNowPlayingInfoPropertyPlaybackRate`: 1.0 = playing, 0.0 = paused)
+- [ ] Artwork created with `MPMediaItemArtwork(boundsSize:requestHandler:)`
+- [ ] NOT using `playbackState` property (macOS only)
+- [ ] NOT updating elapsed time on a timer
+
+### Artwork
+- [ ] Image at least 600x600 pixels
+- [ ] MPMediaItemArtwork block never returns nil (return placeholder if needed)
+- [ ] Single source of truth prevents flickering
+- [ ] Previous artwork load cancelled on track change
+
+### Testing
+- [ ] Lock screen shows correct info
+- [ ] Control Center shows correct info
+- [ ] Play/pause buttons respond
+- [ ] Skip buttons show and respond
+- [ ] Progress bar moves correctly
+- [ ] Survives app background/foreground
+- [ ] Survives phone call interruption
+- [ ] Survives other app playing audio
+- [ ] Tested with Apple Music conflict
+- [ ] Tested with Spotify conflict
+
+### CarPlay (if applicable)
+- [ ] Added `com.apple.developer.carplay-audio` entitlement
+- [ ] CPNowPlayingTemplate configured at `templateApplicationScene(_:didConnect:)`
+- [ ] Custom buttons (if any) configured with CPNowPlayingButton
+- [ ] Tested on CarPlay simulator (I/O → External Displays → CarPlay)
+- [ ] Tested in real vehicle (if available)
+- [ ] Tested both with and without debugger attached
+
+---
+
+## Resources
+
+**WWDC**: 2022-110338, 2017-251, 2019-501
+
+**Docs**: /mediaplayer/mpnowplayinginfocenter, /mediaplayer/mpremotecommandcenter, /mediaplayer/mpnowplayingsession
+
+**Skills**: axiom-avfoundation-ref, axiom-now-playing-carplay, axiom-now-playing-musickit
+
+---
+
+**Last Updated**: 2026-01-04
+**Status**: iOS 18+ discipline skill covering Now Playing, CarPlay, and MusicKit integration
+**Tested**: Based on WWDC 2019-501, WWDC 2022-110338 patterns
diff --git a/.claude/skills/axiom-now-playing/agents/openai.yaml b/.claude/skills/axiom-now-playing/agents/openai.yaml
new file mode 100644
index 0000000..36ff635
--- /dev/null
+++ b/.claude/skills/axiom-now-playing/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Now Playing"
+ short_description: "Now Playing metadata doesn't appear on Lock Screen/Control Center, remote commands (play/pause/skip) don't respond, a..."
diff --git a/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json b/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json
new file mode 100644
index 0000000..0a8de6b
--- /dev/null
+++ b/.claude/skills/axiom-objc-block-retain-cycles/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-objc-block-retain-cycles",
+ "installedAt": "2026-04-12T08:06:31.185Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md b/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md
new file mode 100644
index 0000000..d379f37
--- /dev/null
+++ b/.claude/skills/axiom-objc-block-retain-cycles/SKILL.md
@@ -0,0 +1,601 @@
+---
+name: axiom-objc-block-retain-cycles
+description: Use when debugging memory leaks from blocks, blocks assigned to self or properties, network callbacks, or crashes from deallocated objects - systematic weak-strong pattern diagnosis with mandatory diagnostic rules
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Objective-C Block Retain Cycles
+
+## Overview
+
+Block retain cycles are the #1 cause of Objective-C memory leaks. When a block captures `self` and is stored on that same object (directly or indirectly through an operation/request), you create a circular reference: self → block → self. **Core principle** 90% of block memory leaks stem from missing or incorrectly applied weak-strong patterns, not genuine Apple framework bugs.
+
+## Red Flags — Suspect Block Retain Cycle
+
+If you see ANY of these, suspect a block retain cycle, not something else:
+- Memory grows steadily over time during normal app use
+- UIViewController instances not deallocating (verified in Instruments)
+- Crash: "Sending message to deallocated instance" from network/async callback
+- Network requests or animations prevent view controller from closing
+- Weak reference becomes nil unexpectedly in a block
+- NSLog, NSAssert, or string formatting hiding self references
+- Completion handler fires after the view controller "should be gone"
+- ❌ **FORBIDDEN** Rationalizing as "It's probably normal memory usage"
+ - Memory leaks are never "normal"
+ - Apps should return to baseline memory after user dismisses a screen
+ - Do not rationalize this as "good enough" or "monitor it later"
+
+**Critical distinction** Block retain cycles accumulate silently. A single cycle might be 100KB, but after 50 screens viewed, you have 5MB of dead memory. **MANDATORY: Test on real device (oldest supported model) after fixes, not just simulator.**
+
+## Mandatory First Steps
+
+**ALWAYS run these FIRST** (before changing code):
+
+```objc
+// 1. Identify the leak with Allocations instrument
+// In Xcode: Xcode > Open Developer Tool > Instruments
+// Choose Allocations template
+// Perform an action (open/close a screen with the suspected block)
+// Check if memory doesn't return to baseline
+// Record: "Memory baseline: X MB, after action: Y MB, still allocated: Z objects"
+
+// 2. Use Memory Debugger to trace the cycle
+// Run app, pause at suspected code location
+// Debug > Debug Memory Graph
+// Search for the view controller that should be deallocated
+// Right-click > Show memory graph
+// Look for arrows pointing back to self (the cycle)
+// Record: "ViewController retained by: [operation/block/property]"
+
+// 3. Check if block is assigned to self or self's properties
+// Search for: setBlock:, completion:, handler:, callback:
+// Check: Is the block stored in self.property?
+// Check: Is the block passed to something that retains it (network operation)?
+// Record: "Block assigned to: [property or operation]"
+
+// 4. Search for self references in the block
+// Look for: [self method], self.property, self-> access
+// Look for HIDDEN self references:
+// - NSLog(@"Value: %@", self.property)
+// - NSAssert(self.isValid, @"message")
+// - Format strings: @"Name: %@", self.name
+// Record: "self references found in block: [list]"
+
+// Example output:
+// Memory not returning to baseline ✓
+// ViewController retained by: AFHTTPRequestOperation
+// Operation retains: successBlock
+// Block references self: [self updateUI], NSLog with self.property
+// → DIAGNOSIS: Block retain cycle confirmed
+```
+
+#### What this tells you
+- **Memory stays high** → Leak confirmed, not false alarm
+- **ViewController retained by operation** → Block is the culprit
+- **Block references self** → Pattern: weak-strong needed
+- **Hidden self in NSLog/NSAssert** → Need to check ALL macro calls
+- **No self references found** → Maybe not a block cycle, investigate elsewhere
+
+#### MANDATORY INTERPRETATION
+
+Before changing ANY code, you must confirm ONE of these:
+
+1. If memory doesn't return to baseline AND ViewController still allocated → Block retain cycle exists
+2. If memory returns to baseline → Not a retain cycle, investigate other causes
+3. If cycle exists but you can't find self references → Check for hidden references (macros, indirect property access)
+4. If you find the cycle but don't understand the chain → Trace backward through retained objects in Memory Graph
+
+#### If diagnostics are contradictory or unclear
+- STOP. Do NOT proceed to patterns yet
+- Add more diagnostics: Print the object graph, list retained objects
+- Ask: "If memory is low, why is the ViewController still allocated?"
+- Run Instruments > Leaks instrument if memory graph is confusing
+
+## Decision Tree
+
+```
+Block memory leak suspected?
+├─ Memory stays high after dismiss?
+│ ├─ YES
+│ │ ├─ ViewController still allocated in Memory Graph?
+│ │ │ ├─ YES → Proceed to patterns
+│ │ │ └─ NO → Not a block cycle, check other leaks
+│ │ └─ NO → Not a leak, normal memory usage
+│ │
+│ └─ Crash: "Sending message to deallocated instance"?
+│ ├─ Happens in block/callback?
+│ │ ├─ YES → Block captured weakSelf but it became nil
+│ │ │ └─ Apply Pattern 4 (Guard condition is wrong or missing)
+│ │ └─ NO → Different crash, not block-related
+│ └─ Crash is timing-dependent (only on device)?
+│ └─ YES → Weak reference timing issue, apply Pattern 2
+│
+├─ Block assigned to self or self.property?
+│ ├─ YES → Apply Pattern 1 (weak-strong mandatory)
+│ ├─ Assigned through network operation/timer/animation?
+│ │ └─ YES → Apply Pattern 1 (operation retains block indirectly)
+│ └─ Block called immediately (inline execution)?
+│ ├─ YES → Optional to use weak-strong (no cycle possible)
+│ │ └─ But recommend for consistency with other blocks
+│ └─ NO → Block stored or passed to async method → Use Pattern 1
+│
+├─ Multiple nested blocks?
+│ └─ YES → Apply Pattern 3 (must guard ALL nested blocks)
+│
+├─ Block contains NSAssert, NSLog, or string format with self?
+│ └─ YES → Apply Pattern 2 (macro hides self reference)
+│
+└─ Implemented weak-strong pattern but still leaking?
+ ├─ Check: Is weakSelf used EVERYWHERE?
+ ├─ Check: No direct `self` references mixed in?
+ ├─ Check: Nested blocks also guarded?
+ └─ Check: No __unsafe_unretained used?
+```
+
+## Common Patterns
+
+### Pattern Selection Rules (MANDATORY)
+
+#### Apply ONE pattern at a time, in this order
+
+1. **Always start with Pattern 1** (Weak-Strong Basics)
+ - If block assigned to self or self's properties → Pattern 1
+ - If block passed to operation/request that retains it → Pattern 1
+ - Only proceed to Pattern 2 if pattern still leaks
+
+2. **Then Pattern 2** (Hidden self in Macros)
+ - Only if memory still leaks after applying Pattern 1
+ - Check for NSAssert, NSLog, string formatting
+ - If found, apply Pattern 2
+
+3. **Then Pattern 3** (Nested Blocks)
+ - Only if block has nested callbacks
+ - Each nested block needs its own guard
+ - If found, apply Pattern 3
+
+4. **Then Pattern 4** (Guard Condition Edge Cases)
+ - Only if crash happens with weakSelf approach
+ - Check guard condition is correct
+ - Verify strongSelf used everywhere
+
+#### FORBIDDEN
+- ❌ Applying multiple patterns at once
+- ❌ Skipping Pattern 1 because "I already know weak-strong"
+- ❌ Using __unsafe_unretained as workaround
+- ❌ Using strong self "just this once"
+- ❌ Rationalizing: "The block is too small for a leak"
+
+---
+
+### Pattern 1: Weak-Strong Pattern (MANDATORY)
+
+**PRINCIPLE** Any block that captures `self` must use weak-strong pattern if block is retained by self (directly or transitively).
+
+#### ❌ WRONG (Creates retain cycle)
+```objc
+[self.networkManager GET:@"url" success:^(id response) {
+ self.data = response; // self is retained by block
+ [self updateUI]; // block is retained by operation
+} failure:^(NSError *error) {
+ [self handleError:error]; // CYCLE!
+}];
+```
+
+#### ✅ CORRECT (Breaks the cycle)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.networkManager GET:@"url" success:^(id response) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ strongSelf.data = response;
+ [strongSelf updateUI];
+ }
+} failure:^(NSError *error) {
+ __weak typeof(self) weakSelf2 = self;
+ typeof(self) strongSelf = weakSelf2;
+ if (strongSelf) {
+ [strongSelf handleError:error];
+ }
+}];
+```
+
+#### Why this works
+1. `__weak typeof(self) weakSelf = self;` creates a weak reference outside the block
+2. Block captures weakSelf (weak reference), not self (strong reference)
+3. When block executes, convert to strongSelf (temporary strong ref)
+4. Check if strongSelf is nil (object was deallocated)
+5. Use strongSelf for the duration of the block
+6. strongSelf released when block exits → No cycle
+
+#### Important details
+- Declare weakSelf OUTSIDE the block, not inside
+- Use `typeof(self)` for type safety (works in both ARC and non-ARC)
+- Guard condition MUST use `if (strongSelf)`, not just declare it
+- Never use direct `self` inside the block once weakSelf is declared
+- Apply to EVERY block that captures self
+- ANY block that captures `self` must use weak-strong pattern
+ - This includes: `[self method]`, `self.property`, `self->ivar`
+ - Property access (`self.property = value`) captures self just like method calls
+- Blocks passed to frameworks:
+ - If framework documentation says 'block is called asynchronously' → Use weak-strong pattern (framework retains the block)
+ - If framework documentation says 'block is called immediately' → Still safe to use weak-strong (better practice)
+ - If unsure about framework behavior → Always use weak-strong (doesn't hurt)
+
+#### Capturing variables (avoiding indirect self references)
+```objc
+// ✅ SAFE: Capture simple values extracted from self
+__weak typeof(self) weakSelf = self;
+[self.manager fetch:^(id response) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ NSString *name = strongSelf.name; // Extract value
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSLog(@"Name: %@", name); // Captured the STRING, not self
+ });
+ }
+}];
+
+// ❌ WRONG: Capture properties directly in nested blocks
+__weak typeof(self) weakSelf = self;
+[self.manager fetch:^(id response) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSLog(@"Name: %@", strongSelf.name); // Captures strongSelf again!
+ });
+ }
+}];
+```
+
+When nesting blocks, extract simple values first, then pass them to the inner block. This avoids creating an indirect capture of self through property access.
+
+**Time cost** 30 seconds per block
+
+---
+
+### Pattern 2: Hidden self in Macros
+
+**PRINCIPLE** Macros like NSAssert, NSLog, and string formatting can secretly capture self. You must check them.
+
+#### ❌ WRONG (NSAssert captures self)
+```objc
+[self.button setTapAction:^{
+ NSAssert(self.isValidState, @"State must be valid"); // self captured!
+ [self doWork]; // Another self reference
+}];
+// Leak exists even though you think only [self doWork] captures self
+```
+
+#### ✅ CORRECT (Check for hidden captures)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.button setTapAction:^{
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ // NSAssert still references self indirectly through strongSelf
+ NSAssert(strongSelf.isValidState, @"State must be valid");
+ [strongSelf doWork];
+ }
+}];
+```
+
+#### Common hidden self references
+- `NSAssert(self.condition, ...)` → Use strongSelf instead
+- `NSLog(@"Value: %@", self.property)` → Use strongSelf.property
+- `NSError *error = [NSError errorWithDomain:@"MyApp" ...]` → Safe, doesn't capture self
+- String formatting: `@"Name: %@", self.name` → Use strongSelf.name
+- Inline conditionals: `self.flag ? @"yes" : @"no"` → Use strongSelf.flag
+
+#### How to find them
+1. Search block for all instances of `self.`
+2. Mark them: `[self method]`, `self.property`, `self->ivar`
+3. Check if any are inside macro calls (NSAssert, NSLog, etc.)
+4. Replace with strongSelf
+
+**Time cost** 1 minute per block to audit
+
+---
+
+### Pattern 3: Nested Blocks (Each Needs Guard)
+
+**PRINCIPLE** Nested blocks create a chain: outer block captures self, inner block captures outer block variable (which holds strongSelf), creating a new cycle. Each nested block needs its own weak-strong pattern.
+
+#### ❌ WRONG (Guarded outer block only)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.manager fetchData:^(NSArray *result) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ // Inner block captures strongSelf!
+ [strongSelf.analytics trackEvent:@"Fetched"
+ completion:^{
+ strongSelf.cachedData = result; // Still strong reference!
+ [strongSelf updateUI];
+ }];
+ }
+}];
+```
+
+#### ✅ CORRECT (Guard every nested block)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.manager fetchData:^(NSArray *result) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ // Declare new weak reference for inner block
+ __weak typeof(strongSelf) weakSelf2 = strongSelf;
+
+ [strongSelf.analytics trackEvent:@"Fetched"
+ completion:^{
+ typeof(strongSelf) strongSelf2 = weakSelf2;
+ if (strongSelf2) {
+ strongSelf2.cachedData = result;
+ [strongSelf2 updateUI];
+ }
+ }];
+ }
+}];
+```
+
+#### Why this works
+- Each nesting level needs its own weakSelf/strongSelf pair
+- Outer block: weakSelf → strongSelf
+- Inner block: weakSelf2 → strongSelf2
+- Each level is independent and safe
+
+#### Important details
+- Don't reuse the same weakSelf variable in nested blocks
+- Each nesting level gets a new pair (weakSelf2, strongSelf2)
+- Guard condition MANDATORY for each level
+- Use consistent naming: weakSelf, weakSelf2, weakSelf3 (for readability)
+
+#### Common nested block patterns that need Pattern 3
+- Completion handlers in callbacks
+- `dispatch_async(queue, ^{ ... })`
+- `dispatch_after(time, queue, ^{ ... })`
+- `[NSTimer scheduledTimerWithTimeInterval:... block:^{ ... }]`
+- `[UIView animateWithDuration:... animations:^{ ... }]`
+
+Each of these is a block that might capture strongSelf, requiring its own weak-strong pattern.
+
+#### Example with dispatch_async
+```objc
+__weak typeof(self) weakSelf = self;
+[self.manager fetchData:^(NSArray *result) {
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ __weak typeof(strongSelf) weakSelf2 = strongSelf;
+
+ dispatch_async(dispatch_get_main_queue(), ^{
+ typeof(strongSelf) strongSelf2 = weakSelf2;
+ if (strongSelf2) {
+ strongSelf2.data = result;
+ [strongSelf2 updateUI];
+ }
+ });
+ }
+}];
+```
+
+**Time cost** 1 minute per nesting level
+
+---
+
+### Pattern 4: Guard Condition Edge Cases
+
+**PRINCIPLE** The guard condition `if (strongSelf)` must be correct. Common mistakes: forgetting the guard, wrong condition, or mixing self and strongSelf.
+
+#### ❌ WRONG (Multiple guard failures)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.button setTapAction:^{
+ typeof(self) strongSelf = weakSelf;
+ // MISTAKE 1: Forgot guard condition
+ self.counter++; // CRASH! self is deallocated, accessing freed object
+
+ // MISTAKE 2: Guard exists but used wrong variable
+ if (weakSelf) {
+ [weakSelf doWork]; // weakSelf is weak, might become nil again
+ }
+
+ // MISTAKE 3: Mixed self and strongSelf
+ if (strongSelf) {
+ self.flag = YES; // Used self instead of strongSelf!
+ [strongSelf doWork];
+ }
+}];
+```
+
+#### ✅ CORRECT (Proper guard and consistent usage)
+```objc
+__weak typeof(self) weakSelf = self;
+[self.button setTapAction:^{
+ typeof(self) strongSelf = weakSelf;
+ if (strongSelf) {
+ // CORRECT: Use strongSelf everywhere, never self
+ strongSelf.counter++;
+ strongSelf.flag = YES;
+ [strongSelf doWork];
+ }
+ // If strongSelf is nil, entire block skips gracefully
+}];
+```
+
+#### Why this works
+1. `if (strongSelf)` checks if object still exists
+2. If it does, strongSelf is a strong reference (safe)
+3. If it doesn't (object deallocated), block skips
+4. Using strongSelf everywhere prevents accidental self references
+
+#### Critical rules (MANDATORY, no exceptions)
+- ✅ ALWAYS check `if (strongSelf)` before using it
+- ✅ ALWAYS use strongSelf inside the if block, NEVER direct self
+- ✅ strongSelf is guaranteed valid for the entire block scope
+- ❌ NEVER use `if (!strongSelf) return;` (confuses logic)
+- ❌ NEVER skip the guard to "save code"
+- ❌ NEVER mix weakSelf and strongSelf access
+- ❌ NEVER use strongSelf without guard (GUARANTEED crash)
+
+#### What happens if you get it wrong
+- No guard: Crashes with "Sending message to deallocated instance"
+- Wrong condition: Object still deallocated, still crashes
+- Mixed self/strongSelf: One accidental self defeats entire pattern
+- Using strongSelf without guard: GUARANTEED crash when object is deallocated
+
+#### Inside the guard
+```objc
+if (strongSelf) {
+ strongSelf.data1 = value1;
+ [strongSelf doWork1];
+ [strongSelf doWork2]; // All safe
+}
+// ❌ WRONG: Using strongSelf after guard ends
+strongSelf.data = value2; // CRASH! Outside guard
+```
+
+#### What NOT to do
+```objc
+// ❌ FORBIDDEN: strongSelf without guard guarantees crash
+typeof(self) strongSelf = weakSelf;
+strongSelf.data = value; // CRASH if weakSelf is nil!
+
+// ✅ MANDATORY: Always guard before using strongSelf
+if (strongSelf) {
+ strongSelf.data = value; // Safe
+}
+```
+
+**Time cost** 10 seconds per block to verify guard is correct
+
+---
+
+## Quick Reference Table
+
+| Issue | Check | Fix |
+|-------|-------|-----|
+| Memory not returning to baseline | Does ViewController still exist in Memory Graph? | Apply Pattern 1 (weak-strong) |
+| Crash: "message to deallocated instance" | Is guard condition missing or wrong? | Apply Pattern 4 (correct guard) |
+| Applied weak-strong but still leaking | Are ALL self references using strongSelf? | Check for mixed self/strongSelf |
+| Block contains NSAssert or NSLog | Do they reference self? | Apply Pattern 2 (use strongSelf in macros) |
+| Nested blocks | Is weak-strong applied to EACH level? | Apply Pattern 3 (guard every block) |
+| Not sure if block creates cycle | Is block assigned to self or self.property? | If yes, apply Pattern 1 |
+
+---
+
+## When You're Stuck After 30 Minutes
+
+If you've spent >30 minutes and the leak still exists:
+
+#### STOP. You either
+1. Skipped a mandatory diagnostic step (most common)
+2. Didn't apply weak-strong to ALL blocks (nested blocks missed)
+3. Have hidden self reference (NSAssert, NSLog, string format)
+4. Applied pattern but mixed in direct `self` references
+5. Have a different kind of leak (not block-related)
+
+#### MANDATORY checklist before claiming "skill didn't work"
+
+- [ ] I ran all 4 diagnostic blocks (Allocations, Memory Graph, block search, self reference search)
+- [ ] I confirmed memory doesn't return to baseline in Instruments
+- [ ] I confirmed ViewController is still allocated (not deallocated)
+- [ ] I traced the retention chain (what's holding the ViewController?)
+- [ ] I found ALL blocks that capture self (global search: `[self` in the file)
+- [ ] I checked for hidden self references (NSAssert, NSLog, string formatting)
+- [ ] I applied weak-strong pattern to outer blocks
+- [ ] I applied weak-strong pattern to nested blocks (every nesting level)
+- [ ] I verified NO direct `self` references remain (only strongSelf)
+- [ ] I ran Instruments again and memory returned to baseline
+- [ ] I tested on real device, not just simulator
+- [ ] I cleared Xcode derived data between runs
+
+#### If ALL boxes are checked and still leaking
+- You have a non-block leak (Core Data, timer, delegate, notification)
+- Use Instruments > Leaks instrument to identify the actual cycle
+- Profile for 2-3 minutes: open screen, close screen, repeat 5 times
+- Look at "Leaks" panel—it shows exactly what's not being released
+- Time cost: 15-30 minutes to identify the real culprit
+
+#### If you identify it's NOT a block leak
+- Do not rationalize: "Maybe blocks are fine, I'll ship anyway"
+- Find the actual cycle (could be delegate, timer, property observer, notification)
+- Fix the real issue, not a false positive
+
+#### Time cost transparency
+- Pattern 1: 30 seconds per block
+- Pattern 2: 1 minute per block (audit for hidden self)
+- Pattern 3: 1 minute per nesting level
+- Nested diagnostics if stuck: 15-30 minutes
+- Total for straightforward leak: 5-10 minutes
+
+---
+
+## Common Mistakes
+
+❌ **Forgetting the guard condition**
+- `strongSelf.property = value;` without `if (strongSelf)`
+- Crash when object is deallocated
+- Fix: ALWAYS use `if (strongSelf) { ... }`
+
+❌ **Mixing self and strongSelf in same block**
+- `self.flag = YES; [strongSelf doWork];`
+- One direct `self` reference defeats the entire pattern
+- Fix: ONLY use strongSelf inside the block
+
+❌ **Applying pattern to outer block only**
+- Nested block still captures strongSelf strongly
+- Still leaks
+- Fix: Apply weak-strong to EVERY block
+
+❌ **Using __unsafe_unretained as "workaround"**
+- ❌ FORBIDDEN pattern—unsafe and crashes
+- Creates crashes when object is deallocated
+- Not a solution, worse problem
+- Fix: Use weak-strong pattern instead
+
+❌ **Not checking for hidden self references**
+- `NSLog(@"Value: %@", self.property)` in a block
+- Leak still exists even after applying weak-strong
+- Fix: Audit for NSAssert, NSLog, string formatting
+
+❌ **Rationalizing "it's a small leak"**
+- Single block leak might be 100KB
+- After 50 screens, accumulates to 5MB
+- Eventually app crashes from memory pressure
+- Fix: Fix every block leak, don't rationalize
+
+❌ **Assuming blocks in system frameworks are safe**
+- UIView animations, AFNetworking, dispatch, timers
+- ALL can retain blocks that reference self
+- Fix: Apply weak-strong pattern regardless of source
+
+❌ **Testing only in simulator**
+- Simulator memory pressure is different
+- Leak might not appear until real device under load
+- Fix: Test on real device, oldest supported model
+
+## Real-World Impact
+
+**Before** Block memory leak debugging 2-3 hours per issue
+- Run Allocations, not sure what to look at
+- Search everywhere, no clear diagnostic path
+- Try random fixes, hope one works
+- Ship anyway after sunk cost fallacy
+- Customer reports crashes or slowdown
+
+**After** 5-10 minutes with systematic diagnosis
+- Run Allocations, confirm memory not returning to baseline
+- Memory Graph shows exactly what's retained
+- Find all blocks capturing self with global search
+- Apply weak-strong pattern (30 seconds per block)
+- Test in Instruments, memory returns to baseline
+- Done
+
+**Key insight** Block retain cycles are 100% preventable with weak-strong pattern. There are no exceptions, no "special cases" where strong self is acceptable.
+
+---
+
+**Last Updated**: 2025-11-30
+**Status**: TDD-tested with pressure scenarios
+**Framework**: Objective-C, blocks (closure), ARC
diff --git a/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml b/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml
new file mode 100644
index 0000000..2ddf256
--- /dev/null
+++ b/.claude/skills/axiom-objc-block-retain-cycles/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Obj-C Block Retain Cycles"
+ short_description: "Debugging memory leaks from blocks, blocks assigned to self or properties, network callbacks, or crashes from dealloc..."
diff --git a/.claude/skills/axiom-optimize-build/.openskills.json b/.claude/skills/axiom-optimize-build/.openskills.json
new file mode 100644
index 0000000..7971d37
--- /dev/null
+++ b/.claude/skills/axiom-optimize-build/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-optimize-build",
+ "installedAt": "2026-04-12T08:06:31.186Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-optimize-build/SKILL.md b/.claude/skills/axiom-optimize-build/SKILL.md
new file mode 100644
index 0000000..652d6a0
--- /dev/null
+++ b/.claude/skills/axiom-optimize-build/SKILL.md
@@ -0,0 +1,209 @@
+---
+name: axiom-optimize-build
+description: Use when the user mentions slow builds, build performance, or build time optimization.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# Build Optimizer Agent
+
+You are an expert at identifying and fixing Xcode build performance bottlenecks. Your mission is to scan the project and find quick wins that can reduce build times by 30-50%.
+
+## Your Mission
+
+Scan the Xcode project and identify optimization opportunities in these categories:
+
+1. **Build Settings** (HIGH IMPACT)
+2. **Build Phase Scripts** (MEDIUM-HIGH IMPACT)
+3. **Type Checking Performance** (MEDIUM IMPACT)
+4. **Compiler Flags** (LOW-MEDIUM IMPACT)
+
+For each finding, provide:
+- Category and severity (HIGH/MEDIUM/LOW)
+- Current configuration
+- Recommended fix
+- Expected time savings
+- Implementation steps
+
+## What You Check
+
+### 1. Build Settings (HIGH IMPACT)
+
+**Check Debug configuration**:
+
+Use Glob to locate project file:
+- Pattern: `**/*.xcodeproj/project.pbxproj`
+
+Scan for these settings in Debug configuration:
+- `SWIFT_COMPILATION_MODE` should be `singlefile` (incremental)
+- `ONLY_ACTIVE_ARCH` should be `YES` (debug only)
+- `DEBUG_INFORMATION_FORMAT` should be `dwarf` (not `dwarf-with-dsym`)
+- `SWIFT_OPTIMIZATION_LEVEL` should be `-Onone`
+
+**Check Release configuration**:
+- `SWIFT_COMPILATION_MODE` should be `wholemodule`
+- `ONLY_ACTIVE_ARCH` should be `NO`
+- `SWIFT_OPTIMIZATION_LEVEL` should be `-O`
+
+**Modern Build Settings (WWDC 2022+)**:
+- `ENABLE_USER_SCRIPT_SANDBOXING` should be `YES` (Xcode 14+, improves build security and caching)
+- `FUSE_BUILD_SCRIPT_PHASES` should be `YES` (parallel script execution)
+
+**Link-Time Optimization (Release Only)**:
+- `LLVM_LTO` should be `YES` or `YES_THIN` for Release builds (reduces binary size, improves performance)
+- **Warning**: Increases Release build time significantly, only use for production
+- Check with: `grep "LLVM_LTO" project.pbxproj`
+
+### 2. Build Phase Scripts (MEDIUM-HIGH IMPACT)
+
+```bash
+# Find build phase scripts
+grep -A 10 "shellScript" project.pbxproj
+```
+
+**Red flags**:
+- Scripts running in ALL configurations (should skip debug when possible)
+- Expensive operations without conditional checks:
+ - dSYM uploads
+ - Crashlytics uploads
+ - Code signing scripts
+ - Asset processing
+
+**Example fix**:
+```bash
+# ❌ BAD - Runs in debug AND release
+firebase-crashlytics-upload-symbols
+
+# ✅ GOOD - Skip in debug builds
+if [ "${CONFIGURATION}" = "Release" ]; then
+ firebase-crashlytics-upload-symbols
+fi
+```
+
+### 3. Type Checking Performance (MEDIUM IMPACT)
+
+**Enable type checking warnings**:
+
+Check if these compiler flags are present:
+```bash
+grep "OTHER_SWIFT_FLAGS" project.pbxproj
+```
+
+Recommend adding:
+- `-warn-long-function-bodies 100` (warns if function takes >100ms to type-check)
+- `-warn-long-expression-type-checking 100` (warns if expression takes >100ms)
+
+**How to find slow files**:
+```bash
+# Run build with timing
+xcodebuild -workspace YourApp.xcworkspace \
+ -scheme YourScheme \
+ clean build \
+ OTHER_SWIFT_FLAGS="-Xfrontend -debug-time-function-bodies" | \
+ grep ".[0-9]ms" | \
+ sort -nr | \
+ head -20
+```
+
+### 4. Swift Package Build Plugins (LOW-MEDIUM IMPACT)
+
+```bash
+# Check for prebuilt plugins
+grep -r "prebuiltPlugins" Package.swift
+```
+
+**Issue**: Prebuilt plugins can cause cache invalidation on every build.
+
+**Fix**: Switch to regular build plugins when possible.
+
+### 5. Parallelization Check (INFORMATIONAL)
+
+```bash
+# Check available cores
+sysctl -n hw.ncpu
+```
+
+Recommend setting "Build Active Architecture Only" to YES for debug to maximize parallelization.
+
+### 6. Build Timeline Analysis (Xcode 14+)
+
+**How to access Build Timeline**:
+1. Build your project in Xcode
+2. Open Report Navigator (Cmd+9)
+3. Select most recent build
+4. Click "Editor → Assistant" or View → Navigators → Reports
+5. Look for timeline view showing task duration
+
+**What to look for**:
+- Tasks taking >10 seconds (optimization candidates)
+- Sequential tasks that could be parallelized
+- Script phases blocking compilation
+- Redundant asset processing
+
+**Actionable fixes from Build Timeline**:
+- Move slow scripts to background (`.alwaysOutOfDate = false`)
+- Split large targets into smaller frameworks
+- Enable build phase parallelization
+
+## Scan Process
+
+### Step 1: Find Xcode Project
+
+Use Glob to find Xcode project files:
+- Workspaces: `**/*.xcworkspace`
+- Projects: `**/*.xcodeproj`
+
+### Step 2: Locate project.pbxproj
+
+Use Glob to find project configuration:
+- Pattern: `**/*.xcodeproj/project.pbxproj`
+
+### Step 3: Scan Build Settings
+
+Use grep to check for key build settings:
+
+```bash
+# Check compilation mode
+grep "SWIFT_COMPILATION_MODE" project.pbxproj
+
+# Check architecture settings
+grep "ONLY_ACTIVE_ARCH" project.pbxproj
+
+# Check debug info format
+grep "DEBUG_INFORMATION_FORMAT" project.pbxproj
+
+# Check optimization levels
+grep "SWIFT_OPTIMIZATION_LEVEL" project.pbxproj
+```
+
+### Step 4: Find Build Phase Scripts
+
+```bash
+# Extract all shell scripts from build phases
+grep -A 20 "shellScript" project.pbxproj
+```
+
+### Step 5: Check for Compiler Flags
+
+```bash
+# Look for existing Swift flags
+grep "OTHER_SWIFT_FLAGS" project.pbxproj
+```
+
+## Output Format
+
+Generate a "Build Performance Optimization Report" with:
+1. **Summary**: Potential time savings, counts by severity (HIGH/MEDIUM/LOW)
+2. **Issues by severity**: HIGH first, then MEDIUM, then LOW
+3. **Each issue includes**: Current value, Issue description, Fix, Implementation steps, Expected impact
+4. **Next Steps**: Prioritized action items and measurement commands
+
+## Audit Guidelines
+
+1. **Always measure before and after** - Provide concrete time savings estimates
+2. **Prioritize by impact** - HIGH → MEDIUM → LOW
+3. **Be specific** - Exact settings names, exact values, exact steps
+4. **Check configurations separately** - Debug vs Release have different optimal settings
+5. **Provide commands** - Give exact bash commands for verification
diff --git a/.claude/skills/axiom-optimize-build/agents/openai.yaml b/.claude/skills/axiom-optimize-build/agents/openai.yaml
new file mode 100644
index 0000000..ecdccbd
--- /dev/null
+++ b/.claude/skills/axiom-optimize-build/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Optimize Build"
+ short_description: "The user mentions slow builds, build performance, or build time optimization."
diff --git a/.claude/skills/axiom-ownership-conventions/.openskills.json b/.claude/skills/axiom-ownership-conventions/.openskills.json
new file mode 100644
index 0000000..e770f5c
--- /dev/null
+++ b/.claude/skills/axiom-ownership-conventions/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-ownership-conventions",
+ "installedAt": "2026-04-12T08:06:31.376Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-ownership-conventions/SKILL.md b/.claude/skills/axiom-ownership-conventions/SKILL.md
new file mode 100644
index 0000000..7d287d0
--- /dev/null
+++ b/.claude/skills/axiom-ownership-conventions/SKILL.md
@@ -0,0 +1,455 @@
+---
+name: axiom-ownership-conventions
+description: Use when optimizing large value type performance, working with noncopyable types, reducing ARC traffic, or using InlineArray/Span for zero-copy memory access. Covers borrowing, consuming, inout modifiers, consume operator, ~Copyable types, InlineArray, Span, value generics.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# borrowing & consuming — Parameter Ownership
+
+Explicit ownership modifiers for performance optimization and noncopyable type support.
+
+## When to Use
+
+✅ **Use when:**
+- Large value types being passed read-only (avoid copies)
+- Working with noncopyable types (`~Copyable`)
+- Reducing ARC retain/release traffic
+- Factory methods that consume builder objects
+- Performance-critical code where copies show in profiling
+
+❌ **Don't use when:**
+- Simple types (Int, Bool, small structs)
+- Compiler optimization is sufficient (most cases)
+- Readability matters more than micro-optimization
+- You're not certain about the performance impact
+
+## Quick Reference
+
+| Modifier | Ownership | Copies | Use Case |
+|----------|-----------|--------|----------|
+| (default) | Compiler chooses | Implicit | Most cases |
+| `borrowing` | Caller keeps | Explicit `copy` only | Read-only, large types |
+| `consuming` | Caller transfers | None needed | Final use, factories |
+| `inout` | Caller keeps, mutable | None | Modify in place |
+
+## Default Behavior by Context
+
+| Context | Default | Reason |
+|---------|---------|--------|
+| Function parameters | `borrowing` | Most params are read-only |
+| Initializer parameters | `consuming` | Usually stored in properties |
+| Property setters | `consuming` | Value is stored |
+| Method `self` | `borrowing` | Methods read self |
+
+## Patterns
+
+### Pattern 1: Read-Only Large Struct
+
+```swift
+struct LargeBuffer {
+ var data: [UInt8] // Could be megabytes
+}
+
+// ❌ Default may copy
+func process(_ buffer: LargeBuffer) -> Int {
+ buffer.data.count
+}
+
+// ✅ Explicit borrow — no copy
+func process(_ buffer: borrowing LargeBuffer) -> Int {
+ buffer.data.count
+}
+```
+
+### Pattern 2: Consuming Factory
+
+```swift
+struct Builder {
+ var config: Configuration
+
+ // Consumes self — builder invalid after call
+ consuming func build() -> Product {
+ Product(config: config)
+ }
+}
+
+let builder = Builder(config: .default)
+let product = builder.build()
+// builder is now invalid — compiler error if used
+```
+
+### Pattern 3: Explicit Copy in Borrowing
+
+With `borrowing`, copies must be explicit:
+
+```swift
+func store(_ value: borrowing LargeValue) {
+ // ❌ Error: Cannot implicitly copy borrowing parameter
+ self.cached = value
+
+ // ✅ Explicit copy
+ self.cached = copy value
+}
+```
+
+### Pattern 4: Consume Operator
+
+Transfer ownership explicitly:
+
+```swift
+let data = loadLargeData()
+process(consume data)
+// data is now invalid — compiler prevents use
+```
+
+### Pattern 5: Noncopyable Type
+
+For `~Copyable` types, ownership modifiers are **required**:
+
+```swift
+struct FileHandle: ~Copyable {
+ private let fd: Int32
+
+ init(path: String) throws {
+ fd = open(path, O_RDONLY)
+ guard fd >= 0 else { throw POSIXError.errno }
+ }
+
+ borrowing func read(count: Int) -> Data {
+ // Read without consuming handle
+ var buffer = [UInt8](repeating: 0, count: count)
+ _ = Darwin.read(fd, &buffer, count)
+ return Data(buffer)
+ }
+
+ consuming func close() {
+ Darwin.close(fd)
+ // Handle consumed — can't use after close()
+ }
+
+ deinit {
+ Darwin.close(fd)
+ }
+}
+
+// Usage
+let file = try FileHandle(path: "/tmp/data.txt")
+let data = file.read(count: 1024) // borrowing
+file.close() // consuming — file invalidated
+```
+
+### Pattern 6: Reducing ARC Traffic
+
+```swift
+class ExpensiveObject { /* ... */ }
+
+// ❌ Default: May retain/release
+func inspect(_ obj: ExpensiveObject) -> String {
+ obj.description
+}
+
+// ✅ Borrowing: No ARC traffic
+func inspect(_ obj: borrowing ExpensiveObject) -> String {
+ obj.description
+}
+```
+
+### Pattern 7: Consuming Method on Self
+
+```swift
+struct Transaction {
+ var amount: Decimal
+ var recipient: String
+
+ // After commit, transaction is consumed
+ consuming func commit() async throws {
+ try await sendToServer(self)
+ // self consumed — can't modify or reuse
+ }
+}
+```
+
+## Common Mistakes
+
+### Mistake 1: Over-Optimizing Small Types
+
+```swift
+// ❌ Unnecessary — Int is trivially copyable
+func add(_ a: borrowing Int, _ b: borrowing Int) -> Int {
+ a + b
+}
+
+// ✅ Let compiler optimize
+func add(_ a: Int, _ b: Int) -> Int {
+ a + b
+}
+```
+
+### Mistake 2: Forgetting Explicit Copy
+
+```swift
+func cache(_ value: borrowing LargeValue) {
+ // ❌ Compile error
+ self.values.append(value)
+
+ // ✅ Explicit copy required
+ self.values.append(copy value)
+}
+```
+
+### Mistake 3: Consuming When Borrowing Suffices
+
+```swift
+// ❌ Consumes unnecessarily — caller loses access
+func validate(_ data: consuming Data) -> Bool {
+ data.count > 0
+}
+
+// ✅ Borrow for read-only
+func validate(_ data: borrowing Data) -> Bool {
+ data.count > 0
+}
+```
+
+## ~Copyable Limitations
+
+**Know the constraints before adopting ~Copyable:**
+
+| Limitation | Impact | Workaround |
+|-----------|--------|------------|
+| Can't store in `Array`, `Dictionary`, `Set` | Collections require `Copyable` | Use `Optional` wrapper or manage manually |
+| Can't use with most generics | `` implicitly means `` | Use `` (requires library support) |
+| Protocol conformance restricted | Most protocols require `Copyable` | Use `~Copyable` protocol definitions |
+| Can't capture in closures by default | Closures copy captured values | Use `borrowing` closure parameters |
+| No existential support | `any ~Copyable` doesn't work | Use generics instead |
+
+**Common compiler errors when adopting ownership modifiers:**
+
+```swift
+// Error: "Cannot implicitly copy a borrowing parameter"
+// Fix: Add explicit `copy` or change to consuming
+func store(_ v: borrowing LargeValue) {
+ self.cached = copy v // ✅ Explicit copy
+}
+
+// Error: "Noncopyable type cannot be used with generic"
+// Fix: Constrain generic to ~Copyable
+func use(_ value: borrowing T) { } // ✅
+
+// Error: "Cannot consume a borrowing parameter"
+// Fix: Change to consuming if you need ownership transfer
+func takeOwnership(_ v: consuming FileHandle) { } // ✅
+
+// Error: "Missing 'consuming' or 'borrowing' modifier"
+// Fix: ~Copyable types require explicit ownership on all methods
+struct Token: ~Copyable {
+ borrowing func peek() -> String { ... } // ✅ Explicit
+ consuming func redeem() { ... } // ✅ Explicit
+}
+```
+
+**When NOT to use ~Copyable:**
+- If you need collection storage (arrays, dictionaries)
+- If you need to work with existing generic APIs
+- If the type needs broad protocol conformance
+- Prefer `consuming func` on regular types as a lighter alternative for "use once" semantics
+
+## Performance Considerations
+
+### When Ownership Modifiers Help
+
+- Large structs (arrays, dictionaries, custom value types)
+- High-frequency function calls in tight loops
+- Reference types where ARC traffic is measurable
+- Noncopyable types (required, not optional)
+
+### When to Skip
+
+- Default behavior is almost always optimal
+- Small value types (primitives, small structs)
+- Code where profiling shows no benefit
+- API stability concerns (modifiers affect ABI)
+
+## InlineArray
+
+Fixed-size, stack-allocated array using value generics. No heap allocation, no reference counting, no copy-on-write.
+
+### Declaration
+
+```swift
+@frozen struct InlineArray where Element: ~Copyable
+```
+
+The `let count: Int` is a **value generic** — the size is part of the type, checked at compile time. `InlineArray<3, Int>` and `InlineArray<4, Int>` are different types.
+
+### When to Use InlineArray
+
+| Use InlineArray | Use Array |
+|----------------|-----------|
+| Size known at compile time | Size changes at runtime |
+| Hot path needing zero heap allocation | Copy-on-write sharing is beneficial |
+| Embedded in other value types | Frequently copied between variables |
+| Performance-critical inner loops | General-purpose collection needs |
+
+### Canonical Example
+
+```swift
+// Fixed-size, inline storage — no heap allocation
+var matrix: InlineArray<9, Float> = [1, 0, 0, 0, 1, 0, 0, 0, 1]
+matrix[4] = 2.0
+
+// Type inference works for count, element, or both
+let rgb: InlineArray = [0.2, 0.4, 0.8] // InlineArray<3, Double>
+
+// Eager copy on assignment (no COW)
+var copy = matrix
+copy[0] = 99 // matrix[0] still 1
+```
+
+### Memory Layout
+
+Elements are stored contiguously with no overhead:
+
+```swift
+MemoryLayout>.size // 6 (2 bytes × 3)
+MemoryLayout>.alignment // 2 (same as UInt16)
+```
+
+### ~Copyable Integration
+
+InlineArray supports noncopyable elements — enables fixed-size collections of unique resources:
+
+```swift
+struct Sensor: ~Copyable { var id: Int }
+var sensors: InlineArray<4, Sensor> = ... // Valid: ~Copyable elements allowed
+```
+
+## Span — Safe Contiguous Memory Access
+
+`Span` replaces unsafe pointers with compile-time-enforced safe memory views. Zero runtime overhead.
+
+### The Span Family
+
+| Type | Access | Use Case |
+|------|--------|----------|
+| `Span` | Read-only elements | Safe iteration, passing to algorithms |
+| `MutableSpan` | Read-write elements | In-place mutation without copies |
+| `RawSpan` | Read-only bytes | Binary parsing, protocol decoding |
+| `MutableRawSpan` | Read-write bytes | Binary serialization |
+| `OutputSpan` | Write-only | Initializing new collection storage |
+| `UTF8Span` | Read-only UTF-8 | Safe Unicode processing |
+
+### Accessing Spans
+
+Containers with contiguous storage expose `.span` and `.mutableSpan`:
+
+```swift
+let array = [1, 2, 3, 4]
+let span = array.span // Span
+
+var mutable = [10, 20, 30]
+var ms = mutable.mutableSpan // MutableSpan
+ms[0] = 99
+```
+
+### Lifetime Safety — Compile-Time Enforcement
+
+Spans are **non-escapable** — the compiler guarantees they cannot outlive the container they borrow from:
+
+```swift
+// ❌ Cannot return span that depends on local variable
+func getSpan() -> Span {
+ let array: [UInt8] = Array(repeating: 0, count: 128)
+ return array.span // Compile error
+}
+
+// ❌ Cannot capture span in closure
+let span = array.span
+let closure = { span.count } // Compile error
+
+// ❌ Cannot access span after mutating original
+var array = [1, 2, 3]
+let span = array.span
+array.append(4)
+// span[0] // Compile error: container was modified
+```
+
+These constraints prevent use-after-free, dangling pointers, and overlapping mutation at **compile time** with zero runtime cost.
+
+### Span vs Unsafe Pointers
+
+| | Span | UnsafeBufferPointer |
+|---|------|---------------------|
+| Memory safety | Compile-time enforced | Manual, error-prone |
+| Lifetime tracking | Automatic, non-escapable | None — dangling pointers possible |
+| Runtime overhead | Zero | Zero |
+| Use-after-free | Impossible | Common source of crashes |
+
+### Canonical Example — Binary Parsing
+
+```swift
+func parseHeader(_ data: borrowing [UInt8]) -> Header {
+ var raw = data.span.rawSpan // RawSpan over the array's bytes
+ let magic = raw.unsafeLoadUnaligned(as: UInt32.self)
+ raw = raw.extracting(droppingFirst: 4)
+ let version = raw.unsafeLoadUnaligned(as: UInt16.self)
+ return Header(magic: magic, version: version)
+}
+```
+
+### When to Use Span
+
+- **Replace `UnsafeBufferPointer`** — same performance, compile-time safety
+- **Performance-critical algorithms** — direct memory access without copying
+- **Binary parsing/serialization** — `RawSpan` for byte-level access
+- **Passing data between functions** — borrow the container, pass the span
+- **UTF-8 processing** — `UTF8Span` for safe string byte access
+
+## Value Generics
+
+Value generics allow integer values as generic parameters, making sizes part of the type system:
+
+```swift
+// `let count: Int` is a value generic parameter
+struct InlineArray { ... }
+
+// Different counts = different types
+let a: InlineArray<3, Int> = [1, 2, 3]
+let b: InlineArray<4, Int> = [1, 2, 3, 4]
+// a = b // Compile error: different types
+```
+
+Currently limited to `Int` parameters. Enables stack-allocated, fixed-size abstractions where the compiler verifies size compatibility at compile time.
+
+## Decision Tree
+
+```
+Need explicit ownership?
+├─ Working with ~Copyable type?
+│ └─ Yes → Required (borrowing/consuming)
+├─ Fixed-size collection, no heap allocation?
+│ └─ Yes → InlineArray
+├─ Need safe pointer-like access to contiguous memory?
+│ ├─ Read-only? → Span
+│ ├─ Mutable? → MutableSpan
+│ └─ Raw bytes? → RawSpan / MutableRawSpan
+├─ Large value type passed frequently?
+│ ├─ Read-only? → borrowing
+│ └─ Final use? → consuming
+├─ ARC traffic visible in profiler?
+│ ├─ Read-only? → borrowing
+│ └─ Transferring ownership? → consuming
+└─ Otherwise → Let compiler choose
+```
+
+## Resources
+
+**Swift Evolution**: SE-0377, SE-0453 (Span), SE-0451 (InlineArray), SE-0452 (value generics)
+
+**WWDC**: 2024-10170, 2025-245, 2025-312
+
+**Docs**: /swift/inlinearray, /swift/span
+
+**Skills**: axiom-swift-performance, axiom-swift-concurrency
diff --git a/.claude/skills/axiom-ownership-conventions/agents/openai.yaml b/.claude/skills/axiom-ownership-conventions/agents/openai.yaml
new file mode 100644
index 0000000..bd2d9d4
--- /dev/null
+++ b/.claude/skills/axiom-ownership-conventions/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Ownership Conventions"
+ short_description: "Optimizing large value type performance, working with noncopyable types, reducing ARC traffic, or using InlineArray/S..."
diff --git a/.claude/skills/axiom-passkeys/.openskills.json b/.claude/skills/axiom-passkeys/.openskills.json
new file mode 100644
index 0000000..6d3c108
--- /dev/null
+++ b/.claude/skills/axiom-passkeys/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-passkeys",
+ "installedAt": "2026-04-12T08:06:31.579Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-passkeys/SKILL.md b/.claude/skills/axiom-passkeys/SKILL.md
new file mode 100644
index 0000000..29eb95e
--- /dev/null
+++ b/.claude/skills/axiom-passkeys/SKILL.md
@@ -0,0 +1,461 @@
+---
+name: axiom-passkeys
+description: Use when implementing passkey sign-in, replacing passwords with WebAuthn, configuring ASAuthorizationController, setting up AutoFill-assisted requests, adding automatic passkey upgrades, or migrating from password-based authentication. Covers passkey creation, assertion, cross-device sign-in, credential managers, and the Passwords app.
+license: MIT
+---
+
+# Passkeys
+
+Passkey authentication for iOS apps — registration, assertion, AutoFill-assisted requests, automatic upgrades, combined credential flows, and migration from password-based auth.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Add passkey sign-in to an iOS app
+- ☑ Replace password-based authentication with passkeys
+- ☑ Configure ASAuthorizationController for passkey registration or assertion
+- ☑ Set up AutoFill-assisted passkey requests (QuickType bar)
+- ☑ Add automatic passkey upgrades for existing password users (iOS 18+)
+- ☑ Support combined credential requests (passkey + password + Sign in with Apple)
+- ☑ Configure associated domains for webauthn/webcredentials
+- ☑ Debug passkey assertion failures or missing QuickType suggestions
+
+## Example Prompts
+
+"How do I add passkey sign-in to my app?"
+"My passkeys aren't showing in the QuickType bar"
+"How do I migrate existing password users to passkeys?"
+"ASAuthorizationError.canceled — what's going wrong?"
+"How do I support both passkeys and passwords during migration?"
+"How do I set up associated domains for passkeys?"
+"What's the difference between performRequests and performAutoFillAssistedRequests?"
+"How do I add automatic passkey upgrades on iOS 18?"
+
+## Red Flags
+
+Signs you're heading in the wrong direction:
+
+- ❌ Still using passwords as primary auth when passkeys are available — Passkeys are not "extra security." They are the replacement. Every password-only sign-in is a phishing opportunity you're leaving open.
+- ❌ Not annotating the username field with `.username` textContentType — Without this, the system can't associate the field with passkey credentials. AutoFill won't suggest passkeys for unlabeled fields.
+- ❌ Using `performRequests()` for the primary sign-in flow — this shows a modal sheet instead of putting passkeys in the QuickType bar. Use `performAutoFillAssistedRequests()` for the primary path. Reserve `performRequests()` for registration and explicit "Sign In" button taps.
+- ❌ Setting `userVerification` to `"required"` on the server — This prevents sign-in on devices without biometrics. The platform handles verification appropriately per device. Use `"preferred"` (the default).
+- ❌ Creating passkeys in an extension or non-main-app context — Passkey creation requires the main app target. Extensions can perform assertions but not registrations.
+- ❌ Not supporting combined credential requests — During migration, users may have a passkey, a password, or neither. A single ASAuthorizationController handles all three. Offering only passkeys locks out users who haven't upgraded.
+
+## Why Passkeys
+
+Passkeys are not an incremental improvement over passwords. They are a replacement architecture.
+
+**Phishing-proof**: Each passkey is cryptographically bound to a specific domain. A fake login page on `secure-myapp.com` cannot trigger a passkey created for `myapp.com`. There is no credential to type into the wrong site.
+
+**No credential database to leak**: The server stores only a public key. A breach exposes nothing usable — no password hashes to crack, no shared secrets to replay.
+
+**Single-tap sign-in**: Face ID or Touch ID replaces typing. Registration and assertion are both one-tap flows.
+
+**FIDO Alliance standard**: WebAuthn/CTAP2 protocol. Works across Apple, Google, and Microsoft platforms. Passkeys created on iPhone sync via iCloud Keychain and work on Mac, iPad, and the web.
+
+**Adoption**: Apple ships passkeys as a first-class system feature. iCloud Keychain syncs them. The Passwords app manages them. Third-party credential managers (1Password, Dashlane) support them natively as of iOS 17.
+
+## Associated Domains Setup
+
+Passkeys require an associated domain linking your app to your server. Without this, the system won't offer passkeys for your app.
+
+### 1. Add the Entitlement
+
+In Xcode: Target > Signing & Capabilities > + Associated Domains.
+
+Add:
+```
+webcredentials:example.com
+```
+
+### 2. Host the AASA File
+
+Serve `/.well-known/apple-app-site-association` from your domain over HTTPS with `Content-Type: application/json`:
+
+```json
+{
+ "webcredentials": {
+ "apps": [
+ "TEAMID.com.example.myapp"
+ ]
+ }
+}
+```
+
+**Requirements**:
+- HTTPS with a valid certificate (no self-signed)
+- No redirects — the file must be served directly at the path
+- `TEAMID` is your Apple Developer Team ID, not the bundle ID prefix
+- The file must be at the root domain, not a subdirectory
+
+### 3. Verify
+
+```bash
+curl -s "https://example.com/.well-known/apple-app-site-association" | python3 -m json.tool
+```
+
+Apple's CDN caches the AASA file. Changes can take up to 24 hours to propagate. During development, enable Associated Domains Development in Developer Settings on the device and use the `?mode=developer` query parameter.
+
+## Registration Flow
+
+Registration creates a new passkey and stores it in the user's credential manager.
+
+```swift
+import AuthenticationServices
+
+func registerPasskey(challenge: Data, userName: String, userID: Data) {
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
+ relyingPartyIdentifier: "example.com"
+ )
+
+ let request = provider.createCredentialRegistrationRequest(
+ challenge: challenge,
+ name: userName,
+ userID: userID
+ )
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performRequests()
+}
+```
+
+**Key parameters**:
+- `relyingPartyIdentifier` — Must match your associated domain (no `https://` prefix)
+- `challenge` — Server-generated cryptographic challenge (use at least 16 random bytes). Never reuse challenges.
+- `name` — Display name shown to the user in the passkey prompt and Passwords app
+- `userID` — Opaque identifier for the user account. Do not use email or username — use a random UUID or server-side user ID
+
+### Handling the Registration Response
+
+```swift
+func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization) {
+ guard let credential = authorization.credential
+ as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { return }
+
+ let attestationObject = credential.rawAttestationObject
+ let clientDataJSON = credential.rawClientDataJSON
+ let credentialID = credential.credentialID
+
+ // Send attestationObject, clientDataJSON, credentialID to your server
+ // Server validates and stores the public key
+}
+```
+
+Registration uses `performRequests()` (modal) because the user explicitly chose to create a passkey. This is the one place where modal presentation is correct.
+
+## Assertion Flow (Sign-In)
+
+Two paths for sign-in, each for a different UX context.
+
+### AutoFill-Assisted (Primary Path)
+
+The preferred sign-in flow. Passkeys appear in the QuickType bar when the user taps a text field with `.username` content type. Single-tap sign-in with no modal interruption.
+
+```swift
+func signInWithAutoFill() {
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
+ relyingPartyIdentifier: "example.com"
+ )
+
+ let request = provider.createCredentialAssertionRequest(
+ challenge: serverChallenge
+ )
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performAutoFillAssistedRequests()
+}
+```
+
+**Critical details**:
+- Call `performAutoFillAssistedRequests()` early — before the user focuses the username field. Call it in `viewDidAppear` or when the sign-in view appears.
+- The username `UITextField` must have `.textContentType = .username` set. Without this, the QuickType bar won't show passkey suggestions.
+- Do not set `allowedCredentials` on the request. AutoFill needs to show all available passkeys for the domain.
+- The request stays active until the user selects a credential, navigates away, or you cancel it.
+
+### Modal (Fallback Path)
+
+Use when the user taps a "Sign In" button explicitly, or when you know the username and want to request a specific credential.
+
+```swift
+func signInWithModal(allowedCredentials: [ASAuthorizationPlatformPublicKeyCredentialDescriptor]? = nil) {
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
+ relyingPartyIdentifier: "example.com"
+ )
+
+ let request = provider.createCredentialAssertionRequest(
+ challenge: serverChallenge
+ )
+
+ if let allowedCredentials {
+ request.allowedCredentials = allowedCredentials
+ }
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performRequests()
+}
+```
+
+Use `allowedCredentials` when you know the user's credential IDs (e.g., the user typed their username and your server returned their registered credential IDs). This narrows the passkey selection to that account.
+
+### Handling the Assertion Response
+
+```swift
+func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization) {
+ guard let credential = authorization.credential
+ as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { return }
+
+ let signature = credential.signature
+ let clientDataJSON = credential.rawClientDataJSON
+ let authenticatorData = credential.rawAuthenticatorData
+ let credentialID = credential.credentialID
+ let userID = credential.userID
+
+ // Send to server for verification
+}
+```
+
+## Automatic Passkey Upgrades (iOS 18+)
+
+Silently upgrade password users to passkeys without interrupting their flow. The system shows a brief notification confirming the upgrade — no modal, no extra taps.
+
+### How It Works
+
+When a user signs in with a password, the system can automatically create a passkey for the same account. This happens when:
+1. The credential manager supports automatic upgrades
+2. The user just successfully authenticated with a password for the same account
+3. Your app requests a conditional registration
+
+### Implementation
+
+```swift
+func requestAutomaticUpgrade(challenge: Data, userName: String, userID: Data) {
+ let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
+ relyingPartyIdentifier: "example.com"
+ )
+
+ let request = provider.createCredentialRegistrationRequest(
+ challenge: challenge,
+ name: userName,
+ userID: userID
+ )
+ request.requestStyle = .conditional
+
+ let controller = ASAuthorizationController(authorizationRequests: [request])
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performAutoFillAssistedRequests()
+}
+```
+
+**Key detail**: `.requestStyle = .conditional` makes the registration opportunistic. It will succeed silently when conditions are right and fail silently when they're not. Do not treat the failure callback as an error — it means conditions weren't met this time.
+
+**When to call**: After the user successfully authenticates with a password. Check first whether the user already has a passkey for this account — don't request an upgrade if they do.
+
+### Server Requirements
+
+Your server must be prepared for an asynchronous registration that arrives shortly after a password sign-in. The `userID` and `challenge` must be valid and associated with the session.
+
+## Combined Credential Requests
+
+During migration, your users may have passkeys, passwords, or Sign in with Apple credentials. A single ASAuthorizationController handles all three.
+
+```swift
+func signInWithCombinedRequest() {
+ var requests: [ASAuthorizationRequest] = []
+
+ // Passkey assertion
+ let passkeyProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(
+ relyingPartyIdentifier: "example.com"
+ )
+ requests.append(
+ passkeyProvider.createCredentialAssertionRequest(challenge: serverChallenge)
+ )
+
+ // Password
+ let passwordProvider = ASAuthorizationPasswordProvider()
+ requests.append(passwordProvider.createRequest())
+
+ // Sign in with Apple
+ let appleIDProvider = ASAuthorizationAppleIDProvider()
+ requests.append(appleIDProvider.createRequest())
+
+ let controller = ASAuthorizationController(authorizationRequests: requests)
+ controller.delegate = self
+ controller.presentationContextProvider = self
+ controller.performAutoFillAssistedRequests()
+}
+```
+
+### Handling Multiple Credential Types
+
+```swift
+func authorizationController(controller: ASAuthorizationController,
+ didCompleteWithAuthorization authorization: ASAuthorization) {
+ switch authorization.credential {
+ case let credential as ASAuthorizationPlatformPublicKeyCredentialAssertion:
+ // Passkey sign-in — verify with server
+ handlePasskeyAssertion(credential)
+
+ case let credential as ASPasswordCredential:
+ // Password sign-in — verify, then offer passkey upgrade
+ handlePasswordSignIn(credential)
+
+ case let credential as ASAuthorizationAppleIDCredential:
+ // Apple ID sign-in
+ handleAppleIDSignIn(credential)
+
+ default:
+ break
+ }
+}
+```
+
+After a successful password sign-in, call the automatic upgrade flow to progressively migrate users to passkeys.
+
+## Cross-Device Sign-In
+
+Users can sign in on a device that doesn't have their passkey by using their phone as an authenticator.
+
+**How it works**:
+1. Your app presents a passkey assertion request
+2. The system shows a QR code on the device requesting sign-in
+3. The user scans the QR code with their phone (which has the passkey)
+4. Bluetooth proximity verification confirms the phone is physically nearby
+5. The user authenticates with Face ID/Touch ID on their phone
+6. The assertion completes on the original device
+
+**No app changes required**. This is a system-level feature. Any device that supports passkeys can act as a cross-device authenticator. The communication is end-to-end encrypted through an Apple relay server.
+
+**Bluetooth required**: Both devices must have Bluetooth enabled. This is the proximity check that prevents remote phishing — the authenticating device must be physically near the requesting device.
+
+## Migration Strategy
+
+### Phase 1 — Add Passkey Support Alongside Passwords
+
+Keep existing password auth. Add passkey registration and assertion. Use combined credential requests so both paths work.
+
+**Server changes**: Add WebAuthn endpoints for registration and assertion. Store public keys alongside password hashes. Both auth methods validate to the same user session.
+
+**App changes**: Implement registration flow (offer after password sign-in), assertion flow (AutoFill-assisted), and combined requests.
+
+### Phase 2 — Automatic Upgrades (iOS 18+)
+
+Add conditional registration requests after password sign-ins. Users silently migrate to passkeys over time. Track upgrade metrics to measure adoption.
+
+No user action required. The system handles the upgrade transparently.
+
+### Phase 3 — Reduce Phishable Factors
+
+For accounts with passkeys, consider:
+- Removing password reset flows (passkeys don't need them)
+- Dropping SMS 2FA (passkeys are inherently two-factor: device possession + biometric)
+- Offering account recovery via passkey on another device instead of email/SMS
+
+Do not force-remove passwords. Let users choose to go passwordless. Some users need password access from devices that don't support passkeys.
+
+### Passwords App Integration (iOS 18+)
+
+The Passwords app displays your app's name and icon using OpenGraph metadata from your associated domain. Add to your website's ``:
+
+```html
+
+
+```
+
+This is how your app appears in the user's credential manager. Without it, the Passwords app shows only the domain name.
+
+## Anti-Rationalization
+
+| Rationalization | Reality | Time Cost |
+|----------------|---------|-----------|
+| "Passwords are fine for now" | Every password sign-in is a phishing vector. Credential stuffing attacks cost real money — the average breach costs $4.5M. Passkeys eliminate the entire attack surface. | Ongoing risk vs 2-3 days to implement |
+| "We'll add passkeys later" | AutoFill-assisted passkey requests are the same amount of integration work as a custom password text field with AutoFill. You're not saving time by deferring. | Same implementation effort either way |
+| "Users won't understand passkeys" | Users don't need to understand public-key cryptography. They see "Sign in with Face ID" — one tap. Apple, Google, and Microsoft are shipping passkeys as the default across all platforms. | 0 extra user education needed |
+| "Our server doesn't support WebAuthn" | Server-side WebAuthn libraries exist for every major backend (Python, Node, Go, Ruby, Java, .NET). Most are well-tested and actively maintained. | 1-2 days server-side integration |
+| "What about users without biometrics?" | Device passcode is a valid user verification method. Every supported device has at least a passcode. Setting `userVerification` to `"preferred"` lets the platform handle this correctly. | 0 extra work — platform handles it |
+| "We need password as fallback forever" | Combined credential requests support passwords and passkeys simultaneously. Use automatic upgrades to progressively migrate. You can keep passwords indefinitely while passkeys become primary. | No forced choice — run both |
+
+## Pressure Scenarios
+
+### Scenario 1: "Our users aren't ready for passkeys"
+
+**Context**: Product manager pushes back on passkey adoption, citing user confusion risk.
+
+**Pressure**: "Our users are not technical. They won't understand what a passkey is. Let's stick with passwords and add passkeys next year."
+
+**Reality**: Apple ships passkeys as a built-in system feature across every platform — iPhone, iPad, Mac, Apple Watch, Windows via cross-device auth. Users see "Sign in with Face ID" in the QuickType bar. They do not see "WebAuthn CTAP2 public-key credential." The Passwords app manages passkeys alongside passwords transparently. Apple's own account system, Google accounts, and Microsoft accounts all use passkeys. Your users are already using them elsewhere.
+
+**Correct action**: Implement combined credential requests. Existing password users keep signing in with passwords. Passkeys appear automatically for users whose credential managers support them. Add automatic upgrades (iOS 18+) to progressively migrate without user action.
+
+**Push-back template**: "Users don't need to understand passkeys. They see 'Sign in with Face ID' — one tap, done. Apple, Google, and Amazon already use passkeys for their own sign-in. We add it alongside passwords, so nobody's flow changes. Users who get passkeys automatically get a better experience; everyone else continues as before."
+
+### Scenario 2: "Just ship password auth now, add passkeys later"
+
+**Context**: Deadline pressure on a new app. Developer wants to defer passkey support to a post-launch update.
+
+**Pressure**: "We need to ship by Friday. Password auth works. We'll add passkeys in the next sprint."
+
+**Reality**: Implementing AutoFill-assisted passkey requests is comparable in effort to building a polished password text field with AutoFill support, secure storage, and "forgot password" flows. You're building the ASAuthorizationController integration either way — the question is whether you wire up one provider (passwords) or three (passkeys + passwords + Apple ID). Combined requests add ~30 lines to the delegate.
+
+**Correct action**: Implement combined credential requests from the start. The server needs WebAuthn endpoints, but client-side the work is nearly identical. Shipping with passkey support from day one means you never have to retrofit it, and you avoid the "next sprint" that turns into "next quarter."
+
+**Push-back template**: "AutoFill-assisted passkeys use the same ASAuthorizationController we'd use for password AutoFill. Adding passkey support is ~30 lines in the delegate — not a sprint of work. Shipping without it means we build the password flow now and rebuild the auth flow later to add passkeys. Let's do it once."
+
+## Checklist
+
+Before shipping passkey authentication:
+
+**Associated Domains**:
+- [ ] `webcredentials:yourdomain.com` added to Associated Domains capability
+- [ ] AASA file served at `/.well-known/apple-app-site-association` over HTTPS
+- [ ] AASA file contains correct Team ID and bundle identifier
+- [ ] No redirects on the AASA file path
+- [ ] AASA changes propagated (or developer mode enabled for testing)
+
+**Registration**:
+- [ ] Server generates unique challenge per registration attempt
+- [ ] `userID` is opaque (not email or username)
+- [ ] `relyingPartyIdentifier` matches associated domain exactly
+- [ ] Registration uses `performRequests()` (modal — correct for explicit creation)
+- [ ] Server stores credentialID and public key after successful registration
+
+**Assertion (Sign-In)**:
+- [ ] Username text field has `.textContentType = .username`
+- [ ] AutoFill-assisted request called early (before field focus)
+- [ ] AutoFill path uses `performAutoFillAssistedRequests()`
+- [ ] Modal fallback available via `performRequests()` for explicit sign-in button
+- [ ] `allowedCredentials` not set on AutoFill-assisted requests
+- [ ] Server validates signature, authenticatorData, and clientDataJSON
+
+**Combined Requests** (if supporting multiple auth methods):
+- [ ] Passkey, password, and Apple ID providers all included
+- [ ] Delegate handles all credential types in switch statement
+- [ ] Password sign-in triggers automatic passkey upgrade flow
+
+**Automatic Upgrades** (iOS 18+):
+- [ ] Registration request uses `.requestStyle = .conditional`
+- [ ] Upgrade request uses `performAutoFillAssistedRequests()`
+- [ ] Failure callback treated as "conditions not met" — not an error
+- [ ] Existing passkey checked before requesting upgrade
+
+**Error Handling**:
+- [ ] `ASAuthorizationError.canceled` handled gracefully (user dismissed — not an error)
+- [ ] `ASAuthorizationError.failed` logged with context for debugging
+- [ ] Network failures during server verification don't leave auth in inconsistent state
+
+## Resources
+
+**WWDC**: 2022-10092, 2024-10125
+
+**Docs**: /authenticationservices, /authenticationservices/public-private-key-authentication/supporting-passkeys
+
+**Skills**: axiom-keychain
diff --git a/.claude/skills/axiom-passkeys/agents/openai.yaml b/.claude/skills/axiom-passkeys/agents/openai.yaml
new file mode 100644
index 0000000..defbfbe
--- /dev/null
+++ b/.claude/skills/axiom-passkeys/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Passkeys"
+ short_description: "Implementing passkey sign-in, replacing passwords with WebAuthn, configuring ASAuthorizationController, setting up Au..."
diff --git a/.claude/skills/axiom-performance-profiling/.openskills.json b/.claude/skills/axiom-performance-profiling/.openskills.json
new file mode 100644
index 0000000..8a9bfb1
--- /dev/null
+++ b/.claude/skills/axiom-performance-profiling/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-performance-profiling",
+ "installedAt": "2026-04-12T08:06:31.784Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-performance-profiling/SKILL.md b/.claude/skills/axiom-performance-profiling/SKILL.md
new file mode 100644
index 0000000..20c9dd4
--- /dev/null
+++ b/.claude/skills/axiom-performance-profiling/SKILL.md
@@ -0,0 +1,1077 @@
+---
+name: axiom-performance-profiling
+description: Use when app feels slow, memory grows over time, battery drains fast, or you want to profile proactively - decision trees to choose the right Instruments tool, deep workflows for Time Profiler/Allocations/Core Data, and pressure scenarios for misinterpreting results
+license: MIT
+metadata:
+ version: "1.2.0"
+ last-updated: "TDD-tested with deadline pressure, manager authority pressure, and Self Time vs Total Time misinterpretation scenarios, added 3 real-world examples"
+---
+
+# Performance Profiling
+
+## Overview
+
+iOS app performance problems fall into distinct categories, each with a specific diagnosis tool. This skill helps you **choose the right tool**, **use it effectively**, and **interpret results correctly** under pressure.
+
+**Core principle**: Measure before optimizing. Guessing about performance wastes more time than profiling.
+
+**Requires**: Xcode 15+, iOS 14+
+**Related skills**: `axiom-swiftui-performance` (SwiftUI-specific profiling with Instruments 26), `axiom-memory-debugging` (memory leak diagnosis)
+
+## When to Use Performance Profiling
+
+#### Use this skill when
+- ✅ App feels slow (UI lags, loads take 5+ seconds)
+- ✅ Memory grows over time (Xcode shows increasing memory usage)
+- ✅ Battery drains fast (device gets hot, battery depletes in hours)
+- ✅ You want to profile proactively (before users complain)
+- ✅ You're unsure which Instruments tool to use
+- ✅ Profiling results are confusing or contradictory
+
+#### Use `axiom-memory-debugging` instead when
+- Investigating specific memory leaks with retain cycles
+- Using Instruments Allocations in detail mode
+
+#### Use `axiom-swiftui-performance` instead when
+- Analyzing SwiftUI view body updates
+- Using SwiftUI Instrument specifically
+
+## Performance Decision Tree
+
+Before opening Instruments, narrow down what you're actually investigating.
+
+### Step 1: What's the Symptom?
+
+```
+App performance problem?
+├─ App feels slow or lags (UI interactions stall, scrolling stutters)
+│ └─ → Use Time Profiler (measure CPU usage)
+├─ Memory grows over time (Xcode shows increasing memory)
+│ └─ → Use Allocations (measure object creation)
+├─ Data loading is slow (parsing, database queries, API calls)
+│ └─ → Use Core Data instrument (if using Core Data)
+│ └─ → Use Time Profiler (if it's computation)
+└─ Battery drains fast (device gets hot, depletes in hours)
+ └─ → Use Energy Impact (measure power consumption)
+```
+
+### Step 2: Can You Reproduce It?
+
+**YES** – Use Instruments to measure it (profiling is most accurate)
+
+**NO** – Use profiling proactively
+- Enable Core Data SQL debugging to catch N+1 queries
+- Profile app during normal use (scrolling, loading, navigation)
+- Establish baseline metrics before changes
+
+### Step 3: Which Instruments Tool?
+
+**Time Profiler** – Slowness, UI lag, CPU spikes
+**Allocations** – Memory growth, memory pressure, object counts
+**Core Data** – Query performance, fetch times, fault fires
+**Energy Impact** – Battery drain, sustained power draw
+**Network Link Conditioner** – Connection-related slowness
+**System Trace** – Thread blocking, main thread blocking, scheduling
+
+---
+
+## Time Profiler Deep Dive
+
+Use Time Profiler when your app feels slow or laggy. It measures CPU time spent in each function.
+
+### Workflow: Record and Analyze
+
+#### Step 1: Launch Instruments
+```bash
+open -a Instruments
+```
+
+Select "Time Profiler" template.
+
+#### Step 2: Attach to Running App
+1. Start your app in simulator or device
+2. In Instruments, select your app from the target dropdown
+3. Click Record (red circle)
+4. Interact with the slow part (scroll, tap buttons, load data)
+5. Stop recording after 10-30 seconds of interaction
+
+#### Step 3: Read the Call Stack
+
+The top panel shows a timeline of CPU usage over time. Look for:
+- **Tall spikes** – Brief CPU-intensive operations
+- **Sustained high usage** – Continuous expensive work
+- **Main thread blocking** – UI thread doing work (causes UI lag)
+
+#### Step 4: Drill Down to Hot Spots
+
+In the call tree, click "Heaviest Stack Trace" to see which functions use the most CPU:
+
+```
+Time Profiler Results
+
+MyViewController.viewDidLoad() – 500ms (40% of total)
+ ├─ DataParser.parse() – 350ms
+ │ └─ JSONDecoder.decode() – 320ms
+ └─ UITableView.reloadData() – 150ms
+```
+
+**Self Time** = Time spent IN that function (not in functions it calls)
+**Total Time** = Time spent in that function + everything it calls
+
+### Common Mistakes & Fixes
+
+#### ❌ Mistake 1: Blaming the Wrong Function
+
+```swift
+// ❌ WRONG: Profile shows DataParser.parse() is 80% CPU
+// Conclusion: "DataParser is slow, let me optimize it"
+
+// ✅ RIGHT: Check what DataParser is calling
+// If JSONDecoder.decode() is doing 99% of the work,
+// optimize JSON decoding, not DataParser
+```
+
+**The issue**: A function with high Total Time might be calling slow code, not doing slow work itself.
+
+**Fix**: Look at Self Time, not Total Time. Drill down to see what each function calls.
+
+#### ❌ Mistake 2: Profiling the Wrong Code Path
+
+```swift
+// ❌ WRONG: Profile app in Simulator
+// Simulator CPU is different than real device
+// Results don't reflect actual device performance
+
+// ✅ RIGHT: Profile on actual device
+// Device settings: Developer Mode enabled, Xcode attached
+```
+
+**Fix**: Always profile on actual device for accurate CPU measurements.
+
+#### ❌ Mistake 3: Not Isolating the Problem
+
+```swift
+// ❌ WRONG: Profile entire app startup
+// Sees 2000ms startup time, many functions involved
+
+// ✅ RIGHT: Profile just the slow part
+// "App feels slow when scrolling" → profile only scrolling
+// Separate concerns: startup slow vs interaction slow
+```
+
+**Fix**: Reproduce the specific slow operation, not the entire app.
+
+### Pressure Scenario: "Profile Shows Function X is 80% CPU"
+
+**The temptation**: "I must optimize function X!"
+
+**The reality**: Function X might be:
+- **Calling expensive code** (optimize the called function, not X)
+- **Running on main thread** (move to background, it's already optimized)
+- **Necessary work that looks slow** (baseline is acceptable, user won't notice)
+
+**What to do instead**:
+
+1. **Check Self Time, not Total Time**
+ - Self Time 80%? Function is actually doing expensive work
+ - Self Time 5%, Total Time 80%? Function is calling slow code
+
+2. **Drill down one level**
+ - What is this function calling?
+ - Is the slow code in a library you control?
+
+3. **Check the timeline**
+ - Is this 80% sustained (steady slow) or spikes (occasional stalls)?
+ - Sustained = optimization needed
+ - Spikes = caching might help
+
+4. **Ask: Will users notice?**
+ - 500ms background work = user won't notice
+ - 500ms on main thread = UI stall, user sees it
+ - 50ms on main thread per frame = smooth UI (60fps)
+
+**Time cost**: 5 min (read results) + 2 min (drill down) = **7 minutes to understand**
+
+**Cost of guessing**: 2 hours optimizing wrong function + 1 hour realizing it didn't help + back to square one = **3+ hours wasted**
+
+---
+
+## Allocations Deep Dive
+
+Use Allocations when memory grows over time or you suspect memory pressure issues.
+
+### Workflow: Record and Analyze
+
+#### Step 1: Launch Instruments
+```bash
+open -a Instruments
+```
+
+Select "Allocations" template.
+
+#### Step 2: Attach and Record
+1. Start your app
+2. In Instruments, select your app
+3. Click Record
+4. Perform actions that use memory (load data, display images, navigate)
+5. Stop recording after memory stabilizes or peaks
+
+#### Step 3: Find Memory Growth
+
+Look at the main chart:
+- **Blue line** = Total allocations
+- **Sharp climb** = Memory being allocated
+- **Flat line** = Memory stable (good)
+- **No decline after stopping actions** = Possible leak (or caching)
+
+#### Step 4: Identify Persistent Objects
+
+Under "Statistics":
+- Sort by "Persistent" (objects still alive)
+- Look for surprisingly large object counts:
+ ```
+ UIImage: 500 instances (300MB) – Should be <50 for normal app
+ NSString: 50000 instances – Should be <1000
+ CustomDataModel: 10000 instances – Should be <100
+ ```
+
+### Common Mistakes & Fixes
+
+#### ❌ Mistake 1: Confusing "Memory Grew" with "Memory Leak"
+
+```swift
+// ❌ WRONG: Memory went from 100MB to 500MB
+// Conclusion: "There's a leak, memory keeps growing!"
+
+// ✅ RIGHT: Check what caused the growth
+// Loaded 1000 images (normal)
+// Cached API responses (normal)
+// User has 5000 contacts (normal)
+// Memory is being used correctly
+```
+
+**The issue**: Growing memory ≠ leak. Apps legitimately use more memory when loading data.
+
+**Fix**: Check Allocations for object counts. If images/data count matches what you loaded, it's normal. If object count keeps growing without actions, that's a leak.
+
+#### ❌ Mistake 2: Not Accounting for Caching
+
+```swift
+// ❌ WRONG: Allocations shows 1000 UIImages in memory
+// Conclusion: "Memory leak, too many images!"
+
+// ✅ RIGHT: Check if this is intentional caching
+// ImageCache holds up to 1000 images by design
+// When memory pressure happens, cache is cleared
+// Normal behavior
+```
+
+**Fix**: Distinguish between intended caching and actual leaks. Leaks don't release under memory pressure.
+
+#### ❌ Mistake 3: Profiling Too Short
+
+```swift
+// ❌ WRONG: Record for 5 seconds, see 200MB
+// Conclusion: "App uses 200MB, optimize memory"
+
+// ✅ RIGHT: Record for 2-3 minutes, see full lifecycle
+// Load data: 200MB
+// Navigate away: 180MB (20MB still cached)
+// Navigate back: 190MB (cache reused)
+// Real baseline: ~190MB at steady state
+```
+
+**Fix**: Profile long enough to see memory stabilize. Short recordings capture transient spikes.
+
+### Pressure Scenario: "Memory is 500MB, That's a Leak!"
+
+**The temptation**: "Delete caching, reduce object creation, optimize data structures"
+
+**The reality**: Is 500MB actually large?
+- iPhone 14 Pro has 6GB RAM
+- Instagram uses 400-600MB on load
+- Photos app uses 500MB+ when browsing large library
+- 500MB might be completely normal
+
+**What to do instead**:
+
+1. **Establish baseline on real device**
+ ```bash
+ # On device, open Memory view in Xcode
+ Xcode → Debug → Memory Debugger → Check "Real Memory" at app launch
+ ```
+
+2. **Check object counts, not total memory**
+ - Allocations → Statistics → "Persistent"
+ - Are images, views, or data objects 10x expected count?
+ - If yes, investigate that object type
+ - If no, memory is probably fine
+
+3. **Test under memory pressure**
+ - Xcode → Debug → Simulate Memory Warning
+ - Does memory drop by 50%+? It's caching (normal)
+ - Does memory stay high? Investigate persistent objects
+
+4. **Profile real user journey**
+ - Load data (like user does)
+ - Navigate around (like user does)
+ - Return to app (from background)
+ - Check memory at each step
+
+**Time cost**: 5 min (launch Allocations) + 3 min (record app usage) + 2 min (analyze) = **10 minutes**
+
+**Cost of guessing**: Delete caching to "reduce memory" → app reloads data every screen → slower app → users complain → revert changes = **2+ hours wasted**
+
+---
+
+## Core Data Deep Dive
+
+Use Core Data instrument when your app uses Core Data and data loading is slow.
+
+### Workflow: Enable SQL Debugging and Profile
+
+#### Step 1: Enable Core Data SQL Logging
+
+Add to your launch arguments in Xcode:
+
+```
+Edit Scheme → Run → Arguments Passed On Launch
+Add: -com.apple.CoreData.SQLDebug 1
+```
+
+Now SQLite queries print to console:
+
+```
+CoreData: sql: SELECT ... FROM tracks WHERE artist = ? (time: 0.015s)
+CoreData: sql: SELECT ... FROM albums WHERE id = ? (time: 0.002s)
+```
+
+#### Step 2: Identify N+1 Query Problem
+
+Watch the console during a typical user action (load list, scroll, filter):
+
+```
+❌ BAD: Loading 100 tracks, then querying album for each
+SELECT * FROM tracks (time: 0.050s) → 100 tracks
+SELECT * FROM albums WHERE id = 1 (time: 0.005s)
+SELECT * FROM albums WHERE id = 2 (time: 0.005s)
+SELECT * FROM albums WHERE id = 3 (time: 0.005s)
+... 97 more queries
+Total: 0.050s + (100 × 0.005s) = 0.550s
+
+✅ GOOD: Fetch tracks WITH album relationship (eager loading)
+SELECT tracks.*, albums.* FROM tracks
+LEFT JOIN albums ON tracks.albumId = albums.id
+(time: 0.050s)
+Total: 0.050s
+```
+
+#### Step 3: Profile with Core Data Instrument
+
+```bash
+open -a Instruments
+```
+
+Select "Core Data" template.
+
+Record while performing slow action:
+
+```
+Core Data Results
+
+Fetch Requests: 102
+Average Fetch Time: 12ms
+Slow Fetch: "SELECT * FROM tracks" (180ms)
+
+Fault Fires: 5000
+ → Object accessed, requires fetch from database
+ → Should use prefetching
+```
+
+### Common Mistakes & Fixes
+
+#### ❌ Mistake 1: Not Using Relationships Correctly
+
+```swift
+// ❌ WRONG: Fetch tracks, then access album for each
+let tracks = try context.fetch(Track.fetchRequest())
+for track in tracks {
+ print(track.album.title) // Fires individual query for each
+}
+// Total: 1 + N queries
+
+// ✅ RIGHT: Fetch with relationship prefetching
+let request = Track.fetchRequest()
+request.returnsObjectsAsFaults = false
+request.relationshipKeyPathsForPrefetching = ["album"]
+let tracks = try context.fetch(request)
+for track in tracks {
+ print(track.album.title) // Already loaded
+}
+// Total: 1 query
+```
+
+**Fix**: Use `relationshipKeyPathsForPrefetching` to load related objects upfront.
+
+#### ❌ Mistake 2: Not Using Batching
+
+```swift
+// ❌ WRONG: Fetch 50,000 records all at once
+let request = Track.fetchRequest()
+let allTracks = try context.fetch(request) // Huge memory spike
+
+// ✅ RIGHT: Batch fetch in chunks
+let request = Track.fetchRequest()
+request.fetchBatchSize = 500 // Fetch 500 at a time
+let allTracks = try context.fetch(request) // Memory efficient
+```
+
+**Fix**: Use `fetchBatchSize` for large datasets.
+
+#### ❌ Mistake 3: Not Using Faulting to Reduce Memory
+
+```swift
+// ❌ WRONG: Keep all objects in memory
+let request = Track.fetchRequest()
+request.returnsObjectsAsFaults = false // Keep all in memory
+let allTracks = try context.fetch(request) // 50,000 objects
+// Memory spike if you don't use all of them
+
+// ✅ RIGHT: Use faults (lazy loading)
+let request = Track.fetchRequest()
+// request.returnsObjectsAsFaults = true (default)
+let allTracks = try context.fetch(request) // Just references
+// Only load objects you actually access
+```
+
+**Fix**: Leave `returnsObjectsAsFaults` as default (true) unless you need all objects upfront.
+
+### Pressure Scenario: "Core Data Queries Are Slow, Redesign Schema!"
+
+**The temptation**: "The schema is wrong, I need to restructure everything"
+
+**The reality**: 99% of "slow Core Data" is due to:
+- ❌ Missing indexes
+- ❌ N+1 query problem
+- ❌ Fetching too much data at once
+- ❌ Not using batch size or prefetching
+
+Redesigning the schema is the LAST thing to try.
+
+**What to do instead**:
+
+1. **Enable SQL debugging** (2 min)
+ - Add `-com.apple.CoreData.SQLDebug 1` launch argument
+ - Watch what queries execute
+
+2. **Look for N+1 pattern** (3 min)
+ - Fetching 100 objects, then individual queries for related data?
+ - Add relationship prefetching
+
+3. **Add indexes if needed** (5 min)
+ - `@NSManaged var artist: String` with frequent filtering?
+ - Add `@Index` in schema
+
+4. **Test improvement** (2 min)
+ - Re-run the same action
+ - Compare query count and total time
+ - If 10x faster, you're done
+ - If still slow, go to step 5
+
+5. **Only THEN consider schema changes** (30+ min)
+ - But you probably won't get here
+
+**Time cost**: 12 minutes to diagnose + fix = **12 minutes**
+
+**Cost of schema redesign**: 8 hours design + 4 hours migration + 2 hours testing + 1 hour rollback = **15 hours total**
+
+---
+
+## Quick Reference: Other Tools
+
+### Energy Impact (Battery Drain)
+
+**When to use**: App drains battery fast, device gets hot
+
+**Workflow**:
+1. Launch Instruments → Energy Impact template
+2. Run app normally for 5+ minutes
+3. Look for red/orange sustained usage (bad)
+4. Drill down to see which subsystems drain battery
+
+**Key metrics**:
+- **Sustained Power** – Ongoing energy use (should be minimal)
+- **Peaks** – Brief high usage (acceptable)
+- **CPU** – Process CPU time
+- **GPU** – Graphics rendering
+- **Network** – Cellular/WiFi radio
+- **Location** – GPS usage
+
+**Common issues**:
+- Continuous location updates with 1m accuracy (should be 100m)
+- Running timers that wake the device repeatedly
+- Excessive network calls (batch requests instead)
+- Animating views while not visible
+
+### Network Link Conditioner (Connection Simulation)
+
+**When to use**: App seems slow on 4G, want to test without traveling
+
+**Setup**:
+1. Download Additional Tools for Xcode
+2. Install Network Link Conditioner
+3. Open System Preferences → Network Link Conditioner
+4. Choose profile (3G, LTE, WiFi Slow, etc.)
+5. Enable and activate profile
+6. Run app to test
+
+**Key profiles**:
+- **3G** – 1.6Mbps down, 768Kbps up, 150ms latency
+- **LTE** – 10Mbps down, 5Mbps up, 20ms latency
+- **WiFi Slow** – 10Mbps, 100ms latency
+- **Custom** – Set your own parameters
+
+**Note**: Also covered in ui-testing for network-dependent test scenarios.
+
+### System Trace (Thread Blocking, Scheduling)
+
+**When to use**: UI freezes or is janky, but Time Profiler shows low CPU
+
+**Common cause**: Main thread blocked by background task waiting on lock
+
+**Workflow**:
+1. Launch Instruments → System Trace template
+2. Record while reproducing issue
+3. Look for main thread gaps (blocked, not running)
+4. Drill down to see what's blocking it
+
+**Key metrics**:
+- **Main thread gaps** – Empty spaces = main thread idle/blocked
+- **Core scheduling** – Which threads run when
+- **Lock contention** – Threads waiting for locks
+
+---
+
+## OSSignposter — Custom Performance Instrumentation
+
+While Time Profiler shows where CPU time goes generally, OSSignposter lets you measure specific operations you define. It's the primary tool for custom performance instrumentation on Apple platforms.
+
+### When to Use
+
+- Measuring duration of specific operations (data load, image processing, sync cycle)
+- Creating custom Instruments lanes for your app's operations
+- Bridging to automated performance testing (XCTOSSignpostMetric)
+- Measuring operations that span multiple threads or await points
+
+### Basic API
+
+```swift
+import os
+
+let signposter = OSSignposter(subsystem: "com.app", category: "DataLoad")
+
+// Interval measurement (start → end)
+func loadData() async throws -> [Item] {
+ let signpostID = signposter.makeSignpostID()
+ let state = signposter.beginInterval("Load Items", id: signpostID)
+ defer { signposter.endInterval("Load Items", state) }
+
+ return try await fetchItems()
+}
+
+// Point of interest (single event)
+func cacheHit(for key: String) {
+ signposter.emitEvent("Cache Hit")
+}
+```
+
+### Integration with Instruments
+
+1. Launch Instruments → add "os_signpost" or "Points of Interest" instrument
+2. Record your app performing the instrumented operations
+3. Signpost intervals appear as colored bars in the timeline
+4. Filter by subsystem/category to focus on your operations
+
+### When to Use Signposts vs Time Profiler
+
+| Need | Tool |
+|------|------|
+| General CPU hotspots | Time Profiler |
+| Specific operation duration | OSSignposter |
+| Cross-thread operation timing | OSSignposter |
+| Automated regression testing | OSSignposter + XCTOSSignpostMetric |
+
+---
+
+## Pressure Scenarios
+
+### Scenario 1: "Profiling Shows Different Results Each Run"
+
+**The problem**: You run Time Profiler 3 times, get 200ms, 150ms, 280ms. Which is correct?
+
+**Red flags you might think**:
+- "Results are unreliable, profiling isn't accurate"
+- "Let me just average them"
+- "This is too variable, I can't optimize"
+
+**The reality**: Variance is NORMAL. Different runs hit different:
+- Cache states (cold cache = slower)
+- System load (other apps running)
+- CPU frequency (boost/throttle)
+
+**What to do instead**:
+
+1. **Warm up the cache** (first run always slower)
+ - Perform the action once (cold cache)
+ - Perform again (warm cache) – use this measurement
+
+2. **Control system load**
+ - Close other apps
+ - Don't touch device during profiling
+ - Profile on device (not simulator)
+
+3. **Look for the pattern**
+ - Multiple runs: 150ms, 160ms, 155ms (consistent = good)
+ - Multiple runs: 150ms, 280ms, 240ms (inconsistent = investigate)
+ - Inconsistency = intermittent problem, find it
+
+4. **Trust the slowest run** (worst case scenario)
+ - If range is 150-280ms, assume 280ms is real
+ - Optimize for worst case
+
+**Time cost**: 10 min (run profiler 3x) + 2 min (interpret) = **12 minutes**
+
+**Cost of ignoring variance**: Miss intermittent performance issue → users see occasional freezes → bad reviews
+
+---
+
+### Scenario 2: "Time Profiler and Allocations Show Different Problems"
+
+**The problem**: Time Profiler shows JSON parsing is slow. Allocations show memory use is normal. Which to fix?
+
+**The answer**: Both are real, prioritize differently.
+
+```
+Time Profiler: JSONDecoder.decode() = 500ms
+Allocations: Memory = 250MB (normal for app size)
+
+Result: App is slow AND memory is fine
+Action: Optimize JSON decoding (not memory)
+```
+
+**Common conflicts**:
+
+| Time Profiler | Allocations | Action |
+|---|---|---|
+| High CPU | Normal memory | Optimize computation (reduce CPU) |
+| Low CPU | Memory growing | Find leak or reduce object creation |
+| Both high | Both high | Profile which is user-visible first |
+
+**What to do**:
+
+1. **Prioritize by user impact**
+ - Slowness (UI lag) = fix first
+ - Memory (background issue) = fix second
+
+2. **Check if they're related**
+ - Does JSON parsing leak memory? (No → separate issues)
+ - Does memory growth slow CPU? (Maybe → fix memory first)
+
+3. **Fix in order of impact**
+ - Slow JSON parsing: Affects every data load
+ - Normal memory: No user impact
+ - → Fix JSON parsing
+
+**Time cost**: 5 min (analyze both results) = **5 minutes**
+
+**Cost of fixing wrong problem**: Spend 4 hours optimizing memory that's fine → no improvement to user experience
+
+---
+
+### Scenario 3: "Profiling Under Deadline Pressure"
+
+**The situation**: Manager says "We ship in 2 hours. Is performance acceptable?"
+
+**Red flags you might think**:
+- "Profiling takes too long, let me just ask users"
+- "I don't have time to profile properly, ship as-is"
+- "One quick run will tell me if it's fine"
+
+**The reality**: Profiling takes 15-20 minutes total. That's 1% of your remaining time.
+
+**What to do instead**:
+
+1. **Profile the critical path** (3 min)
+ - What users do most (load list, scroll, search)
+ - Not the entire app, just the slow part
+
+2. **Record one proper run** (5 min)
+ - Cold cache first time
+ - Warm cache second time
+ - Use warm cache results
+
+3. **Interpret quickly** (5 min)
+ - Time Profiler: Any >100ms on main thread? (If no, fine)
+ - Allocations: Any memory growing? (If no, fine)
+
+4. **Ship with confidence** (2 min)
+ - If results are acceptable, ship
+ - If not, you have 90 minutes to fix or delay
+
+**Time cost**: 15 min profiling + 5 min analysis = **20 minutes**
+
+**Cost of not profiling**: Ship with unknown performance → Users hit slowness → Bad reviews → Emergency hotfix 2 weeks later
+
+**Math**: 20 minutes of profiling now << 2+ weeks of post-launch support
+
+---
+
+## CLI Quick Checks (No Instruments)
+
+Xcode ships CLI profiling tools for fast checks without opening Instruments.
+
+### CPU Profiling
+
+```bash
+# Quick 5-second CPU sample of running app
+xcrun sample MyApp 5
+
+# Sample by PID, save to file for analysis
+xcrun sample 12345 5 -file output.txt
+```
+
+**When to use**: Quick CPU check before committing to a full xctrace session. Shows which functions are hot in 5 seconds.
+
+### Memory Profiling
+
+```bash
+# Quick leak check — is there a leak at all?
+xcrun leaks MyApp
+```
+
+For `heap`, `vmmap`, `stringdups`, and a full CLI diagnosis workflow, see `axiom-memory-debugging`.
+
+### Headless Instruments (xctrace)
+
+```bash
+# CPU profile from CLI
+xcrun xctrace record --instrument 'CPU Profiler' --attach 'MyApp' --time-limit 10s --output cpu.trace
+
+# Memory allocations from CLI
+xcrun xctrace record --instrument 'Allocations' --attach 'MyApp' --time-limit 30s --output alloc.trace
+```
+
+See `axiom-xctrace-ref` for comprehensive xctrace reference.
+
+## Quick Reference
+
+### Common Operations
+
+```swift
+// Time Profiler: Launch Instruments
+open -a Instruments
+
+// Core Data: Enable SQL logging
+// Edit Scheme → Run → Arguments Passed On Launch
+-com.apple.CoreData.SQLDebug 1
+
+// Allocations: Check persistent objects
+Instruments → Allocations → Statistics → sort "Persistent"
+
+// Memory warning: Simulate pressure
+Xcode → Debug → Simulate Memory Warning
+
+// Energy Impact: Profile battery drain
+Instruments → Energy Impact template
+
+// Network Link Conditioner: Simulate 3G
+System Preferences → Network Link Conditioner → 3G profile
+```
+
+### Decision Tree Summary
+
+```
+Performance problem?
+├─ App feels slow/laggy?
+│ └─ → Time Profiler (measure CPU)
+├─ Memory grows over time?
+│ └─ → Allocations (find object growth)
+├─ Data loading is slow?
+│ └─ → Core Data instrument (if using Core Data)
+│ └─ → Time Profiler (if computation slow)
+└─ Battery drains fast?
+ └─ → Energy Impact (measure power)
+```
+
+---
+
+## Real-World Examples
+
+### Example 1: Identifying N+1 Query Problem in Core Data
+
+**Scenario**: Your app loads a list of albums with artist names. It's slow (5+ seconds for 100 albums). You suspect Core Data.
+
+**Setup**: Enable SQL logging first
+```bash
+# Edit Scheme → Run → Arguments Passed On Launch
+-com.apple.CoreData.SQLDebug 1
+```
+
+**What you see in console**:
+```
+CoreData: sql: SELECT ... FROM albums WHERE ... (time: 0.050s)
+CoreData: sql: SELECT ... FROM artists WHERE id = 1 (time: 0.003s)
+CoreData: sql: SELECT ... FROM artists WHERE id = 2 (time: 0.003s)
+... 98 more individual queries
+Total: 0.050s + (100 × 0.003s) = 0.350s
+```
+
+**Diagnosis using the skill**:
+- Fetching 100 albums, then individual query for each album's artist = **N+1 query problem** (Core Data Deep Dive, lines 302-325)
+
+**Fix**:
+```swift
+// ❌ WRONG: Each album access triggers separate artist query
+let request = Album.fetchRequest()
+let albums = try context.fetch(request)
+for album in albums {
+ print(album.artist.name) // Extra query for each
+}
+
+// ✅ RIGHT: Prefetch the relationship
+let request = Album.fetchRequest()
+request.returnsObjectsAsFaults = false
+request.relationshipKeyPathsForPrefetching = ["artist"]
+let albums = try context.fetch(request)
+for album in albums {
+ print(album.artist.name) // Already loaded
+}
+```
+
+**Result**: 0.350s → 0.050s (7x faster)
+
+---
+
+### Example 2: Finding Where UI Lag Really Comes From
+
+**Scenario**: Your app UI stalls for 1-2 seconds when loading a view. Your co-lead says "Add background threading everywhere." You want to measure first.
+
+**Workflow using the skill** (Time Profiler Deep Dive, lines 82-118):
+
+1. **Open Instruments**:
+```bash
+open -a Instruments
+# Select "Time Profiler"
+```
+
+2. **Record the stall**:
+```
+App launches
+Time Profiler records
+View loads
+Stall happens (observe the spike in Time Profiler)
+Stop recording
+```
+
+3. **Examine results**:
+```
+Call Stack shows:
+
+viewDidLoad() – 1500ms
+ ├─ loadJSON() – 1200ms (Self Time: 50ms)
+ │ └─ loadImages() – 1150ms (Self Time: 1150ms) ← HERE'S THE CULPRIT
+ ├─ parseData() – 200ms
+ └─ layoutUI() – 100ms
+```
+
+4. **Apply the skill** (lines 173-175):
+```
+loadJSON() has Self Time: 50ms, Total Time: 1200ms
+→ loadJSON() isn't slow, something it CALLS is slow
+→ loadImages() has Self Time: 1150ms
+→ loadImages() is the actual bottleneck
+```
+
+5. **Fix the right thing**:
+```swift
+// ❌ WRONG: Thread everything
+DispatchQueue.global().async { loadJSON() }
+
+// ✅ RIGHT: Thread only the slow part
+func loadJSON() {
+ let data = parseJSON() // 50ms, fine on main
+
+ // Move ONLY the slow part to background
+ DispatchQueue.global().async {
+ let images = loadImages() // 1150ms, now background
+ DispatchQueue.main.async {
+ updateUI(with: images)
+ }
+ }
+}
+```
+
+**Result**: 1500ms → 350ms (4x faster, main thread unblocked)
+
+**Why this matters**: You fixed the ACTUAL bottleneck (1150ms), not guessing blindly about threading.
+
+---
+
+### Example 3: Memory Growing vs Memory Leak
+
+**Scenario**: Allocations shows memory growing from 150MB to 600MB over 30 minutes of app use. Your manager says "Memory leak!" You need to know if it's real.
+
+**Workflow using the skill** (Allocations Deep Dive, lines 199-277):
+
+1. **Launch Allocations in Instruments**
+
+2. **Record normal app usage for 3 minutes**:
+```
+User loads data → memory grows to 400MB
+User navigates around → memory stays at 400MB
+User goes to Settings → memory at 400MB
+User comes back → memory at 400MB
+```
+
+3. **Check Allocations Statistics**:
+```
+Persistent Objects:
+- UIImage: 1200 instances (300MB) ← Large count
+- NSString: 5000 instances (4MB)
+- CustomDataModel: 800 instances (15MB)
+```
+
+4. **Ask the skill questions** (lines 220-240):
+- Are 1200 images legitimately loaded? (User loaded photo library with 1000 photos) → YES
+- Does memory drop if you trigger memory warning? (Simulate with Xcode) → YES, drops to 180MB
+- Is this caching working as designed? → YES
+
+**Diagnosis**: NOT a leak. This is **normal caching** (lines 235-248)
+```
+Memory growing = apps using data users asked for
+Memory dropping under pressure = cache working correctly
+Memory staying high indefinitely = possible leak
+```
+
+5. **Conclusion**:
+```swift
+// ✅ This is working correctly
+let imageCache = NSCache()
+// Holds up to 1200 images by design
+// Clears when system memory pressure happens
+// No leak
+```
+
+**Result**: No action needed. The "leak" is actually the cache doing its job.
+
+---
+
+## Regression-Proofing Pipeline
+
+Performance work isn't done when the fix ships. Without regression detection, optimizations quietly degrade over time. The three-stage pipeline catches regressions at every phase.
+
+### The Three Stages
+
+| Stage | Tool | When | Catches |
+|-------|------|------|---------|
+| Dev | OSSignposter | Writing code | Specific operation timing |
+| CI | XCTest performance tests | Every PR | Regression vs baseline |
+| Production | MetricKit | After release | Real-world degradation |
+
+### Stage 1: Instrument Your Code (OSSignposter)
+
+See OSSignposter section above. Add signpost intervals to performance-critical code paths.
+
+### Stage 2: Automate with XCTest Performance Tests
+
+```swift
+func testDataLoadPerformance() throws {
+ let options = XCTMeasureOptions()
+ options.iterationCount = 10
+
+ measure(metrics: [
+ XCTClockMetric(), // Wall clock time
+ XCTCPUMetric(), // CPU time and cycles
+ XCTMemoryMetric(), // Peak physical memory
+ ], options: options) {
+ loadData()
+ }
+}
+```
+
+#### Available XCTMetric Types
+
+- **XCTClockMetric** — Wall clock duration
+- **XCTCPUMetric** — CPU time, instructions retired, cycles
+- **XCTMemoryMetric** — Peak physical memory during test
+- **XCTStorageMetric** — Logical writes to storage
+- **XCTOSSignpostMetric** — Duration of signposted intervals (bridges Stage 1 → Stage 2)
+- **XCTApplicationLaunchMetric** — App launch time (cold/warm/optimized)
+- **XCTHitchMetric** — Hitch time ratio (scrolling and animation hitches)
+
+#### Setting Baselines
+
+After running once, click the value in Xcode's test results → "Set Baseline". Subsequent runs compare against baseline and fail if regression exceeds tolerance (default 10%).
+
+#### Anti-Pattern: Baseline-Less Performance Tests
+
+```swift
+// ❌ Test always passes — no baseline set
+func testPerformance() {
+ measure { doWork() }
+}
+
+// ✅ Set baseline in Xcode after first run
+// Tests fail when performance regresses beyond tolerance
+```
+
+#### Bridging Signposts to Tests (XCTOSSignpostMetric)
+
+```swift
+// In production code
+let signposter = OSSignposter(subsystem: "com.app", category: "Sync")
+
+func syncData() {
+ let id = signposter.makeSignpostID()
+ let state = signposter.beginInterval("Full Sync", id: id)
+ defer { signposter.endInterval("Full Sync", state) }
+ // ... sync logic
+}
+
+// In test
+func testSyncPerformance() {
+ let metric = XCTOSSignpostMetric(
+ subsystem: "com.app",
+ category: "Sync",
+ name: "Full Sync"
+ )
+ measure(metrics: [metric]) {
+ syncData()
+ }
+}
+```
+
+### Stage 3: Monitor in Production (MetricKit)
+
+See `axiom-metrickit-ref` for comprehensive MetricKit integration. Key metrics to monitor:
+
+- `MXAppLaunchMetric` — Launch time regression
+- `MXAppResponsivenessMetric` — Hang rate increase
+- `MXCPUMetric` — CPU time per foreground session
+- `MXMemoryMetric` — Peak memory growth across versions
+
+---
+
+## Resources
+
+**WWDC**: 2023-10160, 2024-10217, 2025-308, 2025-312
+
+**Docs**: /library/archive/documentation/cocoa/conceptual/coredataperformance, /library/archive/technotes/tn2224, /os/ossignposter, /xctest/xctestcase/measure
+
+**Skills**: axiom-memory-debugging, axiom-swiftui-performance, axiom-swift-concurrency, axiom-metrickit-ref
+
+---
+
+**Targets:** iOS 14+, Swift 5.5+
+**Tools:** Instruments, Core Data
+**History:** See git log for changes
diff --git a/.claude/skills/axiom-performance-profiling/agents/openai.yaml b/.claude/skills/axiom-performance-profiling/agents/openai.yaml
new file mode 100644
index 0000000..6a560f8
--- /dev/null
+++ b/.claude/skills/axiom-performance-profiling/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Performance Profiling"
+ short_description: "App feels slow, memory grows over time, battery drains fast, or you want to profile proactively"
diff --git a/.claude/skills/axiom-photo-library-ref/.openskills.json b/.claude/skills/axiom-photo-library-ref/.openskills.json
new file mode 100644
index 0000000..33691d5
--- /dev/null
+++ b/.claude/skills/axiom-photo-library-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-photo-library-ref",
+ "installedAt": "2026-04-12T08:06:32.217Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-photo-library-ref/SKILL.md b/.claude/skills/axiom-photo-library-ref/SKILL.md
new file mode 100644
index 0000000..3b6917b
--- /dev/null
+++ b/.claude/skills/axiom-photo-library-ref/SKILL.md
@@ -0,0 +1,815 @@
+---
+name: axiom-photo-library-ref
+description: Reference — PHPickerViewController, PHPickerConfiguration, PhotosPicker, PhotosPickerItem, Transferable, PHPhotoLibrary, PHAsset, PHAssetCreationRequest, PHFetchResult, PHAuthorizationStatus, limited library APIs
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Photo Library API Reference
+
+## Quick Reference
+
+```swift
+// SWIFTUI PHOTO PICKER (iOS 16+)
+import PhotosUI
+
+@State private var item: PhotosPickerItem?
+
+PhotosPicker(selection: $item, matching: .images) {
+ Text("Select Photo")
+}
+.onChange(of: item) { _, newItem in
+ Task {
+ if let data = try? await newItem?.loadTransferable(type: Data.self) {
+ // Use image data
+ }
+ }
+}
+
+// UIKIT PHOTO PICKER (iOS 14+)
+var config = PHPickerConfiguration()
+config.selectionLimit = 1
+config.filter = .images
+let picker = PHPickerViewController(configuration: config)
+picker.delegate = self
+
+// SAVE TO CAMERA ROLL
+try await PHPhotoLibrary.shared().performChanges {
+ PHAssetCreationRequest.creationRequestForAsset(from: image)
+}
+
+// CHECK PERMISSION
+let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
+```
+
+---
+
+## PHPickerViewController (iOS 14+)
+
+System photo picker for UIKit apps. No permission required.
+
+### Configuration
+
+```swift
+import PhotosUI
+
+var config = PHPickerConfiguration()
+
+// Selection limit (0 = unlimited)
+config.selectionLimit = 5
+
+// Filter by asset type
+config.filter = .images
+
+// Use photo library (enables asset identifiers)
+config = PHPickerConfiguration(photoLibrary: .shared())
+
+// Preferred asset representation
+config.preferredAssetRepresentationMode = .automatic // default
+// .current - original format
+// .compatible - converted to compatible format
+```
+
+### Filter Options
+
+```swift
+// Basic filters
+PHPickerFilter.images
+PHPickerFilter.videos
+PHPickerFilter.livePhotos
+
+// Combined filters
+PHPickerFilter.any(of: [.images, .videos])
+
+// Exclusion filters (iOS 15+)
+PHPickerFilter.all(of: [.images, .not(.screenshots)])
+PHPickerFilter.not(.livePhotos)
+
+// Playback style filters (iOS 17+)
+PHPickerFilter.any(of: [.cinematicVideos, .slomoVideos])
+```
+
+### Presenting
+
+```swift
+let picker = PHPickerViewController(configuration: config)
+picker.delegate = self
+present(picker, animated: true)
+```
+
+### Delegate
+
+```swift
+extension ViewController: PHPickerViewControllerDelegate {
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ picker.dismiss(animated: true)
+
+ for result in results {
+ // Get asset identifier (if using PHPickerConfiguration(photoLibrary:))
+ let identifier = result.assetIdentifier
+
+ // Load as UIImage
+ result.itemProvider.loadObject(ofClass: UIImage.self) { object, error in
+ guard let image = object as? UIImage else { return }
+ DispatchQueue.main.async {
+ self.displayImage(image)
+ }
+ }
+
+ // Load as Data
+ result.itemProvider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, error in
+ guard let data else { return }
+ // Use data
+ }
+
+ // Load Live Photo
+ result.itemProvider.loadObject(ofClass: PHLivePhoto.self) { object, error in
+ guard let livePhoto = object as? PHLivePhoto else { return }
+ // Use live photo
+ }
+ }
+ }
+}
+```
+
+### PHPickerResult Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `itemProvider` | NSItemProvider | Provides selected asset data |
+| `assetIdentifier` | String? | PHAsset identifier (if using photoLibrary config) |
+
+---
+
+## PhotosPicker (SwiftUI, iOS 16+)
+
+SwiftUI view for photo selection. No permission required.
+
+### Basic Usage
+
+```swift
+import SwiftUI
+import PhotosUI
+
+// Single selection
+@State private var selectedItem: PhotosPickerItem?
+
+PhotosPicker(selection: $selectedItem, matching: .images) {
+ Label("Select Photo", systemImage: "photo")
+}
+
+// Multiple selection
+@State private var selectedItems: [PhotosPickerItem] = []
+
+PhotosPicker(
+ selection: $selectedItems,
+ maxSelectionCount: 5,
+ matching: .images
+) {
+ Text("Select Photos")
+}
+```
+
+### Filters
+
+```swift
+// Images only
+matching: .images
+
+// Videos only
+matching: .videos
+
+// Images and videos
+matching: .any(of: [.images, .videos])
+
+// Live Photos
+matching: .livePhotos
+
+// Exclude screenshots (iOS 15+)
+matching: .all(of: [.images, .not(.screenshots)])
+```
+
+### Selection Behavior
+
+```swift
+PhotosPicker(
+ selection: $items,
+ maxSelectionCount: 10,
+ selectionBehavior: .ordered, // .default, .ordered, .continuous
+ matching: .images
+) { ... }
+```
+
+| Behavior | Description |
+|----------|-------------|
+| `.default` | Standard multi-select |
+| `.ordered` | Selection order preserved |
+| `.continuous` | Live updates as user selects (iOS 17+) |
+
+### Embedded Picker (iOS 17+)
+
+```swift
+PhotosPicker(
+ selection: $items,
+ maxSelectionCount: 10,
+ selectionBehavior: .continuous,
+ matching: .images
+) {
+ Text("Select")
+}
+.photosPickerStyle(.inline) // Embed in view hierarchy
+.photosPickerDisabledCapabilities([.selectionActions])
+.photosPickerAccessoryVisibility(.hidden, edges: .all)
+```
+
+| Style | Description |
+|-------|-------------|
+| `.presentation` | Modal sheet (default) |
+| `.inline` | Embedded in view |
+| `.compact` | Single row |
+
+| Disabled Capability | Effect |
+|---------------------|--------|
+| `.search` | Hide search bar |
+| `.collectionNavigation` | Hide albums |
+| `.stagingArea` | Hide selection review |
+| `.selectionActions` | Hide Add/Cancel |
+
+| Accessory Visibility | Description |
+|----------------------|-------------|
+| `.hidden`, `.automatic`, `.visible` | Per edge |
+
+### HDR Preservation (iOS 17+)
+
+```swift
+PhotosPicker(
+ selection: $items,
+ matching: .images,
+ preferredItemEncoding: .current // Don't transcode, preserve HDR
+) { ... }
+```
+
+| Encoding | Description |
+|----------|-------------|
+| `.automatic` | System decides format |
+| `.current` | Original format, preserves HDR |
+| `.compatible` | Force compatible format |
+
+### Loading Images from PhotosPickerItem
+
+```swift
+// Load as Data (most reliable)
+if let data = try? await item.loadTransferable(type: Data.self),
+ let image = UIImage(data: data) {
+ // Use image
+}
+
+// Custom Transferable for direct UIImage
+struct ImageTransferable: Transferable {
+ let image: UIImage
+
+ static var transferRepresentation: some TransferRepresentation {
+ DataRepresentation(importedContentType: .image) { data in
+ guard let image = UIImage(data: data) else {
+ throw TransferError.importFailed
+ }
+ return ImageTransferable(image: image)
+ }
+ }
+}
+
+// Usage
+if let result = try? await item.loadTransferable(type: ImageTransferable.self) {
+ let image = result.image
+}
+```
+
+### PhotosPickerItem Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `itemIdentifier` | String | Unique identifier |
+| `supportedContentTypes` | [UTType] | Available representations |
+
+### PhotosPickerItem Methods
+
+```swift
+// Load transferable
+func loadTransferable(type: T.Type) async throws -> T?
+
+// Load with progress
+func loadTransferable(
+ type: T.Type,
+ completionHandler: @escaping (Result) -> Void
+) -> Progress
+```
+
+---
+
+## PHPhotoLibrary
+
+Access and modify the photo library.
+
+### Authorization Status
+
+```swift
+// Check current status
+let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
+
+// Request authorization
+let newStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
+```
+
+### PHAuthorizationStatus
+
+| Status | Description |
+|--------|-------------|
+| `.notDetermined` | User hasn't been asked |
+| `.restricted` | Parental controls limit access |
+| `.denied` | User denied access |
+| `.authorized` | Full access granted |
+| `.limited` | Access to user-selected photos only (iOS 14+) |
+
+### Access Levels
+
+```swift
+// Read and write
+PHPhotoLibrary.requestAuthorization(for: .readWrite)
+
+// Add only (save photos, no reading)
+PHPhotoLibrary.requestAuthorization(for: .addOnly)
+```
+
+### Limited Library Picker
+
+```swift
+// Present picker to expand limited selection
+@MainActor
+func presentLimitedLibraryPicker() {
+ guard let viewController = UIApplication.shared.keyWindow?.rootViewController else { return }
+ PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController)
+}
+
+// With completion handler
+PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: viewController) { identifiers in
+ // identifiers: asset IDs user added
+}
+```
+
+### Performing Changes
+
+```swift
+// Async changes
+try await PHPhotoLibrary.shared().performChanges {
+ // Create, update, or delete assets
+}
+
+// With completion handler
+PHPhotoLibrary.shared().performChanges({
+ // Changes
+}) { success, error in
+ // Handle result
+}
+```
+
+### Change Observer
+
+```swift
+class PhotoObserver: NSObject, PHPhotoLibraryChangeObserver {
+
+ override init() {
+ super.init()
+ PHPhotoLibrary.shared().register(self)
+ }
+
+ deinit {
+ PHPhotoLibrary.shared().unregisterChangeObserver(self)
+ }
+
+ func photoLibraryDidChange(_ changeInstance: PHChange) {
+ // Handle changes
+ guard let changes = changeInstance.changeDetails(for: fetchResult) else { return }
+
+ DispatchQueue.main.async {
+ // Update UI with new fetch result
+ let newResult = changes.fetchResultAfterChanges
+ }
+ }
+}
+```
+
+---
+
+## PHAsset
+
+Represents an asset in the photo library.
+
+### Fetching Assets
+
+```swift
+// All photos
+let allPhotos = PHAsset.fetchAssets(with: .image, options: nil)
+
+// With options
+let options = PHFetchOptions()
+options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
+options.fetchLimit = 100
+options.predicate = NSPredicate(format: "mediaType == %d", PHAssetMediaType.image.rawValue)
+
+let recentPhotos = PHAsset.fetchAssets(with: options)
+
+// By identifier
+let assets = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil)
+```
+
+### Asset Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `localIdentifier` | String | Unique ID |
+| `mediaType` | PHAssetMediaType | `.image`, `.video`, `.audio` |
+| `mediaSubtypes` | PHAssetMediaSubtype | `.photoLive`, `.photoPanorama`, etc. |
+| `pixelWidth` | Int | Width in pixels |
+| `pixelHeight` | Int | Height in pixels |
+| `creationDate` | Date? | When taken |
+| `modificationDate` | Date? | Last modified |
+| `location` | CLLocation? | GPS location |
+| `duration` | TimeInterval | Video duration |
+| `isFavorite` | Bool | Marked as favorite |
+| `isHidden` | Bool | In hidden album |
+
+### PHAssetMediaType
+
+| Type | Value |
+|------|-------|
+| `.unknown` | 0 |
+| `.image` | 1 |
+| `.video` | 2 |
+| `.audio` | 3 |
+
+### PHAssetMediaSubtype
+
+| Subtype | Description |
+|---------|-------------|
+| `.photoPanorama` | Panoramic photo |
+| `.photoHDR` | HDR photo |
+| `.photoScreenshot` | Screenshot |
+| `.photoLive` | Live Photo |
+| `.photoDepthEffect` | Portrait mode |
+| `.videoStreamed` | Streamed video |
+| `.videoHighFrameRate` | Slo-mo video |
+| `.videoTimelapse` | Timelapse |
+| `.videoCinematic` | Cinematic mode |
+
+---
+
+## PHAssetCreationRequest
+
+Create new assets in the photo library.
+
+### Creating from UIImage
+
+```swift
+try await PHPhotoLibrary.shared().performChanges {
+ PHAssetCreationRequest.creationRequestForAsset(from: image)
+}
+```
+
+### Creating from File URL
+
+```swift
+try await PHPhotoLibrary.shared().performChanges {
+ PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: imageURL)
+}
+
+// For video
+try await PHPhotoLibrary.shared().performChanges {
+ PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: videoURL)
+}
+```
+
+### Creating with Resources
+
+```swift
+try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCreationRequest.forAsset()
+
+ // Add photo resource
+ let options = PHAssetResourceCreationOptions()
+ options.shouldMoveFile = true // Move instead of copy
+
+ request.addResource(with: .photo, fileURL: photoURL, options: options)
+
+ // Set creation date
+ request.creationDate = Date()
+
+ // Set location
+ request.location = CLLocation(latitude: 37.7749, longitude: -122.4194)
+}
+```
+
+### Deferred Photo Proxy (iOS 17+)
+
+Save camera proxy photos for background processing:
+
+```swift
+// From AVCaptureDeferredPhotoProxy callback
+try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCreationRequest.forAsset()
+
+ // Use .photoProxy to trigger deferred processing
+ request.addResource(with: .photoProxy, data: proxyData, options: nil)
+}
+```
+
+| Resource Type | Description |
+|---------------|-------------|
+| `.photo` | Standard photo |
+| `.video` | Video file |
+| `.photoProxy` | Deferred processing proxy (iOS 17+) |
+| `.adjustmentData` | Edit adjustments |
+
+### Getting Created Asset
+
+```swift
+try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCreationRequest.forAsset()
+ request.addResource(with: .photo, fileURL: url, options: nil)
+
+ // Get placeholder for later fetching
+ let placeholder = request.placeholderForCreatedAsset
+ // placeholder.localIdentifier available after changes complete
+}
+```
+
+### Custom Albums
+
+```swift
+// Create a custom album
+func getOrCreateAlbum(named title: String) async throws -> PHAssetCollection {
+ // Check if album already exists
+ let fetchOptions = PHFetchOptions()
+ fetchOptions.predicate = NSPredicate(format: "title = %@", title)
+ let existing = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions)
+ if let album = existing.firstObject { return album }
+
+ // Create new album
+ var placeholder: PHObjectPlaceholder?
+ try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: title)
+ placeholder = request.placeholderForCreatedAssetCollection
+ }
+ guard let id = placeholder?.localIdentifier,
+ let album = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: nil).firstObject
+ else { throw PhotoError.albumCreationFailed }
+ return album
+}
+
+// Save photo to custom album
+func saveToAlbum(_ image: UIImage, album: PHAssetCollection) async throws {
+ try await PHPhotoLibrary.shared().performChanges {
+ let assetRequest = PHAssetCreationRequest.creationRequestForAsset(from: image)
+ guard let placeholder = assetRequest.placeholderForCreatedAsset,
+ let albumRequest = PHAssetCollectionChangeRequest(for: album) else { return }
+ albumRequest.addAssets([placeholder] as NSFastEnumeration)
+ }
+}
+```
+
+---
+
+## PHFetchResult
+
+Ordered list of assets from a fetch.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `count` | Int | Number of items |
+| `firstObject` | T? | First item |
+| `lastObject` | T? | Last item |
+
+### Methods
+
+```swift
+// Access by index
+let asset = fetchResult.object(at: 0)
+let asset = fetchResult[0]
+
+// Get multiple
+let assets = fetchResult.objects(at: IndexSet(0..<10))
+
+// Iteration
+fetchResult.enumerateObjects { asset, index, stop in
+ // Process asset
+ if shouldStop {
+ stop.pointee = true
+ }
+}
+
+// Check contains
+let contains = fetchResult.contains(asset)
+let index = fetchResult.index(of: asset)
+```
+
+---
+
+## PHImageManager
+
+Request images from assets.
+
+### Request Image
+
+```swift
+let manager = PHImageManager.default()
+
+let options = PHImageRequestOptions()
+options.deliveryMode = .highQualityFormat
+options.resizeMode = .exact
+options.isNetworkAccessAllowed = true // For iCloud photos
+
+let targetSize = CGSize(width: 300, height: 300)
+
+manager.requestImage(
+ for: asset,
+ targetSize: targetSize,
+ contentMode: .aspectFill,
+ options: options
+) { image, info in
+ guard let image else { return }
+
+ // Check if this is the final image
+ let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
+ if !isDegraded {
+ // Final high-quality image
+ }
+}
+```
+
+### PHImageRequestOptions
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `deliveryMode` | PHImageRequestOptionsDeliveryMode | Quality preference |
+| `resizeMode` | PHImageRequestOptionsResizeMode | Resize behavior |
+| `isNetworkAccessAllowed` | Bool | Allow iCloud download |
+| `isSynchronous` | Bool | Synchronous request |
+| `progressHandler` | Block | Download progress |
+| `allowSecondaryDegradedImage` | Bool | Extra callback during deferred processing (iOS 17+) |
+
+### Secondary Degraded Image (iOS 17+)
+
+For photos undergoing deferred processing, get an intermediate quality image:
+
+```swift
+let options = PHImageRequestOptions()
+options.allowSecondaryDegradedImage = true
+
+// Callback order:
+// 1. Low quality (immediate, isDegraded = true)
+// 2. Medium quality (new, isDegraded = true) -- while processing
+// 3. Final quality (isDegraded = false)
+```
+
+### Delivery Modes
+
+| Mode | Description |
+|------|-------------|
+| `.opportunistic` | Fast thumbnail, then high quality |
+| `.highQualityFormat` | Only high quality |
+| `.fastFormat` | Only fast/degraded |
+
+### Request Video
+
+```swift
+manager.requestAVAsset(forVideo: asset, options: nil) { avAsset, audioMix, info in
+ guard let avAsset else { return }
+ // Use AVAsset for playback
+}
+
+// Or export to file
+manager.requestExportSession(
+ forVideo: asset,
+ options: nil,
+ exportPreset: AVAssetExportPresetHighestQuality
+) { session, info in
+ session?.outputURL = outputURL
+ session?.outputFileType = .mp4
+ session?.exportAsynchronously { ... }
+}
+```
+
+---
+
+## PHChange
+
+Represents changes to the photo library.
+
+### Getting Change Details
+
+```swift
+func photoLibraryDidChange(_ changeInstance: PHChange) {
+ guard let changes = changeInstance.changeDetails(for: fetchResult) else { return }
+
+ // Check what changed
+ let hasIncrementalChanges = changes.hasIncrementalChanges
+ let insertedIndexes = changes.insertedIndexes
+ let removedIndexes = changes.removedIndexes
+ let changedIndexes = changes.changedIndexes
+
+ // Get new fetch result
+ let newResult = changes.fetchResultAfterChanges
+
+ // Update collection view
+ DispatchQueue.main.async {
+ if hasIncrementalChanges {
+ collectionView.performBatchUpdates {
+ if let removed = removedIndexes {
+ collectionView.deleteItems(at: removed.map { IndexPath(item: $0, section: 0) })
+ }
+ if let inserted = insertedIndexes {
+ collectionView.insertItems(at: inserted.map { IndexPath(item: $0, section: 0) })
+ }
+ if let changed = changedIndexes {
+ collectionView.reloadItems(at: changed.map { IndexPath(item: $0, section: 0) })
+ }
+ }
+ } else {
+ collectionView.reloadData()
+ }
+ }
+}
+```
+
+---
+
+## Common Code Patterns
+
+### Complete Photo Gallery View
+
+```swift
+import SwiftUI
+import Photos
+
+@MainActor
+class PhotoGalleryViewModel: ObservableObject {
+ @Published var assets: [PHAsset] = []
+ @Published var authorizationStatus: PHAuthorizationStatus = .notDetermined
+
+ func requestAccess() async {
+ authorizationStatus = await PHPhotoLibrary.requestAuthorization(for: .readWrite)
+
+ if authorizationStatus == .authorized || authorizationStatus == .limited {
+ fetchAssets()
+ }
+ }
+
+ func fetchAssets() {
+ let options = PHFetchOptions()
+ options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
+ options.fetchLimit = 100
+
+ let result = PHAsset.fetchAssets(with: .image, options: options)
+ assets = result.objects(at: IndexSet(0..
+NSPhotoLibraryUsageDescription
+Access your photos to share them
+
+
+NSPhotoLibraryAddUsageDescription
+Save photos to your library
+```
+
+## Core Patterns
+
+### Pattern 1: SwiftUI PhotosPicker (iOS 16+)
+
+**Use case**: Let users select photos in a SwiftUI app.
+
+```swift
+import SwiftUI
+import PhotosUI
+
+struct ContentView: View {
+ @State private var selectedItem: PhotosPickerItem?
+ @State private var selectedImage: Image?
+
+ var body: some View {
+ VStack {
+ PhotosPicker(
+ selection: $selectedItem,
+ matching: .images // Filter to images only
+ ) {
+ Label("Select Photo", systemImage: "photo")
+ }
+
+ if let image = selectedImage {
+ image
+ .resizable()
+ .scaledToFit()
+ }
+ }
+ .onChange(of: selectedItem) { _, newItem in
+ Task {
+ await loadImage(from: newItem)
+ }
+ }
+ }
+
+ private func loadImage(from item: PhotosPickerItem?) async {
+ guard let item else {
+ selectedImage = nil
+ return
+ }
+
+ // Load as Data first (more reliable than Image)
+ if let data = try? await item.loadTransferable(type: Data.self),
+ let uiImage = UIImage(data: data) {
+ selectedImage = Image(uiImage: uiImage)
+ }
+ }
+}
+```
+
+**Multi-selection**:
+```swift
+@State private var selectedItems: [PhotosPickerItem] = []
+
+PhotosPicker(
+ selection: $selectedItems,
+ maxSelectionCount: 5,
+ matching: .images
+) {
+ Text("Select Photos")
+}
+```
+
+#### Advanced Filters (iOS 15+/16+)
+
+```swift
+// Screenshots only
+matching: .screenshots
+
+// Screen recordings only
+matching: .screenRecordings
+
+// Slo-mo videos
+matching: .sloMoVideos
+
+// Cinematic videos (iOS 16+)
+matching: .cinematicVideos
+
+// Depth effect photos
+matching: .depthEffectPhotos
+
+// Bursts
+matching: .bursts
+
+// Compound filters with .any, .all, .not
+// Videos AND Live Photos
+matching: .any(of: [.videos, .livePhotos])
+
+// All images EXCEPT screenshots
+matching: .all(of: [.images, .not(.screenshots)])
+
+// All images EXCEPT screenshots AND panoramas
+matching: .all(of: [.images, .not(.any(of: [.screenshots, .panoramas]))])
+```
+
+**Cost**: 15 min implementation, no permissions required
+
+### Pattern 1b: Embedded PhotosPicker (iOS 17+)
+
+**Use case**: Embed picker inline in your UI instead of presenting as sheet.
+
+```swift
+import SwiftUI
+import PhotosUI
+
+struct EmbeddedPickerView: View {
+ @State private var selectedItems: [PhotosPickerItem] = []
+
+ var body: some View {
+ VStack {
+ // Your content above picker
+ SelectedPhotosGrid(items: selectedItems)
+
+ // Embedded picker fills available space
+ PhotosPicker(
+ selection: $selectedItems,
+ maxSelectionCount: 10,
+ selectionBehavior: .continuous, // Live updates as user taps
+ matching: .images
+ ) {
+ // Label is ignored for inline style
+ Text("Select")
+ }
+ .photosPickerStyle(.inline) // Embed instead of present
+ .photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel buttons
+ .photosPickerAccessoryVisibility(.hidden, edges: .all) // Hide nav/toolbar
+ .frame(height: 300) // Control picker height
+ .ignoresSafeArea(.container, edges: .bottom) // Extend to bottom edge
+ }
+ }
+}
+```
+
+**Picker Styles**:
+
+| Style | Description |
+|-------|-------------|
+| `.presentation` | Default modal sheet |
+| `.inline` | Embedded in your view hierarchy |
+| `.compact` | Single row, minimal vertical space |
+
+**Customization modifiers**:
+
+```swift
+// Hide navigation/toolbar accessories
+.photosPickerAccessoryVisibility(.hidden, edges: .all)
+.photosPickerAccessoryVisibility(.hidden, edges: .top) // Just navigation bar
+.photosPickerAccessoryVisibility(.hidden, edges: .bottom) // Just toolbar
+
+// Disable capabilities (hides UI for them)
+.photosPickerDisabledCapabilities([.search]) // Hide search
+.photosPickerDisabledCapabilities([.collectionNavigation]) // Hide albums
+.photosPickerDisabledCapabilities([.stagingArea]) // Hide selection review
+.photosPickerDisabledCapabilities([.selectionActions]) // Hide Add/Cancel
+
+// Continuous selection for live updates
+selectionBehavior: .continuous
+```
+
+**Privacy note**: First time an embedded picker appears, iOS shows an onboarding UI explaining your app can only access selected photos. A privacy badge indicates the picker is out-of-process.
+
+### Pattern 2: UIKit PHPickerViewController (iOS 14+)
+
+**Use case**: Photo selection in UIKit apps.
+
+```swift
+import PhotosUI
+
+class PhotoPickerViewController: UIViewController, PHPickerViewControllerDelegate {
+
+ func showPicker() {
+ var config = PHPickerConfiguration()
+ config.selectionLimit = 1 // 0 = unlimited
+ config.filter = .images // or .videos, .any(of: [.images, .videos])
+
+ let picker = PHPickerViewController(configuration: config)
+ picker.delegate = self
+ present(picker, animated: true)
+ }
+
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
+ picker.dismiss(animated: true)
+
+ guard let result = results.first else { return }
+
+ // Load image asynchronously
+ result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
+ guard let image = object as? UIImage else { return }
+
+ DispatchQueue.main.async {
+ self?.displayImage(image)
+ }
+ }
+ }
+}
+```
+
+**Filter options**:
+```swift
+// Images only
+config.filter = .images
+
+// Videos only
+config.filter = .videos
+
+// Live Photos only
+config.filter = .livePhotos
+
+// Images and videos
+config.filter = .any(of: [.images, .videos])
+
+// Exclude screenshots (iOS 15+)
+config.filter = .all(of: [.images, .not(.screenshots)])
+
+// iOS 16+ filters
+config.filter = .cinematicVideos
+config.filter = .depthEffectPhotos
+config.filter = .bursts
+```
+
+#### UIKit Embedded Picker (iOS 17+)
+
+```swift
+// Configure for embedded use
+var config = PHPickerConfiguration()
+config.selection = .continuous // Live updates instead of waiting for Add button
+config.mode = .compact // Single row layout (optional)
+config.selectionLimit = 10
+
+// Hide accessories
+config.edgesWithoutContentMargins = .all // No margins around picker
+
+// Disable capabilities
+config.disabledCapabilities = [.search, .selectionActions]
+
+let picker = PHPickerViewController(configuration: config)
+picker.delegate = self
+
+// Add as child view controller (required for embedded)
+addChild(picker)
+containerView.addSubview(picker.view)
+picker.view.frame = containerView.bounds
+picker.didMove(toParent: self)
+```
+
+**Updating picker while displayed (iOS 17+)**:
+```swift
+// Deselect assets by their identifiers
+picker.deselectAssets(withIdentifiers: ["assetID1", "assetID2"])
+
+// Reorder assets in selection
+picker.moveAsset(withIdentifier: "assetID", afterAssetWithIdentifier: "otherID")
+```
+
+**Cost**: 20 min implementation, no permissions required
+
+### Pattern 2b: Options Menu & HDR Support (iOS 17+)
+
+The picker now shows an Options menu letting users choose to strip location metadata from photos. This works automatically with PhotosPicker and PHPicker.
+
+**Preserving HDR content**:
+
+By default, picker may transcode to JPEG, losing HDR data. To receive original format:
+
+```swift
+// SwiftUI - Use .current encoding to preserve HDR
+PhotosPicker(
+ selection: $selectedItems,
+ matching: .images,
+ preferredItemEncoding: .current // Don't transcode
+) { ... }
+
+// Loading with original format preservation
+struct HDRImage: Transferable {
+ let data: Data
+
+ static var transferRepresentation: some TransferRepresentation {
+ DataRepresentation(importedContentType: .image) { data in
+ HDRImage(data: data)
+ }
+ }
+}
+
+// Request .image content type (generic) not .jpeg (specific)
+let result = try await item.loadTransferable(type: HDRImage.self)
+```
+
+**UIKit equivalent**:
+```swift
+var config = PHPickerConfiguration()
+config.preferredAssetRepresentationMode = .current // Don't transcode
+```
+
+**Cinematic mode videos**: Picker returns rendered version with depth effects baked in. To get original with decision points, use PhotoKit with library access instead.
+
+### Pattern 3: Handling Limited Library Access
+
+**Use case**: User granted limited access; let them add more photos.
+
+**Suppressing automatic prompt** (iOS 14+):
+
+By default, iOS shows "Select More Photos" prompt when `.limited` is detected. To handle it yourself:
+
+```xml
+
+PHPhotoLibraryPreventAutomaticLimitedAccessAlert
+
+```
+
+**Manual limited access handling**:
+
+```swift
+import Photos
+
+class PhotoLibraryManager {
+
+ func checkAndRequestAccess() async -> PHAuthorizationStatus {
+ let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
+
+ switch status {
+ case .notDetermined:
+ return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
+
+ case .limited:
+ // User granted limited access - show UI to expand
+ await presentLimitedLibraryPicker()
+ return .limited
+
+ case .authorized:
+ return .authorized
+
+ case .denied, .restricted:
+ return status
+
+ @unknown default:
+ return status
+ }
+ }
+
+ @MainActor
+ func presentLimitedLibraryPicker() {
+ guard let windowScene = UIApplication.shared.connectedScenes
+ .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
+ let rootVC = windowScene.windows.first?.rootViewController else {
+ return
+ }
+
+ PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: rootVC)
+ }
+}
+```
+
+**Observe limited selection changes**:
+```swift
+// Register for changes
+PHPhotoLibrary.shared().register(self)
+
+// In delegate
+func photoLibraryDidChange(_ changeInstance: PHChange) {
+ // User may have modified their limited selection
+ // Refresh your photo grid
+}
+```
+
+**Cost**: 30 min implementation
+
+### Pattern 4: Saving Photos to Camera Roll
+
+**Use case**: Save captured or edited photos.
+
+```swift
+import Photos
+
+func saveImageToLibrary(_ image: UIImage) async throws {
+ // Request add-only permission (minimal access)
+ let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
+
+ guard status == .authorized || status == .limited else {
+ throw PhotoError.permissionDenied
+ }
+
+ try await PHPhotoLibrary.shared().performChanges {
+ PHAssetCreationRequest.creationRequestForAsset(from: image)
+ }
+}
+
+// With metadata preservation
+func savePhotoData(_ data: Data, metadata: [String: Any]? = nil) async throws {
+ try await PHPhotoLibrary.shared().performChanges {
+ let request = PHAssetCreationRequest.forAsset()
+
+ // Write data to temp file for addResource
+ let tempURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("jpg")
+ try? data.write(to: tempURL)
+
+ request.addResource(with: .photo, fileURL: tempURL, options: nil)
+ }
+}
+```
+
+**Cost**: 15 min implementation
+
+### Pattern 5: Loading Images from PhotosPickerItem
+
+**Use case**: Properly handle async image loading with error handling.
+
+**The problem**: Default `Image` Transferable only supports PNG. Most photos are JPEG/HEIF.
+
+```swift
+// Custom Transferable for any image format
+struct TransferableImage: Transferable {
+ let image: UIImage
+
+ static var transferRepresentation: some TransferRepresentation {
+ DataRepresentation(importedContentType: .image) { data in
+ guard let image = UIImage(data: data) else {
+ throw TransferError.importFailed
+ }
+ return TransferableImage(image: image)
+ }
+ }
+
+ enum TransferError: Error {
+ case importFailed
+ }
+}
+
+// Usage
+func loadImage(from item: PhotosPickerItem) async -> UIImage? {
+ do {
+ let result = try await item.loadTransferable(type: TransferableImage.self)
+ return result?.image
+ } catch {
+ print("Failed to load image: \(error)")
+ return nil
+ }
+}
+```
+
+**Loading with progress**:
+```swift
+func loadImageWithProgress(from item: PhotosPickerItem) async -> UIImage? {
+ let progress = Progress()
+
+ return await withCheckedContinuation { continuation in
+ _ = item.loadTransferable(type: TransferableImage.self) { result in
+ switch result {
+ case .success(let transferable):
+ continuation.resume(returning: transferable?.image)
+ case .failure:
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+}
+```
+
+**Cost**: 20 min implementation
+
+### Pattern 6: Observing Photo Library Changes
+
+**Use case**: Keep your gallery UI in sync with Photos app.
+
+```swift
+import Photos
+
+class PhotoGalleryViewModel: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
+ @Published var photos: [PHAsset] = []
+
+ private var fetchResult: PHFetchResult?
+
+ override init() {
+ super.init()
+ PHPhotoLibrary.shared().register(self)
+ fetchPhotos()
+ }
+
+ deinit {
+ PHPhotoLibrary.shared().unregisterChangeObserver(self)
+ }
+
+ func fetchPhotos() {
+ let options = PHFetchOptions()
+ options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
+ fetchResult = PHAsset.fetchAssets(with: .image, options: options)
+
+ photos = fetchResult?.objects(at: IndexSet(0..<(fetchResult?.count ?? 0))) ?? []
+ }
+
+ func photoLibraryDidChange(_ changeInstance: PHChange) {
+ guard let fetchResult = fetchResult,
+ let changes = changeInstance.changeDetails(for: fetchResult) else {
+ return
+ }
+
+ DispatchQueue.main.async {
+ self.fetchResult = changes.fetchResultAfterChanges
+ self.photos = changes.fetchResultAfterChanges.objects(at:
+ IndexSet(0..
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyCollectedDataTypes
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+
+
+
+```
+
+### NSPrivacyTracking Declaration
+
+**Does your app track users?**
+
+Tracking = combining user/device data from your app with data from other apps/websites to create a profile for targeted advertising or data broker purposes.
+
+```xml
+NSPrivacyTracking
+
+```
+
+**If `true`**, you must also declare tracking domains:
+
+```xml
+NSPrivacyTrackingDomains
+
+ tracking.example.com
+ analytics.example.com
+
+```
+
+**iOS 17 behavior**: Network requests to tracking domains **automatically blocked** if user hasn't granted ATT permission.
+
+### NSPrivacyCollectedDataTypes
+
+Declare all data your app collects:
+
+```xml
+NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypeName
+
+ NSPrivacyCollectedDataTypeLinked
+
+
+ NSPrivacyCollectedDataTypeTracking
+
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+ NSPrivacyCollectedDataTypePurposeAnalytics
+
+
+
+```
+
+**Common data types**:
+- `NSPrivacyCollectedDataTypeName` - User's name
+- `NSPrivacyCollectedDataTypeEmailAddress`
+- `NSPrivacyCollectedDataTypePhoneNumber`
+- `NSPrivacyCollectedDataTypePhysicalAddress`
+- `NSPrivacyCollectedDataTypePreciseLocation`
+- `NSPrivacyCollectedDataTypeCoarseLocation`
+- `NSPrivacyCollectedDataTypePhotosorVideos`
+- `NSPrivacyCollectedDataTypeContacts`
+- `NSPrivacyCollectedDataTypeUserID`
+
+**Common purposes**:
+- `NSPrivacyCollectedDataTypePurposeAppFunctionality`
+- `NSPrivacyCollectedDataTypePurposeAnalytics`
+- `NSPrivacyCollectedDataTypePurposeProductPersonalization`
+- `NSPrivacyCollectedDataTypePurposeDeveloperAdvertising`
+- `NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising`
+
+### NSPrivacyAccessedAPITypes
+
+Declare Required Reason APIs (see Part 5):
+
+```xml
+NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+```
+
+---
+
+## Part 2: Permission Request UX
+
+### Just-in-Time vs Up-Front
+
+**❌ Don't**: Request all permissions at launch
+```swift
+// BAD - overwhelming and confusing
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ requestCameraPermission()
+ requestLocationPermission()
+ requestNotificationPermission()
+ requestPhotoLibraryPermission()
+ return true
+}
+```
+
+**✅ Do**: Request just-in-time when user triggers feature
+```swift
+// GOOD - clear causality
+@objc func takePhotoButtonTapped() {
+ // Show pre-permission education first
+ showCameraEducation {
+ // Then request permission
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ if granted {
+ self.openCamera()
+ } else {
+ self.showPermissionDeniedAlert()
+ }
+ }
+ }
+}
+```
+
+### Pre-Permission Education Screens
+
+Explain **why** you need permission **before** showing system dialog:
+
+```swift
+func showCameraEducation(completion: @escaping () -> Void) {
+ let alert = UIAlertController(
+ title: "Take Photos",
+ message: "FoodSnap needs camera access to let you photograph your meals and get nutrition information.",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in
+ completion() // Now request actual permission
+ })
+
+ alert.addAction(UIAlertAction(title: "Not Now", style: .cancel))
+
+ present(alert, animated: true)
+}
+```
+
+**Why this works**:
+- User understands value proposition
+- System dialog rejection rate drops 60-80%
+- Better App Store ratings (fewer "why does it need that?" reviews)
+
+### Permission Denied Handling
+
+**Never dead-end the user**:
+
+```swift
+func handleCameraPermission() {
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ openCamera()
+
+ case .notDetermined:
+ showCameraEducation {
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ if granted {
+ self.openCamera()
+ } else {
+ self.showSettingsPrompt()
+ }
+ }
+ }
+
+ case .denied, .restricted:
+ showSettingsPrompt() // Offer to open Settings
+
+ @unknown default:
+ break
+ }
+}
+
+func showSettingsPrompt() {
+ let alert = UIAlertController(
+ title: "Camera Access Required",
+ message: "Please enable camera access in Settings to use this feature.",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Open Settings", style: .default) { _ in
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ })
+
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+
+ present(alert, animated: true)
+}
+```
+
+### Settings Deep Links
+
+Open specific settings screens:
+
+```swift
+// General app settings
+UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+
+// Notification settings (iOS 15.4+)
+UIApplication.shared.open(URL(string: UIApplication.openNotificationSettingsURLString)!)
+```
+
+---
+
+## Part 3: App Tracking Transparency
+
+### When ATT Is Required
+
+You **must** request ATT permission if you:
+- Track users across apps/websites owned by other companies
+- Share user data with data brokers
+- Use third-party SDKs that track (Facebook SDK, Google Analytics, etc.)
+
+You **don't** need ATT if you **only**:
+- Use first-party analytics (no sharing with other companies)
+- Personalize ads based only on data from your own app
+- Use fraud detection/security measures
+
+### ATTrackingManager.requestTrackingAuthorization
+
+```swift
+import AppTrackingTransparency
+import AdSupport
+
+func requestTrackingPermission() {
+ // Check availability (iOS 14.5+)
+ guard #available(iOS 14.5, *) else { return }
+
+ // Wait until app is active
+ // Showing alert too early causes auto-denial
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ ATTrackingManager.requestTrackingAuthorization { status in
+ switch status {
+ case .authorized:
+ // User granted permission
+ // You can now access IDFA and track
+ let idfa = ASIdentifierManager.shared().advertisingIdentifier
+ self.initializeTrackingSDKs(idfa: idfa)
+
+ case .denied:
+ // User denied permission
+ // Do NOT track
+ self.initializeNonTrackingSDKs()
+
+ case .notDetermined:
+ // User closed dialog without choosing
+ // Treat as denied
+ self.initializeNonTrackingSDKs()
+
+ case .restricted:
+ // Device doesn't allow tracking (parental controls)
+ self.initializeNonTrackingSDKs()
+
+ @unknown default:
+ self.initializeNonTrackingSDKs()
+ }
+ }
+ }
+}
+```
+
+### Custom ATT Prompt Message
+
+**Info.plist**:
+```xml
+NSUserTrackingUsageDescription
+This allows us to show you personalized ads and improve your experience
+```
+
+**Best practices**:
+- Be honest and specific
+- Explain user benefit (not company benefit)
+- Keep it concise (1-2 sentences)
+
+**❌ Bad examples**:
+- "We value your privacy" (vague)
+- "This is required for the app to work" (dishonest)
+- "To monetize our app" (user doesn't care)
+
+**✅ Good examples**:
+- "This helps us show you relevant ads for products you might like"
+- "Personalized ads help keep this app free"
+
+### Pre-Tracking Prompt Design
+
+Show your own dialog before ATT system prompt:
+
+```swift
+func showPreTrackingPrompt() {
+ let alert = UIAlertController(
+ title: "Support Free Features",
+ message: "We use tracking to show you personalized ads, which helps keep advanced features free. You can always change this in Settings.",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Continue", style: .default) { _ in
+ self.requestTrackingPermission()
+ })
+
+ alert.addAction(UIAlertAction(title: "Not Now", style: .cancel))
+
+ present(alert, animated: true)
+}
+```
+
+**Why this works**: Education increases opt-in rates by 20-40%.
+
+### Graceful Degradation
+
+**Always provide value without tracking**:
+
+```swift
+func initializeAnalytics() {
+ let status = ATTrackingManager.trackingAuthorizationStatus
+
+ if status == .authorized {
+ // Full featured analytics
+ Analytics.setUserProperty(userID, forName: "user_id")
+ Analytics.enableCrossAppTracking()
+ } else {
+ // Limited, privacy-preserving analytics
+ Analytics.setUserProperty("anonymous", forName: "user_id")
+ Analytics.disableCrossAppTracking()
+ Analytics.enableOnDeviceConversionTracking()
+ }
+}
+```
+
+---
+
+## Part 4: Tracking Domain Management
+
+### Declaring Tracking Domains
+
+In `PrivacyInfo.xcprivacy`:
+
+```xml
+NSPrivacyTracking
+
+
+NSPrivacyTrackingDomains
+
+ tracking.example.com
+ ads.example.com
+
+```
+
+**iOS 17 behavior**: If user denies ATT, network requests to these domains are **automatically blocked**.
+
+### Domain Separation Strategy
+
+**Problem**: Single domain used for both tracking and non-tracking
+
+**Solution**: Separate functionality into different hosts
+
+```
+Before:
+- api.example.com (mixed tracking + app functionality)
+
+After:
+- api.example.com (app functionality only)
+- tracking.example.com (tracking only)
+```
+
+**Update manifest**:
+```xml
+NSPrivacyTrackingDomains
+
+ tracking.example.com
+
+```
+
+Result: App functionality continues working; tracking blocked if denied.
+
+### Points of Interest Instrument (Xcode 15+)
+
+**Detecting unexpected tracking connections**:
+
+1. Xcode → Product → Profile
+2. Choose "Points of Interest" instrument
+3. Run app
+4. Look for "Privacy" track showing network connections
+5. Review flagged domains
+
+**What it shows**: Connections to domains that may be tracking users across apps/websites.
+
+**Action**: Declare these domains in `NSPrivacyTrackingDomains` or stop connecting to them.
+
+---
+
+## Part 5: Required Reason APIs
+
+### What Are Required Reason APIs?
+
+APIs that **could** be misused for fingerprinting (identifying devices without permission).
+
+**Fingerprinting is never allowed**, even with ATT permission.
+
+**Required Reason APIs have approved use cases**. You must declare which approved reason applies to your usage.
+
+### Common Required Reason APIs
+
+| API Category | Examples | Approved Reason Codes |
+|--------------|----------|----------------------|
+| **File timestamp** | `creationDate`, `modificationDate` | `C617.1` - `DDA9.1` |
+| **System boot time** | `systemUptime`, `processInfo.systemUptime` | `35F9.1`, `8FFB.1` |
+| **Disk space** | `NSFileSystemFreeSize`, `volumeAvailableCapacity` | `E174.1`, `7D9E.1` |
+| **Active keyboards** | `activeInputModes` | `54BD.1`, `3EC4.1` |
+| **User defaults** | `UserDefaults` | `CA92.1`, `1C8F.1`, `C56D.1` |
+
+### Example: Disk Space API
+
+**API**: `NSFileSystemFreeSize` / `URLResourceKey.volumeAvailableCapacityKey`
+
+**Approved reasons**:
+- **E174.1**: Check if there's enough space before writing files
+- **7D9E.1**: Display storage information to user
+- **B728.1**: Include disk space in optional analytics (only if user opted in)
+
+**Declaration in manifest**:
+```xml
+NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+```
+
+**Code**:
+```swift
+func checkDiskSpace() -> Bool {
+ do {
+ let values = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
+
+ if let freeSpace = values[.systemFreeSize] as? NSNumber {
+ let requiredSpace: Int64 = 100 * 1024 * 1024 // 100 MB
+ return freeSpace.int64Value > requiredSpace
+ }
+ } catch {
+ print("Error checking disk space: \(error)")
+ }
+
+ return false
+}
+
+// Usage
+if checkDiskSpace() {
+ saveFile() // Approved reason E174.1: Check before writing
+} else {
+ showInsufficientSpaceAlert()
+}
+```
+
+### Example: UserDefaults API
+
+**Approved reasons**:
+- **CA92.1**: Access info stored by app (settings, preferences)
+- **1C8F.1**: Access info stored by App Group
+- **C56D.1**: Access info stored by App Clips
+- **AC6B.1**: Third-party SDK accessing its own defaults
+
+**Declaration**:
+```xml
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+```
+
+### Feedback for Missing Reasons
+
+If your use case isn't covered, use Apple's feedback form:
+https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api
+
+---
+
+## Part 6: Privacy Nutrition Labels
+
+### Data Types and Categories
+
+**Identifiers**:
+- User ID
+- Device ID
+
+**Contact Info**:
+- Name
+- Email address
+- Phone number
+- Physical address
+
+**Location**:
+- Precise location
+- Coarse location
+
+**User Content**:
+- Photos or videos
+- Audio data
+- Gameplay content
+- Customer support messages
+
+**Browsing History**
+**Search History**
+**Financial Info**
+**Health & Fitness**
+**Contacts**
+**Sensitive Info** (racial/ethnic data, political opinions, religious beliefs)
+
+### Data Use Purposes
+
+- **App functionality** - Necessary for core features
+- **Analytics** - Understanding app usage
+- **Product personalization** - Customizing experience
+- **Developer advertising** - Ads for your own products
+- **Third-party advertising** - Ads from other companies
+
+### Linked vs Not Linked
+
+**Linked to user**:
+- Data connected to user identity (name, email, user ID)
+- Example: User profile information
+
+**Not linked to user**:
+- Data not connected to identity (anonymous analytics)
+- Example: Aggregate crash reports
+
+### Tracking Disclosure
+
+Data is used for **tracking** if:
+- Combined with data from other apps/websites
+- Shared with data brokers
+- Used for targeted advertising based on cross-app behavior
+
+**Example declaration**:
+```
+Data Type: Email Address
+Purpose: App Functionality
+Linked to User: Yes
+Used for Tracking: No
+```
+
+---
+
+## Part 7: Xcode Privacy Report
+
+### Generating Report
+
+1. Archive app: Product → Archive
+2. Xcode Organizer → Select archive
+3. Right-click → "Generate Privacy Report"
+4. PDF created showing aggregated privacy data
+
+**What's included**:
+- All privacy manifests (app + third-party SDKs)
+- Collected data types
+- Tracking declaration
+- Required Reason APIs
+
+### Reviewing Report
+
+**Check for**:
+- Unexpected data collection (SDK collecting data you didn't know about)
+- Missing Required Reason declarations
+- Tracking domain discrepancies
+- Third-party SDKs without privacy manifests
+
+**Use for**: Completing Privacy Nutrition Labels in App Store Connect
+
+---
+
+## Part 8: Permission Types
+
+### Camera
+
+```swift
+import AVFoundation
+
+AVCaptureDevice.requestAccess(for: .video) { granted in
+ // Handle response
+}
+
+// Info.plist
+NSCameraUsageDescription
+Take photos of your meals to track nutrition
+```
+
+### Microphone
+
+```swift
+AVAudioSession.sharedInstance().requestRecordPermission { granted in
+ // Handle response
+}
+
+NSMicrophoneUsageDescription
+Record voice memos
+```
+
+### Location
+
+```swift
+import CoreLocation
+
+class LocationManager: NSObject, CLLocationManagerDelegate {
+ let manager = CLLocationManager()
+
+ func requestPermission() {
+ manager.delegate = self
+
+ // Choose one:
+ manager.requestWhenInUseAuthorization() // Only when app is open
+ // OR
+ manager.requestAlwaysAuthorization() // Background location
+ }
+}
+
+// Info.plist (iOS 14+)
+NSLocationWhenInUseUsageDescription
+Show nearby restaurants
+
+NSLocationAlwaysAndWhenInUseUsageDescription
+Track your runs even when the app is in the background
+```
+
+### Photos
+
+```swift
+import Photos
+
+PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
+ switch status {
+ case .authorized, .limited: // .limited = selected photos only
+ // Access granted
+ case .denied, .restricted:
+ // Access denied
+ @unknown default:
+ break
+ }
+}
+
+NSPhotoLibraryUsageDescription
+Save and share your workout photos
+```
+
+### Contacts
+
+```swift
+import Contacts
+
+CNContactStore().requestAccess(for: .contacts) { granted, error in
+ // Handle response
+}
+
+NSContactsUsageDescription
+Invite friends to join you
+```
+
+### Notifications
+
+```swift
+import UserNotifications
+
+UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
+ // Handle response
+}
+
+// No Info.plist entry required
+```
+
+---
+
+## Part 9: Privacy-First Design Patterns
+
+### Data Minimization
+
+**Principle**: Only collect data you actually need
+
+```swift
+// ❌ Bad - collecting unnecessary data
+struct UserProfile {
+ let name: String
+ let email: String
+ let phone: String // Do you really need this?
+ let dateOfBirth: Date // Or this?
+ let socialSecurityNumber: String // Definitely not
+}
+
+// ✅ Good - minimal data collection
+struct UserProfile {
+ let name: String
+ let email: String
+ // That's it
+}
+```
+
+### On-Device Processing
+
+**Principle**: Process data locally when possible
+
+```swift
+// ✅ Good - on-device ML
+import Vision
+
+func analyzePhoto(_ image: UIImage) {
+ let request = VNClassifyImageRequest { request, error in
+ // Results stay on device
+ let classifications = request.results as? [VNClassificationObservation]
+ self.displayResults(classifications)
+ }
+
+ let handler = VNImageRequestHandler(cgImage: image.cgImage!)
+ try? handler.perform([request])
+ // No network request, no data leaving device
+}
+```
+
+### Explaining Value Exchange
+
+**Principle**: Be transparent about why you need data
+
+```swift
+// ✅ Good - clear value proposition
+"We use your location to show nearby restaurants and save your favorite places. Your location is never shared with third parties."
+```
+
+### Transparent Data Practices
+
+**Principle**: Make privacy information easily accessible
+
+```swift
+// Add Privacy Policy link in Settings screen
+struct SettingsView: View {
+ var body: some View {
+ List {
+ Section("About") {
+ Link("Privacy Policy", destination: URL(string: "https://example.com/privacy")!)
+ Link("Data We Collect", destination: URL(string: "https://example.com/data")!)
+ }
+ }
+ }
+}
+```
+
+---
+
+## Common Mistakes
+
+### Requesting permissions at launch
+
+```swift
+// ❌ Wrong
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions...) -> Bool {
+ requestAllPermissions() // User has no context
+ return true
+}
+
+// ✅ Correct
+@objc func cameraButtonTapped() {
+ requestCameraPermission() // Just-in-time
+}
+```
+
+### No explanation before permission dialog
+
+```swift
+// ❌ Wrong
+AVCaptureDevice.requestAccess(for: .video) { granted in }
+
+// ✅ Correct
+showCameraEducation {
+ AVCaptureDevice.requestAccess(for: .video) { granted in }
+}
+```
+
+### Not handling denial gracefully
+
+```swift
+// ❌ Wrong - dead end
+if !granted {
+ return // User stuck
+}
+
+// ✅ Correct - offer alternative
+if !granted {
+ showSettingsPrompt() // Path forward
+}
+```
+
+### Missing tracking domains
+
+```swift
+// ❌ Wrong - privacy manifest declares tracking but no domains
+NSPrivacyTracking
+
+
+
+// ✅ Correct
+NSPrivacyTrackingDomains
+
+ tracking.example.com
+
+```
+
+### Incomplete Required Reason declarations
+
+```swift
+// ❌ Wrong - using UserDefaults without declaring it
+UserDefaults.standard.set(value, forKey: "setting")
+// Privacy manifest has no NSPrivacyAccessedAPITypes entry
+
+// ✅ Correct - declared in manifest with approved reason
+```
+
+---
+
+## Timeline
+
+| Date | Milestone |
+|------|-----------|
+| **WWDC 2023** | Privacy manifests announced |
+| **Fall 2023** | Informational emails begin |
+| **Spring 2024** | App Review enforcement begins |
+| **May 1, 2024** | Privacy manifests required for apps with privacy-impacting SDKs |
+
+---
+
+## Resources
+
+**WWDC**: 2023-10060, 2023-10053
+
+**Docs**: /bundleresources/privacy_manifest_files, /bundleresources/describing-use-of-required-reason-api, /app-store/app-privacy-details, /app-store/user-privacy-and-data-use
+
+**Skills**: axiom-app-intents-ref, axiom-cloudkit-ref, axiom-storage
diff --git a/.claude/skills/axiom-privacy-ux/agents/openai.yaml b/.claude/skills/axiom-privacy-ux/agents/openai.yaml
new file mode 100644
index 0000000..5aa3695
--- /dev/null
+++ b/.claude/skills/axiom-privacy-ux/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Privacy UX"
+ short_description: "Implementing privacy manifests, requesting permissions, App Tracking Transparency UX, or preparing Privacy Nutrition ..."
diff --git a/.claude/skills/axiom-profile-performance/.openskills.json b/.claude/skills/axiom-profile-performance/.openskills.json
new file mode 100644
index 0000000..146f041
--- /dev/null
+++ b/.claude/skills/axiom-profile-performance/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-profile-performance",
+ "installedAt": "2026-04-12T08:06:32.603Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-profile-performance/SKILL.md b/.claude/skills/axiom-profile-performance/SKILL.md
new file mode 100644
index 0000000..19d894b
--- /dev/null
+++ b/.claude/skills/axiom-profile-performance/SKILL.md
@@ -0,0 +1,276 @@
+---
+name: axiom-profile-performance
+description: Use when the user wants automated performance profiling, headless Instruments analysis, or CLI-based trace collection.
+license: MIT
+disable-model-invocation: true
+---
+
+
+> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.
+# Performance Profiler Agent
+
+You are an expert at automated performance profiling using `xctrace` CLI.
+
+## Core Principle
+
+**Measurement before optimization.** Record actual performance data, analyze it programmatically, and provide actionable findings—all without requiring the Instruments GUI.
+
+## Your Mission
+
+When the user requests performance profiling:
+1. Detect available targets (simulators, devices, running apps)
+2. Help user select what to profile (if not specified)
+3. Record a trace with appropriate instrument
+4. Export and analyze the data
+5. Report findings with severity and recommendations
+
+## Mandatory First Steps
+
+**ALWAYS run these discovery commands FIRST**:
+
+```bash
+# 1. Check for booted simulators
+echo "=== Booted Simulators ==="
+xcrun simctl list devices booted -j 2>/dev/null | jq -r '.devices | to_entries[] | .value[] | "\(.name) (\(.udid))"'
+
+# 2. Find running apps in simulator (if any simulator is booted)
+echo ""
+echo "=== Running Simulator Apps ==="
+BOOTED_UDID=$(xcrun simctl list devices booted -j 2>/dev/null | jq -r '.devices | to_entries[] | .value[0].udid // empty' | head -1)
+if [ -n "$BOOTED_UDID" ]; then
+ xcrun simctl spawn "$BOOTED_UDID" launchctl list 2>/dev/null | grep UIKitApplication | head -10
+else
+ echo "No booted simulator found"
+fi
+
+# 3. Check if user specified an app (look for common app processes)
+echo ""
+echo "=== Mac Apps (for reference) ==="
+pgrep -lf "\.app" 2>/dev/null | grep -vE "com\.apple|Xcode|Simulator|Google|Chrome|Safari|Finder|Dock" | head -5
+```
+
+### Interpreting Results
+
+**Ready to profile**:
+- Booted simulator with running app → Use simulator profiling
+- User specifies app name → Use that name
+
+**Need user input**:
+- Multiple booted simulators → Ask which one
+- No running app specified → Ask what to profile
+- No simulators booted → Ask if they want to boot one or profile a mac app
+
+## Template Selection
+
+Choose the right instrument based on user request:
+
+| User Says | Instrument | Time Limit |
+|-----------|------------|------------|
+| "CPU", "slow", "performance", "Time Profiler" | `CPU Profiler` | 10s |
+| "memory", "allocations", "RAM" | `Allocations` | 30s |
+| "leaks", "retain cycle" | `Leaks` | 30s |
+| "SwiftUI", "view updates", "body" | `SwiftUI` | 10s |
+| "launch", "startup", "cold start" | (special workflow) | n/a |
+| "concurrency", "actors", "tasks" | `Swift Tasks` + `Swift Actors` | 10s |
+| (unspecified) | `CPU Profiler` | 10s |
+
+## Recording Workflow
+
+### Standard Profiling (Attach to Running App)
+
+```bash
+# Create temp directory for traces
+TRACE_DIR="/tmp/axiom-traces"
+mkdir -p "$TRACE_DIR"
+
+# Get simulator UUID
+SIMULATOR_UDID=$(xcrun simctl list devices booted -j | jq -r '.devices | to_entries[] | .value[0].udid' | head -1)
+
+# Record trace (replace INSTRUMENT and APP_NAME)
+xcrun xctrace record \
+ --instrument 'CPU Profiler' \
+ --device "$SIMULATOR_UDID" \
+ --attach 'APP_NAME' \
+ --time-limit 10s \
+ --no-prompt \
+ --output "$TRACE_DIR/profile.trace"
+```
+
+### Launch Profiling (App Launch Time)
+
+```bash
+# For app launch profiling, use --launch instead of --attach
+# First, find the app bundle
+APP_PATH=$(find ~/Library/Developer/CoreSimulator/Devices/*/data/Containers/Bundle/Application -name "*.app" -type d 2>/dev/null | grep -i "AppName" | head -1)
+
+# Or for Mac app
+APP_PATH="/Applications/AppName.app"
+
+# Record launch
+xcrun xctrace record \
+ --instrument 'CPU Profiler' \
+ --time-limit 30s \
+ --no-prompt \
+ --output "$TRACE_DIR/launch.trace" \
+ --launch -- "$APP_PATH"
+```
+
+### All-Processes Profiling
+
+```bash
+# For general system profiling (when no specific app)
+xcrun xctrace record \
+ --instrument 'CPU Profiler' \
+ --device "$SIMULATOR_UDID" \
+ --all-processes \
+ --time-limit 10s \
+ --no-prompt \
+ --output "$TRACE_DIR/system.trace"
+```
+
+## Export and Analysis
+
+### Export Trace Data
+
+```bash
+# First, check what data is available
+echo "=== Available Tables ==="
+xcrun xctrace export --input "$TRACE_DIR/profile.trace" --toc 2>&1 | grep -E '
"$TRACE_DIR/cpu-profile.xml" 2>&1
+```
+
+### Analyze CPU Profile
+
+Look for in the exported XML:
+1. **High cycle counts** - Functions with large `` values
+2. **Main thread activity** - Samples on "Main Thread" (affects UI responsiveness)
+3. **Hot functions** - Functions appearing frequently in backtraces
+
+```bash
+# Quick analysis: Find processes with most samples
+echo "=== Process Sample Counts ==="
+grep -o 'process.*fmt="[^"]*"' "$TRACE_DIR/cpu-profile.xml" | sort | uniq -c | sort -rn | head -10
+
+# Find most common function frames
+echo ""
+echo "=== Hot Functions ==="
+grep -o 'name="[^"]*"' "$TRACE_DIR/cpu-profile.xml" | sort | uniq -c | sort -rn | head -20
+```
+
+## Output Format
+
+Provide a clear, structured report:
+
+```markdown
+## Performance Profile Results
+
+### Recording Summary
+- **Instrument**: [CPU Profiler/Allocations/Leaks/SwiftUI]
+- **Target**: [App name or "All Processes"]
+- **Device**: [Simulator name or "Mac"]
+- **Duration**: [10s/30s]
+- **Trace file**: [path]
+
+### Key Findings
+
+#### CRITICAL
+- [Issue with highest impact]
+
+#### HIGH
+- [Significant issues]
+
+#### MEDIUM
+- [Notable patterns]
+
+### Top Hot Functions
+| Rank | Function | Samples | % of Total |
+|------|----------|---------|------------|
+| 1 | function_name | 150 | 15% |
+| 2 | ... | ... | ... |
+
+### Recommendations
+1. [Specific actionable recommendation]
+2. [Next investigation step]
+
+### Next Steps
+- To investigate further: [specific command or action]
+- To open in Instruments GUI: `open [trace path]`
+```
+
+## Decision Tree
+
+```
+User requests profiling
+↓
+Run mandatory discovery (simulators, running apps)
+↓
+├─ User specified app name → Use that name
+├─ Multiple options available → Ask user to choose
+├─ No targets found → Help user boot simulator or specify app
+↓
+Determine instrument from user request
+↓
+├─ CPU/slow/performance → CPU Profiler
+├─ Memory/allocations → Allocations
+├─ Leaks → Leaks
+├─ SwiftUI → SwiftUI
+├─ Launch → Launch workflow
+├─ Unspecified → CPU Profiler (default)
+↓
+Record trace (10-30s depending on instrument)
+↓
+Export to XML
+↓
+Analyze and report findings
+```
+
+## Error Handling
+
+### Common Issues
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| "Unable to attach to process" | App not running | Ask user to launch app first |
+| "No such device" | Wrong UDID | Re-run device discovery |
+| "Document Missing Template Error" | Used --template | Use --instrument instead |
+| Empty trace | No activity during recording | Ask user to interact with app during profile |
+| Permission denied | Privacy settings | Check System Preferences > Privacy |
+
+### When to Stop and Report
+
+If you encounter:
+- No simulators or apps to profile → Help user set up
+- Recording fails repeatedly → Report error details
+- Export produces no data → Note the issue, suggest GUI Instruments
+- User needs real-time analysis → Suggest opening trace in Instruments GUI
+
+## Cleanup
+
+After analysis is complete:
+
+```bash
+# Offer to clean up traces
+echo "Traces saved in: $TRACE_DIR"
+echo "To open in Instruments: open '$TRACE_DIR/profile.trace'"
+echo "To clean up: rm -rf '$TRACE_DIR'"
+```
+
+## Tips for Better Profiles
+
+1. **Warm up first**: Run the slow operation once before profiling to avoid cold-cache effects
+2. **Isolate the issue**: Profile just the slow operation, not the entire app
+3. **Sufficient duration**: 10s minimum for CPU, 30s for memory/leaks
+4. **Active usage**: Interact with the app during profiling to capture real behavior
+5. **Multiple runs**: Consider profiling 2-3 times to identify consistent patterns
+
+## Related
+
+- `axiom-xctrace-ref` — Full xctrace CLI reference
+- `axiom-performance-profiling` — Manual Instruments workflows
+- `axiom-memory-debugging` — Memory leak diagnosis
+- `axiom-swiftui-performance` — SwiftUI-specific profiling
diff --git a/.claude/skills/axiom-profile-performance/agents/openai.yaml b/.claude/skills/axiom-profile-performance/agents/openai.yaml
new file mode 100644
index 0000000..5a26338
--- /dev/null
+++ b/.claude/skills/axiom-profile-performance/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Profile Performance"
+ short_description: "The user wants automated performance profiling, headless Instruments analysis, or CLI-based trace collection."
diff --git a/.claude/skills/axiom-push-notifications-diag/.openskills.json b/.claude/skills/axiom-push-notifications-diag/.openskills.json
new file mode 100644
index 0000000..b259766
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-push-notifications-diag",
+ "installedAt": "2026-04-12T08:06:33.331Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-push-notifications-diag/SKILL.md b/.claude/skills/axiom-push-notifications-diag/SKILL.md
new file mode 100644
index 0000000..9b68cd0
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-diag/SKILL.md
@@ -0,0 +1,532 @@
+---
+name: axiom-push-notifications-diag
+description: Use when push notifications fail to arrive, token registration errors occur, notifications work in development but not production, silent push does not wake app, rich notification media is missing, or Live Activity stops updating via push. Covers APNs errors, environment mismatches, Focus mode filtering, service extension failures, FCM diagnostics.
+license: MIT
+---
+
+# Push Notification Diagnostics
+
+Systematic troubleshooting for push notification failures: missing notifications, token registration errors, environment mismatches, silent push throttling, and service extension problems.
+
+## Overview
+
+**Core Principle**: When push notifications don't work, the problem is usually:
+1. **Token/registration failures** (never registered, wrong format, expired) — 30%
+2. **Entitlement/provisioning mismatch** (capability missing, wrong environment) — 25%
+3. **Payload structure errors** (missing keys, wrong types, invalid JSON) — 15%
+4. **Focus/interruption suppression** (iOS 15+ filtering, provisional auth) — 15%
+5. **Service extension failures** (timeout, crash, missing mutable-content) — 10%
+6. **Delivery timing/throttling** (silent push budget, APNs coalescing) — 5%
+
+**Always verify entitlements and token registration BEFORE debugging payload or delivery logic.**
+
+## Red Flags
+
+Symptoms that indicate push-specific issues:
+
+| Symptom | Likely Cause |
+|---------|--------------|
+| No notifications at all | Missing Push Notification capability or provisioning profile |
+| Works in dev, not production | Sending to sandbox APNs with production token (or vice versa) |
+| Token registration fails on Simulator | Expected — Simulator cannot register for remote notifications |
+| Notifications appear without sound | Missing .sound in authorization options or payload |
+| Rich notification shows plain text | Missing mutable-content: 1 in payload |
+| Image not showing in notification | Service extension failed silently — check serviceExtensionTimeWillExpire |
+| Silent push not waking app | System throttling (~2-3/hour), or app was force-quit by user |
+| Notifications stopped after iOS update | Focus mode enabled by default in iOS 15+; check interruption level |
+| Badge shows wrong number | Multiple notifications sent without explicit badge count reset |
+| Actions not appearing | Category identifier mismatch between payload and registered categories |
+| Notification appears twice | Both local and remote notification scheduled for same event |
+| FCM works on Android, not iOS | Missing APNs auth key upload in Firebase Console |
+
+## Anti-Rationalization
+
+| Rationalization | Why It Fails | Time Cost |
+|----------------|--------------|-----------|
+| "It worked yesterday, so entitlements are fine" | Provisioning profiles get regenerated during signing changes. Always re-verify. | 30-60 min debugging code when the profile lost push capability |
+| "The server says their payload is fine" | 55% of push failures are client-side (entitlements + tokens). Verify independently with curl. | 1-2 hours of finger-pointing before someone checks |
+| "I'll skip token verification, the error is clearly in the payload" | Wrong-environment tokens are the #1 cause of "works in dev, not production." | 30+ min debugging valid payloads sent to invalid tokens |
+| "Focus mode doesn't matter, we use default interruption level" | Default (`active`) is filtered by Focus. Only `time-sensitive` and `critical` break through. | Hours adding code workarounds for a payload-level fix |
+| "Silent push is reliable, we use it for sync" | System throttles to ~2-3/hour and ignores force-quit apps. It's a hint, not a guarantee. | Architecture rework when silent push can't sustain real-time sync |
+| "Service extension is set up, so rich notifications should work" | Extension needs correct bundle ID suffix, mutable-content in payload, AND completing within 30s. | 30+ min when any one of the three prerequisites is missing |
+| "FCM handles everything, I don't need to understand APNs" | FCM wraps APNs. Token type confusion, missing p8 key upload, and swizzling conflicts are all APNs-level problems. | Hours debugging FCM when the issue is APNs configuration |
+| "I'll test on Simulator first" | Simulator cannot register for remote notifications. No APNs token = no real push testing. | Wasted test cycle discovering Simulator limitations |
+| "Let me rewrite the notification handler" | 80% of push failures are configuration (entitlements, tokens, environment), not code. | Hours rewriting working code while the config stays broken |
+| "This worked on iOS 17, the bug must be in our code" | Each iOS version changes Focus defaults, interruption filtering, and provisional behavior. | Debugging code when the fix is a payload or Settings change |
+
+## Mandatory First Steps
+
+Before investigating code, run these diagnostics:
+
+### Step 1: Verify Push Notification Entitlements
+
+```bash
+security cms -D -i path/to/embedded.mobileprovision | grep -A1 "aps-environment"
+```
+
+**Expected output**:
+- ✅ `development` or `production` → Entitlement present
+- ❌ No aps-environment key → Push Notifications capability not enabled in Xcode
+
+**How to find the provisioning profile**:
+```bash
+# For installed app on device
+find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3
+```
+
+### Step 2: Check Token Registration
+
+```swift
+func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ print("✅ APNs token: \(token)")
+ print("✅ Token length: \(token.count) chars")
+}
+
+func application(_ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ print("❌ Registration failed: \(error.localizedDescription)")
+}
+```
+
+**Expected output**:
+- ✅ 64-character hex token → Registration successful
+- ❌ "no valid aps-environment entitlement" → Capability misconfigured
+- ❌ No callback fires at all → `registerForRemoteNotifications()` never called
+
+**Critical**: Both callbacks must be in `AppDelegate`, not `SceneDelegate`. SwiftUI apps need `@UIApplicationDelegateAdaptor`.
+
+### Step 3: Validate Payload with curl
+
+```bash
+curl -v \
+ --header "apns-topic: com.your.bundle.id" \
+ --header "apns-push-type: alert" \
+ --header "authorization: bearer $JWT_TOKEN" \
+ --data '{"aps":{"alert":{"title":"Test","body":"Hello"}}}' \
+ --http2 https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKEN
+```
+
+**Expected output**:
+- ✅ HTTP/2 200 → Payload accepted by APNs
+- ❌ 400 BadDeviceToken → Token format wrong or expired
+- ❌ 403 ExpiredProviderToken → JWT older than 1 hour
+- ❌ 403 InvalidProviderToken → Wrong key ID, team ID, or key
+- ❌ 410 Unregistered → App uninstalled or token invalidated
+- ❌ 413 PayloadTooLarge → Exceeds 4096 bytes
+
+### Step 4: Check Authorization Status
+
+```swift
+let settings = await UNUserNotificationCenter.current().notificationSettings()
+print("Authorization: \(settings.authorizationStatus.rawValue)")
+print("Alert: \(settings.alertSetting.rawValue)")
+print("Sound: \(settings.soundSetting.rawValue)")
+print("Badge: \(settings.badgeSetting.rawValue)")
+```
+
+**Expected output**:
+- ✅ authorizationStatus = 2 → Authorized
+- ⚠️ authorizationStatus = 3 → Provisional (appears silently in Notification Center)
+- ❌ authorizationStatus = 1 → Denied by user
+- ❌ authorizationStatus = 0 → Not determined (never requested)
+
+## Decision Trees
+
+### Tree 1: Not Receiving Any Notifications
+
+```
+Not receiving any notifications?
+│
+├─ Check Step 1 (entitlements)
+│ ├─ No aps-environment key?
+│ │ └─ Enable Push Notifications in Signing & Capabilities → DONE
+│ └─ aps-environment present → continue
+│
+├─ Check Step 2 (token registration)
+│ ├─ didFailToRegister called?
+│ │ ├─ "no valid aps-environment" → Regenerate provisioning profile
+│ │ └─ Other error → Check network, device (not Simulator)
+│ ├─ Neither callback fires?
+│ │ └─ Verify registerForRemoteNotifications() called after app launch
+│ └─ Token received → continue
+│
+├─ Check Step 3 (payload delivery)
+│ ├─ HTTP 200 but no notification?
+│ │ └─ Check Step 4 (authorization status)
+│ ├─ 400 BadDeviceToken?
+│ │ └─ Token expired or wrong environment → Re-register
+│ └─ 403/410 error?
+│ └─ Fix auth credentials or re-register device
+│
+└─ Check Step 4 (user authorization)
+ ├─ Status: denied?
+ │ └─ User must enable in Settings → Show settings prompt
+ ├─ Status: notDetermined?
+ │ └─ Call requestAuthorization() → Was never requested
+ └─ Status: authorized but still no notifications?
+ └─ Check Focus mode, Do Not Disturb, notification grouping
+```
+
+### Tree 2: Works in Dev, Not Production
+
+```
+Works in development, fails in production?
+│
+├─ APNs endpoint correct?
+│ ├─ Dev: api.sandbox.push.apple.com
+│ └─ Prod: api.push.apple.com
+│ └─ Using sandbox endpoint with production build? → Switch endpoint
+│
+├─ Token environment matches?
+│ ├─ Dev and production tokens are DIFFERENT
+│ │ └─ Server storing dev token, sending to prod APNs? → Re-register on prod build
+│ └─ Server distinguishes token environments? → Add environment flag to token storage
+│
+├─ Auth method correct?
+│ ├─ .p8 key (token-based)?
+│ │ └─ Same key works for both environments ✅
+│ └─ .p12 certificate?
+│ ├─ Dev cert → Only works with sandbox
+│ └─ Prod cert → Only works with production
+│ └─ Wrong cert for environment? → Generate correct certificate
+│
+└─ Using FCM?
+ ├─ APNs auth key (.p8) uploaded to Firebase Console?
+ │ └─ Missing? → Upload in Project Settings > Cloud Messaging
+ └─ Key uploaded but wrong Team ID?
+ └─ Verify Team ID matches Apple Developer account
+```
+
+### Tree 3: Silent Notifications Not Waking App
+
+```
+Silent push not waking app?
+│
+├─ Payload correct?
+│ ├─ Has "content-available": 1 in aps?
+│ │ └─ Missing? → Add to aps dictionary
+│ ├─ Has NO "alert", "badge", or "sound" in aps?
+│ │ └─ Has alert? → Not a silent push; system treats as visible notification
+│ └─ Payload valid → continue
+│
+├─ Headers correct?
+│ ├─ apns-push-type: background?
+│ │ └─ Missing or wrong? → Must be "background" for silent push
+│ └─ apns-priority: 5?
+│ └─ Using 10? → Silent push MUST use priority 5
+│
+├─ Background mode enabled?
+│ ├─ "Remote notifications" checked in Background Modes capability?
+│ │ └─ Missing? → Enable in Signing & Capabilities
+│ └─ application(_:didReceiveRemoteNotification:fetchCompletionHandler:) implemented?
+│ └─ Missing? → Implement the delegate method
+│
+├─ App state?
+│ ├─ Force-quit by user (swiped up)?
+│ │ └─ System will NOT wake force-quit apps for silent push
+│ └─ Suspended or background?
+│ └─ Should wake — continue debugging
+│
+└─ System throttling?
+ ├─ Budget: ~2-3 silent pushes per hour
+ │ └─ Exceeding? → Reduce frequency, batch updates
+ └─ Device in Low Power Mode?
+ └─ Further reduces background execution budget
+```
+
+### Tree 4: Rich Notification Missing Media
+
+```
+Rich notification not showing image/video?
+│
+├─ Payload has mutable-content: 1?
+│ └─ Missing? → Required for Notification Service Extension to fire
+│
+├─ Notification Service Extension target exists?
+│ ├─ Missing? → File > New > Target > Notification Service Extension
+│ └─ Exists → continue
+│
+├─ Extension bundle ID correct?
+│ ├─ Must be: {app-bundle-id}.{extension-name}
+│ │ Example: com.myapp.NotificationService
+│ └─ Wrong prefix? → Fix bundle ID to match parent app
+│
+├─ Download completing in time?
+│ ├─ Extension has ~30 seconds to modify notification
+│ │ └─ Large file? → Use thumbnail URL, not full resolution
+│ └─ serviceExtensionTimeWillExpire called?
+│ └─ Must deliver bestAttemptContent with fallback text
+│
+├─ Attachment created correctly?
+│ ├─ File written to disk before creating UNNotificationAttachment?
+│ │ └─ Must write to tmp directory, then create attachment from file URL
+│ └─ File type supported?
+│ ├─ Images: JPEG, GIF, PNG (max 10MB)
+│ ├─ Audio: AIFF, WAV, MP3, M4A (max 5MB)
+│ └─ Video: MPEG, MPEG-2, MP4, AVI (max 50MB)
+│
+└─ App groups configured?
+ └─ Extension and app share data via App Groups?
+ └─ Missing? → Add same App Group to both targets
+```
+
+### Tree 5: Live Activity Not Updating via Push
+
+```
+Live Activity not updating from push?
+│
+├─ APNs topic correct?
+│ ├─ Must be: {bundleID}.push-type.liveactivity
+│ │ └─ Using plain bundle ID? → Append .push-type.liveactivity
+│ └─ Topic correct → continue
+│
+├─ Push type header correct?
+│ ├─ apns-push-type: liveactivity?
+│ │ └─ Using "alert"? → Must be "liveactivity"
+│ └─ Correct → continue
+│
+├─ Content-state matches ActivityAttributes.ContentState?
+│ ├─ JSON keys match Swift property names exactly?
+│ │ └─ Mismatch? → Decoding fails silently
+│ └─ Using custom CodingKeys or JSONEncoder strategies?
+│ └─ Custom strategies NOT supported — use default key encoding
+│
+├─ Push token being sent to server?
+│ ├─ Observing pushTokenUpdates on the Activity?
+│ │ └─ Missing? → Must iterate Activity.pushTokenUpdates async sequence
+│ └─ Token changes when Activity restarts?
+│ └─ Must handle token rotation — send updated token to server
+│
+└─ Rate limiting?
+ ├─ Frequent updates: ~10-12 per hour per Activity
+ │ └─ Exceeding? → Batch updates, reduce frequency
+ └─ Alert updates (sound/vibration): ~3-4 per hour
+ └─ Exceeding? → Reserve alerts for critical state changes
+```
+
+### Tree 6: Notifications Stopped After iOS Update
+
+```
+Notifications stopped working after iOS update?
+│
+├─ Focus mode auto-enabled? (iOS 15+)
+│ ├─ Check Settings > Focus
+│ │ └─ Focus active? → App may not be in allowed list
+│ └─ No Focus active → continue
+│
+├─ Interruption level filtering?
+│ ├─ Default level is .active (may be filtered by Focus)
+│ │ └─ Need to break through Focus? → Use .timeSensitive or .critical
+│ ├─ .timeSensitive requires capability
+│ │ └─ Missing? → Add Time Sensitive Notifications capability
+│ └─ .critical requires Apple entitlement
+│ └─ Only for health/safety/security apps — apply via Apple Developer
+│
+├─ Provisional authorization behavior changed?
+│ ├─ iOS 15+ provisional notifications appear in Notification Summary
+│ │ └─ User may not see them → Request full authorization
+│ └─ Was relying on provisional? → Prompt for explicit permission
+│
+└─ Communication notifications require INSendMessageIntent?
+ ├─ iOS 15+ communication notifications need SiriKit intent
+ │ └─ Missing? → Donate INSendMessageIntent before showing notification
+ └─ Intent donated but still filtered?
+ └─ Check that sender is in user's contacts
+```
+
+## Push Notification Console Workflow
+
+Apple's Push Notification Console provides server-free testing:
+
+#### Navigate to Console
+
+1. Open https://icloud.developer.apple.com/dashboard
+2. Select "Push Notifications" from sidebar
+3. Choose your app's bundle ID
+
+#### Send Test Notification
+
+1. Enter device token (from Step 2 diagnostic)
+2. Select environment (Sandbox/Production)
+3. Compose payload or use template
+4. Send and observe delivery status
+
+#### Check Delivery Logs
+
+1. Copy `apns-id` from the response header of your push request
+2. Use Push Notification Console to look up delivery status by `apns-id`
+3. Status shows: accepted, delivered, dropped (with reason)
+
+#### Common Console Findings
+
+| Status | Meaning | Action |
+|--------|---------|--------|
+| Delivered | APNs delivered to device | Problem is on-device (auth, Focus, extension) |
+| Dropped: Unregistered | Token invalid | Re-register device |
+| Dropped: DeviceTokenNotForTopic | Bundle ID mismatch | Fix apns-topic header |
+| Stored | Device offline, will deliver later | Wait or check device connectivity |
+
+## Simulator Testing with simctl
+
+Simulators cannot register for remote notifications, but you can test notification handling:
+
+```bash
+cat > test-push.apns << 'EOF'
+{
+ "Simulator Target Bundle": "com.your.bundle.id",
+ "aps": {
+ "alert": {
+ "title": "Test",
+ "body": "Hello from simctl"
+ },
+ "sound": "default",
+ "mutable-content": 1
+ }
+}
+EOF
+
+xcrun simctl push booted com.your.bundle.id test-push.apns
+```
+
+#### Simulator Limitations
+
+- ✅ Notification appearance and content
+- ✅ Notification Service Extension processing
+- ✅ Notification Content Extension (custom UI)
+- ✅ Action handling and categories
+- ❌ APNs token registration (always fails)
+- ❌ Silent push waking app accurately
+- ❌ Live Activity push updates
+
+#### Drag-and-Drop Alternative
+
+Drag a `.apns` file directly onto the Simulator window to deliver it. Requires `"Simulator Target Bundle"` key in the payload.
+
+## Common FCM Diagnostics
+
+### Swizzling Conflict
+
+**Symptom**: Token callback not firing with Firebase
+
+**Cause**: Method swizzling disabled but manual forwarding not implemented
+
+**Diagnostic**:
+```swift
+// Check Info.plist
+// FirebaseAppDelegateProxyEnabled = NO means YOU must forward tokens
+```
+
+**Fix** (if swizzling disabled):
+```swift
+func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ Messaging.messaging().apnsToken = deviceToken
+}
+```
+
+### Token Mismatch
+
+**Symptom**: Server has FCM token, but APNs delivery fails
+
+**Cause**: FCM token and APNs token are different. FCM wraps APNs token.
+
+**Diagnostic**:
+```swift
+// FCM token (send this to YOUR server)
+Messaging.messaging().token { token, error in
+ print("FCM token: \(token ?? "nil")")
+}
+
+// APNs token (FCM handles this internally)
+// Do NOT send raw APNs token to your server when using FCM
+```
+
+### Missing APNs Key in Firebase Console
+
+**Symptom**: FCM works on Android, notifications not arriving on iOS
+
+**Fix**:
+1. Firebase Console → Project Settings → Cloud Messaging
+2. Upload APNs Authentication Key (.p8)
+3. Enter Key ID and Team ID
+4. Verify bundle ID matches your app
+
+## Quick Reference Table
+
+| Symptom | Check | Fix |
+|---------|-------|-----|
+| No notifications at all | Step 1: entitlements | Enable Push Notification capability, regenerate profile |
+| Token registration fails | Step 2: callbacks | Implement both delegate methods in AppDelegate |
+| 400 BadDeviceToken | Token format | Re-register; check hex encoding (no spaces, no angle brackets) |
+| 403 InvalidProviderToken | JWT/certificate | Regenerate JWT; verify key ID, team ID, bundle ID |
+| 410 Unregistered | Device state | App uninstalled or token rotated — remove from server |
+| Works dev not prod | Step 3: curl both | Switch APNs endpoint; tokens differ per environment |
+| Silent push ignored | Payload + headers | content-available: 1, push-type: background, priority: 5 |
+| Rich media missing | Extension | Add mutable-content: 1, check extension bundle ID and timeout |
+| Live Activity stale | Topic format | Use {bundleID}.push-type.liveactivity topic |
+| Focus mode filtering | Interruption level | Use .timeSensitive for important notifications |
+| FCM iOS failure | Firebase Console | Upload .p8 key with correct Key ID and Team ID |
+| Actions not showing | Category ID | Match category identifier in payload to registered categories |
+
+## Pressure Scenarios
+
+### Scenario 1: "Server team says the problem is on the iOS side"
+
+**Context**: Push notifications stopped working. The backend team says their payload is fine and the problem must be in the app.
+
+**Pressure**: Skip client-side diagnostics and assume the server is right. Start rewriting notification handling code.
+
+**Reality**: 55% of push failures are entitlement/token issues (Steps 1-2), not code bugs. The server may be sending to the wrong environment or using an expired token.
+
+**Correct action**: Run all 4 mandatory diagnostic steps before touching code. Share the curl test (Step 3) results with the server team — this objectively proves which side has the issue.
+
+**Push-back template**: "Let me verify the client-side chain first — I can share the curl results in 5 minutes so we both know exactly where the failure is."
+
+### Scenario 2: "Notifications stopped after iOS update, ship a fix today"
+
+**Context**: Users report notifications stopped working after updating to a new iOS version. Management wants a hotfix today.
+
+**Pressure**: Start debugging notification code immediately. Assume Apple broke something.
+
+**Reality**: New iOS versions often enable Focus mode by default or change interruption level filtering. 15% of push failures are Focus/interruption suppression — no code change needed on your side.
+
+**Correct action**: Check Tree 6 ("Notifications stopped after iOS update"). Verify Focus mode settings on test devices before changing any code. If Focus is filtering, the fix is setting the correct `interruption-level` in the payload, not rewriting notification handling.
+
+**Push-back template**: "iOS updates often change Focus mode defaults. Let me check interruption levels first — if that's the cause, the fix is a one-line payload change, not a code rewrite."
+
+### Scenario 3: "Silent push worked last week, nothing changed"
+
+**Context**: Background content sync via silent push stopped working. "We didn't change anything."
+
+**Pressure**: Deep-dive into background execution code. Assume a regression.
+
+**Reality**: Silent push has a system-enforced throttle budget (~2-3/hour). If usage increased, or if users force-quit the app, silent push stops working regardless of code quality. Also, the provisioning profile may have been regenerated without the push entitlement.
+
+**Correct action**: Follow Tree 3 ("Silent notifications not waking app"). Check throttle budget, force-quit state, and entitlements before debugging code.
+
+**Push-back template**: "Silent push has a system throttle budget. Let me verify we haven't exceeded it and that the app hasn't been force-quit on test devices — those are the two most common causes."
+
+## Checklist
+
+Before escalating push notification issues:
+
+- [ ] Push Notification capability enabled in Xcode (Step 1)
+- [ ] Provisioning profile contains aps-environment (Step 1)
+- [ ] Token registration callback fires with 64-char hex token (Step 2)
+- [ ] curl to APNs returns HTTP/2 200 (Step 3)
+- [ ] User authorized notifications, status = 2 (Step 4)
+- [ ] APNs environment matches build type (sandbox/production)
+- [ ] Focus mode not filtering notifications on test device
+- [ ] Tested on physical device (not Simulator for token registration)
+- [ ] For FCM: APNs auth key uploaded to Firebase Console
+- [ ] For silent push: background mode enabled, priority 5, no alert keys
+
+## Resources
+
+**WWDC**: 2021-10091, 2023-10025, 2023-10185
+
+**Docs**: /usernotifications, /usernotifications/testing-notifications-using-the-push-notification-console
+
+**Skills**: axiom-push-notifications, axiom-push-notifications-ref, axiom-extensions-widgets
diff --git a/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml b/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml
new file mode 100644
index 0000000..8420863
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Push Notifications Diagnostics"
+ short_description: "Push notifications fail to arrive, token registration errors occur, notifications work in development but not product..."
diff --git a/.claude/skills/axiom-push-notifications-ref/.openskills.json b/.claude/skills/axiom-push-notifications-ref/.openskills.json
new file mode 100644
index 0000000..0863571
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-push-notifications-ref",
+ "installedAt": "2026-04-12T08:06:33.708Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-push-notifications-ref/SKILL.md b/.claude/skills/axiom-push-notifications-ref/SKILL.md
new file mode 100644
index 0000000..bafb210
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-ref/SKILL.md
@@ -0,0 +1,837 @@
+---
+name: axiom-push-notifications-ref
+description: Use when needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API, notification category/action registration, service extension lifecycle, local notification triggers, Live Activity push headers, or broadcast push format. Covers complete push notification API surface.
+license: MIT
+---
+
+# Push Notifications API Reference
+
+Comprehensive API reference for APNs HTTP/2 transport, UserNotifications framework, and push-driven features including Live Activities and broadcast push.
+
+## Quick Reference
+
+```swift
+// AppDelegate — minimal remote notification setup
+class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
+ func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ UNUserNotificationCenter.current().delegate = self
+ return true
+ }
+
+ func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ sendTokenToServer(token)
+ }
+
+ func application(_ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ print("Registration failed: \(error)")
+ }
+
+ // Show notifications when app is in foreground
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
+ return [.banner, .sound, .badge]
+ }
+
+ // Handle notification tap / action response
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse) async {
+ let userInfo = response.notification.request.content.userInfo
+ // Route to appropriate screen based on userInfo
+ }
+}
+```
+
+---
+
+## APNs Transport Reference
+
+### Endpoints
+
+| Environment | Host | Port |
+|-------------|------|------|
+| Development | api.sandbox.push.apple.com | 443 or 2197 |
+| Production | api.push.apple.com | 443 or 2197 |
+
+### Request Format
+
+```
+POST /3/device/{device_token}
+Host: api.push.apple.com
+Authorization: bearer {jwt_token}
+apns-topic: {bundle_id}
+apns-push-type: alert
+Content-Type: application/json
+```
+
+### APNs Headers
+
+| Header | Required | Values | Notes |
+|--------|----------|--------|-------|
+| apns-push-type | Yes | alert, background, liveactivity, voip, complication, fileprovider, mdm, location | Must match payload content |
+| apns-topic | Yes | Bundle ID (or .push-type.liveactivity suffix) | Required for token-based auth |
+| apns-priority | No | 10 (immediate), 5 (power-conscious), 1 (low) | Default: 10 for alert, 5 for background |
+| apns-expiration | No | UNIX timestamp or 0 | 0 = deliver once, don't store |
+| apns-collapse-id | No | String ≤64 bytes | Replaces matching notification on device |
+| apns-id | No | UUID (lowercase) | Returned by APNs for tracking |
+| authorization | Token auth | bearer {JWT} | Not needed for certificate auth |
+| apns-unique-id | Response only | UUID | Use with Push Notifications Console delivery log |
+
+### Response Codes
+
+| Status | Meaning | Common Cause |
+|--------|---------|--------------|
+| 200 | Success | |
+| 400 | Bad request | Malformed JSON, missing required header |
+| 403 | Forbidden | Expired JWT, wrong team/key, topic mismatch |
+| 404 | Not found | Invalid device token path |
+| 405 | Method not allowed | Not using POST |
+| 410 | Unregistered | Device token no longer active (app uninstalled) |
+| 413 | Payload too large | Exceeds 4KB (5KB for VoIP) |
+| 429 | Too many requests | Rate limited by APNs |
+| 500 | Internal server error | APNs issue, retry |
+| 503 | Service unavailable | APNs overloaded, retry with backoff |
+
+---
+
+## JWT Authentication Reference
+
+### JWT Header
+
+```json
+{ "alg": "ES256", "kid": "{10-char Key ID}" }
+```
+
+### JWT Claims
+
+```json
+{ "iss": "{10-char Team ID}", "iat": {unix_timestamp} }
+```
+
+### Rules
+
+| Rule | Detail |
+|------|--------|
+| Algorithm | ES256 (P-256 curve) |
+| Signing key | APNs auth key (.p8 from developer portal) |
+| Token lifetime | Max 1 hour (403 ExpiredProviderToken if older) |
+| Refresh interval | Between 20 and 60 minutes |
+| Scope | One key works for all apps in team, both environments |
+
+### Authorization Header Format
+
+```
+authorization: bearer eyAia2lkIjog...
+```
+
+---
+
+## Payload Reference
+
+### `aps` Dictionary Keys
+
+| Key | Type | Purpose | Since |
+|-----|------|---------|-------|
+| alert | Dict/String | Alert content | iOS 10 |
+| badge | Number | App icon badge (0 removes) | iOS 10 |
+| sound | String/Dict | Audio playback | iOS 10 |
+| thread-id | String | Notification grouping | iOS 10 |
+| category | String | Actionable notification type | iOS 10 |
+| content-available | Number (1) | Silent background push | iOS 10 |
+| mutable-content | Number (1) | Triggers service extension | iOS 10 |
+| target-content-id | String | Window/content identifier | iOS 13 |
+| interruption-level | String | passive/active/time-sensitive/critical | iOS 15 |
+| relevance-score | Number 0-1 | Notification summary sorting | iOS 15 |
+| filter-criteria | String | Focus filter matching | iOS 15 |
+| stale-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
+| content-state | Dict | Live Activity content update | iOS 16.1 |
+| timestamp | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
+| event | String | start/update/end (Live Activity) | iOS 16.1 |
+| dismissal-date | Number | UNIX timestamp (Live Activity) | iOS 16.1 |
+| attributes-type | String | Live Activity struct name | iOS 17 |
+| attributes | Dict | Live Activity init data | iOS 17 |
+
+### Alert Dictionary Keys
+
+| Key | Type | Purpose |
+|-----|------|---------|
+| title | String | Short title |
+| subtitle | String | Secondary description |
+| body | String | Full message |
+| launch-image | String | Launch screen filename |
+| title-loc-key | String | Localization key for title |
+| title-loc-args | [String] | Title format arguments |
+| subtitle-loc-key | String | Localization key for subtitle |
+| subtitle-loc-args | [String] | Subtitle format arguments |
+| loc-key | String | Localization key for body |
+| loc-args | [String] | Body format arguments |
+
+### Sound Dictionary (Critical Alerts)
+
+```json
+{ "critical": 1, "name": "alarm.aiff", "volume": 0.8 }
+```
+
+### Interruption Level Values
+
+| Value | Behavior | Requires |
+|-------|----------|----------|
+| passive | No sound/wake. Notification summary only. | Nothing |
+| active | Default. Sound + banner. | Nothing |
+| time-sensitive | Breaks scheduled delivery. Banner persists. | Time Sensitive capability |
+| critical | Overrides DND and ringer switch. | Apple approval + entitlement |
+
+### Example Payloads
+
+#### Basic Alert
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title": "New Message",
+ "subtitle": "From Alice",
+ "body": "Hey, are you free for lunch?"
+ },
+ "badge": 3,
+ "sound": "default"
+ }
+}
+```
+
+#### Localized with loc-key/loc-args
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title-loc-key": "MESSAGE_TITLE",
+ "title-loc-args": ["Alice"],
+ "loc-key": "MESSAGE_BODY",
+ "loc-args": ["Alice", "lunch"]
+ },
+ "sound": "default"
+ }
+}
+```
+
+#### Silent Background Push
+
+```json
+{
+ "aps": {
+ "content-available": 1
+ },
+ "custom-key": "sync-update"
+}
+```
+
+#### Rich Notification (Service Extension)
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title": "Photo shared",
+ "body": "Alice shared a photo with you"
+ },
+ "mutable-content": 1,
+ "sound": "default"
+ },
+ "image-url": "https://example.com/photo.jpg"
+}
+```
+
+#### Critical Alert
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title": "Server Down",
+ "body": "Production database is unreachable"
+ },
+ "sound": { "critical": 1, "name": "default", "volume": 1.0 },
+ "interruption-level": "critical"
+ }
+}
+```
+
+#### Time-Sensitive with Category
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title": "Package Delivered",
+ "body": "Your order has been delivered to the front door"
+ },
+ "interruption-level": "time-sensitive",
+ "category": "DELIVERY",
+ "sound": "default"
+ },
+ "order-id": "12345"
+}
+```
+
+---
+
+## UNUserNotificationCenter API Reference
+
+### Key Methods
+
+| Method | Purpose |
+|--------|---------|
+| requestAuthorization(options:) | Request permission |
+| notificationSettings() | Check current status |
+| add(_:) | Schedule notification request |
+| getPendingNotificationRequests() | List scheduled |
+| removePendingNotificationRequests(withIdentifiers:) | Cancel scheduled |
+| getDeliveredNotifications() | List in notification center |
+| removeDeliveredNotifications(withIdentifiers:) | Remove from center |
+| setNotificationCategories(_:) | Register actionable types |
+| setBadgeCount(_:) | Update badge (iOS 16+) |
+| supportsContentExtensions | Check content extension support |
+
+### UNAuthorizationOptions
+
+| Option | Purpose |
+|--------|---------|
+| .alert | Display alerts |
+| .badge | Update badge count |
+| .sound | Play sounds |
+| .carPlay | Show in CarPlay |
+| .criticalAlert | Critical alerts (requires entitlement) |
+| .provisional | Trial delivery without prompting |
+| .providesAppNotificationSettings | "Configure in App" button in Settings |
+| .announcement | Siri announcement (deprecated iOS 15+) |
+
+### UNAuthorizationStatus
+
+| Value | Meaning |
+|-------|---------|
+| .notDetermined | No prompt shown yet |
+| .denied | User denied or disabled in Settings |
+| .authorized | User explicitly granted |
+| .provisional | Provisional trial delivery |
+| .ephemeral | App Clip temporary |
+
+### Request Authorization
+
+```swift
+let center = UNUserNotificationCenter.current()
+
+let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
+if granted {
+ await MainActor.run {
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+}
+```
+
+### Check Settings
+
+```swift
+let settings = await center.notificationSettings()
+
+switch settings.authorizationStatus {
+case .authorized: break
+case .denied:
+ // Direct user to Settings
+case .provisional:
+ // Upgrade to full authorization
+case .notDetermined:
+ // Request authorization
+case .ephemeral:
+ // App Clip — temporary
+@unknown default: break
+}
+```
+
+### Delegate Methods
+
+```swift
+// Foreground presentation — called when notification arrives while app is active
+func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification) async
+ -> UNNotificationPresentationOptions {
+ return [.banner, .sound, .badge]
+}
+
+// Action response — called when user taps notification or action button
+func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse) async {
+ let actionIdentifier = response.actionIdentifier
+ let userInfo = response.notification.request.content.userInfo
+
+ switch actionIdentifier {
+ case UNNotificationDefaultActionIdentifier:
+ // User tapped notification body
+ break
+ case UNNotificationDismissActionIdentifier:
+ // User dismissed (requires .customDismissAction on category)
+ break
+ default:
+ // Custom action
+ break
+ }
+}
+
+// Settings — called when user taps "Configure in App" from notification settings
+func userNotificationCenter(_ center: UNUserNotificationCenter,
+ openSettingsFor notification: UNNotification?) {
+ // Navigate to in-app notification settings
+}
+```
+
+---
+
+## UNNotificationCategory and UNNotificationAction API
+
+### Category Registration
+
+```swift
+let likeAction = UNNotificationAction(
+ identifier: "LIKE",
+ title: "Like",
+ options: []
+)
+
+let replyAction = UNTextInputNotificationAction(
+ identifier: "REPLY",
+ title: "Reply",
+ options: [],
+ textInputButtonTitle: "Send",
+ textInputPlaceholder: "Type a message..."
+)
+
+let deleteAction = UNNotificationAction(
+ identifier: "DELETE",
+ title: "Delete",
+ options: [.destructive, .authenticationRequired]
+)
+
+let messageCategory = UNNotificationCategory(
+ identifier: "MESSAGE",
+ actions: [likeAction, replyAction, deleteAction],
+ intentIdentifiers: [],
+ hiddenPreviewsBodyPlaceholder: "New message",
+ categorySummaryFormat: "%u more messages",
+ options: [.customDismissAction]
+)
+
+UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
+```
+
+### Action Options
+
+| Option | Effect |
+|--------|--------|
+| .authenticationRequired | Requires device unlock |
+| .destructive | Red text display |
+| .foreground | Launches app to foreground |
+
+### Category Options
+
+| Option | Effect |
+|--------|--------|
+| .customDismissAction | Fires delegate on dismiss |
+| .allowInCarPlay | Show actions in CarPlay |
+| .hiddenPreviewsShowTitle | Show title when previews hidden |
+| .hiddenPreviewsShowSubtitle | Show subtitle when previews hidden |
+| .allowAnnouncement | Siri can announce (deprecated iOS 15+) |
+
+### UNNotificationActionIcon (iOS 15+)
+
+```swift
+let icon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
+let action = UNNotificationAction(
+ identifier: "LIKE",
+ title: "Like",
+ options: [],
+ icon: icon
+)
+```
+
+---
+
+## UNNotificationServiceExtension API
+
+Modifies notification content before display. Runs in a separate extension process.
+
+### Lifecycle
+
+| Method | Window | Purpose |
+|--------|--------|---------|
+| didReceive(_:withContentHandler:) | ~30 seconds | Modify notification content |
+| serviceExtensionTimeWillExpire() | Called at deadline | Deliver best attempt immediately |
+
+### Implementation
+
+```swift
+class NotificationService: UNNotificationServiceExtension {
+ var contentHandler: ((UNNotificationContent) -> Void)?
+ var bestAttemptContent: UNMutableNotificationContent?
+
+ override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ self.contentHandler = contentHandler
+ bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
+
+ guard let content = bestAttemptContent,
+ let imageURLString = content.userInfo["image-url"] as? String,
+ let imageURL = URL(string: imageURLString) else {
+ contentHandler(request.content)
+ return
+ }
+
+ // Download and attach image
+ let task = URLSession.shared.downloadTask(with: imageURL) { url, _, error in
+ defer { contentHandler(content) }
+ guard let url = url, error == nil else { return }
+
+ let attachment = try? UNNotificationAttachment(
+ identifier: "image",
+ url: url,
+ options: [UNNotificationAttachmentOptionsTypeHintKey: "public.jpeg"]
+ )
+ if let attachment = attachment {
+ content.attachments = [attachment]
+ }
+ }
+ task.resume()
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ if let content = bestAttemptContent {
+ contentHandler?(content)
+ }
+ }
+}
+```
+
+### Supported Attachment Types
+
+| Type | Extensions | Max Size |
+|------|-----------|----------|
+| Image | .jpg, .gif, .png | 10 MB |
+| Audio | .aif, .wav, .mp3 | 5 MB |
+| Video | .mp4, .mpeg | 50 MB |
+
+### Payload Requirement
+
+The notification payload must include `"mutable-content": 1` in the `aps` dictionary for the service extension to fire.
+
+---
+
+## Local Notifications API
+
+### Trigger Types
+
+| Trigger | Use Case | Repeating |
+|---------|----------|-----------|
+| UNTimeIntervalNotificationTrigger | After N seconds | Yes (≥60s) |
+| UNCalendarNotificationTrigger | Specific date/time | Yes |
+| UNLocationNotificationTrigger | Enter/exit region | Yes |
+
+### Time Interval Trigger
+
+```swift
+let content = UNMutableNotificationContent()
+content.title = "Reminder"
+content.body = "Time to take a break"
+content.sound = .default
+
+let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 300, repeats: false)
+
+let request = UNNotificationRequest(
+ identifier: "break-reminder",
+ content: content,
+ trigger: trigger
+)
+
+try await UNUserNotificationCenter.current().add(request)
+```
+
+### Calendar Trigger
+
+```swift
+var dateComponents = DateComponents()
+dateComponents.hour = 9
+dateComponents.minute = 0
+
+let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
+
+let request = UNNotificationRequest(
+ identifier: "daily-9am",
+ content: content,
+ trigger: trigger
+)
+
+try await UNUserNotificationCenter.current().add(request)
+```
+
+### Location Trigger
+
+```swift
+import CoreLocation
+
+let center = CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090)
+let region = CLCircularRegion(center: center, radius: 100, identifier: "apple-park")
+region.notifyOnEntry = true
+region.notifyOnExit = false
+
+let trigger = UNLocationNotificationTrigger(region: region, repeats: false)
+
+let request = UNNotificationRequest(
+ identifier: "arrived-at-office",
+ content: content,
+ trigger: trigger
+)
+
+try await UNUserNotificationCenter.current().add(request)
+```
+
+### Limitations
+
+| Limitation | Detail |
+|-----------|--------|
+| Minimum repeat interval | 60 seconds for UNTimeIntervalNotificationTrigger |
+| Location authorization | Location trigger requires When In Use or Always authorization |
+| No service extensions | Local notifications do not trigger UNNotificationServiceExtension |
+| No background wake | Local notifications cannot use content-available for background processing |
+| App extensions | Local notifications cannot be scheduled from app extensions (use app group + main app) |
+| Pending limit | 64 pending notification requests per app |
+
+---
+
+## Live Activity Push Headers
+
+### Required Headers
+
+| Header | Value |
+|--------|-------|
+| apns-push-type | liveactivity |
+| apns-topic | {bundleID}.push-type.liveactivity |
+| apns-priority | 5 (routine) or 10 (time-sensitive) |
+
+### Event Types
+
+| Event | Purpose | Required Fields |
+|-------|---------|----------------|
+| start | Start Live Activity remotely | attributes-type, attributes, content-state, timestamp |
+| update | Update content | content-state, timestamp |
+| end | End Live Activity | timestamp (content-state optional) |
+
+### Update Payload
+
+```json
+{
+ "aps": {
+ "timestamp": 1709913600,
+ "event": "update",
+ "content-state": {
+ "homeScore": 2,
+ "awayScore": 1,
+ "inning": "Top 7"
+ }
+ }
+}
+```
+
+### Start Payload (Push-to-Start Token)
+
+```json
+{
+ "aps": {
+ "timestamp": 1709913600,
+ "event": "start",
+ "content-state": {
+ "homeScore": 0,
+ "awayScore": 0,
+ "inning": "Top 1"
+ },
+ "attributes-type": "GameAttributes",
+ "attributes": {
+ "homeTeam": "Giants",
+ "awayTeam": "Dodgers"
+ },
+ "alert": {
+ "title": "Game Starting",
+ "body": "Giants vs Dodgers is about to begin"
+ }
+ }
+}
+```
+
+### Start Payload (Channel-Based)
+
+```json
+{
+ "aps": {
+ "timestamp": 1709913600,
+ "event": "start",
+ "content-state": {
+ "homeScore": 0,
+ "awayScore": 0,
+ "inning": "Top 1"
+ },
+ "attributes-type": "GameAttributes",
+ "attributes": {
+ "homeTeam": "Giants",
+ "awayTeam": "Dodgers"
+ }
+ }
+}
+```
+
+### End Payload
+
+```json
+{
+ "aps": {
+ "timestamp": 1709913600,
+ "event": "end",
+ "dismissal-date": 1709917200,
+ "content-state": {
+ "homeScore": 5,
+ "awayScore": 3,
+ "inning": "Final"
+ }
+ }
+}
+```
+
+### Push-to-Start Token
+
+```swift
+// Observe push-to-start tokens (iOS 17.2+)
+for await token in Activity.pushToStartTokenUpdates {
+ let tokenString = token.map { String(format: "%02x", $0) }.joined()
+ sendPushToStartTokenToServer(tokenString)
+}
+```
+
+### Activity Push Token
+
+```swift
+// Observe activity-specific push tokens
+for await tokenData in activity.pushTokenUpdates {
+ let token = tokenData.map { String(format: "%02x", $0) }.joined()
+ sendActivityTokenToServer(token, activityId: activity.id)
+}
+```
+
+Content-state encoding rule: the system always uses default JSONDecoder — do not use custom encoding strategies in your ActivityAttributes.ContentState.
+
+---
+
+## Broadcast Push API (iOS 18+)
+
+Server-to-many push for Live Activities without tracking individual device tokens.
+
+### Endpoint
+
+```
+POST /4/broadcasts/apps/{TOPIC}
+```
+
+### Headers
+
+| Header | Value |
+|--------|-------|
+| apns-push-type | liveactivity |
+| apns-channel-id | {channelID} |
+| authorization | bearer {JWT} |
+
+### Subscribe via Channel
+
+```swift
+try Activity.request(
+ attributes: attributes,
+ content: .init(state: initialState, staleDate: nil),
+ pushType: .channel(channelId)
+)
+```
+
+### Channel Storage Policies
+
+| Policy | Behavior | Budget |
+|--------|----------|--------|
+| No Storage | Deliver only to connected devices | Higher |
+| Most Recent Message | Store latest for offline devices | Lower |
+
+---
+
+## Command-Line Testing
+
+### JWT Generation
+
+```bash
+JWT_ISSUE_TIME=$(date +%s)
+JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
+JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
+JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
+JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
+AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"
+```
+
+### Send Alert Push
+
+```bash
+curl -v \
+ --header "apns-topic: $TOPIC" \
+ --header "apns-push-type: alert" \
+ --header "authorization: bearer $AUTHENTICATION_TOKEN" \
+ --data '{"aps":{"alert":"test"}}' \
+ --http2 https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}
+```
+
+### Send Live Activity Push
+
+```bash
+curl \
+ --header "apns-topic: com.example.app.push-type.liveactivity" \
+ --header "apns-push-type: liveactivity" \
+ --header "apns-priority: 10" \
+ --header "authorization: bearer $AUTHENTICATION_TOKEN" \
+ --data '{
+ "aps": {
+ "timestamp": '$(date +%s)',
+ "event": "update",
+ "content-state": { "score": "2-1" }
+ }
+ }' \
+ --http2 https://api.sandbox.push.apple.com/3/device/$ACTIVITY_PUSH_TOKEN
+```
+
+### Simulator Push
+
+```bash
+xcrun simctl push booted com.example.app payload.json
+```
+
+### Simulator Payload File
+
+```json
+{
+ "Simulator Target Bundle": "com.example.app",
+ "aps": {
+ "alert": { "title": "Test", "body": "Hello" },
+ "sound": "default"
+ }
+}
+```
+
+---
+
+## Resources
+
+**WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069
+
+**Docs**: /usernotifications, /usernotifications/sending-notification-requests-to-apns, /usernotifications/generating-a-remote-notification, /activitykit
+
+**Skills**: axiom-push-notifications, axiom-push-notifications-diag, axiom-extensions-widgets
diff --git a/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml b/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml
new file mode 100644
index 0000000..06a2a48
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Push Notifications Reference"
+ short_description: "Needing APNs HTTP/2 transport details, JWT authentication setup, payload key reference, UNUserNotificationCenter API,..."
diff --git a/.claude/skills/axiom-push-notifications/.openskills.json b/.claude/skills/axiom-push-notifications/.openskills.json
new file mode 100644
index 0000000..ae21c89
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-push-notifications",
+ "installedAt": "2026-04-12T08:06:32.964Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-push-notifications/SKILL.md b/.claude/skills/axiom-push-notifications/SKILL.md
new file mode 100644
index 0000000..47aaba0
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications/SKILL.md
@@ -0,0 +1,918 @@
+---
+name: axiom-push-notifications
+description: Use when implementing remote or local push notifications, requesting notification permission, managing APNs device tokens, adding notification actions/categories, building service extensions, or debugging push delivery failures. Covers APNs, FCM, Live Activity push transport, broadcast push, communication notifications, Focus interaction.
+license: MIT
+---
+
+# Push Notifications
+
+Remote and local notification patterns for iOS. Covers permission flow, APNs registration, token management, payload design, actionable notifications, rich notifications with service extensions, communication notifications, Focus interaction, and Live Activity push transport.
+
+## When to Use This Skill
+
+Use when you need to:
+- ☑ Implementing remote (APNs) push notifications
+- ☑ Requesting notification permissions
+- ☑ Managing device tokens and server sync
+- ☑ Adding actionable notifications with categories/actions
+- ☑ Building rich notifications with service extensions
+- ☑ Communication notifications with avatars (iOS 15+)
+- ☑ Time Sensitive or Critical alerts
+- ☑ Updating Live Activities via push (transport layer)
+- ☑ Broadcast push for large audiences (iOS 18+)
+- ☑ Local notification scheduling
+
+## Example Prompts
+
+"How do I set up push notifications?"
+"When should I ask for notification permission?"
+"My push notifications aren't arriving"
+"How do I add buttons to notifications?"
+"How do I show images in push notifications?"
+"How do I send push updates to a Live Activity?"
+"What's the difference between APNs token and FCM token?"
+"How do I make notifications break through Focus mode?"
+"My notifications work in development but not production"
+"How do I handle notification taps to open a specific screen?"
+
+## Red Flags
+
+Signs you're making this harder than it needs to be:
+
+- ❌ Requesting permission on first launch before user understands value
+- ❌ Caching device tokens locally instead of requesting fresh each launch
+- ❌ Sending the same token to sandbox AND production APNs
+- ❌ Using `content-available: 1` without understanding silent push throttling (~2-3/hour)
+- ❌ Not implementing `serviceExtensionTimeWillExpire` fallback
+- ❌ Force-unwrapping device token or assuming registration always succeeds
+- ❌ Hardcoding APNs host instead of switching sandbox/production by environment
+- ❌ Setting `apns-priority: 10` for all notifications (drains battery, gets throttled)
+- ❌ Exceeding 4KB payload without realizing APNs silently rejects it
+- ❌ Using FCM without disabling method swizzling when you have custom delegate handling
+- ❌ Not handling foreground notification presentation (notifications silently dropped)
+- ❌ Overusing Time Sensitive interruption level (erodes user trust, they'll disable all notifications)
+
+## Mandatory First Steps
+
+Before implementing any push notification feature:
+
+### 1. Enable Push Notification Capability
+
+- Xcode: Target → Signing & Capabilities → + Push Notifications
+- Adds `aps-environment` entitlement
+- Verify in Apple Developer Portal that the App ID has Push Notifications enabled
+
+### 2. Register for Remote Notifications
+
+```swift
+// AppDelegate
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ UNUserNotificationCenter.current().delegate = self
+ UIApplication.shared.registerForRemoteNotifications()
+ return true
+}
+
+func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ sendTokenToServer(token) // Never cache locally — tokens change
+}
+
+func application(_ application: UIApplication,
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ // Simulator cannot register. Log, don't crash.
+}
+```
+
+### 3. Request Authorization (in context, not at launch)
+
+```swift
+let center = UNUserNotificationCenter.current()
+let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
+if granted {
+ await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
+}
+```
+
+Request when user action makes notification value obvious (e.g., after scheduling a reminder, subscribing to updates). The system prompts only once — bad timing means permanent denial.
+
+## Permission Flow
+
+### Pattern 1: Standard Authorization
+
+Request in context after user understands the value:
+
+```swift
+func subscribeToUpdates() async {
+ let center = UNUserNotificationCenter.current()
+ let settings = await center.notificationSettings()
+
+ switch settings.authorizationStatus {
+ case .notDetermined:
+ let granted = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
+ if granted == true {
+ await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
+ }
+ case .authorized, .provisional:
+ // Already have permission
+ break
+ case .denied:
+ // Redirect to Settings
+ promptToOpenSettings()
+ case .ephemeral:
+ break
+ @unknown default:
+ break
+ }
+}
+```
+
+### Pattern 2: Provisional Authorization (iOS 12+)
+
+Notifications appear quietly in Notification Center with Keep/Turn Off buttons. No permission dialog shown to user.
+
+```swift
+let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge, .provisional])
+```
+
+Good for apps where users haven't yet discovered notification value. They see notifications quietly and choose to promote them.
+
+### Pattern 3: Authorization Status Check
+
+Always check before scheduling or assuming permission:
+
+```swift
+let settings = await UNUserNotificationCenter.current().notificationSettings()
+guard settings.authorizationStatus == .authorized else {
+ // Handle missing permission
+ return
+}
+```
+
+### Pattern 4: Handling Denial
+
+Redirect to Settings when user has denied:
+
+```swift
+func promptToOpenSettings() {
+ // iOS 16+
+ if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
+ UIApplication.shared.open(url)
+ } else {
+ // Fallback: general app settings
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ }
+}
+```
+
+## Token Management
+
+### Token Format
+
+```swift
+func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ let token = deviceToken.map { String(format: "%02x", $0) }.joined()
+ sendTokenToServer(token)
+}
+```
+
+### Key Rules
+
+- **Never cache locally** — request fresh at every app launch via `registerForRemoteNotifications()`
+- **Sandbox ≠ production** — tokens are different per APNs environment, endpoints are different (`api.sandbox.push.apple.com` vs `api.push.apple.com`)
+- **Server sync** — send token + bundle ID + user ID + environment (sandbox/production) to your server
+- **Tokens change** after backup restore, device migration, reinstall, or OS updates
+
+## Notification Types Decision Tree
+
+```
+What type of notification?
+│
+├─ Alert (user-visible)
+│ ├─ Passive — informational, no sound, appears in history
+│ │ interruption-level: "passive"
+│ │
+│ ├─ Active — default, sound + banner
+│ │ interruption-level: "active" (or omit, it's default)
+│ │
+│ ├─ Time Sensitive — breaks through scheduled summary, not Focus
+│ │ interruption-level: "time-sensitive"
+│ │ Requires: Time Sensitive Notifications capability
+│ │
+│ └─ Critical — breaks through Do Not Disturb and mute switch
+│ interruption-level: "critical"
+│ Requires: Apple entitlement approval (medical, safety, security)
+│
+├─ Communication (iOS 15+)
+│ Shows sender avatar, name, breaks Focus for allowed contacts
+│ Requires: INSendMessageIntent + Communication Notifications capability
+│ Configured in service extension via content.updating(from: intent)
+│
+├─ Silent / Background
+│ content-available: 1, no alert/sound/badge
+│ Throttled to ~2-3 per hour
+│ apns-priority: 5 (MUST be 5, not 10)
+│ App gets ~30 seconds background execution
+│
+└─ Live Activity
+ apns-push-type: liveactivity
+ apns-topic: {bundleID}.push-type.liveactivity
+ Updates/starts/ends Live Activities remotely
+```
+
+## Payload Design Patterns
+
+### Basic Alert
+
+```json
+{
+ "aps": {
+ "alert": {
+ "title": "New Message",
+ "subtitle": "From Alice",
+ "body": "Hey, are you free for lunch?"
+ },
+ "sound": "default",
+ "badge": 3
+ }
+}
+```
+
+### Sound Options
+
+```json
+{
+ "aps": {
+ "alert": { "title": "Notification", "body": "With custom sound" },
+ "sound": "custom-sound.aiff"
+ }
+}
+```
+
+Critical alert (requires Apple entitlement):
+```json
+{
+ "aps": {
+ "alert": { "title": "Emergency", "body": "Critical alert" },
+ "sound": {
+ "critical": 1,
+ "name": "alarm.aiff",
+ "volume": 0.8
+ }
+ }
+}
+```
+
+### Badge
+
+```json
+{
+ "aps": {
+ "badge": 5
+ }
+}
+```
+
+Set to `0` to remove badge.
+
+### Localized Content
+
+```json
+{
+ "aps": {
+ "alert": {
+ "loc-key": "NEW_MESSAGE_FORMAT",
+ "loc-args": ["Alice", "lunch"]
+ }
+ }
+}
+```
+
+### Custom Data
+
+Place custom data outside the `aps` dictionary:
+
+```json
+{
+ "aps": {
+ "alert": { "title": "Order Update", "body": "Your order shipped" }
+ },
+ "orderId": "12345",
+ "deepLink": "/orders/12345"
+}
+```
+
+### Relevance Score and Thread ID
+
+```json
+{
+ "aps": {
+ "alert": { "title": "Breaking News", "body": "..." },
+ "relevance-score": 0.8,
+ "thread-id": "news-breaking",
+ "interruption-level": "time-sensitive"
+ }
+}
+```
+
+- `relevance-score` (0.0–1.0): ranking for notification summary (iOS 15+)
+- `thread-id`: groups notifications into conversations in Notification Center
+
+### Payload Size Limits
+
+| Type | Max Size |
+|------|----------|
+| Standard push | 4KB |
+| VoIP push | 5KB |
+| Live Activity | 4KB |
+
+APNs silently rejects oversized payloads. No error returned to sender.
+
+## Categories and Actions
+
+### Register Categories at Launch
+
+```swift
+func registerNotificationCategories() {
+ let replyAction = UNTextInputNotificationAction(
+ identifier: "REPLY_ACTION",
+ title: "Reply",
+ options: [])
+
+ // iOS 15+: actions with icons
+ let likeIcon = UNNotificationActionIcon(systemImageName: "hand.thumbsup")
+ let likeAction = UNNotificationAction(
+ identifier: "LIKE_ACTION",
+ title: "Like",
+ options: [],
+ icon: likeIcon)
+
+ let messageCategory = UNNotificationCategory(
+ identifier: "MESSAGE",
+ actions: [replyAction, likeAction],
+ intentIdentifiers: [],
+ options: [.customDismissAction])
+
+ UNUserNotificationCenter.current().setNotificationCategories([messageCategory])
+}
+```
+
+### Set Category in Payload
+
+```json
+{
+ "aps": {
+ "alert": { "title": "Alice", "body": "Are you free?" },
+ "category": "MESSAGE"
+ }
+}
+```
+
+### Handle Action Response
+
+```swift
+extension AppDelegate: UNUserNotificationCenterDelegate {
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void) {
+ let userInfo = response.notification.request.content.userInfo
+
+ switch response.actionIdentifier {
+ case "REPLY_ACTION":
+ if let textResponse = response as? UNTextInputNotificationResponse {
+ handleReply(text: textResponse.userText, userInfo: userInfo)
+ }
+ case "LIKE_ACTION":
+ handleLike(userInfo: userInfo)
+ case UNNotificationDefaultActionIdentifier:
+ // User tapped the notification itself
+ handleNotificationTap(userInfo: userInfo)
+ case UNNotificationDismissActionIdentifier:
+ // User dismissed (requires .customDismissAction on category)
+ handleDismiss(userInfo: userInfo)
+ default:
+ break
+ }
+
+ completionHandler()
+ }
+}
+```
+
+## Service Extension Patterns
+
+### Pattern 1: Media Enrichment
+
+Download and attach images, audio, or video to notifications.
+
+**Payload requirement**: Must include `"mutable-content": 1`:
+
+```json
+{
+ "aps": {
+ "alert": { "title": "Photo", "body": "Alice sent a photo" },
+ "mutable-content": 1
+ },
+ "imageURL": "https://example.com/photo.jpg"
+}
+```
+
+**Service extension**:
+
+```swift
+class NotificationService: UNNotificationServiceExtension {
+ var contentHandler: ((UNNotificationContent) -> Void)?
+ var bestAttemptContent: UNMutableNotificationContent?
+
+ override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler:
+ @escaping (UNNotificationContent) -> Void) {
+ self.contentHandler = contentHandler
+ bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
+
+ guard let bestAttemptContent,
+ let imageURLString = bestAttemptContent.userInfo["imageURL"] as? String,
+ let imageURL = URL(string: imageURLString) else {
+ contentHandler(request.content)
+ return
+ }
+
+ // Download image
+ let task = URLSession.shared.downloadTask(with: imageURL) { [weak self] url, _, error in
+ guard let self, let url, error == nil else {
+ contentHandler(self?.bestAttemptContent ?? request.content)
+ return
+ }
+
+ // Move to tmp with proper extension
+ let tmpURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString)
+ .appendingPathExtension("jpg")
+ try? FileManager.default.moveItem(at: url, to: tmpURL)
+
+ if let attachment = try? UNNotificationAttachment(identifier: "image",
+ url: tmpURL,
+ options: nil) {
+ bestAttemptContent.attachments = [attachment]
+ }
+
+ contentHandler(bestAttemptContent)
+ }
+ task.resume()
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ // 30-second window exceeded — deliver what we have
+ if let contentHandler, let bestAttemptContent {
+ contentHandler(bestAttemptContent)
+ }
+ }
+}
+```
+
+### Pattern 2: End-to-End Decryption
+
+Decrypt payload in service extension before display:
+
+```swift
+override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler:
+ @escaping (UNNotificationContent) -> Void) {
+ self.contentHandler = contentHandler
+ bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
+
+ guard let bestAttemptContent,
+ let encryptedBody = bestAttemptContent.userInfo["encryptedBody"] as? String else {
+ contentHandler(request.content)
+ return
+ }
+
+ if let decrypted = decrypt(encryptedBody) {
+ bestAttemptContent.body = decrypted
+ } else {
+ bestAttemptContent.body = "(Encrypted message)"
+ }
+
+ contentHandler(bestAttemptContent)
+}
+
+override func serviceExtensionTimeWillExpire() {
+ if let contentHandler, let bestAttemptContent {
+ bestAttemptContent.body = "(Encrypted message)"
+ contentHandler(bestAttemptContent)
+ }
+}
+```
+
+**30-second processing window**: If `didReceive` doesn't call `contentHandler` within ~30 seconds, `serviceExtensionTimeWillExpire` is called. Always deliver `bestAttemptContent` as fallback — if neither method calls the handler, the notification vanishes entirely.
+
+## Communication Notifications (iOS 15+)
+
+Show sender avatar and name. Can break through Focus for allowed contacts.
+
+```swift
+// In your Notification Service Extension
+import Intents
+
+override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler:
+ @escaping (UNNotificationContent) -> Void) {
+ guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
+ contentHandler(request.content)
+ return
+ }
+
+ // 1. Create sender persona
+ let senderImage = INImage(url: avatarURL) // or INImage(imageData:)
+ let sender = INPerson(
+ personHandle: INPersonHandle(value: "alice@example.com", type: .emailAddress),
+ nameComponents: nil,
+ displayName: "Alice",
+ image: senderImage,
+ contactIdentifier: nil,
+ customIdentifier: "user-alice-123"
+ )
+
+ // 2. Create message intent
+ let intent = INSendMessageIntent(
+ recipients: nil, // nil for 1:1, set for group
+ outgoingMessageType: .outgoingMessageText,
+ content: bestAttemptContent.body,
+ speakableGroupName: nil, // set for group conversations
+ conversationIdentifier: "conversation-123",
+ serviceName: nil,
+ sender: sender,
+ attachments: nil
+ )
+
+ // 3. Donate interaction
+ let interaction = INInteraction(intent: intent, response: nil)
+ interaction.direction = .incoming
+ interaction.donate(completion: nil)
+
+ // 4. Update content with intent
+ do {
+ let updatedContent = try bestAttemptContent.updating(from: intent)
+ contentHandler(updatedContent)
+ } catch {
+ contentHandler(bestAttemptContent)
+ }
+}
+```
+
+**Requirements**:
+- Communication Notifications capability in Xcode
+- Notification Service Extension target
+- `mutable-content: 1` in payload
+
+**Focus breakthrough**: Communication notifications from contacts the user has allowed in Focus settings will break through. Use sparingly — overuse erodes trust.
+
+## Foreground Notification Handling
+
+Without this delegate method, notifications received while the app is in foreground are **silently dropped**:
+
+```swift
+extension AppDelegate: UNUserNotificationCenterDelegate {
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler:
+ @escaping (UNNotificationPresentationOptions) -> Void) {
+ // Show notification even when app is in foreground
+ completionHandler([.banner, .sound, .badge])
+ }
+}
+```
+
+Set `UNUserNotificationCenter.current().delegate = self` in `didFinishLaunchingWithOptions` — before the app finishes launching.
+
+## Live Activity Push Transport
+
+Push updates to Live Activities remotely via APNs.
+
+### Observe Push Token
+
+```swift
+let activity = try Activity.request(
+ attributes: attributes,
+ content: initialContent,
+ pushType: .token
+)
+
+Task {
+ for await pushToken in activity.pushTokenUpdates {
+ let pushTokenString = pushToken.reduce("") {
+ $0 + String(format: "%02x", $1)
+ }
+ try await sendPushToken(pushTokenString: pushTokenString)
+ }
+}
+```
+
+### APNs Headers for Live Activity
+
+| Header | Value |
+|--------|-------|
+| apns-push-type | `liveactivity` |
+| apns-topic | `{bundleID}.push-type.liveactivity` |
+| apns-priority | `5` (routine) or `10` (time-sensitive) |
+
+### Update Payload
+
+```json
+{
+ "aps": {
+ "timestamp": 1234567890,
+ "event": "update",
+ "content-state": {
+ "currentStep": "outForDelivery",
+ "estimatedArrival": "2:30 PM"
+ },
+ "stale-date": 1234571490,
+ "dismissal-date": 1234575090,
+ "relevance-score": 75.0
+ }
+}
+```
+
+### Event Types
+
+| Event | Purpose |
+|-------|---------|
+| `update` | Update content-state |
+| `start` | Start a new Live Activity remotely (iOS 17.2+) |
+| `end` | End the activity |
+
+### Key Rules
+
+- `content-state` **must match** `ActivityAttributes.ContentState` exactly — no custom JSON encoding strategies, property names must be identical
+- `timestamp` is required — APNs uses it to discard stale updates
+- `stale-date` shows a visual indicator that data is outdated
+- `dismissal-date` controls when an ended activity disappears from Lock Screen
+- `relevance-score` orders multiple active Live Activities
+- Priority budget enforced — excessive `apns-priority: 10` gets throttled
+- Add `NSSupportsLiveActivitiesFrequentUpdates` to Info.plist for high-frequency apps (sports, navigation)
+
+For ActivityKit UI, attributes, and Dynamic Island layout, see axiom-extensions-widgets.
+
+## Broadcast Push (iOS 18+)
+
+Channel-based delivery for large audiences (sports scores, flight status, breaking news).
+
+### Setup
+
+1. Enable Broadcast Push Notifications capability in Apple Developer Portal
+2. Create channels via Apple's Broadcast Push API
+
+### Subscribe to Channel
+
+```swift
+let activity = try Activity.request(
+ attributes: attributes,
+ content: initialContent,
+ pushType: .channel(channelId)
+)
+```
+
+### Server Sends to Channel
+
+```
+POST /4/broadcasts/apps/{TOPIC}
+```
+
+### Key Rules
+
+- Only available for Live Activities (not regular push)
+- Channel storage policies: **No Storage** (higher budget) vs **Most Recent Message** (stores last update)
+- Manage channel lifecycle — delete unused channels (total active channels are limited)
+- Channels are identified by opaque IDs, not user-facing names
+- More efficient than per-device token delivery for 1-to-many scenarios
+
+## FCM as Provider
+
+If using Firebase Cloud Messaging as your push provider, watch for these gotchas:
+
+### 1. Swizzling Trap
+
+FCM swizzles `UNUserNotificationCenterDelegate` methods and `didRegisterForRemoteNotifications` by default. If you have custom delegate handling, they conflict.
+
+**Fix**: Set in Info.plist:
+```xml
+FirebaseAppDelegateProxyEnabled
+
+```
+
+Then manually pass the APNs token to FCM:
+```swift
+func application(_ application: UIApplication,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ Messaging.messaging().apnsToken = deviceToken
+}
+```
+
+### 2. Dual Token Confusion
+
+FCM token ≠ APNs device token. They are completely different. Send the correct one to the correct backend.
+
+- FCM token → your server (for sending via FCM)
+- APNs token → only needed if sending directly via APNs
+
+### 3. APNs Auth Key Upload
+
+Upload your .p8 APNs authentication key to Firebase Console → Project Settings → Cloud Messaging. Without this, development builds work (FCM uses sandbox automatically) but production builds silently fail.
+
+### 4. Silent Push Payload Size
+
+FCM's `content_available` maps to APNs `content-available`, but FCM may add extra fields to the payload. Monitor total size to avoid exceeding the 4KB limit.
+
+## Anti-Patterns
+
+### Anti-Pattern 1: Requesting Permission at App Launch
+
+**Wrong**:
+```swift
+func application(_ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ return true
+}
+```
+
+**Right**:
+```swift
+// After user taps "Subscribe to updates" or completes onboarding
+func subscribeButtonTapped() async {
+ let granted = try? await UNUserNotificationCenter.current()
+ .requestAuthorization(options: [.alert, .sound, .badge])
+ if granted == true {
+ await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
+ }
+}
+```
+
+**Why it matters**: The system only shows the permission dialog once. If the user hasn't seen value yet, they tap "Don't Allow" reflexively. ~60% of users who deny never re-enable in Settings. You get one shot.
+
+### Anti-Pattern 2: Caching Device Tokens
+
+**Wrong**:
+```swift
+func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
+ let tokenString = token.map { String(format: "%02x", $0) }.joined()
+ UserDefaults.standard.set(tokenString, forKey: "pushToken") // Stale after restore
+}
+```
+
+**Right**:
+```swift
+func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
+ let tokenString = token.map { String(format: "%02x", $0) }.joined()
+ sendTokenToServer(tokenString) // Fresh every launch
+}
+```
+
+**Why it matters**: Tokens change after backup restore, device migration, reinstall, and sometimes after OS updates. A stale cached token means your server sends to a token APNs no longer recognizes — notifications silently vanish.
+
+### Anti-Pattern 3: Ignoring Service Extension Timeout
+
+**Wrong**:
+```swift
+override func didReceive(_ request: UNNotificationRequest,
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ // Download large image, no timeout handling
+ downloadImage(from: url) { image in
+ // If this takes >30 seconds, notification vanishes entirely
+ contentHandler(modifiedContent)
+ }
+}
+// serviceExtensionTimeWillExpire not implemented
+```
+
+**Right**:
+```swift
+override func serviceExtensionTimeWillExpire() {
+ if let contentHandler, let bestAttemptContent {
+ // Deliver whatever we have — text without image is better than nothing
+ contentHandler(bestAttemptContent)
+ }
+}
+```
+
+**Why it matters**: The service extension has a ~30 second window. If neither `didReceive` nor `serviceExtensionTimeWillExpire` calls the content handler, the notification disappears completely. Users never see it.
+
+### Anti-Pattern 4: Using Time Sensitive for Everything
+
+**Wrong**:
+```json
+{
+ "aps": {
+ "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
+ "interruption-level": "time-sensitive"
+ }
+}
+```
+
+**Right**:
+```json
+{
+ "aps": {
+ "alert": { "title": "Weekly Newsletter", "body": "Check out this week's articles" },
+ "interruption-level": "passive"
+ }
+}
+```
+
+**Why it matters**: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive ones. Reserve it for genuinely time-bound events (delivery arriving, meeting starting, security alerts). Apple can also revoke the capability.
+
+## Pressure Scenarios
+
+### Scenario 1: "Ship Push Notifications by Friday"
+
+**Context**: PM needs push notifications working for a demo.
+
+**Pressure**: "Just ask for permission at launch, we'll fix it later."
+
+**Reality**: The system only prompts once. If the user denies, you need them to manually enable in Settings. ~60% of users never do. "Fix it later" means permanently lower opt-in rates.
+
+**Correct action**:
+1. Implement push registration and delivery without the permission prompt first
+2. Add contextual permission request after a user action that makes notification value obvious
+3. Test both grant and deny flows end-to-end
+
+**Push-back template**: "Permission timing directly affects our opt-in rate. A 2-hour investment now prevents a 30% lower notification reach permanently. Let me implement the contextual prompt — it's the same amount of code, just in the right place."
+
+### Scenario 2: "Notifications Work in Dev but Not Production"
+
+**Context**: App Store build doesn't receive push notifications.
+
+**Pressure**: "Something is wrong with APNs, let's file a radar."
+
+**Reality**: 95% of the time it's a sandbox/production token mismatch. Dev builds use `api.sandbox.push.apple.com`, production uses `api.push.apple.com`. Tokens are different per environment. The same token sent to the wrong endpoint silently fails.
+
+**Correct action**:
+1. Verify server is using the correct APNs endpoint for the build type
+2. Check that the token was obtained from a production build (not a dev/TestFlight token sent to production endpoint)
+3. If using FCM, verify the APNs authentication key (.p8) is uploaded to Firebase Console
+
+**Push-back template**: "Before filing a radar, let me verify our token/environment configuration. This is the number one cause of 'works in dev, not production' and takes 5 minutes to check."
+
+### Scenario 3: "Just Send Everything as Time Sensitive"
+
+**Context**: Product wants maximum notification visibility.
+
+**Pressure**: "Users need to see our notifications immediately."
+
+**Reality**: iOS shows users which apps overuse Time Sensitive. Users who feel interrupted will disable ALL notifications from your app — not just Time Sensitive. Apple can also revoke the entitlement for abuse.
+
+**Correct action**:
+1. Classify notifications by genuine urgency
+2. Use `passive` for informational, `active` (default) for normal engagement, `time-sensitive` only for truly time-bound events
+3. Document the classification for the team so backend engineers apply the right level
+
+**Push-back template**: "Overusing Time Sensitive will cause users to disable our notifications entirely. Let's classify by urgency — most notifications should be active, with time-sensitive reserved for genuinely time-bound events like delivery arrivals or expiring offers."
+
+## Checklist
+
+Before shipping push notifications:
+
+**Entitlements**:
+- ☑ Push Notifications capability added in Xcode
+- ☑ Provisioning profile includes aps-environment
+- ☑ Communication Notifications capability (if using communication type)
+
+**Permissions**:
+- ☑ Authorization requested in context (not at launch)
+- ☑ Denial handled gracefully (Settings redirect or degraded experience)
+- ☑ Authorization status checked before scheduling
+- ☑ Provisional authorization considered for trial period
+
+**Token Management**:
+- ☑ Token sent to server on every launch (never cached)
+- ☑ Server stores token per environment (sandbox/production)
+- ☑ Token refresh handled (pushTokenUpdates for Live Activities)
+
+**Payload**:
+- ☑ Payload under 4KB (5KB for VoIP)
+- ☑ Category identifier matches registered categories
+- ☑ Interruption level appropriate for content urgency
+- ☑ Custom data placed outside aps dictionary
+
+**Service Extension** (if applicable):
+- ☑ mutable-content: 1 set in payload
+- ☑ serviceExtensionTimeWillExpire delivers fallback content
+- ☑ App group configured for shared data access
+
+**Testing**:
+- ☑ Tested with Push Notifications Console or curl
+- ☑ Tested both foreground and background delivery
+- ☑ Tested on physical device (Simulator has no APNs token)
+
+## Resources
+
+**WWDC**: 2021-10091, 2023-10025, 2023-10185, 2024-10069
+
+**Docs**: /usernotifications, /usernotifications/unusernotificationcenter, /activitykit
+
+**Skills**: axiom-push-notifications-ref, axiom-push-notifications-diag, axiom-extensions-widgets, axiom-background-processing
diff --git a/.claude/skills/axiom-push-notifications/agents/openai.yaml b/.claude/skills/axiom-push-notifications/agents/openai.yaml
new file mode 100644
index 0000000..bc9e849
--- /dev/null
+++ b/.claude/skills/axiom-push-notifications/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Push Notifications"
+ short_description: "Implementing remote or local push notifications, requesting notification permission, managing APNs device tokens, add..."
diff --git a/.claude/skills/axiom-realitykit-diag/.openskills.json b/.claude/skills/axiom-realitykit-diag/.openskills.json
new file mode 100644
index 0000000..eaf7d16
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-diag/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-realitykit-diag",
+ "installedAt": "2026-04-12T08:06:34.441Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-realitykit-diag/SKILL.md b/.claude/skills/axiom-realitykit-diag/SKILL.md
new file mode 100644
index 0000000..c5e7c7c
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-diag/SKILL.md
@@ -0,0 +1,455 @@
+---
+name: axiom-realitykit-diag
+description: Use when RealityKit entities not visible, anchors not tracking, gestures not responding, performance drops, materials wrong, or multiplayer sync fails
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# RealityKit Diagnostics
+
+Systematic diagnosis for common RealityKit issues with time-cost annotations.
+
+## When to Use This Diagnostic Skill
+
+Use this skill when:
+- Entity added but not visible in the scene
+- AR anchor not tracking or content floating
+- Tap/drag gestures not responding on 3D entities
+- Frame rate dropping or stuttering
+- Material looks wrong (too dark, too bright, incorrect colors)
+- Multiplayer entities not syncing across devices
+- Physics bodies not colliding or passing through each other
+
+For RealityKit architecture patterns and best practices, see `axiom-realitykit`. For API reference, see `axiom-realitykit-ref`.
+
+---
+
+## Mandatory First Step: Enable Debug Visualization
+
+**Time cost**: 10 seconds vs hours of blind debugging
+
+```swift
+// In your RealityView or ARView setup
+#if DEBUG
+// Xcode: Debug → Attach to Process → Show RealityKit Statistics
+// Or enable in code:
+arView.debugOptions = [
+ .showStatistics, // Entity count, draw calls, FPS
+ .showPhysics, // Collision shapes
+ .showAnchorOrigins, // Anchor positions
+ .showAnchorGeometry // Detected plane geometry
+]
+#endif
+```
+
+If you can't see collision shapes with `.showPhysics`, your `CollisionComponent` is missing or misconfigured. **Fix collision before debugging gestures or physics.**
+
+---
+
+## Symptom 1: Entity Not Visible
+
+**Time saved**: 30-60 min → 2-5 min
+
+```
+Entity added but nothing appears
+│
+├─ Is the entity added to the scene?
+│ └─ NO → Add to RealityView content:
+│ content.add(entity)
+│ ✓ Entities must be in the scene graph to render
+│
+├─ Does the entity have a ModelComponent?
+│ └─ NO → Add mesh and material:
+│ entity.components[ModelComponent.self] = ModelComponent(
+│ mesh: .generateBox(size: 0.1),
+│ materials: [SimpleMaterial(color: .red, isMetallic: false)]
+│ )
+│ ✓ Bare Entity is invisible — it's just a container
+│
+├─ Is the entity's scale zero or nearly zero?
+│ └─ CHECK → Print: entity.scale
+│ USD models may import with unexpected scale.
+│ Try: entity.scale = SIMD3(repeating: 0.01) for meter-scale models
+│
+├─ Is the entity behind the camera?
+│ └─ CHECK → Print: entity.position(relativeTo: nil)
+│ In RealityKit, -Z is forward (toward screen).
+│ Try: entity.position = SIMD3(0, 0, -0.5) (half meter in front)
+│
+├─ Is the entity inside another object?
+│ └─ CHECK → Move to a known visible position:
+│ entity.position = SIMD3(0, 0, -1)
+│
+├─ Is the entity's isEnabled set to false?
+│ └─ CHECK → entity.isEnabled = true
+│ Also check parent: entity.isEnabledInHierarchy
+│
+├─ Is the entity on an untracked anchor?
+│ └─ CHECK → Verify anchor is tracking:
+│ entity.isAnchored (should be true)
+│ If using plane anchor, ensure surface is detected first
+│
+└─ Is the material transparent or OcclusionMaterial?
+ └─ CHECK → Inspect material:
+ If using PhysicallyBasedMaterial, check baseColor is not black
+ If using blending = .transparent, check opacity > 0
+```
+
+### Quick Diagnostic
+
+```swift
+func diagnoseVisibility(_ entity: Entity) {
+ print("Name: \(entity.name)")
+ print("Is enabled: \(entity.isEnabled)")
+ print("In hierarchy: \(entity.isEnabledInHierarchy)")
+ print("Is anchored: \(entity.isAnchored)")
+ print("Position (world): \(entity.position(relativeTo: nil))")
+ print("Scale: \(entity.scale)")
+ print("Has model: \(entity.components[ModelComponent.self] != nil)")
+ print("Children: \(entity.children.count)")
+}
+```
+
+---
+
+## Symptom 2: Anchor Not Tracking
+
+**Time saved**: 20-45 min → 3-5 min
+
+```
+AR content not appearing or floating
+│
+├─ Is the AR session running?
+│ └─ For RealityView on iOS 18+, AR runs automatically
+│ For ARView, check: arView.session.isRunning
+│
+├─ Is SpatialTrackingSession configured? (iOS 18+)
+│ └─ CHECK → Ensure tracking modes requested:
+│ let config = SpatialTrackingSession.Configuration(
+│ tracking: [.plane, .object])
+│ let result = await session.run(config)
+│ if let notSupported = result {
+│ // Handle unsupported modes
+│ }
+│
+├─ Is the anchor type appropriate for the environment?
+│ ├─ .plane(.horizontal) → Need a flat surface visible to camera
+│ ├─ .plane(.vertical) → Need a wall visible to camera
+│ ├─ .image → Image must be in "AR Resources" asset catalog
+│ ├─ .face → Front camera required (not rear)
+│ └─ .body → Full body must be visible
+│
+├─ Is minimumBounds too large?
+│ └─ CHECK → Reduce minimum bounds:
+│ AnchorEntity(.plane(.horizontal, classification: .any,
+│ minimumBounds: SIMD2(0.1, 0.1))) // Smaller = detects sooner
+│
+├─ Is the device supported?
+│ └─ CHECK → Plane detection requires A12+ chip
+│ Face tracking requires TrueDepth camera
+│ Body tracking requires A12+ chip
+│
+└─ Is the environment adequate?
+ └─ CHECK → AR needs:
+ - Adequate lighting (not too dark)
+ - Textured surfaces (not blank walls)
+ - Stable device position during initial detection
+```
+
+---
+
+## Symptom 3: Gesture Not Responding
+
+**Time saved**: 15-30 min → 2-3 min
+
+```
+Tap/drag on entity does nothing
+│
+├─ Does the entity have a CollisionComponent?
+│ └─ NO → Add collision shapes:
+│ entity.generateCollisionShapes(recursive: true)
+│ // or manual:
+│ entity.components[CollisionComponent.self] = CollisionComponent(
+│ shapes: [.generateBox(size: SIMD3(0.1, 0.1, 0.1))])
+│ ✓ Collision shapes are REQUIRED for gesture hit testing
+│
+├─ [visionOS] Does the entity have InputTargetComponent?
+│ └─ NO → Add it:
+│ entity.components[InputTargetComponent.self] = InputTargetComponent()
+│ ✓ Required on visionOS for gesture input
+│
+├─ Is the gesture attached to the RealityView?
+│ └─ CHECK → Gesture must be on the view, not the entity:
+│ RealityView { content in ... }
+│ .gesture(TapGesture().targetedToAnyEntity().onEnded { ... })
+│
+├─ Is the collision shape large enough to hit?
+│ └─ CHECK → Enable .showPhysics to see shapes
+│ Shapes too small = hard to tap.
+│ Try: .generateBox(size: SIMD3(repeating: 0.1)) minimum
+│
+├─ Is the entity behind another entity?
+│ └─ CHECK → Front entities may block gestures on back entities
+│ Ensure collision is on the intended target
+│
+└─ Is the entity enabled?
+ └─ CHECK → entity.isEnabled must be true
+ Disabled entities don't receive input
+```
+
+### Quick Diagnostic
+
+```swift
+func diagnoseGesture(_ entity: Entity) {
+ print("Has collision: \(entity.components[CollisionComponent.self] != nil)")
+ print("Has input target: \(entity.components[InputTargetComponent.self] != nil)")
+ print("Is enabled: \(entity.isEnabled)")
+ print("Is anchored: \(entity.isAnchored)")
+
+ if let collision = entity.components[CollisionComponent.self] {
+ print("Collision shapes: \(collision.shapes.count)")
+ }
+}
+```
+
+---
+
+## Symptom 4: Performance Problems
+
+**Time saved**: 1-3 hours → 10-20 min
+
+```
+Frame rate dropping or stuttering
+│
+├─ How many entities are in the scene?
+│ └─ CHECK → Print entity count:
+│ var count = 0
+│ func countEntities(_ entity: Entity) {
+│ count += 1
+│ for child in entity.children { countEntities(child) }
+│ }
+│ Under 100: unlikely to be entity count
+│ 100-500: review for optimization
+│ 500+: definitely needs optimization
+│
+├─ Are mesh/material resources shared?
+│ └─ NO → Share resources across identical entities:
+│ let sharedMesh = MeshResource.generateBox(size: 0.05)
+│ let sharedMaterial = SimpleMaterial(color: .white, isMetallic: false)
+│ // Reuse for all instances
+│ ✓ RealityKit batches entities with identical resources
+│
+├─ Is a System creating components every frame?
+│ └─ CHECK → Look for allocations in update():
+│ Creating ModelComponent, CollisionComponent, or materials
+│ every frame causes GC pressure.
+│ Cache resources, only update when values change.
+│
+├─ Are collision shapes mesh-based?
+│ └─ CHECK → Replace generateCollisionShapes(recursive: true)
+│ with simple shapes (box, sphere, capsule) for dynamic entities
+│
+├─ Is generateCollisionShapes called repeatedly?
+│ └─ CHECK → Call once during setup, not every frame
+│
+├─ Are there too many physics bodies?
+│ └─ CHECK → Dynamic bodies are most expensive.
+│ Convert distant/static objects to .static mode.
+│ Remove physics from non-interactive entities.
+│
+└─ Is the model polygon count too high?
+ └─ CHECK → Decimate models for real-time use.
+ Target: <100K triangles total for mobile AR.
+ Use LOD (Level of Detail) for distant objects.
+```
+
+---
+
+## Symptom 5: Material Looks Wrong
+
+**Time saved**: 15-45 min → 5-10 min
+
+```
+Colors, lighting, or textures look incorrect
+│
+├─ Is the scene too dark?
+│ └─ CHECK → Missing environment lighting:
+│ Add DirectionalLightComponent or EnvironmentResource
+│ In AR, RealityKit uses real-world lighting automatically
+│ In non-AR, you must provide lighting explicitly
+│
+├─ Is the baseColor set?
+│ └─ CHECK → PhysicallyBasedMaterial defaults to white
+│ material.baseColor = .init(tint: .red)
+│ If using a texture, verify it loaded:
+│ try TextureResource(named: "albedo")
+│
+├─ Is metallic set incorrectly?
+│ └─ CHECK → metallic = 1.0 makes surfaces mirror-like
+│ Most real objects: metallic = 0.0
+│ Only metals (gold, silver, chrome): metallic = 1.0
+│
+├─ Is the texture semantic wrong?
+│ └─ CHECK → Use correct semantic:
+│ .color for albedo/baseColor textures
+│ .raw for data textures (metallic, roughness)
+│ .normal for normal maps
+│ .hdrColor for HDR textures
+│
+├─ Is the model upside down or inside out?
+│ └─ CHECK → Try:
+│ material.faceCulling = .none (shows both sides)
+│ If that fixes it, the model normals are flipped
+│
+└─ Is blending/transparency unexpected?
+ └─ CHECK → material.blending
+ Default is .opaque
+ For transparency: .transparent(opacity: ...)
+```
+
+---
+
+## Symptom 6: Physics Not Working
+
+**Time saved**: 20-40 min → 5-10 min
+
+```
+Objects pass through each other or don't collide
+│
+├─ Do both entities have CollisionComponent?
+│ └─ NO → Both sides of a collision need CollisionComponent
+│
+├─ Does the moving entity have PhysicsBodyComponent?
+│ └─ NO → Add physics body:
+│ entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
+│ mode: .dynamic)
+│
+├─ Are collision groups/filters configured correctly?
+│ └─ CHECK → Entities must be in compatible groups:
+│ Default: group = .default, mask = .all
+│ If using custom groups, verify mask includes the other group
+│
+├─ Is the physics mode correct?
+│ ├─ Two .static bodies → Never collide (both immovable)
+│ ├─ .dynamic + .static → Correct (common setup)
+│ ├─ .dynamic + .dynamic → Both move on collision
+│ └─ .kinematic + .dynamic → Kinematic pushes dynamic
+│
+├─ Is the collision shape appropriate?
+│ └─ CHECK → .showPhysics debug option
+│ Shape may be too small, offset, or wrong type
+│
+└─ Are entities on different anchors?
+ └─ CHECK → "Physics bodies and colliders affect only
+ entities that share the same anchor" (Apple docs)
+ Move entities under the same anchor for physics interaction
+```
+
+---
+
+## Symptom 7: Multiplayer Sync Issues
+
+**Time saved**: 30-60 min → 10-15 min
+
+```
+Entities not appearing on other devices
+│
+├─ Does the entity have SynchronizationComponent?
+│ └─ NO → Add it:
+│ entity.components[SynchronizationComponent.self] =
+│ SynchronizationComponent()
+│
+├─ Is the MultipeerConnectivityService set up?
+│ └─ CHECK → Verify MCSession is connected before syncing
+│
+├─ Are custom components Codable?
+│ └─ NO → Non-Codable components don't sync
+│ struct MyComponent: Component, Codable { ... }
+│
+├─ Does the entity have an owner?
+│ └─ CHECK → Only the owner can modify synced properties
+│ Request ownership before modifying:
+│ entity.requestOwnership { result in ... }
+│
+└─ Is the entity anchored?
+ └─ CHECK → Unanchored entities may not sync position correctly
+ Use a shared world anchor for reliable positioning
+```
+
+---
+
+## Common Mistakes
+
+| Mistake | Time Cost | Fix |
+|---------|-----------|-----|
+| No CollisionComponent on interactive entity | 15-30 min | `entity.generateCollisionShapes(recursive: true)` |
+| Missing InputTargetComponent on visionOS | 10-20 min | Add `InputTargetComponent()` |
+| Gesture on wrong view (not RealityView) | 10-15 min | Attach `.gesture()` to `RealityView` |
+| Entity scale wrong for USD model | 15-30 min | Check units: meters vs centimeters |
+| No lighting in non-AR scene | 10-20 min | Add `DirectionalLightComponent` |
+| Storing entity refs in System | 30-60 min crash debugging | Query with `EntityQuery` each frame |
+| Components not registered | 10-15 min | Call `registerComponent()` in app init |
+| Systems not registered | 10-15 min | Call `registerSystem()` before scene load |
+| Physics across different anchors | 20-40 min | Put interacting entities under same anchor |
+| Calling generateCollisionShapes every frame | Performance degradation | Call once during setup |
+
+---
+
+## Diagnostic Quick Reference
+
+| Symptom | First Check | Time Saved |
+|---------|-------------|------------|
+| Not visible | Has ModelComponent? Scale > 0? | 30-60 min |
+| No gesture response | Has CollisionComponent? | 15-30 min |
+| Not tracking | Anchor type matches environment? | 20-45 min |
+| Frame drops | Entity count? Resource sharing? | 1-3 hours |
+| Wrong colors | Has lighting? Metallic value? | 15-45 min |
+| No collision | Both have CollisionComponent? Same anchor? | 20-40 min |
+| No sync | SynchronizationComponent? Codable? | 30-60 min |
+| Sim OK, device crash | Metal features? Texture format? | 15-30 min |
+
+---
+
+## Symptom 8: Works in Simulator, Crashes on Device
+
+**Time cost**: 15-30 min (often misdiagnosed as model issue)
+
+```
+Q1: Is the crash a Metal error (MTLCommandBuffer, shader compilation)?
+├─ YES → Simulator uses software rendering, device uses real GPU
+│ Common causes:
+│ - Custom Metal shaders with unsupported features
+│ - Texture formats not supported on device GPU
+│ - Exceeding device texture size limits (max 8192x8192 on older)
+│ Fix: Check device GPU family, use supported formats
+│
+└─ NO → Check next
+
+Q2: Is it an out-of-memory crash?
+├─ YES → Simulator has more RAM available
+│ Common: Large USDZ files with uncompressed textures
+│ Fix: Compress textures, reduce polygon count, use LOD
+│ Check: USDZ file size (keep < 50MB for reliable loading)
+│
+└─ NO → Check next
+
+Q3: Is it an AR-related crash (camera, tracking)?
+├─ YES → Simulator has no real camera/sensors
+│ Fix: Test AR features on device only, use simulator for UI/layout
+│
+└─ NO → Check device capabilities
+ - A12+ required for RealityKit
+ - LiDAR for scene reconstruction
+ - TrueDepth for face tracking
+```
+
+---
+
+## Resources
+
+**WWDC**: 2019-603, 2019-605, 2023-10080, 2024-10103
+
+**Docs**: /realitykit, /realitykit/entity, /realitykit/collisioncomponent, /realitykit/physicsbodycomponent
+
+**Skills**: axiom-realitykit, axiom-realitykit-ref
diff --git a/.claude/skills/axiom-realitykit-diag/agents/openai.yaml b/.claude/skills/axiom-realitykit-diag/agents/openai.yaml
new file mode 100644
index 0000000..d02c68e
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-diag/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "RealityKit Diagnostics"
+ short_description: "RealityKit entities not visible, anchors not tracking, gestures not responding, performance drops, materials wrong, o..."
diff --git a/.claude/skills/axiom-realitykit-ref/.openskills.json b/.claude/skills/axiom-realitykit-ref/.openskills.json
new file mode 100644
index 0000000..46dab1c
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-realitykit-ref",
+ "installedAt": "2026-04-12T08:06:34.831Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-realitykit-ref/SKILL.md b/.claude/skills/axiom-realitykit-ref/SKILL.md
new file mode 100644
index 0000000..1a5e85d
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-ref/SKILL.md
@@ -0,0 +1,652 @@
+---
+name: axiom-realitykit-ref
+description: RealityKit API reference — Entity, Component, System, RealityView, Model3D, anchor types, material system, physics, collision, animation, audio, accessibility
+license: MIT
+compatibility: [iOS 13+, macOS 10.15+, visionOS 1.0+, tvOS 26+]
+metadata:
+ version: "1.0.0"
+---
+
+# RealityKit API Reference
+
+Complete API reference for RealityKit organized by category.
+
+## When to Use This Reference
+
+Use this reference when:
+- Looking up specific RealityKit API signatures or properties
+- Checking which component types are available
+- Finding the right anchor type for an AR experience
+- Browsing material properties and options
+- Setting up physics body parameters
+- Looking up animation or audio API details
+- Checking platform availability for specific APIs
+
+---
+
+## Part 1: Entity API
+
+### Entity
+
+```swift
+// Creation
+let entity = Entity()
+let entity = Entity(components: [TransformComponent(), ModelComponent(...)])
+
+// Async loading
+let entity = try await Entity(named: "scene", in: .main)
+let entity = try await Entity(contentsOf: url)
+
+// Clone
+let clone = entity.clone(recursive: true)
+```
+
+### Entity Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `name` | `String` | Identifier for lookup |
+| `id` | `ObjectIdentifier` | Unique identity |
+| `isEnabled` | `Bool` | Local enabled state |
+| `isEnabledInHierarchy` | `Bool` | Effective enabled (considers parents) |
+| `isActive` | `Bool` | Entity is in an active scene |
+| `isAnchored` | `Bool` | Has anchoring or anchored ancestor |
+| `scene` | `RealityKit.Scene?` | Owning scene |
+| `parent` | `Entity?` | Parent entity |
+| `children` | `Entity.ChildCollection` | Child entities |
+| `components` | `Entity.ComponentSet` | All attached components |
+| `anchor` | `HasAnchoring?` | Nearest anchoring ancestor |
+
+### Entity Hierarchy Methods
+
+```swift
+entity.addChild(child)
+entity.addChild(child, preservingWorldTransform: true)
+entity.removeChild(child)
+entity.removeFromParent()
+entity.findEntity(named: "name") // Recursive search
+```
+
+### Entity Subclasses
+
+| Class | Purpose | Key Component |
+|-------|---------|---------------|
+| `Entity` | Base container | Transform only |
+| `ModelEntity` | Renderable object | ModelComponent |
+| `AnchorEntity` | AR anchor point | AnchoringComponent |
+| `PerspectiveCamera` | Virtual camera | PerspectiveCameraComponent |
+| `DirectionalLight` | Sun/directional | DirectionalLightComponent |
+| `PointLight` | Point light | PointLightComponent |
+| `SpotLight` | Spot light | SpotLightComponent |
+| `TriggerVolume` | Invisible collision zone | CollisionComponent |
+| `ViewAttachmentEntity` | SwiftUI view in 3D | visionOS |
+| `BodyTrackedEntity` | Body-tracked entity | BodyTrackingComponent |
+
+---
+
+## Part 2: Component Catalog
+
+### Transform
+
+```swift
+// Properties
+entity.position // SIMD3, local
+entity.orientation // simd_quatf
+entity.scale // SIMD3
+entity.transform // Transform struct
+
+// World-space
+entity.position(relativeTo: nil)
+entity.orientation(relativeTo: nil)
+entity.setPosition(pos, relativeTo: nil)
+
+// Utilities
+entity.look(at: target, from: position, relativeTo: nil)
+```
+
+### ModelComponent
+
+```swift
+let component = ModelComponent(
+ mesh: MeshResource.generateBox(size: 0.1),
+ materials: [SimpleMaterial(color: .red, isMetallic: true)]
+)
+entity.components[ModelComponent.self] = component
+```
+
+### MeshResource Built-in Generators
+
+| Method | Parameters |
+|--------|-----------|
+| `.generateBox(size:)` | `SIMD3` or single `Float` |
+| `.generateBox(size:cornerRadius:)` | Rounded box |
+| `.generateSphere(radius:)` | `Float` |
+| `.generatePlane(width:depth:)` | `Float`, `Float` |
+| `.generatePlane(width:height:)` | Vertical plane |
+| `.generateCylinder(height:radius:)` | `Float`, `Float` |
+| `.generateCone(height:radius:)` | `Float`, `Float` |
+| `.generateText(_:)` | `String`, with options |
+
+### CollisionComponent
+
+```swift
+let component = CollisionComponent(
+ shapes: [
+ .generateBox(size: SIMD3(0.1, 0.2, 0.1)),
+ .generateSphere(radius: 0.05),
+ .generateCapsule(height: 0.3, radius: 0.05),
+ .generateConvex(from: meshResource)
+ ],
+ mode: .default, // .default or .trigger
+ filter: CollisionFilter(
+ group: CollisionGroup(rawValue: 1),
+ mask: .all
+ )
+)
+```
+
+### ShapeResource Types
+
+| Method | Description | Performance |
+|--------|-------------|-------------|
+| `.generateBox(size:)` | Axis-aligned box | Fastest |
+| `.generateSphere(radius:)` | Sphere | Fast |
+| `.generateCapsule(height:radius:)` | Capsule | Fast |
+| `.generateConvex(from:)` | Convex hull from mesh | Moderate |
+| `.generateStaticMesh(from:)` | Exact mesh | Slowest (static only) |
+
+### PhysicsBodyComponent
+
+```swift
+let component = PhysicsBodyComponent(
+ massProperties: .init(
+ mass: 1.0,
+ inertia: SIMD3(repeating: 0.1),
+ centerOfMass: .zero
+ ),
+ material: .generate(
+ staticFriction: 0.5,
+ dynamicFriction: 0.3,
+ restitution: 0.4
+ ),
+ mode: .dynamic // .dynamic, .static, .kinematic
+)
+```
+
+| Mode | Behavior |
+|------|----------|
+| `.dynamic` | Physics simulation controls position |
+| `.static` | Immovable, participates in collisions |
+| `.kinematic` | Code-controlled, affects dynamic bodies |
+
+### PhysicsMotionComponent
+
+```swift
+var motion = PhysicsMotionComponent()
+motion.linearVelocity = SIMD3(0, 5, 0)
+motion.angularVelocity = SIMD3(0, .pi, 0)
+entity.components[PhysicsMotionComponent.self] = motion
+```
+
+### CharacterControllerComponent
+
+```swift
+entity.components[CharacterControllerComponent.self] = CharacterControllerComponent(
+ radius: 0.3,
+ height: 1.8,
+ slopeLimit: .pi / 4,
+ stepLimit: 0.3
+)
+
+// Move character with gravity
+entity.moveCharacter(
+ by: SIMD3(0.1, -0.01, 0),
+ deltaTime: Float(context.deltaTime),
+ relativeTo: nil
+)
+```
+
+### AnchoringComponent
+
+```swift
+// Plane detection
+AnchoringComponent(.plane(.horizontal, classification: .table,
+ minimumBounds: SIMD2(0.2, 0.2)))
+AnchoringComponent(.plane(.vertical, classification: .wall,
+ minimumBounds: SIMD2(0.5, 0.5)))
+
+// World position
+AnchoringComponent(.world(transform: float4x4(...)))
+
+// Image anchor
+AnchoringComponent(.image(group: "AR Resources", name: "poster"))
+
+// Face tracking
+AnchoringComponent(.face)
+
+// Body tracking
+AnchoringComponent(.body)
+```
+
+### Plane Classification
+
+| Classification | Description |
+|----------------|-------------|
+| `.table` | Horizontal table surface |
+| `.floor` | Floor surface |
+| `.ceiling` | Ceiling surface |
+| `.wall` | Vertical wall |
+| `.door` | Door |
+| `.window` | Window |
+| `.seat` | Chair/couch |
+
+### Light Components
+
+```swift
+// Directional
+let light = DirectionalLightComponent(
+ color: .white,
+ intensity: 1000,
+ isRealWorldProxy: false
+)
+light.shadow = DirectionalLightComponent.Shadow(
+ maximumDistance: 10,
+ depthBias: 0.01
+)
+
+// Point
+PointLightComponent(
+ color: .white,
+ intensity: 1000,
+ attenuationRadius: 5
+)
+
+// Spot
+SpotLightComponent(
+ color: .white,
+ intensity: 1000,
+ innerAngleInDegrees: 30,
+ outerAngleInDegrees: 60,
+ attenuationRadius: 10
+)
+```
+
+### Accessibility
+
+```swift
+var accessibility = AccessibilityComponent()
+accessibility.label = "Red cube"
+accessibility.value = "Interactive 3D object"
+accessibility.traits = .button
+accessibility.isAccessibilityElement = true
+entity.components[AccessibilityComponent.self] = accessibility
+```
+
+### Additional Components
+
+| Component | Purpose | Platform |
+|-----------|---------|----------|
+| `OpacityComponent` | Fade entity in/out | All |
+| `GroundingShadowComponent` | Contact shadow beneath entity | All |
+| `InputTargetComponent` | Enable gesture input | visionOS |
+| `HoverEffectComponent` | Highlight on gaze/hover | visionOS |
+| `SynchronizationComponent` | Multiplayer entity sync | All |
+| `ImageBasedLightComponent` | Custom environment lighting | All |
+| `ImageBasedLightReceiverComponent` | Receive IBL from source | All |
+
+---
+
+## Part 3: System API
+
+### System Protocol
+
+```swift
+protocol System {
+ init(scene: RealityKit.Scene)
+ func update(context: SceneUpdateContext)
+}
+```
+
+### SceneUpdateContext
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `deltaTime` | `TimeInterval` | Time since last update |
+| `scene` | `RealityKit.Scene` | The scene |
+
+```swift
+// Query entities
+context.entities(matching: query, updatingSystemWhen: .rendering)
+```
+
+### EntityQuery
+
+```swift
+// Has specific component
+EntityQuery(where: .has(HealthComponent.self))
+
+// Has multiple components
+EntityQuery(where: .has(HealthComponent.self) && .has(ModelComponent.self))
+
+// Does not have component
+EntityQuery(where: .has(EnemyComponent.self) && !.has(DeadComponent.self))
+```
+
+### Scene Events
+
+| Event | Trigger |
+|-------|---------|
+| `SceneEvents.Update` | Every frame |
+| `SceneEvents.DidAddEntity` | Entity added to scene |
+| `SceneEvents.DidRemoveEntity` | Entity removed from scene |
+| `SceneEvents.AnchoredStateChanged` | Anchor tracking changes |
+| `CollisionEvents.Began` | Two entities start colliding |
+| `CollisionEvents.Updated` | Collision continues |
+| `CollisionEvents.Ended` | Collision ends |
+| `AnimationEvents.PlaybackCompleted` | Animation finishes |
+
+```swift
+scene.subscribe(to: CollisionEvents.Began.self, on: entity) { event in
+ // event.entityA, event.entityB, event.impulse
+}
+```
+
+---
+
+## Part 4: RealityView API
+
+### Initializers
+
+```swift
+// Basic (iOS 18+, visionOS 1.0+)
+RealityView { content in
+ // make: Add entities to content
+}
+
+// With update
+RealityView { content in
+ // make
+} update: { content in
+ // update: Called when SwiftUI state changes
+}
+
+// With placeholder
+RealityView { content in
+ // make (async loading)
+} placeholder: {
+ ProgressView()
+}
+
+// With attachments (visionOS)
+RealityView { content, attachments in
+ // make
+} update: { content, attachments in
+ // update
+} attachments: {
+ Attachment(id: "label") { Text("Hello") }
+}
+```
+
+### RealityViewContent
+
+```swift
+content.add(entity)
+content.remove(entity)
+content.entities // EntityCollection
+
+// iOS/macOS — camera content
+content.camera // RealityViewCameraContent (non-visionOS)
+```
+
+### Gestures on RealityView
+
+```swift
+RealityView { content in ... }
+ .gesture(TapGesture().targetedToAnyEntity().onEnded { value in
+ let entity = value.entity
+ })
+ .gesture(DragGesture().targetedToAnyEntity().onChanged { value in
+ value.entity.position = value.convert(value.location3D,
+ from: .local, to: .scene)
+ })
+ .gesture(RotateGesture().targetedToAnyEntity().onChanged { value in
+ // Handle rotation
+ })
+ .gesture(MagnifyGesture().targetedToAnyEntity().onChanged { value in
+ // Handle scale
+ })
+```
+
+---
+
+## Part 5: Model3D API
+
+```swift
+// Simple display
+Model3D(named: "robot")
+
+// With phases
+Model3D(named: "robot") { phase in
+ switch phase {
+ case .empty:
+ ProgressView()
+ case .success(let model):
+ model.resizable().scaledToFit()
+ case .failure(let error):
+ Text("Failed: \(error.localizedDescription)")
+ @unknown default:
+ EmptyView()
+ }
+}
+
+// From URL
+Model3D(url: modelURL)
+```
+
+---
+
+## Part 6: Material System
+
+### SimpleMaterial
+
+```swift
+var material = SimpleMaterial()
+material.color = .init(tint: .blue)
+material.metallic = .init(floatLiteral: 1.0)
+material.roughness = .init(floatLiteral: 0.3)
+```
+
+### PhysicallyBasedMaterial
+
+```swift
+var material = PhysicallyBasedMaterial()
+material.baseColor = .init(tint: .white,
+ texture: .init(try .load(named: "albedo")))
+material.metallic = .init(floatLiteral: 0.0)
+material.roughness = .init(floatLiteral: 0.5)
+material.normal = .init(texture: .init(try .load(named: "normal")))
+material.ambientOcclusion = .init(texture: .init(try .load(named: "ao")))
+material.emissiveColor = .init(color: .blue)
+material.emissiveIntensity = 2.0
+material.clearcoat = .init(floatLiteral: 0.8)
+material.clearcoatRoughness = .init(floatLiteral: 0.1)
+material.specular = .init(floatLiteral: 0.5)
+material.sheen = .init(color: .white)
+material.anisotropyLevel = .init(floatLiteral: 0.5)
+material.blending = .transparent(opacity: .init(floatLiteral: 0.5))
+material.faceCulling = .back // .none, .front, .back
+```
+
+### UnlitMaterial
+
+```swift
+var material = UnlitMaterial()
+material.color = .init(tint: .red,
+ texture: .init(try .load(named: "texture")))
+material.blending = .transparent(opacity: .init(floatLiteral: 0.8))
+```
+
+### Special Materials
+
+```swift
+// Occlusion — invisible but hides content behind it
+let occlusionMaterial = OcclusionMaterial()
+
+// Video
+let videoMaterial = VideoMaterial(avPlayer: avPlayer)
+```
+
+### TextureResource Loading
+
+```swift
+// From bundle
+let texture = try await TextureResource(named: "texture")
+
+// From URL
+let texture = try await TextureResource(contentsOf: url)
+
+// With options
+let texture = try await TextureResource(named: "texture",
+ options: .init(semantic: .color)) // .color, .raw, .normal, .hdrColor
+```
+
+---
+
+## Part 7: Animation
+
+### Transform Animation
+
+```swift
+entity.move(
+ to: Transform(
+ scale: .one,
+ rotation: targetRotation,
+ translation: targetPosition
+ ),
+ relativeTo: entity.parent,
+ duration: 1.5,
+ timingFunction: .easeInOut
+)
+```
+
+### Timing Functions
+
+| Function | Curve |
+|----------|-------|
+| `.default` | System default |
+| `.linear` | Constant speed |
+| `.easeIn` | Slow start |
+| `.easeOut` | Slow end |
+| `.easeInOut` | Slow start and end |
+
+### Playing Loaded Animations
+
+```swift
+// All animations from USD
+for animation in entity.availableAnimations {
+ let controller = entity.playAnimation(animation)
+}
+
+// With options
+let controller = entity.playAnimation(
+ animation.repeat(count: 3),
+ transitionDuration: 0.3,
+ startsPaused: false
+)
+```
+
+### AnimationPlaybackController
+
+```swift
+let controller = entity.playAnimation(animation)
+controller.pause()
+controller.resume()
+controller.stop()
+controller.speed = 0.5 // Half speed
+controller.blendFactor = 1.0 // Full blend
+controller.isComplete // Check completion
+```
+
+---
+
+## Part 8: Audio
+
+### AudioFileResource
+
+```swift
+// Load
+let resource = try AudioFileResource.load(
+ named: "sound.wav",
+ configuration: .init(
+ shouldLoop: true,
+ shouldRandomizeStartTime: false,
+ mixGroupName: "effects"
+ )
+)
+```
+
+### Audio Components
+
+```swift
+// Spatial (3D positional)
+entity.components[SpatialAudioComponent.self] = SpatialAudioComponent(
+ directivity: .beam(focus: 0.5),
+ distanceAttenuation: .rolloff(factor: 1.0),
+ gain: 0 // dB
+)
+
+// Ambient (non-positional, uniform)
+entity.components[AmbientAudioComponent.self] = AmbientAudioComponent(
+ gain: -6
+)
+
+// Channel (multi-channel output)
+entity.components[ChannelAudioComponent.self] = ChannelAudioComponent(
+ gain: 0
+)
+```
+
+### Playback
+
+```swift
+let controller = entity.playAudio(resource)
+controller.pause()
+controller.stop()
+controller.gain = -3 // Adjust volume (dB)
+controller.speed = 1.5 // Pitch shift
+
+entity.stopAllAudio()
+```
+
+---
+
+## Part 9: RealityRenderer (Metal Integration)
+
+```swift
+// Low-level Metal rendering of RealityKit content
+let renderer = try RealityRenderer()
+renderer.entities.append(entity)
+
+// Render to Metal texture
+let descriptor = RealityRenderer.CameraOutput.Descriptor(
+ colorFormat: .bgra8Unorm,
+ depthFormat: .depth32Float
+)
+try renderer.render(
+ viewMatrix: viewMatrix,
+ projectionMatrix: projectionMatrix,
+ size: size,
+ colorTexture: colorTexture,
+ depthTexture: depthTexture
+)
+```
+
+---
+
+## Resources
+
+**WWDC**: 2019-603, 2019-605, 2021-10074, 2022-10074, 2023-10080, 2024-10103, 2024-10153
+
+**Docs**: /realitykit, /realitykit/entity, /realitykit/component, /realitykit/system, /realitykit/realityview, /realitykit/model3d, /realitykit/modelentity, /realitykit/anchorentity, /realitykit/physicallybasedmaterial
+
+**Skills**: axiom-realitykit, axiom-realitykit-diag, axiom-scenekit-ref
diff --git a/.claude/skills/axiom-realitykit-ref/agents/openai.yaml b/.claude/skills/axiom-realitykit-ref/agents/openai.yaml
new file mode 100644
index 0000000..5e678ce
--- /dev/null
+++ b/.claude/skills/axiom-realitykit-ref/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "RealityKit Reference"
+ short_description: "RealityKit API reference"
diff --git a/.claude/skills/axiom-realitykit/.openskills.json b/.claude/skills/axiom-realitykit/.openskills.json
new file mode 100644
index 0000000..06cf62d
--- /dev/null
+++ b/.claude/skills/axiom-realitykit/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-realitykit",
+ "installedAt": "2026-04-12T08:06:34.076Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-realitykit/SKILL.md b/.claude/skills/axiom-realitykit/SKILL.md
new file mode 100644
index 0000000..617dd15
--- /dev/null
+++ b/.claude/skills/axiom-realitykit/SKILL.md
@@ -0,0 +1,929 @@
+---
+name: axiom-realitykit
+description: Use when building 3D content, AR experiences, or spatial computing with RealityKit. Covers ECS architecture, SwiftUI integration, RealityView, AR anchors, materials, physics, interaction, multiplayer, performance.
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# RealityKit Development Guide
+
+**Purpose**: Build 3D content, AR experiences, and spatial computing apps using RealityKit's Entity-Component-System architecture
+**iOS Version**: iOS 13+ (base), iOS 18+ (RealityView on iOS), visionOS 1.0+
+**Xcode**: Xcode 15+
+
+## When to Use This Skill
+
+Use this skill when:
+- Building any 3D experience (AR, games, visualization, spatial computing)
+- Creating SwiftUI apps with 3D content (RealityView, Model3D)
+- Implementing AR with anchors (world, image, face, body tracking)
+- Working with Entity-Component-System (ECS) architecture
+- Setting up physics, collisions, or spatial interactions
+- Building multiplayer or shared AR experiences
+- Migrating from SceneKit to RealityKit
+- Targeting visionOS
+
+Do NOT use this skill for:
+- SceneKit maintenance (use `axiom-scenekit`)
+- 2D games (use `axiom-spritekit`)
+- Metal shader programming (use `axiom-metal-migration-ref`)
+- Pure GPU compute (use Metal directly)
+
+---
+
+## 1. Mental Model: ECS vs Scene Graph
+
+### Scene Graph (SceneKit)
+
+In SceneKit, nodes own their properties. A node IS a renderable, collidable, animated thing.
+
+### Entity-Component-System (RealityKit)
+
+In RealityKit, entities are **empty containers**. Components add data. Systems process that data.
+
+```
+Entity (identity + hierarchy)
+ ├── TransformComponent (position, rotation, scale)
+ ├── ModelComponent (mesh + materials)
+ ├── CollisionComponent (collision shapes)
+ ├── PhysicsBodyComponent (mass, mode)
+ └── [YourCustomComponent] (game-specific data)
+
+System (processes entities with specific components each frame)
+```
+
+**Why ECS matters**:
+- **Composition over inheritance**: Combine any components on any entity
+- **Data-oriented**: Systems process arrays of components efficiently
+- **Decoupled logic**: Systems don't know about each other
+- **Testable**: Components are pure data, Systems are pure logic
+
+### The ECS Mental Shift
+
+| Scene Graph Thinking | ECS Thinking |
+|---------------------|--------------|
+| "The player node moves" | "The movement system processes entities with MovementComponent" |
+| "Add a method to the node subclass" | "Add a component, create a system" |
+| "Override `update(_:)` in the node" | "Register a System that queries for components" |
+| "The node knows its health" | "HealthComponent holds data, DamageSystem processes it" |
+
+---
+
+## 2. Entity Hierarchy
+
+### Creating Entities
+
+```swift
+// Empty entity
+let entity = Entity()
+entity.name = "player"
+
+// Entity with components
+let entity = Entity()
+entity.components[ModelComponent.self] = ModelComponent(
+ mesh: .generateBox(size: 0.1),
+ materials: [SimpleMaterial(color: .blue, isMetallic: false)]
+)
+
+// ModelEntity convenience (has ModelComponent built in)
+let box = ModelEntity(
+ mesh: .generateBox(size: 0.1),
+ materials: [SimpleMaterial(color: .red, isMetallic: true)]
+)
+```
+
+### Hierarchy Management
+
+```swift
+// Parent-child
+parent.addChild(child)
+child.removeFromParent()
+
+// Find entities
+let found = root.findEntity(named: "player")
+
+// Enumerate
+for child in entity.children {
+ // Process children
+}
+
+// Clone
+let clone = entity.clone(recursive: true)
+```
+
+### Transform
+
+```swift
+// Local transform (relative to parent)
+entity.position = SIMD3(0, 1, 0)
+entity.orientation = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 1, 0))
+entity.scale = SIMD3(repeating: 2.0)
+
+// World-space queries
+let worldPos = entity.position(relativeTo: nil)
+let worldTransform = entity.transform(relativeTo: nil)
+
+// Set world-space transform
+entity.setPosition(SIMD3(1, 0, 0), relativeTo: nil)
+
+// Look at a point
+entity.look(at: targetPosition, from: entity.position, relativeTo: nil)
+```
+
+---
+
+## 3. Components
+
+### Built-in Components
+
+| Component | Purpose |
+|-----------|---------|
+| `Transform` | Position, rotation, scale |
+| `ModelComponent` | Mesh geometry + materials |
+| `CollisionComponent` | Collision shapes for physics and interaction |
+| `PhysicsBodyComponent` | Mass, physics mode (dynamic/static/kinematic) |
+| `PhysicsMotionComponent` | Linear and angular velocity |
+| `AnchoringComponent` | AR anchor attachment |
+| `SynchronizationComponent` | Multiplayer sync |
+| `PerspectiveCameraComponent` | Camera settings |
+| `DirectionalLightComponent` | Directional light |
+| `PointLightComponent` | Point light |
+| `SpotLightComponent` | Spot light |
+| `CharacterControllerComponent` | Character physics controller |
+| `AudioMixGroupsComponent` | Audio mixing |
+| `SpatialAudioComponent` | 3D positional audio |
+| `AmbientAudioComponent` | Non-positional audio |
+| `ChannelAudioComponent` | Multi-channel audio |
+| `OpacityComponent` | Entity transparency |
+| `GroundingShadowComponent` | Contact shadow |
+| `InputTargetComponent` | Gesture input (visionOS) |
+| `HoverEffectComponent` | Hover highlight (visionOS) |
+| `AccessibilityComponent` | VoiceOver support |
+
+### Custom Components
+
+```swift
+struct HealthComponent: Component {
+ var current: Int
+ var maximum: Int
+
+ var percentage: Float {
+ Float(current) / Float(maximum)
+ }
+}
+
+// Register before use (typically in app init)
+HealthComponent.registerComponent()
+
+// Attach to entity
+entity.components[HealthComponent.self] = HealthComponent(current: 100, maximum: 100)
+
+// Read
+if let health = entity.components[HealthComponent.self] {
+ print(health.current)
+}
+
+// Modify
+entity.components[HealthComponent.self]?.current -= 10
+```
+
+### Component Lifecycle
+
+Components are value types (structs). When you read a component, modify it, and write it back, you're replacing the entire component:
+
+```swift
+// Read-modify-write pattern
+var health = entity.components[HealthComponent.self]!
+health.current -= damage
+entity.components[HealthComponent.self] = health
+```
+
+**Anti-pattern**: Holding a reference to a component and expecting mutations to propagate. Components are copied on read.
+
+---
+
+## 4. Systems
+
+### System Protocol
+
+```swift
+struct DamageSystem: System {
+ // Define which components this system needs
+ static let query = EntityQuery(where: .has(HealthComponent.self))
+
+ init(scene: RealityKit.Scene) {
+ // One-time setup
+ }
+
+ func update(context: SceneUpdateContext) {
+ for entity in context.entities(matching: Self.query,
+ updatingSystemWhen: .rendering) {
+ var health = entity.components[HealthComponent.self]!
+ if health.current <= 0 {
+ entity.removeFromParent()
+ }
+ }
+ }
+}
+
+// Register system
+DamageSystem.registerSystem()
+```
+
+### System Best Practices
+
+- **One responsibility per system**: MovementSystem, DamageSystem, RenderingSystem — not GameLogicSystem
+- **Query filtering**: Use precise queries to avoid processing irrelevant entities
+- **Order matters**: Systems run in registration order. Register dependencies first.
+- **Avoid storing entity references**: Query each frame instead. Entity references can become stale.
+
+### Event Handling
+
+```swift
+// Subscribe to collision events
+scene.subscribe(to: CollisionEvents.Began.self) { event in
+ let entityA = event.entityA
+ let entityB = event.entityB
+ // Handle collision
+}
+
+// Subscribe to scene update
+scene.subscribe(to: SceneEvents.Update.self) { event in
+ let deltaTime = event.deltaTime
+ // Per-frame logic
+}
+```
+
+---
+
+## 5. SwiftUI Integration
+
+### RealityView (iOS 18+, visionOS 1.0+)
+
+```swift
+struct ContentView: View {
+ var body: some View {
+ RealityView { content in
+ // make closure — called once
+ let box = ModelEntity(
+ mesh: .generateBox(size: 0.1),
+ materials: [SimpleMaterial(color: .blue, isMetallic: false)]
+ )
+ content.add(box)
+
+ } update: { content in
+ // update closure — called when SwiftUI state changes
+ }
+ }
+}
+```
+
+### RealityView with Camera (iOS)
+
+On iOS, `RealityView` provides a camera content parameter for configuring the AR or virtual camera:
+
+```swift
+RealityView { content, attachments in
+ // Load 3D content
+ if let model = try? await ModelEntity(named: "scene") {
+ content.add(model)
+ }
+}
+```
+
+### Loading Content Asynchronously
+
+```swift
+RealityView { content in
+ // Load from bundle
+ if let entity = try? await Entity(named: "MyScene", in: .main) {
+ content.add(entity)
+ }
+
+ // Load from URL
+ if let entity = try? await Entity(contentsOf: modelURL) {
+ content.add(entity)
+ }
+}
+```
+
+### Model3D (Simple Display)
+
+```swift
+// Simple 3D model display (no interaction)
+Model3D(named: "toy_robot") { model in
+ model
+ .resizable()
+ .scaledToFit()
+} placeholder: {
+ ProgressView()
+}
+```
+
+### SwiftUI Attachments (visionOS)
+
+```swift
+RealityView { content, attachments in
+ let entity = ModelEntity(mesh: .generateSphere(radius: 0.1))
+ content.add(entity)
+
+ if let label = attachments.entity(for: "priceTag") {
+ label.position = SIMD3(0, 0.15, 0)
+ entity.addChild(label)
+ }
+} attachments: {
+ Attachment(id: "priceTag") {
+ Text("$9.99")
+ .padding()
+ .glassBackgroundEffect()
+ }
+}
+```
+
+### State Binding Pattern
+
+```swift
+struct GameView: View {
+ @State private var score = 0
+
+ var body: some View {
+ VStack {
+ Text("Score: \(score)")
+
+ RealityView { content in
+ let scene = try! await Entity(named: "GameScene")
+ content.add(scene)
+ } update: { content in
+ // React to state changes
+ // Note: update is called when SwiftUI state changes,
+ // not every frame. Use Systems for per-frame logic.
+ }
+ }
+ }
+}
+```
+
+---
+
+## 6. AR on iOS
+
+### AnchorEntity
+
+```swift
+// Horizontal plane
+let anchor = AnchorEntity(.plane(.horizontal, classification: .table,
+ minimumBounds: SIMD2(0.2, 0.2)))
+
+// Vertical plane
+let anchor = AnchorEntity(.plane(.vertical, classification: .wall,
+ minimumBounds: SIMD2(0.5, 0.5)))
+
+// World position
+let anchor = AnchorEntity(world: SIMD3(0, 0, -1))
+
+// Image anchor
+let anchor = AnchorEntity(.image(group: "AR Resources", name: "poster"))
+
+// Face anchor (front camera)
+let anchor = AnchorEntity(.face)
+
+// Body anchor
+let anchor = AnchorEntity(.body)
+```
+
+### SpatialTrackingSession (iOS 18+)
+
+```swift
+let session = SpatialTrackingSession()
+let configuration = SpatialTrackingSession.Configuration(tracking: [.plane, .object])
+let result = await session.run(configuration)
+
+if let notSupported = result {
+ // Handle unsupported tracking on this device
+ for denied in notSupported.deniedTrackingModes {
+ print("Not supported: \(denied)")
+ }
+}
+```
+
+### AR Best Practices
+
+- Anchor entities to detected surfaces rather than world positions for stability
+- Use plane classification (`.table`, `.floor`, `.wall`) to place content appropriately
+- Start with horizontal plane detection — it's the most reliable
+- Test on real devices; simulator AR is limited
+- Provide visual feedback during surface detection (coaching overlay)
+
+---
+
+## 7. Interaction
+
+### ManipulationComponent (iOS, visionOS)
+
+```swift
+// Enable drag, rotate, scale gestures
+entity.components[ManipulationComponent.self] = ManipulationComponent(
+ allowedModes: .all // .translate, .rotate, .scale
+)
+
+// Also requires CollisionComponent for hit testing
+entity.generateCollisionShapes(recursive: true)
+```
+
+### InputTargetComponent (visionOS)
+
+```swift
+// Required for visionOS gesture input
+entity.components[InputTargetComponent.self] = InputTargetComponent()
+entity.components[CollisionComponent.self] = CollisionComponent(
+ shapes: [.generateBox(size: SIMD3(0.1, 0.1, 0.1))]
+)
+```
+
+### Gesture Integration with SwiftUI
+
+```swift
+RealityView { content in
+ let entity = ModelEntity(mesh: .generateBox(size: 0.1))
+ entity.generateCollisionShapes(recursive: true)
+ entity.components.set(InputTargetComponent())
+ content.add(entity)
+}
+.gesture(
+ TapGesture()
+ .targetedToAnyEntity()
+ .onEnded { value in
+ let tappedEntity = value.entity
+ // Handle tap
+ }
+)
+.gesture(
+ DragGesture()
+ .targetedToAnyEntity()
+ .onChanged { value in
+ value.entity.position = value.convert(value.location3D,
+ from: .local, to: .scene)
+ }
+)
+```
+
+### Hit Testing
+
+```swift
+// Ray-cast from screen point
+if let result = arView.raycast(from: screenPoint,
+ allowing: .estimatedPlane,
+ alignment: .horizontal).first {
+ let worldPosition = result.worldTransform.columns.3
+ // Place entity at worldPosition
+}
+```
+
+---
+
+## 8. Materials and Rendering
+
+### Material Types
+
+| Material | Purpose | Customization |
+|----------|---------|---------------|
+| `SimpleMaterial` | Solid color or texture | Color, metallic, roughness |
+| `PhysicallyBasedMaterial` | Full PBR | All PBR maps (base color, normal, metallic, roughness, AO, emissive) |
+| `UnlitMaterial` | No lighting response | Color or texture, always fully lit |
+| `OcclusionMaterial` | Invisible but occludes | AR content hiding behind real objects |
+| `VideoMaterial` | Video playback on surface | AVPlayer-driven |
+| `ShaderGraphMaterial` | Custom shader graph | Reality Composer Pro |
+| `CustomMaterial` | Metal shader functions | Full Metal control |
+
+### PhysicallyBasedMaterial
+
+```swift
+var material = PhysicallyBasedMaterial()
+material.baseColor = .init(tint: .white,
+ texture: .init(try! .load(named: "albedo")))
+material.metallic = .init(floatLiteral: 0.0)
+material.roughness = .init(floatLiteral: 0.5)
+material.normal = .init(texture: .init(try! .load(named: "normal")))
+material.ambientOcclusion = .init(texture: .init(try! .load(named: "ao")))
+material.emissiveColor = .init(color: .blue)
+material.emissiveIntensity = 2.0
+
+let entity = ModelEntity(
+ mesh: .generateSphere(radius: 0.1),
+ materials: [material]
+)
+```
+
+### OcclusionMaterial (AR)
+
+```swift
+// Invisible plane that hides 3D content behind it
+let occluder = ModelEntity(
+ mesh: .generatePlane(width: 1, depth: 1),
+ materials: [OcclusionMaterial()]
+)
+occluder.position = SIMD3(0, 0, 0)
+anchor.addChild(occluder)
+```
+
+### Environment Lighting
+
+```swift
+// Image-based lighting
+if let resource = try? await EnvironmentResource(named: "studio_lighting") {
+ // Apply via RealityView content
+}
+```
+
+---
+
+## 9. Physics and Collision
+
+### Collision Shapes
+
+```swift
+// Generate from mesh (accurate but expensive)
+entity.generateCollisionShapes(recursive: true)
+
+// Manual shapes (prefer for performance)
+entity.components[CollisionComponent.self] = CollisionComponent(
+ shapes: [
+ .generateBox(size: SIMD3(0.1, 0.2, 0.1)), // Box
+ .generateSphere(radius: 0.1), // Sphere
+ .generateCapsule(height: 0.3, radius: 0.05) // Capsule
+ ]
+)
+```
+
+### Physics Body
+
+```swift
+// Dynamic — physics simulation controls movement
+entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
+ massProperties: .init(mass: 1.0),
+ material: .generate(staticFriction: 0.5,
+ dynamicFriction: 0.3,
+ restitution: 0.4),
+ mode: .dynamic
+)
+
+// Static — immovable collision surface
+ground.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
+ mode: .static
+)
+
+// Kinematic — code-controlled, participates in collisions
+platform.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
+ mode: .kinematic
+)
+```
+
+### Collision Groups and Filters
+
+```swift
+// Define groups
+let playerGroup = CollisionGroup(rawValue: 1 << 0)
+let enemyGroup = CollisionGroup(rawValue: 1 << 1)
+let bulletGroup = CollisionGroup(rawValue: 1 << 2)
+
+// Filter: player collides with enemies and bullets
+entity.components[CollisionComponent.self] = CollisionComponent(
+ shapes: [.generateSphere(radius: 0.1)],
+ filter: CollisionFilter(
+ group: playerGroup,
+ mask: enemyGroup | bulletGroup
+ )
+)
+```
+
+### Collision Events
+
+```swift
+// Subscribe in RealityView make closure or System
+scene.subscribe(to: CollisionEvents.Began.self, on: playerEntity) { event in
+ let otherEntity = event.entityA == playerEntity ? event.entityB : event.entityA
+ handleCollision(with: otherEntity)
+}
+```
+
+### Applying Forces
+
+```swift
+if var motion = entity.components[PhysicsMotionComponent.self] {
+ motion.linearVelocity = SIMD3(0, 5, 0) // Impulse up
+ entity.components[PhysicsMotionComponent.self] = motion
+}
+```
+
+---
+
+## 10. Animation
+
+### Transform Animation
+
+```swift
+// Animate to position over duration
+entity.move(
+ to: Transform(
+ scale: SIMD3(repeating: 1.5),
+ rotation: simd_quatf(angle: .pi, axis: SIMD3(0, 1, 0)),
+ translation: SIMD3(0, 2, 0)
+ ),
+ relativeTo: entity.parent,
+ duration: 2.0,
+ timingFunction: .easeInOut
+)
+```
+
+### Playing USD Animations
+
+```swift
+if let entity = try? await Entity(named: "character") {
+ // Play all available animations
+ for animation in entity.availableAnimations {
+ entity.playAnimation(animation.repeat())
+ }
+}
+```
+
+### Animation Playback Control
+
+```swift
+let controller = entity.playAnimation(animation)
+controller.pause()
+controller.resume()
+controller.speed = 2.0 // 2x playback speed
+controller.blendFactor = 0.5 // Blend with current state
+```
+
+---
+
+## 11. Audio
+
+### Spatial Audio
+
+```swift
+// Load audio resource
+let resource = try! AudioFileResource.load(named: "engine.wav",
+ configuration: .init(shouldLoop: true))
+
+// Create entity with spatial audio
+let audioEntity = Entity()
+audioEntity.components[SpatialAudioComponent.self] = SpatialAudioComponent()
+let controller = audioEntity.playAudio(resource)
+
+// Position the audio source in 3D space
+audioEntity.position = SIMD3(2, 0, -1)
+```
+
+### Ambient Audio
+
+```swift
+entity.components[AmbientAudioComponent.self] = AmbientAudioComponent()
+entity.playAudio(backgroundMusic)
+```
+
+---
+
+## 12. Performance
+
+### Entity Count
+
+- **Under 100 entities**: No concerns
+- **100-1000 entities**: Monitor with RealityKit debugger
+- **1000+ entities**: Use instancing and LOD strategies
+
+### Instancing
+
+```swift
+// Share mesh and material across many entities
+let sharedMesh = MeshResource.generateSphere(radius: 0.01)
+let sharedMaterial = SimpleMaterial(color: .white, isMetallic: false)
+
+for i in 0..<1000 {
+ let entity = ModelEntity(mesh: sharedMesh, materials: [sharedMaterial])
+ entity.position = randomPosition()
+ parent.addChild(entity)
+}
+```
+
+RealityKit automatically batches entities with identical mesh and material resources.
+
+### Component Churn
+
+**Anti-pattern**: Creating and replacing components every frame.
+
+```swift
+// BAD — component allocation every frame
+func update(context: SceneUpdateContext) {
+ for entity in context.entities(matching: query, updatingSystemWhen: .rendering) {
+ entity.components[ModelComponent.self] = ModelComponent(
+ mesh: .generateBox(size: 0.1),
+ materials: [newMaterial] // New allocation every frame
+ )
+ }
+}
+
+// GOOD — modify existing component
+func update(context: SceneUpdateContext) {
+ for entity in context.entities(matching: query, updatingSystemWhen: .rendering) {
+ // Only update when actually needed
+ if needsUpdate {
+ var model = entity.components[ModelComponent.self]!
+ model.materials = [cachedMaterial]
+ entity.components[ModelComponent.self] = model
+ }
+ }
+}
+```
+
+### Collision Shape Optimization
+
+- Use simple shapes (box, sphere, capsule) instead of mesh-based collision
+- `generateCollisionShapes(recursive: true)` is convenient but expensive
+- For static geometry, generate shapes once during setup
+
+### Profiling
+
+Use Xcode's RealityKit debugger:
+- **Entity Inspector**: View entity hierarchy and components
+- **Statistics Overlay**: Entity count, draw calls, triangle count
+- **Physics Visualization**: Show collision shapes
+
+---
+
+## 13. Multiplayer
+
+### Synchronization Basics
+
+```swift
+// Components sync automatically if they conform to Codable
+struct ScoreComponent: Component, Codable {
+ var points: Int
+}
+
+// SynchronizationComponent controls what syncs
+entity.components[SynchronizationComponent.self] = SynchronizationComponent()
+```
+
+### MultipeerConnectivityService
+
+```swift
+let service = try MultipeerConnectivityService(session: mcSession)
+// Entities with SynchronizationComponent auto-sync across peers
+```
+
+### Ownership
+
+- Only the **owner** of an entity can modify it
+- Request ownership before modifying shared entities
+- Non-Codable component data does not sync
+
+---
+
+## 14. Anti-Patterns
+
+### Anti-Pattern 1: UIKit-Style Thinking in ECS
+
+**Time cost**: Hours of frustration from fighting the architecture
+
+```swift
+// BAD — subclassing Entity for behavior
+class PlayerEntity: Entity {
+ func takeDamage(_ amount: Int) { /* logic in entity */ }
+}
+
+// GOOD — component holds data, system has logic
+struct HealthComponent: Component { var hp: Int }
+struct DamageSystem: System {
+ static let query = EntityQuery(where: .has(HealthComponent.self))
+ func update(context: SceneUpdateContext) {
+ // Process damage here
+ }
+}
+```
+
+### Anti-Pattern 2: Monolithic Entities
+
+**Time cost**: Untestable, inflexible architecture
+
+Don't put all game logic in one entity type. Split into components that can be mixed and matched.
+
+### Anti-Pattern 3: Frame-Based Updates Without Systems
+
+**Time cost**: Missed frame updates, inconsistent behavior
+
+```swift
+// BAD — timer-based updates
+Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { _ in
+ entity.position.x += 0.01
+}
+
+// GOOD — System update
+struct MovementSystem: System {
+ static let query = EntityQuery(where: .has(VelocityComponent.self))
+ func update(context: SceneUpdateContext) {
+ for entity in context.entities(matching: Self.query,
+ updatingSystemWhen: .rendering) {
+ let velocity = entity.components[VelocityComponent.self]!
+ entity.position += velocity.value * Float(context.deltaTime)
+ }
+ }
+}
+```
+
+### Anti-Pattern 4: Not Generating Collision Shapes for Interactive Entities
+
+**Time cost**: 15-30 min debugging "why taps don't work"
+
+Gestures require `CollisionComponent`. If an entity has `InputTargetComponent` (visionOS) or `ManipulationComponent` but no `CollisionComponent`, gestures will never fire.
+
+### Anti-Pattern 5: Storing Entity References in Systems
+
+**Time cost**: Crashes from stale references
+
+```swift
+// BAD — entity might be removed between frames
+struct BadSystem: System {
+ var playerEntity: Entity? // Stale reference risk
+
+ func update(context: SceneUpdateContext) {
+ playerEntity?.position.x += 0.1 // May crash
+ }
+}
+
+// GOOD — query each frame
+struct GoodSystem: System {
+ static let query = EntityQuery(where: .has(PlayerComponent.self))
+
+ func update(context: SceneUpdateContext) {
+ for entity in context.entities(matching: Self.query,
+ updatingSystemWhen: .rendering) {
+ entity.position.x += Float(context.deltaTime)
+ }
+ }
+}
+```
+
+---
+
+## 15. Code Review Checklist
+
+- [ ] Custom components registered via `registerComponent()` before use
+- [ ] Systems registered via `registerSystem()` before scene loads
+- [ ] Components are value types (structs), not classes
+- [ ] Read-modify-write pattern used for component updates
+- [ ] Interactive entities have `CollisionComponent`
+- [ ] visionOS interactive entities have `InputTargetComponent`
+- [ ] Collision shapes are simple (box/sphere/capsule) where possible
+- [ ] No entity references stored across frames in Systems
+- [ ] Mesh and material resources shared across identical entities
+- [ ] Component updates only occur when values actually change
+- [ ] USD/USDZ format used for 3D assets (not .scn)
+- [ ] Async loading used for all model/scene loading
+- [ ] `[weak self]` in closure-based subscriptions if retaining view/controller
+
+---
+
+## 16. Pressure Scenarios
+
+### Scenario 1: "ECS Is Overkill for Our Simple App"
+
+**Pressure**: Team wants to avoid learning ECS, just needs one 3D model displayed
+
+**Wrong approach**: Skip ECS, jam all logic into RealityView closures.
+
+**Correct approach**: Even simple apps benefit from ECS. A single `ModelEntity` in a `RealityView` is already using ECS — you're just not adding custom components yet. Start simple, add components as complexity grows.
+
+**Push-back template**: "We're already using ECS — Entity and ModelComponent. The pattern scales. Adding a custom component when we need behavior is one struct definition, not an architecture change."
+
+### Scenario 2: "Just Use SceneKit, We Know It"
+
+**Pressure**: Team has SceneKit experience, RealityKit is unfamiliar
+
+**Wrong approach**: Build new features in SceneKit.
+
+**Correct approach**: SceneKit is soft-deprecated. New features won't be added. Invest in RealityKit now — the ECS concepts transfer to other game engines (Unity, Unreal, Bevy) if needed.
+
+**Push-back template**: "SceneKit is in maintenance mode — no new features, only security patches. Every line of SceneKit we write is migration debt. RealityKit's concepts (Entity, Component, System) are industry-standard ECS."
+
+### Scenario 3: "Make It Work Without Collision Shapes"
+
+**Pressure**: Deadline, collision shape setup seems complex
+
+**Wrong approach**: Skip collision shapes, use position-based proximity detection.
+
+**Correct approach**: `entity.generateCollisionShapes(recursive: true)` takes one line. Without it, gestures won't work and physics won't collide. The "shortcut" creates more debugging time than it saves.
+
+**Push-back template**: "Collision shapes are required for gestures and physics. It's one line: `entity.generateCollisionShapes(recursive: true)`. Skipping it means gestures silently fail — a harder bug to diagnose."
+
+---
+
+## Resources
+
+**WWDC**: 2019-603, 2019-605, 2021-10074, 2022-10074, 2023-10080, 2023-10081, 2024-10103, 2024-10153
+
+**Docs**: /realitykit, /realitykit/entity, /realitykit/realityview, /realitykit/modelentity, /realitykit/anchorentity, /realitykit/component
+
+**Skills**: axiom-realitykit-ref, axiom-realitykit-diag, axiom-scenekit, axiom-scenekit-ref
diff --git a/.claude/skills/axiom-realitykit/agents/openai.yaml b/.claude/skills/axiom-realitykit/agents/openai.yaml
new file mode 100644
index 0000000..d0a103b
--- /dev/null
+++ b/.claude/skills/axiom-realitykit/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "RealityKit"
+ short_description: "Building 3D content, AR experiences, or spatial computing with RealityKit"
diff --git a/.claude/skills/axiom-realm-migration-ref/.openskills.json b/.claude/skills/axiom-realm-migration-ref/.openskills.json
new file mode 100644
index 0000000..3a6ddeb
--- /dev/null
+++ b/.claude/skills/axiom-realm-migration-ref/.openskills.json
@@ -0,0 +1,7 @@
+{
+ "source": "CharlesWiltgen/Axiom",
+ "sourceType": "git",
+ "repoUrl": "https://github.com/CharlesWiltgen/Axiom",
+ "subpath": "axiom-codex/skills/axiom-realm-migration-ref",
+ "installedAt": "2026-04-12T08:06:35.274Z"
+}
\ No newline at end of file
diff --git a/.claude/skills/axiom-realm-migration-ref/SKILL.md b/.claude/skills/axiom-realm-migration-ref/SKILL.md
new file mode 100644
index 0000000..4b99a38
--- /dev/null
+++ b/.claude/skills/axiom-realm-migration-ref/SKILL.md
@@ -0,0 +1,793 @@
+---
+name: axiom-realm-migration-ref
+description: Use when migrating from Realm to SwiftData - comprehensive migration guide covering pattern equivalents, threading model conversion, schema migration strategies, CloudKit sync transition, and real-world scenarios
+license: MIT
+metadata:
+ version: "1.0.0"
+---
+
+# Realm to SwiftData Migration — Reference Guide
+
+**Purpose**: Complete migration path from Realm to SwiftData
+**Swift Version**: Swift 5.9+ (Swift 6 with strict concurrency recommended)
+**iOS Version**: iOS 17+ (iOS 26+ recommended)
+**Context**: Realm Device Sync sunset Sept 30, 2025. This guide is essential for Realm users migrating before deadline.
+
+---
+
+## Critical Timeline
+
+**Realm Device Sync** DEPRECATION DEADLINE = September 30, 2025
+
+If your app uses Realm Sync:
+- ⚠️ You MUST migrate by September 30, 2025
+- ✅ SwiftData is the recommended replacement
+- ⏰ Time remaining: Depends on current date, but migrations take 2-8 weeks for production apps
+
+**This guide** provides everything needed for successful migration.
+
+---
+
+## Migration Strategy Overview
+
+```
+Phase 1 (Week 1-2): Preparation & Planning
+├─ Audit current Realm usage
+├─ Understand model relationships
+├─ Plan data migration path
+└─ Set up test environment
+
+Phase 2 (Week 2-3): Development
+├─ Create SwiftData models from Realm schemas
+├─ Implement data migration logic
+├─ Convert threading model to async/await
+└─ Test with real data
+
+Phase 3 (Week 3-4): Migration
+├─ Migrate existing app users' data
+├─ Run in parallel (Realm + SwiftData)
+├─ Verify CloudKit sync works
+└─ Monitor for issues
+
+Phase 4 (Week 4+): Production
+├─ Deploy update with parallel persistence
+├─ Gradual cutover from Realm to SwiftData
+├─ Deprecate Realm code
+└─ Monitor CloudKit sync health
+```
+
+---
+
+## Part 1: Pattern Equivalents
+
+### Model Definition Conversion
+
+#### Realm → SwiftData: Basic Model
+
+```swift
+// REALM
+class RealmTrack: Object {
+ @Persisted(primaryKey: true) var id: String
+ @Persisted var title: String
+ @Persisted var artist: String
+ @Persisted var duration: TimeInterval
+ @Persisted var genre: String?
+}
+
+// SWIFTDATA
+@Model
+final class Track {
+ @Attribute(.unique) var id: String // remove if using CloudKit sync
+ var title: String
+ var artist: String
+ var duration: TimeInterval
+ var genre: String?
+
+ init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) {
+ self.id = id
+ self.title = title
+ self.artist = artist
+ self.duration = duration
+ self.genre = genre
+ }
+}
+```
+
+**Key differences**:
+- Realm: `@Persisted(primaryKey: true)` → SwiftData: `@Attribute(.unique)` (not supported with CloudKit sync — remove if using CloudKit)
+- Realm: Implicit init → SwiftData: Explicit init required
+- Realm: `Object` base class → SwiftData: `@Model` macro on `final class`
+
+#### Realm → SwiftData: Relationships
+
+```swift
+// REALM: One-to-Many
+class RealmAlbum: Object {
+ @Persisted(primaryKey: true) var id: String
+ @Persisted var title: String
+ @Persisted var tracks: RealmSwiftCollection
+}
+
+// SWIFTDATA: One-to-Many
+@Model
+final class Album {
+ @Attribute(.unique) var id: String
+ var title: String
+
+ @Relationship(deleteRule: .cascade, inverse: \Track.album)
+ var tracks: [Track] = []
+}
+
+@Model
+final class Track {
+ @Attribute(.unique) var id: String
+ var title: String
+ var album: Album? // Inverse automatically maintained
+}
+```
+
+**Key differences**:
+- Realm: Explicit `RealmSwiftCollection` type → SwiftData: Native `[Track]` array
+- Realm: Manual relationship management → SwiftData: Inverse relationships automatic
+- Realm: No delete rules → SwiftData: `deleteRule: .cascade / .nullify / .deny`
+
+#### Realm → SwiftData: Indexes
+
+```swift
+// REALM
+class RealmTrack: Object {
+ @Persisted(primaryKey: true) var id: String
+ @Persisted(indexed: true) var genre: String
+ @Persisted(indexed: true) var releaseDate: Date
+}
+
+// SWIFTDATA
+@Model
+final class Track {
+ @Attribute(.unique) var id: String
+ @Attribute(.indexed) var genre: String = ""
+ @Attribute(.indexed) var releaseDate: Date = Date()
+}
+```
+
+---
+
+## Part 2: Threading Model Conversion
+
+### Realm Threading → Swift Concurrency
+
+#### Realm: Manual Thread Handling
+
+```swift
+class RealmDataManager {
+ func fetchTracksOnBackground() {
+ DispatchQueue.global().async {
+ let realm = try! Realm() // Must get Realm on each thread
+ let tracks = realm.objects(RealmTrack.self)
+
+ DispatchQueue.main.async {
+ self.updateUI(tracks: Array(tracks))
+ }
+ }
+ }
+
+ func saveTrackOnBackground(_ track: RealmTrack) {
+ DispatchQueue.global().async {
+ let realm = try! Realm()
+ try! realm.write {
+ realm.add(track)
+ }
+ }
+ }
+}
+```
+
+**Problems**:
+- Manual DispatchQueue threading error-prone
+- Easy to access objects on wrong thread
+- No compile-time guarantees
+
+#### SwiftData: Actor-Based Concurrency
+
+```swift
+actor SwiftDataManager {
+ let modelContainer: ModelContainer
+
+ func fetchTracks() async -> [Track] {
+ let context = ModelContext(modelContainer)
+ let descriptor = FetchDescriptor