← Back to blog
SecurityArchitecture

Local-first architecture: why your code never leaves your machine

NKNitesh K.FounderJanuary 25, 20266 min read

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

The following data never leaves the user's machine under any circumstances:

  • 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)

Team sync is opt-in and metadata-only. When enabled, we sync: session metadata (name, timestamps, duration, status), event types and timestamps but not content, task summaries as short strings extracted from conversation with no source code, decisions containing reasoning about code but no code itself, token usage counts and cost data, and plan steps. Source code never crosses the wire.

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.

Zero-trust by default. Cloud is opt-in and metadata-only. Your code stays on your machine — because that's where it belongs.

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.