1. Call out any step that requires a human — running commands on real hardware, creating accounts, provisioning API keys, configuring third-party services, app store setup, DNS, etc. Do not block on these: state them plainly in your final response so they can be tracked, and move on. This repo deliberately keeps no human-task file; tracking lives outside it.
2. Docs travel with the change they describe — update them on the same branch as the code, so a doc and the behavior it documents are never separately reviewable or separately mergeable.
| Layer | Technology | Notes |
|---|---|---|
| **Shared business logic** | Kotlin Multiplatform (KMP) | shared/ — targets iOS, macOS, Android |
| **iOS/macOS app** | SwiftUI + KMP framework | iosApp/ |
| **macOS CLI** | Kotlin/Native (Clikt) | cli/ |
| **Backend API** | Rust 2024, Axum 0.8 | server/ — deployed to AWS Lambda via cargo-lambda |
| **Database (server)** | Postgres on RDS t4g.nano | sqlx with compile-time checked queries |
| **File storage** | AWS S3 | Vault files stored at {user_id}/{type}/{file}.md |
| **Push notifications** | AWS SNS → APNs (iOS/macOS), FCM (Android future) | Silent push to trigger background sync |
| **AI (LLM + Vision)** | Claude on AWS Bedrock | aws-sdk-bedrockruntime — classify, structure, enrich, OCR. IAM auth, no API keys. |
| **Voice transcription** | Amazon Transcribe | aws-sdk-transcribe — async batch, reads audio from S3. Replaces Whisper. |
| **Infrastructure** | AWS (Lambda, API Gateway, RDS, S3, SNS, Bedrock, Transcribe) | Terraform in server/deploy/terraform/ |
| **Local DB (client)** | SQLite via SQLDelight | Index is disposable — rebuilt from .md files |
| **HTTP client (KMP)** | Ktor 3.0.3 | Used for both sync API calls and direct S3 transfers |
| **Auth** | Email/password + Sign in with Apple → JWT | Multi-provider, account linking. See docs/AUTH_ARCHITECTURE.md |
See docs/TECH_DECISIONS.md for the full rationale behind each decision (why S3 over iCloud, why Rust over Go/Kotlin, why AWS over Azure, etc.).
brew install openjdk@17export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home```bash
./gradlew :shared:build
./gradlew :shared:compileKotlinMacosArm64
./gradlew :cli:linkDebugExecutableMacosArm64
./cli/build/bin/macosArm64/debugExecutable/steel.kexe
```
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home # macOS
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 # Linux/CI
./gradlew :shared:jvmTest
./gradlew :shared:jvmTest --tests "com.steelnotes.editor.FormattingEngineTest"
./gradlew :shared:macosArm64Test
./gradlew :shared:allTests
open shared/build/reports/tests/jvmTest/index.html
```
Test architecture: Tests live in shared/src/commonTest/ and run on all targets. A JVM target is configured specifically to enable running tests on Linux and CI without Xcode. JVM actual implementations for expect declarations live in shared/src/jvmMain/.
Pull requests into dev or main run .github/workflows/ci.yml: :shared:jvmTest and the Rust suite always, plus the arm64 Lambda build when server paths changed.
Deploys are automatic and gated on those same suites, which live in reusable _test-*.yml workflows so CI and the deploy gates can never drift. Merging to dev deploys the dev server; merging to main deploys production and uploads a TestFlight build. Nothing to click, nothing to tag. See docs/CICD_PLAN.md for the environment layout and server/DEPLOY.md for the server specifics.
Three things to know when touching this:
xcodebuild test per _test-ios.yml, which is kept dispatch-only for exactly that invocation. Nothing automated will catch an iOS regression for you.cargo fmt --check, clippy -D warnings) is a blocking CI job, not advisory: it has no continue-on-error, so a fmt diff turns the whole run red. It doesn't gate deploys — those hang off _test-server.yml / _test-shared.yml — but a red CI on dev is a red CI. The job only runs when server/ changed, so a fmt diff can land on one push and surface on a later, unrelated one. Run cargo fmt in server/ before pushing Rust.java_home -v 21, so CI needs a JDK registered with the OS, not an actions/setup-java toolcache install. The remaining macOS job asserts this up front.Current test suites:
| Suite | File | Tests |
|---|---|---|
| MarkdownDocumentParser | editor/MarkdownDocumentParserTest.kt | Block parsing, inline span parsing |
| MarkdownDocumentSerializer | editor/MarkdownDocumentSerializerTest.kt | Round-trip (parse → serialize) fidelity |
| FormattingEngine | editor/FormattingEngineTest.kt | Toggle bold/italic/code/highlight, headings, links, lists, blockquotes |
| MarkdownDocument | editor/MarkdownDocumentTest.kt | Model rawText serialization, document text property |
| ResourceViewPlanner | model/ResourceViewPlannerTest.kt | Which segments a resource offers and which one opens, per ResourceKind |
| CompanionNote | vault/CompanionNoteTest.kt (jvmTest) | Resolving the Note that holds a resource's user notes (resources: [id]) |
| SearchEngine | search/SearchEngineTest.kt (jvmTest) | FTS + author/resource LIKE fallback, dedupe, limit, type filter, snippet windowing/stripping |
| SearchResultResourceKind | search/SearchResultResourceKindTest.kt (jvmTest) | Search results carry the ResourceKind that drives each row's glyph |
| EvalCaseMaterialization | sync/EvalCaseMaterializationTest.kt | Eval cases never take the lazy binary-pull path, and a missing one is a gap to repair |
| EvalCaseRepairPull | sync/EvalCaseRepairPullTest.kt (jvmTest) | A case recorded as synced but absent from disk is re-fetched, without a full state call on the clean path |
| AttachmentCacheEvalSweep | attachments/AttachmentCacheEvalSweepTest.kt (jvmTest) | The time-GC sweep evicts idle photos but never eval cases |
```bash
steel --vault /path/to/vault init
steel --vault /path/to/vault create resource --title "Title" --author "Author" --tags "t1,t2" --body "..."
steel --vault /path/to/vault list [--type note|resource|thought|question]
steel --vault /path/to/vault search "query" [--type resource]
steel --vault /path/to/vault read <note-id>
steel --vault /path/to/vault delete <note-id>
steel --vault /path/to/vault index
steel --vault /path/to/vault --json list # JSON output for programmatic use
```
Default vault: ~/.steel-notes/vault
```
steel-notes/
├── shared/ # KMP shared Kotlin module (all business logic)
│ └── src/
│ ├── commonMain/ # Platform-agnostic code
│ ├── appleMain/ # Apple platform actuals (iOS + macOS)
│ ├── iosMain/ # iOS-specific code
│ └── macosMain/ # macOS-specific code
├── cli/ # Kotlin/Native CLI executable
│ └── src/
│ ├── commonMain/ # CLI commands (Clikt)
│ └── macosMain/ # macOS platform glue (getenv etc.)
├── iosApp/ # SwiftUI iOS/macOS app (not yet implemented)
├── gradle/ # Version catalog (libs.versions.toml)
└── AGENTS.md # This file
```
com.steelnotes.model — Data models: VaultItem (sealed interface), Note, Resource, Thought, Question. See docs/NOTE_ARCHITECTURE.mdcom.steelnotes.parser — FrontmatterParser, SteelUri, NoteLinkExtractor, TransclusionExtractor, TransclusionResolver, AnnotationParser, NoteSerializer, NoteFactorycom.steelnotes.db — SQLDelight generated code: SteelDatabase, SteelNotesQueriescom.steelnotes.search — SearchEngine (FTS5 + BM25), SearchResult, SearchFiltercom.steelnotes.vault — VaultManager (CRUD), VaultIndexer, PlatformFileSystem (expect/actual)com.steelnotes.platform — DatabaseDriverFactory (expect/actual)1. .md files with YAML frontmatter are the source of truth
2. FrontmatterParser + NoteFactory convert files → typed Note objects
3. VaultManager writes notes to disk AND inserts into SQLite index
4. SearchEngine queries FTS5 for full-text search
5. VaultIndexer rebuilds the entire SQLite index from vault files
Every .md file has YAML frontmatter with --- delimiters. The type and id fields are required.
See /Users/ethanpitts/Downloads/steel-notes-spec.md for full schema.
See docs/SWIFT_PITFALLS.md for the comprehensive list of Swift, SwiftUI, KMP interop, XcodeGen, and simulator pitfalls. Read it before touching iosApp/. Every entry caused a real build failure or runtime crash.
./gradlew (wrapper, pinned to 8.10), never the system gradle command../gradlew invocation. The system may default to a different JDK.cargo lambda build can leave target/lambda/<bin>/bootstrap.zip stale** even when the Rust binary changed. Before deploying Lambda code, either delete the old zip and rebuild with cargo lambda build --output-format zip ... or otherwise verify the zip timestamp/hash changed. Do not assume the existing bootstrap.zip is fresh.applyDefaultHierarchyTemplate()** creates appleMain, iosMain, macosMain resource sets automatically. Don't manually dependsOn() — it conflicts with the template.expect/actual classes** produce warnings about being in Beta. Suppressed via kotlin.suppressExpectActualClasses=true in gradle.properties.ExperimentalForeignApi** opt-in required for all Apple platform interop code (NSFileManager, NSString, posix). Add @file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) at the top of files.kotlin.native.cacheKind.macosArm64=none) due to linker issues with the current Kotlin/Native version.NULLS FIRST/NULLS LAST not supported. Use CASE WHEN x IS NULL THEN 0 ELSE 1 END instead.SearchNotes, SearchNotesFiltered) that are different from the Notes table type. You can't use one where the other is expected.-lsqlite3 linker flag** required for any executable or framework that uses SQLDelight's native driver. Add via linkerOpts("-lsqlite3") in build.gradle.kts.help parameter from constructors (CliktCommand, option(), argument()). Use the builder pattern instead if help text is needed.argument() names** are positional — don't pass help = as a named parameter.{vaultRoot}/{type.directory}/{slug}.md — e.g., resources/beyond-good-and-evil.md{prefix}-{YYYY-MM-DD}-{sequence} — e.g., src-2026-03-20-001steel indexCross-device sync is designed but not yet implemented. See docs/SYNC_ARCHITECTURE.md for the full design.
Key points for implementers:
POST /vault/push rejects if base_version doesn't match server. Client downloads the server version and runs ConflictResolver.steel/sync.db (separate from the disposable index.db)com.steelnotes.sync (SyncManager, SyncClient, ChangeDetector, ConflictResolver) and com.steelnotes.auth (AuthManager, TokenStore)AuthService.swift, SignInView.swift, SyncService.swift, ConflictResolutionView.swift, SyncSettingsView.swiftserver/ directory (monorepo)server/deploy/terraform/A static dashboard at plan/index.html is auto-generated from PLAN.md. It shows implementation progress by phase with expandable task details.
plan/build.py** — Python script that parses markdown files, extracts task statuses (✅/🚧/⬜ and [ ]/[x] checkboxes), and generates plan/index.html + plan/data.json.github/workflows/deploy-plan.yml** — GitHub Actions workflow that runs build.py and syncs plan/ to an S3 bucket on every push to maindev → S3 endpoint)```bash
python3 plan/build.py
open plan/index.html
```
The site rebuilds automatically on push to main. If you update PLAN.md, the next merge to main will update the site.
1. Cloud Vault Sync (Phase 2) — S3 + Rust/Axum backend + KMP sync client. See docs/SYNC_BUILD_GUIDE.md for the actionable build plan and docs/SYNC_ARCHITECTURE.md for the full design spec.
2. Capture Layer & AI Parsing (Phase 3) — Share Sheet, URL → structured Resource, AI-powered classification and transposition. Spec TBD.
3. iOS UI Polish (Phase 4) — Reader, Editor, Search views. Intentionally delayed.
steel update CLI commandWhen asked for a screenshot or visual proof:
1. Build and run on the iOS Simulator via xcodebuildmcp simulator build-and-run
2. Navigate to the relevant screen using xcodebuildmcp ui-automation tap (use snapshot-ui for precise coordinates)
3. Capture with xcrun simctl io <simulator-id> screenshot /tmp/<name>.png
4. Upload to S3: aws s3 cp /tmp/<name>.png s3://dev.pittsventures.com/<name>.png --content-type image/png
5. Share the URL: https://dev.pittsventures.com/<name>.png
Use descriptive filenames (e.g., blockquote-style.png, transclusion-card.png). Images are publicly accessible once uploaded.