← Back to Plan

Sync Architecture

Sync Architecture β€” Cross-Device Vault Synchronization

Design Principles

1. .md files remain the sole source of truth β€” SQLite is never synced, always rebuilt locally

2. Local-first β€” the app works fully offline; sync is additive, never blocking

3. We control the sync β€” custom API + S3 storage, no dependency on platform cloud behavior

4. Conflicts are visible, not silent β€” users must see and resolve merge conflicts

5. Client does the heavy lifting β€” the API is a thin coordination layer; file bytes go directly to/from S3 via presigned URLs

6. Cross-platform from day one β€” nothing Apple-specific in the sync protocol


High-Level Architecture

```

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”

β”‚ Client (per device) β”‚

β”‚ β”‚

β”‚ VaultManager / SearchEngine / VaultIndexer β”‚

β”‚ β”‚ β”‚

β”‚ β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚

β”‚ β”‚ SyncManager β”‚ ← orchestrator β”‚

β”‚ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚

β”‚ β”‚ β”‚

β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚

β”‚ β”‚ β”‚ β”‚ β”‚

β”‚ β”Œβ”€β”€β–Όβ”€β”€β” β”Œβ”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β–Όβ”€β”€β”€β” β”‚

β”‚ β”‚Changβ”‚ β”‚Confliβ”‚ β”‚Index β”‚ β”‚

β”‚ β”‚Detecβ”‚ β”‚Resol β”‚ β”‚Rebui β”‚ β”‚

β”‚ β””β”€β”€β”¬β”€β”€β”˜ β””β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”¬β”€β”€β”€β”˜ β”‚

β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚

β”‚ β”‚ β”‚

β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” β”‚

β”‚ β”‚ SyncClient β”‚ ← Ktor HTTP client β”‚

β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚

β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”‚

β”‚ HTTPS

β”‚

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”

β”‚ Backend β”‚

β”‚ β”‚

β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚

β”‚ β”‚ Sync API │────▢│ Postgres β”‚ β”‚ S3 / R2 β”‚ β”‚

β”‚ β”‚ (REST) β”‚ β”‚ (users, β”‚ β”‚ (vault files) β”‚ β”‚

β”‚ β”‚ │──┐ β”‚ devices, β”‚ β”‚ β”‚ β”‚

β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ file state)β”‚ β”‚ /{user_id}/ β”‚ β”‚

β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ sources/ β”‚ β”‚

β”‚ β”‚ β”‚ thoughts/ β”‚ β”‚

β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ questions/ β”‚ β”‚

β”‚ └─▢│ APNs / FCM β”‚ β”‚ syntheses/ β”‚ β”‚

β”‚ β”‚ (push) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚

β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚

β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

```

