Steel Notes

Implementation Plan · Updated 2026-08-30 00:57 UTC
Agent Guide
238
Tasks
157
Complete
2
In Progress
79
Not Started
66%
Done
Implementation Phases

Phase 1 — Foundation

DONE
21 0 0
100%
Phase 1A — Shared Kotlin Core 15/15
Set up KMP project (iOS, macOS targets)
shared/build.gradle.kts, SKIE enabled for Swift interop
Define SQLDelight schema (notes, links, annotations, FTS5)
SteelNotes.sq — full schema with triggers and named queries
Build frontmatter parser (YAML parse + serialize)
FrontmatterParser.kt — hand-rolled, no external YAML dep
Build annotation parser (blockquote + list-item format)
AnnotationParser.kt
Build wikilink extractor ([[links]] → markdown links on save)
*(superseded — [[ ]] removed; see NoteLinkExtractor.kt and LINKING_ARCHITECTURE.md)*
Define note models (Resource, Thought, Question, Synthesis)
Note.kt, NoteType.kt, Statuses.kt, Annotation.kt, Connection.kt. See Phase 6 for Note model refactor.
Implement NoteFactory (frontmatter → typed model)
NoteFactory.kt
Implement NoteSerializer (typed model → markdown)
NoteSerializer.kt
Implement IdGenerator (src-2026-03-20-001 pattern)
IdGenerator.kt
Create platform file I/O abstraction
FileSystem.kt (expect) + PlatformFileSystem.kt (appleMain/NSFileManager)
Implement VaultManager (CRUD on .md + DB sync)
VaultManager.kt — init, create, update, delete, list, wikilink indexing
Implement VaultIndexer (full + incremental reindex)
VaultIndexer.kt — fullReindex, indexFile, deindexFile
Implement FTS5 search engine (BM25 ranked)
SearchEngine.kt — search, findRelated, filters
Create DatabaseDriverFactory (platform SQLite driver)
DatabaseDriverFactory.kt (expect + appleMain NativeSqliteDriver)
Build macOS CLI (init, create, list, search, read, delete, index)
cli/ — all 7 commands, --json flag, Clikt framework
Phase 1B — iOS App Shell 6/6
Create Xcode project + App target
iosApp/project.yml (XcodeGen), generates SteelNotes.xcodeproj
Create SteelNotesApp.swift entry point
iosApp/SteelNotes/App/SteelNotesApp.swift
Link KMP framework into Xcode project
embedAndSignAppleFrameworkForXcode Gradle task in pre-build script
Build SharedCoreService.swift (Swift bridge to Kotlin)
iosApp/SteelNotes/Services/SharedCoreService.swift — facade over VaultManager, SearchEngine, VaultIndexer
Set up dependency injection
iosApp/SteelNotes/DI/AppContainer.swift — ObservableObject with vault path resolution
Build navigation coordinator
iosApp/SteelNotes/Navigation/AppNavigation.swift — compact drawer navigation, deep links, programmatic drawer open/close animation, and smoother interactive drag-open settling

Phase 2 — Cloud Vault Sync 🚧 PRIORITY: ACTIVE

DONE
39 0 0
100%

Goal: Get .md files syncing across devices via S3 + custom REST API. This is the highest-priority feature — ship sync before rich iOS editing.

See SYNC_ARCHITECTURE.md for the full sync design spec.

See AUTH_ARCHITECTURE.md for the multi-provider auth design.

See SYNC_BUILD_GUIDE.md for the actionable build plan.

