Skip to content
← Back to blog
SecurityArchitecture

Local-first architecture: why local mode never uploads your code

NKNitesh K.FounderJanuary 25, 20266 min read
Updated 2026-08-20: this post originally described cloud sync as metadata-only. That was inaccurate — at the default sync level, cloud sync (Pro tier and above) uploads prompt text, agent response text, tool inputs/outputs, and code diffs, not just session metadata. The sections below are corrected. Local mode, with no account, remains fully local as described.

Operon sits in an unusual position for a developer tool: it captures almost everything. Every prompt you send to an AI coding tool. Every response it gives. Every file it reads. Every shell command it runs. Every decision it reasons through. That's a comprehensive record of how you think and work — and it includes your proprietary code context.

When we were designing the data architecture, we had a fundamental choice: cloud-first (store everything in Supabase, sync locally) or local-first (store everything in SQLite, optionally sync metadata to cloud). We chose local-first, and we think it's the only defensible choice for a tool with this level of access to sensitive data.

Why SQLite

SQLite is embedded — it runs in-process with no network socket, no authentication, no server to configure or secure. A single file on disk. This eliminates an entire class of attack surface that comes with running a local database server.

We run SQLite in WAL (Write-Ahead Logging) mode. WAL mode gives us concurrent reads with non-blocking writes, which matters because Operon is writing event data continuously while the renderer is reading it for display. It also gives us crash safety: if Electron crashes mid-write, the WAL file ensures the database is never left in a corrupt state.

Schema versioning is handled through a migrations table. Every schema change ships as a numbered migration that runs at startup. If the database is newer than the app's expected schema version — user downgraded the app — we refuse to open it rather than silently corrupt data. On startup, we also run PRAGMA integrity_check: if it fails, we move the corrupt database to a backup location and create a fresh one.

What stays local

In local mode — no account, no sync — none of this leaves the machine:

  • Terminal output (raw PTY bytes)
  • File contents read or edited by the AI during sessions
  • Git diffs and commit message content
  • Shell command history
  • Full AI response text
  • User prompt text

What syncs (optionally)

Cloud sync requires a Pro tier account and is opt-in. When enabled, the default sync level uploads: session metadata (name, timestamps, duration, status), event types and timestamps, task summaries, decisions with reasoning about code, token usage and cost data, plan steps — and also prompt text, full AI response text, tool inputs/outputs, and code diffs. Your repository is never uploaded as files; you can turn off the code-content toggle per project to stop diffs and tool I/O from syncing.

The sync model: outbox pattern

We use an outbox pattern for sync. Every syncable record, when created or updated, gets a corresponding entry in the sync_outbox table with a status of 'pending'. A background sync worker reads pending outbox entries, pushes them to Supabase, and marks them 'sent'. On the receive side, we poll for records updated after our last sync timestamp and apply them locally.

typescript
// src/main/sync/sync-engine.ts
async function pushPending(): Promise<void> {
  const pending = db.prepare(`
    SELECT * FROM sync_outbox
    WHERE status = 'pending'
    ORDER BY created_at ASC
    LIMIT 100
  `).all() as SyncOutboxEntry[];

  for (const entry of pending) {
    try {
      await supabase
        .from(entry.table_name)
        .upsert(JSON.parse(entry.payload), { onConflict: "id" });

      db.prepare(`
        UPDATE sync_outbox SET status = 'sent', sent_at = ? WHERE id = ?
      `).run(Date.now(), entry.id);
    } catch (err) {
      db.prepare(`
        UPDATE sync_outbox SET status = 'error', error = ? WHERE id = ?
      `).run(String(err), entry.id);
    }
  }
}

Data isolation: userId ownership guards

Every IPC handler that reads or writes user data enforces ownership. We have two guard functions — verifySessionAccess(userId, sessionId) and verifyProjectAccess(userId, projectId) — that check the user_id column on the record before returning it. A signed-in user cannot access another user's session data even if they know the session ID.

This matters on shared machines. If two developers use the same Operon installation on a shared workstation, their data is fully isolated at the database level. Switching accounts clears all in-memory state and the renderer has no access to the previous user's session data.

Hook security

The Claude Code hook receiver runs an HTTP server on 127.0.0.1:47777. Binding to loopback only means it's not accessible from other machines on the network. Each request is authenticated via a token in the URL path — the token is generated once at first run and persisted to ~/.operon/config.json. It's reused across restarts so Claude Code's registered hook URL stays valid.

Sign-out teardown

Sign-out is a full teardown, not just clearing the auth token. We end any active sessions (persisting final state to SQLite), kill all running PTY processes, stop file watchers, stop the DaemonBridge WAL watcher, and clear all in-memory state including the Zustand store. The renderer is then shown the sign-in screen with no residual state from the previous user's session.

The future: E2E encryption

The current sync model trusts Supabase to store metadata correctly. Our next step is end-to-end encryption: encrypting sync payloads with a team passphrase before they leave the machine, so Supabase stores only ciphertext it can't read. The encryption key is derived from the team passphrase using PBKDF2 and stored only on team members' machines.

We're also planning per-user SQLite encryption using SQLCipher, and a self-hosted relay option for enterprise teams that need zero cloud dependency. The architecture supports all three: the local-first foundation means adding encryption is an additive change, not a rewrite.

Local by default. Cloud sync is opt-in, requires a Pro+ account, and uploads session content — including code diffs — unless you turn code-content sync off per project. Your repository is never uploaded as files.

Related posts

Stay in the loop

Subscribe to updates

New engineering posts, architecture deep dives, and product updates. No noise — only signal.

No spam. Unsubscribe anytime.