Why This Architecture

  • **S3/R2 for file storage** β€” the API never touches file bytes. Clients upload/download directly via presigned URLs. This keeps the API stateless and cheap.
  • **Postgres for coordination** β€” tracks users, devices, and per-file version state. The API queries this to produce diffs.
  • **Push notifications** β€” when device A syncs a change, the API tells devices B and C to pull. Near real-time sync without polling.
  • **Ktor client already in deps** β€” the KMP shared module already has Ktor 3.0.3. We use it for the sync HTTP client.

  • Authentication

    **Full auth architecture has moved to AUTH_ARCHITECTURE.md.**
    Summary: multi-provider auth supporting CLI (email/password), Sign in with Apple, and future OAuth providers. One user can link multiple sign-in methods. All providers issue the same JWT β€” downstream code only sees user_id.
    Key change from the original design: users table no longer has provider/provider_id columns. Those live in a separate auth_providers table to support account linking.

    Sync API

    The API is a thin coordination layer. It never reads or writes vault file contents β€” that goes directly to S3.

    Endpoints

    POST /auth/login

    Exchange identity provider token for access/refresh tokens.

    POST /auth/refresh

    Exchange refresh token for new access token.

    POST /devices/register

    Register this device for push notifications.

    ```json

    {

    "device_name": "Ethan's iPhone",

    "platform": "ios",

    "push_token": "abc123..."

    }

    ```

    GET /vault/state

    Returns the server's view of the user's vault β€” every file with its current version.

    Response:

    ```json

    {

    "files": [

    {

    "path": "sources/beyond-good-and-evil.md",

    "version": 3,

    "content_hash": "sha256:abc123...",

    "size_bytes": 2048,

    "updated_at": "2026-03-24T10:30:00Z",

    "updated_by_device": "Ethan's MacBook"

    }

    ],

    "deleted_since": [

    {

    "path": "thoughts/tht-2026-03-20-001.md",

    "deleted_at": "2026-03-23T15:00:00Z",

    "deleted_by_device": "Ethan's iPhone"

    }

    ],

    "server_time": "2026-03-24T12:00:00Z"

    }

    ```

    The client can optionally pass ?since={ISO timestamp} to get only changes since last sync (incremental). Without it, the full state is returned.

    POST /vault/push

    Client wants to upload one or more files. API validates, returns presigned S3 upload URLs.

    Request:

    ```json

    {

    "files": [

    {

    "path": "sources/new-article.md",

    "content_hash": "sha256:def456...",

    "size_bytes": 1024,

    "base_version": null

    },

    {

    "path": "sources/beyond-good-and-evil.md",

    "content_hash": "sha256:ghi789...",

    "size_bytes": 2100,

    "base_version": 3

    }

    ]

    }

    ```

    Response:

    ```json

    {

    "uploads": [

    {

    "path": "sources/new-article.md",

    "upload_url": "https://s3.../presigned-put-url",

    "new_version": 1

    },

    {

    "path": "sources/beyond-good-and-evil.md",

    "upload_url": "https://s3.../presigned-put-url",

    "new_version": 4

    }

    ],

    "conflicts": []

    }

    ```

    If base_version doesn't match the server's current version, the file is returned in conflicts instead of uploads:

    ```json

    {

    "uploads": [...],

    "conflicts": [

    {

    "path": "sources/beyond-good-and-evil.md",

    "your_base_version": 3,

    "server_version": 4,

    "server_hash": "sha256:xyz...",

    "download_url": "https://s3.../presigned-get-url"

    }

    ]

    }

    ```

    The client must resolve the conflict before it can push that file.

    POST /vault/push/confirm

    After uploading file bytes to S3, the client confirms the push so the API updates version state and notifies other devices.

    Request:

    ```json

    {

    "confirmed": [

    { "path": "sources/new-article.md", "version": 1 },

    { "path": "sources/beyond-good-and-evil.md", "version": 4 }

    ]

    }

    ```

    The API then:

    1. Updates file_state in Postgres

    2. Sends push notification to all other devices for this user

    POST /vault/pull

    Client wants to download files. API returns presigned S3 download URLs.

    Request:

    ```json

    {

    "files": [

    "sources/new-article.md",

    "thoughts/tht-2026-03-24-001.md"

    ]

    }

    ```

    Response:

    ```json

    {

    "downloads": [

    {

    "path": "sources/new-article.md",

    "download_url": "https://s3.../presigned-get-url",

    "version": 1,

    "content_hash": "sha256:abc...",

    "size_bytes": 1024

    }

    ]

    }

    ```

    POST /vault/delete

    Client deleted files locally, propagate to server.

    Request:

    ```json

    {

    "files": [

    { "path": "thoughts/tht-2026-03-20-001.md", "base_version": 2 }

    ]

    }

    ```

    Same version check as push β€” if base_version doesn't match, it's a conflict (file was edited on another device after this device last synced).

    GET /vault/export

    Returns a presigned URL for a zip of the entire vault. For data portability.

    Server-Side State (Postgres)

    ```sql

    CREATE TABLE file_state (

    user_id UUID NOT NULL REFERENCES users(id),

    path TEXT NOT NULL, -- relative path in vault

    version INTEGER NOT NULL DEFAULT 1,

    content_hash TEXT NOT NULL, -- SHA-256

    size_bytes BIGINT NOT NULL,

    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    updated_by UUID REFERENCES devices(id),

    is_deleted BOOLEAN NOT NULL DEFAULT false,

    deleted_at TIMESTAMPTZ,

    PRIMARY KEY (user_id, path)

    );

    -- Tombstones are kept for 90 days so devices that haven't synced

    -- in a while can still learn about deletions.

    CREATE INDEX idx_file_state_deleted ON file_state(user_id, is_deleted, deleted_at);

    ```

    S3 / R2 Bucket Layout

    ```

    steel-notes-vaults/

    └── {user_id}/

    β”œβ”€β”€ sources/

    β”‚ β”œβ”€β”€ beyond-good-and-evil.md

    β”‚ └── thinking-fast-and-slow.md

    β”œβ”€β”€ thoughts/

    β”‚ └── tht-2026-03-20-001.md

    β”œβ”€β”€ questions/

    β”‚ └── qst-2026-03-20-001.md

    └── syntheses/

    └── syn-2026-03-20-001.md

    ```

    Each file is stored at its natural vault path, prefixed by user ID. No versioning in S3 β€” Postgres file_state.version is the version authority. (S3 versioning can be enabled as a backup safety net but is not part of the sync protocol.)

    Push Notifications

    When a push/confirm or delete is processed, the API sends a silent push to all other devices for that user:

    APNs (iOS/macOS):

    ```json

    {

    "aps": { "content-available": 1 },

    "steel-notes": {

    "event": "vault-changed",

    "changed_paths": ["sources/new-article.md"],

    "server_time": "2026-03-24T12:00:00Z"

    }

    }

    ```

    FCM (Android, future):

    Same payload structure, delivered as a data message.

    The client receives this in the background, triggers SyncManager.syncNow(), and pulls the changed files. From the user's perspective, notes appear on other devices within seconds.


    Client-Side Sync Protocol

    SyncClient (Ktor)

    Lives in commonMain. Thin wrapper around the API endpoints.

    ```

    SyncClient

    β”œβ”€β”€ login(identityToken: String): AuthTokens

    β”œβ”€β”€ refreshToken(refreshToken: String): AuthTokens

    β”œβ”€β”€ registerDevice(name: String, platform: String, pushToken: String?)

    β”œβ”€β”€ getVaultState(since: Instant?): VaultState

    β”œβ”€β”€ requestPush(files: List<PushRequest>): PushResponse // returns presigned URLs

    β”œβ”€β”€ confirmPush(confirmed: List<FileVersion>)

    β”œβ”€β”€ requestPull(paths: List<String>): PullResponse // returns presigned URLs

    β”œβ”€β”€ deleteFiles(files: List<DeleteRequest>): DeleteResponse

    β”œβ”€β”€ uploadToS3(url: String, content: ByteArray) // direct S3 PUT

    β”œβ”€β”€ downloadFromS3(url: String): ByteArray // direct S3 GET

    └── exportVault(): String // returns download URL

    ```

    SyncManager Orchestration

    ```

    SyncManager

    β”œβ”€β”€ Properties

    β”‚ β”œβ”€β”€ syncClient: SyncClient

    β”‚ β”œβ”€β”€ conflictResolver: ConflictResolver

    β”‚ β”œβ”€β”€ syncState: SyncStateDatabase // .steel/sync.db

    β”‚ β”œβ”€β”€ status: StateFlow<SyncStatus> // exposed to UI

    β”‚ └── pendingConflicts: StateFlow<List<SyncConflict>>

    β”‚

    β”œβ”€β”€ Lifecycle

    β”‚ β”œβ”€β”€ start() β†’ load sync state, trigger initial sync

    β”‚ β”œβ”€β”€ stop() β†’ flush pending writes

    β”‚ └── syncNow() β†’ full sync cycle (called on push notification or pull-to-refresh)

    β”‚

    β”œβ”€β”€ Full Sync Cycle (syncNow)

    β”‚ β”‚

    β”‚ β”‚ ── Phase 1: Detect local changes ──

    β”‚ β”œβ”€β”€ Scan local vault files β†’ compute hashes

    β”‚ β”œβ”€β”€ Compare against sync_state table

    β”‚ β”œβ”€β”€ Build list of locally changed/added/deleted files

    β”‚ β”‚

    β”‚ β”‚ ── Phase 2: Get remote state ──

    β”‚ β”œβ”€β”€ GET /vault/state?since={last_sync_time}

    β”‚ β”œβ”€β”€ Compare remote state against sync_state

    β”‚ β”œβ”€β”€ Build list of remotely changed/added/deleted files

    β”‚ β”‚

    β”‚ β”‚ ── Phase 3: Resolve ──

    β”‚ β”œβ”€β”€ Files changed only locally β†’ push

    β”‚ β”œβ”€β”€ Files changed only remotely β†’ pull

    β”‚ β”œβ”€β”€ Files changed on both sides β†’ conflict resolution

    β”‚ β”œβ”€β”€ Files deleted remotely, unchanged locally β†’ delete local

    β”‚ β”œβ”€β”€ Files deleted remotely, changed locally β†’ conflict

    β”‚ β”œβ”€β”€ Files deleted locally, unchanged remotely β†’ push delete

    β”‚ β”œβ”€β”€ Files deleted locally, changed remotely β†’ conflict

    β”‚ β”‚

    β”‚ β”‚ ── Phase 4: Execute ──

    β”‚ β”œβ”€β”€ POST /vault/push β†’ get presigned URLs β†’ upload to S3 β†’ confirm

    β”‚ β”œβ”€β”€ POST /vault/pull β†’ get presigned URLs β†’ download from S3 β†’ write locally

    β”‚ β”œβ”€β”€ POST /vault/delete β†’ propagate deletions

    β”‚ β”œβ”€β”€ Update sync_state for all resolved files

    β”‚ β”œβ”€β”€ Trigger VaultIndexer for changed/added/deleted files

    β”‚ β”‚

    β”‚ β”‚ ── Phase 5: Record ──

    β”‚ └── Update last_sync_time

    β”‚

    β”œβ”€β”€ onLocalFileChanged(path)

    β”‚ β”œβ”€β”€ Update sync_state β†’ pending_push

    β”‚ β”œβ”€β”€ Debounce 2s (user may be mid-edit)

    β”‚ └── Run sync cycle (push only for this file)

    β”‚

    β”œβ”€β”€ onPushNotificationReceived(changedPaths)

    β”‚ └── Run sync cycle (pull only for changed paths)

    β”‚

    └── onConflictResolved(path, mergedContent)

    β”œβ”€β”€ Write merged file locally

    β”œβ”€β”€ Push to server (base_version = server's version since we've seen it)

    β”œβ”€β”€ Update sync_state β†’ synced

    └── Reindex note

    ```

    Sync Status States

    ```

    enum SyncStatus {

    IDLE, // All files synced

    SYNCING, // Active push/pull in progress

    OFFLINE, // No network, changes queued locally

    ERROR, // API error (auth, quota, server down)

    UNAUTHENTICATED,// Not logged in

    CONFLICT // One or more files need manual resolution

    }

    ```

    Local Sync State (.steel/sync.db)

    ```sql

    CREATE TABLE sync_state (

    relative_path TEXT PRIMARY KEY,

    content_hash TEXT NOT NULL, -- SHA-256 of local file

    server_version INTEGER NOT NULL, -- version from server (0 if never pushed)

    sync_status TEXT NOT NULL, -- 'synced' | 'pending_push' | 'pending_pull' | 'conflict'

    last_synced_at TEXT -- ISO-8601, null if never synced

    );

    CREATE TABLE sync_meta (

    key TEXT PRIMARY KEY,

    value TEXT NOT NULL

    );

    -- Keys: 'last_sync_time', 'user_id', 'device_id'

    CREATE TABLE sync_log (

    id INTEGER PRIMARY KEY AUTOINCREMENT,

    timestamp TEXT NOT NULL,

    event_type TEXT NOT NULL, -- 'push' | 'pull' | 'conflict_resolved' | 'delete' | 'error'

    relative_path TEXT NOT NULL,

    detail TEXT

    );

    ```


    Conflict Resolution

    Unchanged from the original design β€” this is entirely client-side logic and doesn't depend on the transport backend.

    When Conflicts Happen

    A conflict occurs when POST /vault/push returns a file in conflicts[] because base_version doesn't match the server's version. This means another device pushed a change since this device last synced.

    Conflict Strategy: Frontmatter + Body Separate Merge

    Since our .md files have a consistent structure (YAML frontmatter + markdown body), we can do smarter merging than generic text diff:

    ```

    ConflictResolver

    β”œβ”€β”€ resolve(localContent: String, remoteContent: String, baseContent: String?): MergeResult

    β”‚ β”œβ”€β”€ Split each version into frontmatter + body

    β”‚ β”œβ”€β”€ Merge frontmatter fields independently:

    β”‚ β”‚ β”œβ”€β”€ Non-conflicting field changes β†’ auto-merge

    β”‚ β”‚ β”œβ”€β”€ Same field changed to same value β†’ auto-merge

    β”‚ β”‚ └── Same field changed to different values β†’ CONFLICT

    β”‚ β”œβ”€β”€ Merge body:

    β”‚ β”‚ β”œβ”€β”€ Only one side changed body β†’ take that side

    β”‚ β”‚ β”œβ”€β”€ Both changed, non-overlapping regions β†’ auto-merge (if base available)

    β”‚ β”‚ └── Both changed, overlapping regions β†’ CONFLICT

    β”‚ └── Return MergeResult

    ```

    baseContent is available when the client kept the previous version before editing. The client should store this in sync.db or a shadow file for three-way merge support.

    ```

    sealed interface MergeResult {

    data class AutoMerged(val merged: String) : MergeResult

    data class NeedsUserInput(

    val localVersion: String,

    val remoteVersion: String,

    val baseVersion: String?,

    val conflictDescription: String

    ) : MergeResult

    }

    ```

    Auto-Merge Rules

    ScenarioResolution
    Only frontmatter updated timestamp differsTake the later timestamp
    Tags added on one device, different tags added on otherUnion of both tag sets
    Status changed on one device, body edited on otherAccept both changes
    Annotations added on one device, body edited on otherAccept both changes
    Body edited in non-overlapping sections (3-way merge)Merge both changes

    Manual Resolution Required

    ScenarioUI Treatment
    Same paragraph edited differently on two devicesSide-by-side diff, user picks or edits
    Status changed to different values on two devicesShow both options, user picks
    Note deleted on one device, edited on other"Deleted on [device]. Keep the edited version?"
    Body rewritten substantially on bothShow both, user picks or combines

    Conflict UI

    1. Note appears in vault list with a conflict badge

    2. Tapping opens ConflictResolutionView:

  • Side-by-side or inline diff
  • "Keep Local" / "Keep Remote" / "Edit Merged" buttons
  • Device names and timestamps for each version
  • 3. After resolution, merged file is pushed via normal sync

    Conflicts never block the app. The local version is used while conflicts queue.


    First Launch & Account Setup

    New User, New Device

    1. User opens app β†’ sees onboarding β†’ signs in with Apple

    2. Client calls POST /auth/login β†’ gets tokens + user_id

    3. Client calls POST /devices/register β†’ registers for push

    4. GET /vault/state returns empty β†’ client initializes local vault

    5. User creates first note β†’ triggers push sync

    Existing User, New Device

    1. Signs in β†’ POST /auth/login returns existing user_id

    2. Registers device for push

    3. GET /vault/state returns all files β†’ client pulls entire vault via presigned URLs

    4. Show progress: "Downloading 47 of 312 notes..."

    5. After pull completes β†’ VaultIndexer.fullReindex() β†’ ready

    Migration from Local-Only Vault

    1. User had a local vault before accounts existed (or was offline-only)

    2. On sign-in, detect existing local vault with files

    3. Prompt: "Upload your existing vault to sync across devices?"

    4. Push all local files β†’ server creates file_state entries


    Module & File Organization

    Shared Module (Kotlin β€” commonMain)

    ```

    com.steelnotes.sync/

    β”œβ”€β”€ SyncManager.kt // Orchestrator (full sync cycle, lifecycle)

    β”œβ”€β”€ SyncClient.kt // Ktor HTTP client for Sync API

    β”œβ”€β”€ SyncModels.kt // Data classes (SyncFile, SyncEvent, SyncStatus, API DTOs)

    β”œβ”€β”€ SyncStateDatabase.sq // SQLDelight schema for .steel/sync.db

    β”œβ”€β”€ ChangeDetector.kt // Hash-based local change detection

    └── ConflictResolver.kt // Frontmatter-aware three-way merge

    com.steelnotes.auth/

    β”œβ”€β”€ AuthManager.kt // Token storage, refresh, login flow

    β”œβ”€β”€ AuthModels.kt // AuthTokens, User, Device

    └── TokenStore.kt // expect/actual β€” Keychain (Apple) / Keystore (Android)

    ```

    Apple Platform (appleMain)

    ```

    com.steelnotes.auth/

    └── AppleTokenStore.kt // Keychain-based secure token storage

    com.steelnotes.platform/

    └── PushTokenProvider.kt // APNs device token retrieval

    ```

    iOS App (Swift)

    ```

    SteelNotes/

    β”œβ”€β”€ Services/

    β”‚ β”œβ”€β”€ SyncService.swift // Swift wrapper around SyncManager

    β”‚ └── AuthService.swift // Sign in with Apple UI flow

    β”œβ”€β”€ Views/

    β”‚ β”œβ”€β”€ Auth/

    β”‚ β”‚ └── SignInView.swift // Sign in with Apple button + onboarding

    β”‚ β”œβ”€β”€ Settings/

    β”‚ β”‚ └── SyncSettingsView.swift // Account info, sync toggle, storage usage

    β”‚ └── Sync/

    β”‚ β”œβ”€β”€ SyncStatusView.swift // Toolbar sync indicator

    β”‚ └── ConflictResolutionView.swift

    └── ViewModels/

    β”œβ”€β”€ AuthViewModel.swift

    └── SyncViewModel.swift

    ```

    Backend (server/ β€” Rust + Axum, monorepo)

    ```

    server/

    β”œβ”€β”€ Cargo.toml

    β”œβ”€β”€ src/

    β”‚ β”œβ”€β”€ main.rs // Axum router setup, Lambda/local server entry

    β”‚ β”œβ”€β”€ routes/

    β”‚ β”‚ β”œβ”€β”€ mod.rs

    β”‚ β”‚ β”œβ”€β”€ auth.rs // POST /auth/login, /auth/refresh

    β”‚ β”‚ β”œβ”€β”€ devices.rs // POST /devices/register

    β”‚ β”‚ β”œβ”€β”€ vault.rs // GET /vault/state, POST /vault/push, pull, delete, export

    β”‚ β”‚ └── health.rs // GET /health

    β”‚ β”œβ”€β”€ middleware/

    β”‚ β”‚ └── auth.rs // JWT extraction + validation (tower middleware)

    β”‚ β”œβ”€β”€ services/

    β”‚ β”‚ β”œβ”€β”€ mod.rs

    β”‚ β”‚ β”œβ”€β”€ s3.rs // aws-sdk-s3 presigned URL generation

    β”‚ β”‚ β”œβ”€β”€ apple_auth.rs // Validate Apple identity tokens (JWKS)

    β”‚ β”‚ β”œβ”€β”€ push.rs // APNs via a]2 (HTTP/2), FCM future

    β”‚ β”‚ └── tokens.rs // JWT creation + refresh token management

    β”‚ β”œβ”€β”€ db/

    β”‚ β”‚ β”œβ”€β”€ mod.rs

    β”‚ β”‚ β”œβ”€β”€ models.rs // User, Device, FileState structs (sqlx::FromRow)

    β”‚ β”‚ β”œβ”€β”€ queries.rs // Typed queries via sqlx

    β”‚ β”‚ └── migrations/ // sqlx migrations

    β”‚ β”‚ β”œβ”€β”€ 001_users.sql

    β”‚ β”‚ β”œβ”€β”€ 002_devices.sql

    β”‚ β”‚ └── 003_file_state.sql

    β”‚ β”œβ”€β”€ error.rs // AppError β†’ HTTP response mapping

    β”‚ └── config.rs // Env-based config (DATABASE_URL, S3_BUCKET, etc.)

    β”œβ”€β”€ tests/

    β”‚ └── integration/ // API integration tests

    β”œβ”€β”€ Dockerfile // Multi-stage: build with rust, run with distroless

    └── deploy/

    └── terraform/ // AWS infrastructure (Lambda, RDS, S3, API Gateway)

    ```

    Stack: Rust 2024 edition, Axum 0.8, sqlx (Postgres, compile-time checked queries), aws-sdk-s3, jsonwebtoken, tower (middleware)

    Deployment: AWS Lambda via cargo-lambda (Rust compiles to a single binary, ~5MB, cold starts ~50ms). Can also run as a standalone binary on ECS Fargate if Lambda latency becomes a concern.


    Implementation Phases

    Phase 3A β€” Auth & API Foundation

  • [ ] server/ Rust project setup: Cargo.toml, Axum router, config, error handling
  • [ ] sqlx migrations: users, devices, file_state tables
  • [ ] POST /auth/login β€” Apple identity token validation (JWKS), JWT issuance
  • [ ] POST /auth/refresh β€” refresh token rotation
  • [ ] POST /devices/register β€” device + push token registration
  • [ ] Tower middleware: JWT extraction + validation
  • [ ] S3 bucket setup + presigned URL generation (aws-sdk-s3)
  • [ ] Terraform: Lambda + API Gateway + RDS + S3 + IAM roles
  • [ ] cargo-lambda build + deploy pipeline
  • [ ] Client: AuthManager, TokenStore (expect/actual for Keychain), SyncClient skeleton
  • [ ] Client: SignInView with Sign in with Apple
  • Phase 3B β€” Core Sync Protocol

  • [ ] GET /vault/state β€” full and incremental (?since=)
  • [ ] POST /vault/push β€” version check + presigned upload URLs
  • [ ] POST /vault/push/confirm β€” update file_state + trigger push notifications
  • [ ] POST /vault/pull β€” presigned download URLs
  • [ ] POST /vault/delete β€” version-checked deletion with tombstones
  • [ ] Client: SyncManager full sync cycle
  • [ ] Client: SyncStateDatabase (sync.db schema)
  • [ ] Client: ChangeDetector β€” hash-based local diffing
  • [ ] Client: first-launch flow (new user vs. existing user vs. migration)
  • Phase 3C β€” Push Notifications & Real-Time

  • [ ] APNs integration (server-side)
  • [ ] Silent push payload for vault-changed events
  • [ ] Client: handle background push β†’ trigger syncNow()
  • [ ] Client: SyncStatusView β€” toolbar sync indicator
  • [ ] Debounce and batching for rapid local edits (2s debounce)
  • Phase 3D β€” Conflict Resolution

  • [ ] ConflictResolver β€” frontmatter + body split merge
  • [ ] Three-way merge for non-overlapping body edits
  • [ ] Auto-merge rules (tags, timestamps, non-overlapping changes)
  • [ ] ConflictResolutionView β€” side-by-side diff UI
  • [ ] Conflict queue with badge in vault list
  • Phase 3E β€” Polish & Data Portability

  • [ ] GET /vault/export β€” zip download of entire vault
  • [ ] SyncSettingsView β€” account info, storage usage, device list
  • [ ] Sync log viewer (debug screen)
  • [ ] Error handling β€” auth failures, quota, network timeouts, server down
  • [ ] Offline queue β€” batch pending changes, push when back online
  • [ ] Tombstone cleanup (90-day retention)

  • Edge Cases & Decisions

    Delete Propagation

    When a note is deleted on device A:

    1. Device A removes local file, calls POST /vault/delete with base_version

    2. API marks file_state.is_deleted = true, sets deleted_at

    3. Push notification sent to other devices

    4. Other devices pull state, see deletion, remove local file

    5. Safety net: If the file was edited locally since last sync, surface as conflict ("Deleted on iPhone, but edited here. Keep?")

    6. Tombstones kept for 90 days so stale devices can catch up

    Offline Behavior

  • All changes are local-first. The app works identically offline.
  • Changes queue in sync_state as pending_push
  • When connectivity returns, SyncManager runs a full sync cycle
  • Multiple offline edits on multiple devices β†’ may produce conflicts on reconnect
  • Conflicts are queued and shown in UI; they never block anything
  • Large Vaults / Initial Sync

  • Presigned URLs allow parallel downloads β€” client can pull 10 files concurrently
  • Show progress: "Downloading 47 of 312 notes..."
  • Initial sync of 1,000 small .md files (~50MB) should complete in under 30 seconds on a reasonable connection
  • Storage Quotas

  • Free tier: 100MB per user (enough for ~50,000 notes)
  • Paid tier: 1GB+ (relevant when attachments are added)
  • API returns 429 if quota exceeded; client shows "Storage full" in sync settings
  • Rate Limiting

  • API rate limits: 100 requests/minute per user
  • Sync batches files (up to 50 per push/pull request) to stay well within limits
  • Push notifications are coalesced β€” if 10 files change in 5 seconds, one push with all paths
  • Security

  • All API traffic over HTTPS
  • S3 presigned URLs expire after 5 minutes
  • JWT access tokens expire after 1 hour
  • Files stored in S3 with server-side encryption (SSE-S3)
  • **Future: E2E encryption** β€” client encrypts file content before upload, only the user's devices have the key. This is explicitly deferred but the architecture supports it (the API and S3 never need to read file contents).

  • Decided

  • **Backend language:** Rust (Axum + sqlx + aws-sdk-s3). Chosen for memory efficiency (~10-30MB per instance), fast cold starts on Lambda (~50ms), and best tail latency. The API is thin enough that Rust's slower dev velocity is acceptable.
  • **Hosting:** AWS β€” Lambda + API Gateway (API), RDS Postgres (state), S3 (vault files), SNS (push notifications). Starting config: Lambda pay-per-request + RDS t4g.nano (~$10-15/month at low user counts).
  • **Repo structure:** Monorepo. Backend lives in server/ directory alongside shared/, cli/, and iosApp/.
  • Open Questions

    1. Pricing model? Free with limits + paid subscription? Storage cost per user is negligible for text files (~$0.001/month). Real costs are compute (Lambda invocations) and push notifications. Need to decide before Phase 3E.

    2. Binary attachments (images, PDFs)? Not yet supported but S3 handles them naturally. When the time comes, large files use multipart upload and the quota system becomes important. Define an attachments/ convention now.

    3. E2E encryption timeline? The architecture supports it (API/S3 never read file contents), but key management UX is complex. Defer to post-launch or make it a premium feature?

    4. Lambda vs ECS Fargate? Start with Lambda for cost (pay-per-request, $0 at idle). If p99 latency from cold starts becomes a problem at scale, migrate to Fargate with minimum 1 task. The Rust binary runs on either with zero code changes.