Phase 2A — Auth & API Foundation 14/14
Scaffold server/ Rust project: Cargo.toml, Axum router, config, error handling
server/src/main.rs — Axum 0.8, public/protected route groups, Lambda + standalone entry
Write Postgres migrations: users, auth_providers, refresh_tokens, devices, file_state
004_auth_providers_refresh_tokens.sql — migrates provider data, adds auth_providers + refresh_tokens
Implement POST /auth/register — email/password sign-up (Argon2id)
server/src/routes/auth.rs — Argon2id hashing, creates user + auth_provider + device
Implement POST /auth/login — unified endpoint with grant_type discriminator
Apple + email/password grants via #[serde(tag = "grant_type")] enum
Implement POST /auth/refresh — refresh token rotation
DB-backed rotation — old token deleted, new token issued on each refresh
Implement POST /auth/link — link additional provider to existing account
Protected endpoint; supports Apple + email providers; idempotent
Implement POST /devices/register — device + push token registration
server/src/routes/devices.rs — upsert by user_id + device_name
Add Tower middleware: JWT extraction + validation
server/src/middleware/auth.rs — extracts user_id + device_id into extensions
Configure S3 bucket + presigned URL generation (aws-sdk-s3)
server/src/services/s3.rs — presigned PUT/GET/DELETE
Write Terraform: Lambda + API Gateway + RDS + S3 + IAM roles
server/deploy/terraform/main.tf — full infra definition
Build client AuthManager.kt, TokenStore.kt (expect/actual for Keychain)
com.steelnotes.auth — AuthManager + TokenStore (Keychain stub, in-memory for now)
Build client SyncClient.kt skeleton (Ktor HTTP wrapper)
com.steelnotes.sync.SyncClient — all endpoints + S3 direct upload/download
Add CLI steel auth register/login/status/logout commands
cli/Main.kt — AuthCommand with register/login/status/logout subcommands, tokens in ~/.steelnotes/auth.json (mode 600)
Build iOS SignInView.swift with Sign in with Apple
iosApp/Views/Auth/SignInView.swift — Sign in with Apple + skip option
Phase 2B — Core Sync Protocol 9/9
Implement GET /vault/state — full and incremental (?since=)
server/src/routes/vault.rs — filters by updated_at, separates active/deleted
Implement POST /vault/push — version check + presigned upload URLs
Version mismatch returns in conflicts[] with download URL
Implement POST /vault/push/confirm — update file_state + trigger push notifications
Updates file_state, vault_size, notifies other devices via SNS
Implement POST /vault/pull — presigned download URLs
Returns presigned GET URLs for requested paths
Implement POST /vault/delete — version-checked deletion with tombstones
Marks is_deleted=true, deletes from S3, notifies devices
Define client SyncStateDatabase.sq (.steel/sync.db schema)
SyncState.sq — sync_state, sync_meta, sync_log tables + full query set
Build client ChangeDetector.kt — SHA-256 hash-based local file diffing
Sha256.kt (expect/actual, CommonCrypto on Apple) + ChangeDetector.kt
Build client SyncManager.kt — full sync cycle (5 phases)
5-phase orchestrator: detect → compare → resolve → execute → record
Implement client first-launch flows (new user / existing user / migration)
Handled by SyncManager.syncNow() — detects new/existing/migration via change detection + remote state comparison
Phase 2C — Push Notifications & Real-Time 5/5
Integrate APNs server-side via SNS
server/src/services/push.rs — SNS fanout to APNs with silent push payload
Define silent push payload for vault-changed events
Payload struct defined with content-available: 1
Handle client background push → trigger syncNow()
AppDelegate.swift handles silent push → posts .vaultChangedRemotely → SyncService.syncNow()
Build client SyncStatusView — toolbar sync indicator
SyncStatusView.swift — toolbar icon (syncing/synced/offline/error/conflict) + SyncDetailView
Add debounce & batching for rapid local edits (2s debounce)
SyncManager.onLocalFileChanged() — 2s debounce via coroutine Job
Phase 2D — Conflict Resolution 5/5

Design choice: Conflicts resolve automatically — no manual diff UI. Last-write-wins for overlapping edits, auto-merge for non-overlapping. Users should never see a conflict screen.

Build ConflictResolver.kt — frontmatter + body split merge
ConflictResolver.kt — splits YAML frontmatter from body, merges independently
Implement three-way merge for non-overlapping body edits
Line-level 3-way merge in threeWayMergeLines()
Implement auto-merge rules (tags union, timestamps, non-overlapping)
Tags → union, timestamps → latest wins, non-overlapping → merge both
Add last-write-wins fallback for overlapping edits
ConflictResolver returns AutoMerged with remote (newer) version when 3-way merge fails; SyncManager.processConflicts() always auto-resolves
Log auto-resolved conflicts to sync log for auditability
SyncStateStore.log("auto_merge", ...) and log("last_write_wins", ...) entries for all conflict resolutions
Phase 2E — Polish & Data Portability 6/6

Deferred — not priority until auth, sync, and capture are solid.

Implement GET /vault/export — zip download of entire vault
Returns presigned download URLs for all vault files; client assembles locally
Build SyncSettingsView.swift — account info, storage, device list
SyncSettingsView.swift — account status, sync detail, sign out
Build sync log viewer (debug screen)
SyncDetailView.swift — status dot, sync now button, filterable log list (push/pull/merge/delete/conflict events), relative timestamps, path + detail display
Add error handling: auth failures, quota, network timeouts
AppError enum covers auth/quota/rate-limit/network; SyncManager catches exceptions with offline fallback
Build offline queue — batch pending changes, push when online
SyncManager marks files PENDING_PUSH immediately, syncs on next syncNow()
Add tombstone cleanup (90-day retention cron)
POST /maintenance/cleanup — deletes tombstones >90 days + expired refresh tokens

Phase 3 — Capture Layer & AI Processing

IN PROGRESS
2 2 22
8%

Goal: Fastest possible capture, with background AI that classifies, structures, and links notes automatically. AI is additive — original capture always preserved.

See CAPTURE_ARCHITECTURE.md for the full design spec.

Phase 3A — Capture Surfaces 0/3
Add CLI steel add command (URLs, text, files)
Single command, no flags — AI classifies after
Build inbox system (inbox/capture-{timestamp}.md with minimal frontmatter)
Raw capture written immediately, AI processes async
Build bookmarklet / browser extension (future)
Phase 3B — Background AI Pipeline 2/18
Build server-side processing trigger (new inbox file → Lambda job)
Fires on POST /vault/push/confirm for inbox/ paths
Add aws-sdk-bedrockruntime + aws-sdk-transcribe to Cargo.toml
Same SDK pattern as existing S3/SNS crates
Create server/src/services/bedrock.rs — Bedrock client wrapper
InvokeModel for text, vision via Claude on Bedrock. Reuse AWS config.
Create server/src/services/transcribe.rs — Amazon Transcribe wrapper
StartTranscriptionJob reads audio from S3, polls for completion
Update Terraform — add Bedrock + Transcribe IAM policies to Lambda role
bedrock:InvokeModel + transcribe:Start/GetTranscriptionJob
🚧Update Lambda config — increase memory to 512MB, timeout to 60s
Needed for Bedrock inference latency
Build AI pipeline: transcribe (voice notes via Amazon Transcribe)
Async batch — audio in S3 → transcript text
Build AI pipeline: OCR / extract (images → text via Bedrock Vision, PDFs → text, URLs → HTML)
Claude Vision on Bedrock for photos (book covers, pages), standard extraction for PDFs/URLs
Stabilize iOS book-cover debug detection overlays (Vision + Hough)
Fixed live overlay projection for resizeAspectFill using preview-layer metadata rect conversion, mapped live Vision orientation from AVCaptureConnection to CGImagePropertyOrientation (including corrected direction to avoid inverted X/Y tracking), normalized batch image orientation, aligned corners frame-to-frame before temporal tracking to preserve perspective direction under tilt, added adaptive candidate smoothing to reduce random box jumps without lagging behind motion, merged vertically aligned split Vision rectangles (top/bottom segments) into a single cover candidate, uses a larger internal Vision observation pool so merge logic still works even when user-facing max detections is set low, and now shows secondary detections in live debug while boosting merged-candidate ranking by area gain
Wire Gemini vision path end-to-end (env config, provider selection, sync + async capture parity)
Shared provider selection now drives both /ai/analyze and the async worker; local env docs include GEMINI_API_KEY; deployment must use a freshly regenerated target/lambda/<bin>/bootstrap.zip because cargo lambda build can leave that zip stale even when the Rust binary changed, and Terraform's placeholder.zip is not the real deployment artifact.
Build AI pipeline: resolve (check if Resource already exists in vault)
FTS title/author match — avoids duplicate Resources
Build AI pipeline: classify (Resource vs Thought vs Annotation on existing Resource)
Claude on Bedrock. Bundle of book cover + pages → existing Resource + new Annotation
Build AI pipeline: structure (build note(s) from extracted content)
Claude on Bedrock. One capture bundle may produce multiple notes
Build AI pipeline: enrich (add tags, generate ID, update frontmatter)
Claude on Bedrock. Additive only — original capture block preserved
Build AI pipeline: link (FTS for related notes, match open Questions)
Add markdown links to related notes in a "Related" section
🚧Preserve analyzed capture image paths when attaching to existing resources
Current shared AI result processing derives quote/source image paths from noteId, which points at the existing resource instead of the capture bundle
Build AI pipeline: file (move from inbox/ to resources/ or thoughts/)
Sync processed .md back to all devices
Add processing_status indicator in vault list
Shows "processing" badge until AI pipeline completes
Phase 3C — Transposition & Resurfacing 0/5
Build TranspositionEngine — annotations → themed groups + questions
AI reads annotations across resources, extracts patterns
Build Insights feed — auto-generated connections, dismissable
Replaces approval gate; user can dismiss, edit, or promote to notes
Build QuestionEngine — lifecycle, getQuestionsToResurface()
Questions generated by AI, not manually captured
Add question resurfacing on app launch
Show inbox of stale open questions
Build GraphEngine — relationship tracking and traversal
GraphEngine.kt

Phase 4 — iOS UI Polish

IN PROGRESS
31 0 3
91%

Goal: Full-featured iOS reading and editing experience. Delayed intentionally — sync and capture are higher value than on-phone editing right now.

Theme & Adaptive Color System 6/6
Build adaptive color system (light/dark mode)
Theme/Theme.swift — programmatic adaptive colors via UIColor trait collection, follows system preference
Define note type colors (resource, thought, question, synthesis)
Distinct brand colors per type, tuned for both light and dark
Define status colors (per-status adaptive palette)
All statuses across all note types have adaptive color pairs
Build reusable StatusBadge and TagsView components
Views/Components/ — shared across vault, detail, search
Add NoteType theme extensions (icon, color, display names)
Centralized in Theme.swifticonName, color, displayName, singularName
Integrate theme tint into app and navigation
SteelNotesApp.swift + AppNavigation.swift.tint(._steelAccent)
Phase 4A — Capture Views 9/9
Build CaptureView.swift — camera-first capture
Camera auto-opens, PhotosPicker for library, saves to inbox with tags
Tune microphone press/release haptics in capture view
CaptureView.swift record button now fires immediate touch-down impact and keeps release feedback
Build photo strip component (horizontal scroll, tap to remove)
Horizontal scroll thumbnails with X-to-remove, add-more menu
Build iOS Share Sheet extension (SteelNotesShare/ target)
SteelNotesShare/ — accepts text, URLs, images via App Group
Configure App Group for share extension ↔ main app data
group.com.steelnotes.shared — entitlements + UserDefaults + auto-import on launch
Build WidgetKit extension (SteelNotesWidget/ target)
SteelNotesWidget/QuickCaptureWidget + RecentNotesWidget
Build small widget: quick capture button + last note preview
Deep links to steelnotes://capture, shows last note title
Build medium widget: recent notes list (3-4 items)
Shows title, type icon, timestamp for up to 4 recent notes
Add widget deep links into main app
steelnotes:// URL scheme — capture, note/{id}, search, vault routes
Phase 4B — Vault View Enhancements 5/5
Implement VaultView.swift — async loading, pull-to-refresh
Full VaultViewModel binding, .refreshable, type chip picker, + button, themed backgrounds
Build resource row cell (title, author, status badge)
NoteRow shows title, status badge, tags, author (for resources), relative date
Build thought/question row cells
Covered by generic NoteRow with type-aware status colors
Add type filter chip picker (replaces segmented control)
Horizontal scroll chips with type icon + color, animated selection
Add navigation to note detail
NavigationLink(value:) + navigationDestination(for:)
Phase 4C — Reader View 4/5
Build NoteDetailView.swift — note reader with metadata
Header, metadata (dates, tags, URL), body text, edit button
Build MarkdownBodyView.swift — markdown renderer
Headings, bold/italic, code blocks, blockquotes, lists, links
Build annotation display in reader
AnnotationCard — type icon, highlighted text, note, color-coded by type
Build related notes section in reader
Uses findRelated() FTS5 query, shows type icon + title links
Build annotation overlay (select text → pick type → write note)
Phase 4D — Editor View 2/3
Build EditorView.swift — create/edit all note types
Form-based: title, type-specific fields, tags, body. Create + update flows.
Build frontmatter editor (title, tags, status)
Title, tags, and status picker for all note types
Add wikilink autocomplete ([[ trigger)
Phase 4E — Resource Creation 2/3
Build new resource sheet (URL input → fetch title)
URL field exists in EditorView; auto-fetch TBD
Build manual resource creation form
EditorView handles Resource creation with author + URL fields
Add status change control (unread → reading → read)
Status picker in EditorView, persists on save
Phase 4F — Search 3/3
Build SearchView.swift — search with debounced FTS5
.searchable modifier, 300ms debounce, result rows with snippet + score
Add type + status filter chips
Horizontal scroll FilterChips — type toggles show contextual status options
Add result row with snippet preview
SearchResultRow — type icon, title, snippet, match percentage

Phase 5 — Depth

UPCOMING
0 0 4
0%
Phase 5A — Graph & Synthesis 0/1
Build idea graph visualization (GraphView.swift)
Phase 5B — Advanced Reading 0/2
Build PDF reader + annotation
Add weekly digest notification
Phase 5C — macOS App 0/1
Build macOS SwiftUI app

Phase 8 — Android Implementation

UPCOMING
0 0 26
0%

Goal: Bring Steel Notes to Android using Jetpack Compose for UI and the existing KMP shared module for all business logic. Same architecture as iOS — UI layer only, all logic in Kotlin shared.

Phase 8A — Android App Shell 0/5
Create Android project with Jetpack Compose
androidApp/ — Gradle module, Material 3, min SDK 26
Link KMP shared module into Android project
Add shared as Gradle dependency, no framework bridging needed (Kotlin native)
Set up dependency injection (Koin or Hilt)
VaultManager, SearchEngine, SyncManager, AuthManager
Build navigation (Jetpack Navigation Compose)
Bottom nav: Vault, Search, Capture
Build sign-in screen (email/password + Google Sign-In)
Uses existing AuthManager.kt from shared module
Phase 8B — Android Vault & Reader 0/5
Build VaultScreen — note list with type filters
LazyColumn, type chip picker, pull-to-refresh
Build NoteDetailScreen — reader with transclusion rendering
Render ![](steel://id) inline as embedded cards
Build SearchScreen — FTS5 search with debounce
Uses existing SearchEngine.kt from shared module
Build markdown renderer (Compose)
Headings, bold/italic, code blocks, blockquotes, links
Add Note type theming (icons, colors, status badges)
Material 3 color system
Phase 8C — Android Editor & Capture 0/4
Build EditorScreen — create/edit all vault item types
Form-based: title, type fields, tags, body
Build CaptureScreen — camera-first capture
CameraX, photo strip, save to inbox
Build Android Share Sheet target
Accept text, URLs, images via Android Intent filters
Build Home Screen widget (Glance)
Quick capture button + recent notes
Phase 8D — Android Sync & Notifications 0/4
Integrate sync via existing SyncManager.kt
Uses shared KMP sync module, triggered on file changes
Implement FCM push notification handler
Silent push → trigger syncNow(), replaces APNs path
Build sync status indicator
Toolbar icon: syncing/synced/offline/error
Add TokenStore actual for Android (EncryptedSharedPreferences)
Secure token storage, replaces iOS Keychain actual
Phase 8E — Android Platform Integration 0/4
Add PlatformFileSystem actual for Android
Context.filesDir based file I/O
Add DatabaseDriverFactory actual for Android
Android SQLite driver for SQLDelight
Add Sha256 actual for Android
java.security.MessageDigest
Configure ProGuard / R8 rules for KMP
Keep serialization classes, KMP framework
Phase 8F — Android Rich Editor (Compose) 0/4
Build RichEditor composable — TextField with AnnotatedString
Mirrors iOS editor behavior
Build formatting toolbar for Compose
Same button set as iOS, shared formatting logic from KMP
Build AnnotatedString ↔ markdown converter
Platform-specific attributed text mapping
Add ![[ and [[ autocomplete for Compose
Transclusion embed + navigation link

Phase 6 — Note Model Refactor

IN PROGRESS
32 0 3
91%

Goal: Introduce Note as the primary engagement unit. Resources hold external content. Thoughts and Questions keep their own identity but are transcluded into Notes. Synthesis type eliminated.

See NOTE_ARCHITECTURE.md for the full design spec.

Phase 6A — Model + Parser 9/9
Rename sealed interface NoteVaultItem across codebase
Frees Note for the concrete engagement class
Add NoteType.NOTE to enum ("note", "notes" directory)
NoteType.kt
Deprecate NoteType.SYNTHESIS with @Deprecated annotation
Keep for backward-compat parsing
Add NoteStatus enum (ACTIVE, PARKED, ARCHIVED)
Statuses.kt
Add Note data class implementing VaultItem
Fields: resources, status. No thoughts/questions list — those are body transclusions
Add NoteType.NOTE branch to NoteFactory
Parse resources from frontmatter, status as NoteStatus
Add Note serialization branch to NoteSerializer
Write resources, status, created, updated
Create TransclusionExtractor.kt — extract ![](steel://id) from body
Regex: !\[\[([^\]]+)]], same code-block exclusion as WikilinkExtractor
Update WikilinkExtractor regex to skip ![[ via negative lookbehind
(?<!!)\[\[([^\]]+)]]
Phase 6B — Storage Layer 8/8
Add resource_refs TEXT column to notes table
SteelNotes.sq — nullable, comma-separated Resource IDs
Update insertNote query with resource_refs parameter
Update VaultManager.insertNoteIntoDb() for Note type
Map Note.resources → comma-separated resource_refs
Update VaultManager.dbNoteToModel() for Note type
Parse resource_refs back to List<String>
Update VaultManager.indexLinks() — extract transclusions
TransclusionExtractor + link_type = "transclusion"
Update VaultManager.indexLinks() — index Note.resources
Structured links for resource references
Add createResourceWithNote() to VaultManager
Creates Resource + companion Note in one call
Update VaultIndexer — transclusion link indexing
Phase 6C — CLI + Transclusion Resolution 6/6
Create TransclusionResolver.kt
Replace ![](steel://id) with rendered content at display time
Add NoteType.NOTE to CLI CreateCommand + --resources option
Update CLI CreateCommandsteel create resource auto-creates companion Note
Uses createResourceWithNote()
Update CLI ListCommand icon for Note type
Update CLI ReadCommand — resolve transclusions for display
Raw ![](steel://id) for --json output
Update CLI help text — add note, remove synthesis
Phase 6D — Migration 2/2
Build steel migrate syntheses command
Converts syntheses/*.mdnotes/*.md, remaps IDs and fields
Create companion Notes for existing Resources (backfill)
steel migrate backfill-resources — creates Notes for orphan Resources
Phase 6E — iOS Updates 7/7
Update NoteDetailView.swift — render transclusions inline
TransclusionCard component, extracts ![](steel://id) and renders embedded content as cards
Update VaultView.swift — add Note type to filter, icons, colors
Note type first in type picker, VaultItem base type throughout
Update StatusBadge.swift — add NoteStatus color/label mapping
Active/Parked/Archived status colors with VaultItem extension
Update EditorView.swift — create/edit Notes
NoteStatus picker, Note creation/update, uses NoteType.directory for file paths
Update CaptureView.swift — route captures through Resource + Note pair
Uses createResourceWithNote() to create Resource + companion Note
Update SharedCoreService.swift — add resolveTransclusions() method
resolveTransclusions() via TransclusionResolver, extractTransclusionIds(), createResourceWithNote()
Update Theme.swift — add Note type icon, color, display name
doc.text.fill icon, blue noteColor, NoteStatus colors (active/parked)
Phase 6F — Capture Pipeline Update 0/3
Update AI pipeline step 4 (Structure) — split Resource content from user capture context
Resource gets extracted content, Note gets user's voice note / reaction
Update AI pipeline step 7 (File) — create Resource + Note pair, not just Resource
Move to resources/ + notes/
Update steel add to create inbox capture that produces Resource + Note pair

Phase 7 — Rich Markdown Editor

IN PROGRESS
25 0 9
74%

Goal: Replace the plain TextEditor with a rich editing experience. Users can type markdown syntax (which renders live) or apply formatting via a floating toolbar. iOS 26's native TextEditor + AttributedString is the foundation.

Architecture decision: Build on iOS 26's native TextEditor with AttributedString and AttributedTextSelection — no WebView layer, no third-party dependencies. Formatting operations (toggle bold, insert link, etc.) live in the shared Kotlin module; platform-native code handles the actual text rendering and input.

Cross-platform strategy (Pattern 1): Shared Kotlin module owns the document model and formatting operations. Swift handles the AttributedString editor on iOS. Android rich editor is in Phase 8F. ~30-40% code sharing on logic, fully native editing feel on each platform.

Phase 7A — Shared Document Model (KMP) 8/8
Define MarkdownSpan sealed class (bold, italic, code, heading, link, highlight)
MarkdownDocument.kt — PlainSpan, BoldSpan, ItalicSpan, BoldItalicSpan, CodeSpan, LinkSpan, WikilinkSpan, HighlightSpan
Build MarkdownDocument model (list of MarkdownBlock with spans)
ParagraphBlock, HeadingBlock, CodeBlock, BlockquoteBlock, ListItemBlock, TransclusionBlock, HorizontalRuleBlock, BlankBlock
Build MarkdownParser — markdown string → MarkdownDocument
MarkdownDocumentParser.kt — block + inline span parsing
Build MarkdownSerializerMarkdownDocument → markdown string
MarkdownDocumentSerializer.kt — uses block rawText for round-trip fidelity
Build FormattingEngine — toggle operations on spans
FormattingEngine.kt — toggleBold/Italic/Code/Highlight, toggleHeading, toggleListItem, toggleBlockquote
Add transclusion insertion helper — insertTransclusion(id)
Ensures ![](steel://id) on own line with surrounding newlines
Add note link insertion helper — insertNoteLink(id, title)
Inserts [[id]] at cursor
Unit tests for round-trip parsing (markdown → model → markdown)
119 tests: parser, serializer round-trip, formatting engine, model rawText. JVM target added for Linux/CI.
Phase 7B — iOS Rich Editor (SwiftUI, requires iOS 26+) 8/11
Build RichEditorView.swiftTextEditor with formatting toolbar
Monospaced text editor with FormattingToolbar shown when focused
Build FormattingToolbar.swift — floating toolbar above keyboard
Bold, italic, code, highlight, heading menu, list, quote, link, note link, embed buttons
Implement toolbar actions via FormattingEngine
Each button calls shared KMP FormattingEngine methods
Build live markdown → attributed string converter
Requires iOS 26 AttributedTextSelection — current editor uses plain text + toolbar
Add ![[ autocomplete — search vault items, insert transclusion
VaultItemPickerSheet with search, selects item and inserts ![](steel://id)
Add [[ autocomplete — search vault items, insert note link
Toolbar search prompt now queries vault items and inserts [[id]] navigation links
Add link insertion sheet (title + URL fields)
URL field sheet, inserts link via FormattingEngine.insertLink()
Build AttributedString → markdown serializer for save
Requires iOS 26 — current editor saves plain markdown directly
Define custom AttributeScope for highlight color
Requires iOS 26
Refactor editor to full-screen NavigationStack push
VaultView + NoteDetailView use .navigationDestination instead of .sheet
Integrate into EditorView.swift — replace plain TextEditor
RichEditorView replaces Form body section, metadata in compact header
Phase 7C — Editor Polish 9/15
Add 8pt keyboard gap for editor formatting toolbar
UnifiedEditorViewController.swift wraps the existing inputAccessoryView toolbar in a transparent container to preserve styling while hovering 8pt above the keyboard
Fix iOS keyboard autocapitalization desync while typing
UnifiedEditorView.swift now sends UITextInput delegate notifications in a consistent selection/text order for manual editorStorage edits, preventing stuck-uppercase behavior
Fix trailing newline offset-map desync in rich editor storage
MarkdownEditorStorage.splitLines(_:) now preserves trailing empty lines so newline edits keep display text and source offsets aligned (fixes EditorStorageIntegrationTests.interleavedEdits)
Fix editor storage integration test crash from weak text storage lifetime
EditorStorageIntegrationTests now retains NSTextStorage strongly per test storage instance and clamps plain-edit ranges, fixing the immutable-string crash and fast-path index-0 regressions
Fix autocorrect/source mapping drift at formatting boundaries
OffsetMap.sourceNSRangeForEdit(for:) now uses insertion-aware boundary mapping, and both MarkdownEditorStorage.applyDisplayEdit and UnifiedEditorView fast-path reconciliation route through it so autocorrect replacements stay inside the intended markdown span
Fix overlapping style toggle wrapper detection (* vs **)
FormattingEngine now matches only exact wrapper runs (not substrings inside longer marker runs), so italic toggles on bold text produce proper overlap (e.g. **word*****word***)
Fix stale image attachment views after drag/drop
MarkdownEditorStorage.rebuildAll() now supports recursive teardown of tagged attachment root views, but only when shouldTeardownAttachmentViewsOnNextRebuild is set by drag/drop mutation paths (to avoid EXC_BAD_ACCESS during normal layout-driven rebuilds). It also coalesces re-entrant rebuild requests (isRebuilding + pendingRebuild) so note-open flows that trigger setBodyText and viewDidLayoutSubviews rebuilds back-to-back do not race TextKit. ImageAttachmentProvider.loadView() explicitly detaches any prior root view before rebuilding to prevent stale provider views persisting on screen
Add heading level picker (H1-H4) in toolbar
Long-press or submenu for heading levels
Add list insertion (bulleted, numbered)
Toolbar button toggles list item prefix
Add blockquote toggle
> prefix toggle
Add image insertion from photo library
Toolbar photo picker copies selected images into attachments/editor-.../photo-1.jpg and inserts markdown image references at the cursor
Add undo/redo support
Native UndoManager integration
Fix transclusion/image drag and drop in live editor
Retains overlay drag delegates, supports local reordering, uses anchored local image drag payloads to resolve the exact source line, avoids highlighting images from the trailing newline cursor position, prevents local image/transclusion drags from inserting raw payload text, accepts external image drops into markdown, snaps block indicators to line boundaries, improves photo drag preview anchoring, shows a full-width insertion line plus row-slot indicators for image row drops, routes local attachment moves through the in-memory drag payload instead of async provider loading, defers source mutations until after the UIKit drop cycle, and skips selection changes for external image drops so the editor no longer removes both images or crashes on stale UITextPosition state
Add keyboard shortcuts (⌘B bold, ⌘I italic, ⌘K link)
.keyboardShortcut modifiers on toolbar actions
Add focus mode — dim everything except current paragraph
Optional writing mode, toggled from toolbar

Phase 9 — App Store Launch

IN PROGRESS
7 0 12
37%
Phase 9A — Signing & Distribution Identity 3/3
Create an Apple Distribution certificate
Done 2026-08-17 via a local archive + -exportArchive … -allowProvisioningUpdates. The cert is **Cloud Managed Apple Distribution**, so it will not appear in security find-identity — the private key lives on Apple's servers and is fetched at export. Expires 2027-08-17
Register provisioning profiles for all three bundle IDs
Done 2026-08-17. Cloud signing covered both extensions unaided — see T4.4 in CICD_PLAN.md. All three signed with one cert, sharing a build number, app group intact after re-signing
Create the App Store Connect app record
My Apps → +, bundle ID com.steelnotes.app. TestFlight uploads fail without a record, which is separate from registering the bundle ID. Note the store name must be globally unique — "Steel" is likely taken; this is independent of CFBundleDisplayName
Phase 9B — CI Upload Credentials (CICD_PLAN.md M6) 4/4
Generate an App Store Connect API key
Users and Access → Integrations → App Store Connect API → Team Keys, role **Admin** — *not* App Manager. Cloud signing must create and download the distribution certificate and profiles, which App Manager cannot do; such a key archives fine then fails at export with Cloud signing permission error and a misleading No profiles ... were found underneath. The .p8 downloads exactly once
Add APPSTORE_API_PRIVATE_KEY, APPSTORE_API_KEY_ID, APPSTORE_ISSUER_ID secrets
Set 2026-08-17, re-issued 2026-08-18 with the Admin key after the App Manager key failed at export. Key ID is in the key's row; Issuer ID sits above the table and is shared across keys. Rotating the key changes only the first two — the issuer is account-wide
Add GOOGLE_BOOKS_API_KEY secret
Set 2026-08-02. Release builds hit the Books API for real; CI tests use the empty template
First green TestFlight upload from main
2026-08-18, run 32094369705. .github/workflows/deploy-ios.yml runs automatically on merge once the secrets exist. The earlier run 32088036309 archived and tested green, then failed at export on the API key's role — a local export succeeding proves nothing there: it authenticates as the Xcode-signed-in Apple ID, CI as the key
Phase 9C — Store Metadata & Privacy 0/7
Complete the privacy nutrition labels
Must cover what the sync backend stores. Vault files live in S3 under a user ID, and paths are anonymized — see PATH_ANONYMIZATION.md
Publish a privacy policy at a stable URL
Required field on the app record
Declare encryption usage
ITSAppUsesNonExemptEncryption: false is already set in iosApp/project.yml
Write description, keywords, support URL
Produce screenshots for every required device size
Declare the account-deletion path
Apple requires in-app account deletion where accounts exist. DELETE /account is implemented and covered by server/tests/account_deletion.rs
Set the age rating and content-rights answers
Phase 9D — Review Readiness 0/5
Provide a demo account for App Review
Reviewers cannot get past a sign-in wall without one
Write review notes covering Sign in with Apple and the share extension
Non-obvious flows get rejected as "unable to locate features"
Verify Sign in with Apple meets the equivalence requirement
Required when any third-party sign-in is offered — see AUTH_ARCHITECTURE.md
Confirm the AI features degrade gracefully offline and on payment-required
Server returns 402 once the trial expires; the paywall must not look like a broken screen
Run a full TestFlight pass on physical hardware
Simulator testing does not exercise the keychain, push, or StoreKit paths