<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rohan Sharma</title>
    <description>The latest articles on DEV Community by Rohan Sharma (@rohansx).</description>
    <link>https://dev.to/rohansx</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1102938%2Fae387859-20a6-4666-b45b-1243da39b320.jpg</url>
      <title>DEV Community: Rohan Sharma</title>
      <link>https://dev.to/rohansx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rohansx"/>
    <language>en</language>
    <item>
      <title>Your AI Agent's API Keys Are Exposed. Here's the Structural Fix.</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Wed, 25 Mar 2026 17:58:03 +0000</pubDate>
      <link>https://dev.to/rohansx/your-ai-agents-api-keys-are-exposed-heres-the-structural-fix-3f00</link>
      <guid>https://dev.to/rohansx/your-ai-agents-api-keys-are-exposed-heres-the-structural-fix-3f00</guid>
      <description>&lt;p&gt;&lt;strong&gt;Every agent framework stores credentials in plaintext. Wardn makes that architecturally impossible.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;AI agents are shipping fast. CrewAI, AutoGen, LangChain, Claude Code — they all need API keys to function. And they all store them the same way: environment variables, &lt;code&gt;.env&lt;/code&gt; files, or config YAML sitting on disk in plaintext.&lt;/p&gt;

&lt;p&gt;That's not a configuration problem. It's a &lt;strong&gt;structural vulnerability&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A compromised agent, a malicious skill, a commodity stealer, or even a prompt injection — any of these gets full, unrestricted access to your real API keys. And once a key leaks, there's no rate limit, no blast radius control, no way to know which agent was responsible.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://github.com/rohansx/wardn" rel="noopener noreferrer"&gt;&lt;strong&gt;wardn&lt;/strong&gt;&lt;/a&gt; to fix this at the architecture level.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem, Visualized
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                    TYPICAL AGENT SETUP                       │
│                                                             │
│  ~/.env                                                     │
│  ┌─────────────────────────────────────────┐                │
│  │ OPENAI_KEY=sk-proj-real-key-abc123      │  ← plaintext   │
│  │ ANTHROPIC_KEY=sk-ant-real-key-xyz789    │  ← readable    │
│  └─────────────────────────────────────────┘  ← by anyone   │
│                                                             │
│  Agent Process Memory                                       │
│  ┌─────────────────────────────────────────┐                │
│  │ env::var("OPENAI_KEY")                  │                │
│  │ → "sk-proj-real-key-abc123"             │  ← in memory   │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  LLM Context Window                                         │
│  ┌─────────────────────────────────────────┐                │
│  │ "Use Authorization: Bearer sk-proj-..." │  ← in context  │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  Agent Logs                                                 │
│  ┌─────────────────────────────────────────┐                │
│  │ POST api.openai.com                     │                │
│  │ Authorization: Bearer sk-proj-real-...  │  ← in logs     │
│  └─────────────────────────────────────────┘                │
└─────────────────────────────────────────────────────────────┘

Attack surface: env files, process memory, context window, logs
Any single compromise = full credential access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four places where your real API key sits exposed. Four vectors for theft. And this is the &lt;strong&gt;default&lt;/strong&gt; in every major agent framework today.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Placeholder Tokens + Network-Layer Injection
&lt;/h2&gt;

&lt;p&gt;Wardn introduces a simple but powerful architectural change: &lt;strong&gt;agents never hold real credentials&lt;/strong&gt;. Instead, they get cryptographically random placeholder tokens that are worthless outside the local proxy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│                     WARDN ARCHITECTURE                       │
│                                                             │
│  Agent Environment                                          │
│  ┌─────────────────────────────────────────┐                │
│  │ OPENAI_KEY=wdn_placeholder_a1b2c3d4e5f6 │  ← useless    │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  Agent Logs                                                 │
│  ┌─────────────────────────────────────────┐                │
│  │ Authorization: Bearer wdn_placeholder_… │  ← useless    │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  LLM Context Window                                         │
│  ┌─────────────────────────────────────────┐                │
│  │ "wdn_placeholder_a1b2c3d4e5f6"         │  ← useless    │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  ┌─────────────────────────────────────────┐                │
│  │           WARDN ENCRYPTED VAULT         │                │
│  │  ┌─────────────────────────────────┐    │                │
│  │  │ AES-256-GCM + Argon2id KDF     │    │                │
│  │  │ OPENAI_KEY = sk-proj-real-...   │    │  ← encrypted  │
│  │  │ ANTHROPIC_KEY = sk-ant-real-... │    │  ← on disk     │
│  │  └─────────────────────────────────┘    │                │
│  └─────────────────────────────────────────┘                │
│                                                             │
│  Attack surface: encrypted vault (passphrase-protected)     │
│  Agent compromise = attacker gets useless placeholder       │
└─────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real credential exists in exactly &lt;strong&gt;two places&lt;/strong&gt;: encrypted on disk, and briefly in the proxy's memory during request forwarding. The agent, its logs, its context window — all hold worthless placeholders.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Proxy Works
&lt;/h2&gt;

&lt;p&gt;Wardn runs a local HTTP proxy (default &lt;code&gt;localhost:7777&lt;/code&gt;) that intercepts agent requests and performs a six-stage pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Agent sends request
        Authorization: Bearer wdn_placeholder_a1b2c3d4...
                    │
                    ▼
    ┌───────────────────────────────┐
    │        WARDN PROXY            │
    │      localhost:7777           │
    │                               │
    │  ┌─── REQUEST PIPELINE ────┐  │
    │  │                         │  │
    │  │  ① Identify Agent       │  │  x-warden-agent header
    │  │         │               │  │
    │  │  ② Resolve Placeholder  │  │  wdn_placeholder → credential name
    │  │         │               │  │
    │  │  ③ Check Authorization  │  │  agent + domain allowed?
    │  │         │               │  │
    │  │  ④ Check Rate Limit     │  │  token bucket per agent × cred
    │  │         │               │  │
    │  │  ⑤ Inject Real Key      │  │  decrypt from vault, swap in
    │  │         │               │  │
    │  │  ⑥ Forward Request      │  │  send to external API
    │  │                         │  │
    │  └─────────────────────────┘  │
    │                               │
    │  ┌── RESPONSE PIPELINE ────┐  │
    │  │                         │  │
    │  │  ⑦ Strip Real Key       │  │  remove credential from body
    │  │         │               │  │
    │  │  ⑧ Return to Agent      │  │  clean response, placeholder only
    │  │                         │  │
    │  └─────────────────────────┘  │
    └───────────────────────────────┘
                    │
                    ▼
           External API
    (only place real key exists in transit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;strong&gt;response pipeline&lt;/strong&gt; — step 7 strips real credentials from API responses before they reach the agent. Some APIs echo back your key in response headers or error messages. Wardn catches that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-Agent Isolation
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Each agent gets its own &lt;strong&gt;unique placeholder&lt;/strong&gt; for the same credential:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                     ┌──────────────────┐
                     │   WARDN VAULT    │
                     │                  │
                     │  OPENAI_KEY =    │
                     │  sk-proj-real... │
                     └────────┬─────────┘
                              │
              ┌───────────────┼───────────────┐
              │               │               │
              ▼               ▼               ▼
     ┌────────────┐  ┌────────────┐  ┌────────────┐
     │ researcher │  │   writer   │  │  analyzer  │
     │            │  │            │  │            │
     │ wdn_plc_   │  │ wdn_plc_   │  │ wdn_plc_   │
     │ a1b2c3d4   │  │ e5f6g7h8   │  │ i9j0k1l2   │
     └────────────┘  └────────────┘  └────────────┘
      Different          Different       Different
      placeholder        placeholder     placeholder
      Same real key      Same real key   Same real key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If one agent is compromised, its placeholder is revoked without affecting others. You know exactly which agent leaked. And the leaked token is useless — it only works through the local proxy with the correct agent identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Zero-Downtime Key Rotation
&lt;/h2&gt;

&lt;p&gt;When you need to rotate a compromised key, agents don't even notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;BEFORE ROTATION                    AFTER ROTATION

Vault:                             Vault:
  OPENAI_KEY &lt;span class="o"&gt;=&lt;/span&gt; sk-proj-OLD         OPENAI_KEY &lt;span class="o"&gt;=&lt;/span&gt; sk-proj-NEW  ← changed

researcher → wdn_plc_a1b2c3d4     researcher → wdn_plc_a1b2c3d4  ← same
writer     → wdn_plc_e5f6g7h8     writer     → wdn_plc_e5f6g7h8  ← same

&lt;span class="nv"&gt;$ &lt;/span&gt;wardn vault rotate OPENAI_KEY
&lt;span class="c"&gt;# Enter new value → done.&lt;/span&gt;
&lt;span class="c"&gt;# Zero agent restarts. Zero config changes. Zero downtime.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Placeholders are bound to credential &lt;strong&gt;names&lt;/strong&gt;, not values. Rotate the underlying key and every agent's placeholder keeps working — now resolving to the new key.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Encryption Stack
&lt;/h2&gt;

&lt;p&gt;Wardn's vault isn't a glorified JSON file with a password. It's built on serious cryptography:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────┐
│                VAULT FILE FORMAT                    │
│                                                    │
│  Bytes 0-3    "WDNV"           Magic identifier    │
│  Bytes 4-5    Version          u16 little-endian    │
│  Bytes 6-21   Salt             16 random bytes      │
│  Bytes 22+    Encrypted Payload                     │
│               ├── 12-byte nonce (random per write)  │
│               ├── ciphertext (variable length)      │
│               └── 16-byte authentication tag        │
│                                                    │
├────────────────────────────────────────────────────┤
│              KEY DERIVATION                         │
│                                                    │
│  Algorithm:  Argon2id                              │
│  Memory:     19,456 KiB (19 MiB)                   │
│  Iterations: 2                                     │
│  Parallelism: 1                                    │
│  Output:     256-bit key                           │
│                                                    │
│  (OWASP 2024 minimum parameters)                   │
│                                                    │
├────────────────────────────────────────────────────┤
│              ENCRYPTION                            │
│                                                    │
│  Algorithm:  AES-256-GCM                           │
│  Nonce:      12 bytes (random per encryption)      │
│  Tag:        16 bytes (authenticated encryption)   │
│                                                    │
├────────────────────────────────────────────────────┤
│              MEMORY SAFETY                         │
│                                                    │
│  SensitiveString  →  Zeroized on drop              │
│  SensitiveBytes   →  Zeroized on drop              │
│  Debug output     →  "[REDACTED]"                  │
│                                                    │
├────────────────────────────────────────────────────┤
│              PERSISTENCE                           │
│                                                    │
│  Atomic writes:  write to .tmp → rename            │
│  No partial state, no corruption window            │
└────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Argon2id&lt;/strong&gt; for key derivation — resistant to GPU and side-channel attacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AES-256-GCM&lt;/strong&gt; for authenticated encryption — tamper-evident&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zeroize on drop&lt;/strong&gt; — sensitive data scrubbed from memory when no longer needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic writes&lt;/strong&gt; — vault file is never in a half-written state&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Built-In Credential Scanner
&lt;/h2&gt;

&lt;p&gt;Already have keys scattered across your projects? Wardn finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;wardn migrate &lt;span class="nt"&gt;--source&lt;/span&gt; claude-code &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

╔══════════════════════════════════════════════════════════════╗
║                   CREDENTIAL SCAN RESULTS                   ║
╠══════════════════════════════════════════════════════════════╣
║                                                              ║
║  Source: ~/.claude                                            ║
║  Files scanned: 47                                           ║
║                                                              ║
║  ┌──────────┬──────────────────┬──────────┬────────────┐     ║
║  │ Severity │ Pattern          │ Count    │ Score      │     ║
║  ├──────────┼──────────────────┼──────────┼────────────┤     ║
║  │ CRITICAL │ OpenAI &lt;span class="o"&gt;(&lt;/span&gt;sk-proj&lt;span class="o"&gt;)&lt;/span&gt; │    2     │  80 pts    │     ║
║  │ CRITICAL │ Anthropic &lt;span class="o"&gt;(&lt;/span&gt;sk-a&lt;span class="o"&gt;)&lt;/span&gt; │    1     │  40 pts    │     ║
║  │ HIGH     │ GitHub &lt;span class="o"&gt;(&lt;/span&gt;ghp_&lt;span class="o"&gt;)&lt;/span&gt;    │    3     │  60 pts    │     ║
║  │ MEDIUM   │ Slack &lt;span class="o"&gt;(&lt;/span&gt;xoxb-&lt;span class="o"&gt;)&lt;/span&gt;    │    1     │  10 pts    │     ║
║  └──────────┴──────────────────┴──────────┴────────────┘     ║
║                                                              ║
║  Risk Score: 190 / 400  ████████████░░░░░░░░  HIGH           ║
║                                                              ║
║  Run without &lt;span class="nt"&gt;--dry-run&lt;/span&gt; to migrate to encrypted vault         ║
╚══════════════════════════════════════════════════════════════╝
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;20+ credential patterns detected across severity levels. Supports scanning Claude Code configs, OpenClaw, or any directory. Risk scoring weights critical credentials (OpenAI, Anthropic, Stripe live keys) higher than generic tokens.&lt;/p&gt;




&lt;h2&gt;
  
  
  MCP Integration: Agent-Native Credential Access
&lt;/h2&gt;

&lt;p&gt;Wardn ships with a built-in &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; for direct integration with Claude Code, Cursor, and other MCP-capable tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ wardn serve --mcp --agent my-agent

┌────────────────────────────────────────────────────┐
│              WARDN MCP SERVER                       │
│              Transport: stdio                       │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │  Tool: get_credential_ref                     │  │
│  │  → Returns placeholder token for a credential │  │
│  │  → Per-agent isolation enforced               │  │
│  │  → Real value NEVER returned                  │  │
│  └──────────────────────────────────────────────┘  │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │  Tool: list_credentials                       │  │
│  │  → Lists authorized credentials + metadata    │  │
│  │  → Filtered by agent's access list            │  │
│  │  → Shows: name, domains, rate_limit (bool)    │  │
│  └──────────────────────────────────────────────┘  │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │  Tool: check_rate_limit                       │  │
│  │  → Query remaining quota                      │  │
│  │  → Returns: remaining, limit, retry_after     │  │
│  └──────────────────────────────────────────────┘  │
│                                                    │
│  All tools are READ-ONLY                           │
│  No credential values ever returned                │
│  Session bound to agent_id at connection time      │
└────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three read-only tools. An agent can check what credentials it has access to, get its placeholder token, and query its rate limit — but it can &lt;strong&gt;never&lt;/strong&gt; retrieve the actual credential value.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate Limiting: Blast Radius Control
&lt;/h2&gt;

&lt;p&gt;A looping agent or a compromised tool can rack up thousands of API calls in minutes. Wardn enforces per-credential, per-agent token bucket rate limiting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────┐
│             RATE LIMIT: Token Bucket                │
│                                                    │
│  Credential: OPENAI_KEY                            │
│  Config:     200 calls / hour                      │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │                                              │  │
│  │  researcher  ████████████████████░░░░  180   │  │
│  │  writer      ██████████████░░░░░░░░░  140   │  │
│  │  analyzer    ████████████████████████  200   │  │
│  │                                              │  │
│  │  ← tokens remaining (refill: 0.055/sec) →   │  │
│  └──────────────────────────────────────────────┘  │
│                                                    │
│  Credential: ANTHROPIC_KEY                         │
│  Config:     100 calls / hour                      │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │                                              │  │
│  │  researcher  ██████████████████████░░   92   │  │
│  │                                              │  │
│  │  writer: NOT AUTHORIZED                      │  │
│  │  analyzer: NOT AUTHORIZED                    │  │
│  │                                              │  │
│  └──────────────────────────────────────────────┘  │
│                                                    │
│  Each agent has independent token buckets          │
│  One agent hitting limit doesn't affect others     │
└────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configuration in &lt;code&gt;wardn.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[warden.credentials.OPENAI_KEY]&lt;/span&gt;
&lt;span class="py"&gt;rate_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;max_calls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;per&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hour"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;allowed_agents&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"researcher"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"writer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"analyzer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;allowed_domains&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"api.openai.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[warden.credentials.ANTHROPIC_KEY]&lt;/span&gt;
&lt;span class="py"&gt;rate_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;max_calls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;per&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hour"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;allowed_agents&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"researcher"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;allowed_domains&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"api.anthropic.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What This Defeats
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack Vector&lt;/th&gt;
&lt;th&gt;Without Wardn&lt;/th&gt;
&lt;th&gt;With Wardn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; file theft&lt;/td&gt;
&lt;td&gt;Real keys exposed&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;.env&lt;/code&gt; files exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malicious skill reads &lt;code&gt;$OPENAI_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Gets &lt;code&gt;sk-proj-real-...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Gets &lt;code&gt;wdn_placeholder_...&lt;/code&gt; (useless)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stealer targets agent config&lt;/td&gt;
&lt;td&gt;Finds real credentials&lt;/td&gt;
&lt;td&gt;Finds only placeholders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt injection exfiltrates key&lt;/td&gt;
&lt;td&gt;Key is in context window&lt;/td&gt;
&lt;td&gt;Key was never in context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent logs scraped&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Authorization: Bearer sk-proj-...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Authorization: Bearer wdn_placeholder_...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full agent compromise&lt;/td&gt;
&lt;td&gt;Attacker has real key&lt;/td&gt;
&lt;td&gt;Attacker has useless token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Looping agent burns budget&lt;/td&gt;
&lt;td&gt;Unlimited API calls&lt;/td&gt;
&lt;td&gt;Rate limit per agent per credential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API response echoes key&lt;/td&gt;
&lt;td&gt;Key reaches agent memory&lt;/td&gt;
&lt;td&gt;Stripped by response pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This isn't defense in depth. It's &lt;strong&gt;defense by architecture&lt;/strong&gt;. The real key physically cannot reach the agent process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
cargo &lt;span class="nb"&gt;install &lt;/span&gt;wardn

&lt;span class="c"&gt;# Create an encrypted vault&lt;/span&gt;
wardn vault create

&lt;span class="c"&gt;# Store your credentials (interactive, no echo)&lt;/span&gt;
wardn vault &lt;span class="nb"&gt;set &lt;/span&gt;OPENAI_KEY
wardn vault &lt;span class="nb"&gt;set &lt;/span&gt;ANTHROPIC_KEY

&lt;span class="c"&gt;# Get placeholder tokens for your agents&lt;/span&gt;
wardn vault get OPENAI_KEY &lt;span class="nt"&gt;--agent&lt;/span&gt; researcher
&lt;span class="c"&gt;# → wdn_placeholder_a1b2c3d4e5f6g7h8&lt;/span&gt;

&lt;span class="c"&gt;# Start the proxy&lt;/span&gt;
wardn serve

&lt;span class="c"&gt;# Or start proxy + MCP server for Claude Code / Cursor&lt;/span&gt;
wardn serve &lt;span class="nt"&gt;--mcp&lt;/span&gt; &lt;span class="nt"&gt;--agent&lt;/span&gt; my-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point your agent's HTTP client at &lt;code&gt;localhost:7777&lt;/code&gt;. Set the placeholder as the API key. Done.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;Wardn is a security middleware layer for AI agents. Today, it solves credential isolation. The same proxy architecture extends to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Request auditing&lt;/strong&gt; — full visibility into what agents are actually calling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain allowlisting&lt;/strong&gt; — agents can only reach approved APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost attribution&lt;/strong&gt; — know exactly which agent is spending what&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy enforcement&lt;/strong&gt; — agent-specific rules beyond just rate limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI agent ecosystem is growing fast. The security primitives haven't kept up. We think credential isolation is the foundation everything else builds on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;wardn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/rohansx/wardn" rel="noopener noreferrer"&gt;github.com/rohansx/wardn&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Crates.io:&lt;/strong&gt; &lt;a href="https://crates.io/crates/wardn" rel="noopener noreferrer"&gt;crates.io/crates/wardn&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Wardn is written in Rust. ~4,500 lines. Zero unsafe. AES-256-GCM + Argon2id. One binary, no external services.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>security</category>
      <category>agents</category>
    </item>
    <item>
      <title>SQLite as a Graph Database: Recursive CTEs, Semantic Search, and Why We Ditched Neo4j</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Tue, 24 Mar 2026 08:05:21 +0000</pubDate>
      <link>https://dev.to/rohansx/sqlite-as-a-graph-database-recursive-ctes-semantic-search-and-why-we-ditched-neo4j-1ai</link>
      <guid>https://dev.to/rohansx/sqlite-as-a-graph-database-recursive-ctes-semantic-search-and-why-we-ditched-neo4j-1ai</guid>
      <description>&lt;p&gt;Knowledge graphs are having a moment. Every AI agent framework wants one. The typical stack looks like this: Neo4j for graph storage, OpenAI for extraction, Docker to run it all. Three moving parts, two network dependencies, one &lt;code&gt;docker-compose.yml&lt;/code&gt; you'll fight with for an hour.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://github.com/rohansx/ctxgraph" rel="noopener noreferrer"&gt;ctxgraph&lt;/a&gt; to see how far you can get with just SQLite. The answer: surprisingly far. 0.800 combined F1 on extraction benchmarks, zero API calls, ~2 seconds for 50 episodes, single binary. No Docker. No API keys. No Neo4j.&lt;/p&gt;

&lt;p&gt;This post walks through the actual implementation: the schema, the recursive CTEs that make SQLite behave like a graph database, and the 3-mode search fusion that ties it together.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Too Much Infrastructure for a Knowledge Graph
&lt;/h2&gt;

&lt;p&gt;If you want a knowledge graph for your dev team today, the minimum viable stack is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Neo4j&lt;/strong&gt; -- requires Docker or a managed instance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An LLM API&lt;/strong&gt; -- OpenAI, Anthropic, etc. for entity/relation extraction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An embedding service&lt;/strong&gt; -- for semantic search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vector database&lt;/strong&gt; -- Pinecone, Qdrant, etc. for embedding storage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's four services for what is conceptually a simple thing: "store facts about my codebase and let me query them."&lt;/p&gt;

&lt;p&gt;We wanted something that ships as a single binary, works offline, and stores everything in a single file you can &lt;code&gt;cp&lt;/code&gt; to a backup drive. SQLite was the obvious choice for storage. The question was whether it could handle graph operations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBUcmFkaXRpb25hbFsiVHJhZGl0aW9uYWwgU3RhY2siXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIERbIkRvY2tlciJdIC0tLSBOWyJOZW80aiJdCiAgICAgICAgT1siT3BlbkFJIEFQSSJdIC0tLSBFWFsiRXh0cmFjdGlvbiJdCiAgICAgICAgUFsiUGluZWNvbmUgLyBRZHJhbnQiXSAtLS0gRU1bIkVtYmVkZGluZ3MiXQogICAgICAgIFBZWyJQeXRob24iXSAtLS0gR0xbIkdsdWUgQ29kZSJdCiAgICBlbmQKICAgIHN1YmdyYXBoIENUWFsiY3R4Z3JhcGgiXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIEJJTlsiU2luZ2xlIFJ1c3QgQmluYXJ5Il0KICAgICAgICBCSU4gLS0tIFNRWyJTUUxpdGUgRmlsZSJdCiAgICBlbmQKICAgIFRyYWRpdGlvbmFsIC0uICJ2cyIgLi0-IENUWAogICAgc3R5bGUgVHJhZGl0aW9uYWwgZmlsbDojZmVmMmYyLHN0cm9rZTojZGMyNjI2CiAgICBzdHlsZSBDVFggZmlsbDojZjBmZGY0LHN0cm9rZTojMTZhMzRhCiAgICBzdHlsZSBCSU4gZmlsbDojMTZhMzRhLGNvbG9yOiNmZmYsc3Ryb2tlOiMxNTgwM2QKICAgIHN0eWxlIFNRIGZpbGw6IzE2YTM0YSxjb2xvcjojZmZmLHN0cm9rZTojMTU4MDNk" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBUcmFkaXRpb25hbFsiVHJhZGl0aW9uYWwgU3RhY2siXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIERbIkRvY2tlciJdIC0tLSBOWyJOZW80aiJdCiAgICAgICAgT1siT3BlbkFJIEFQSSJdIC0tLSBFWFsiRXh0cmFjdGlvbiJdCiAgICAgICAgUFsiUGluZWNvbmUgLyBRZHJhbnQiXSAtLS0gRU1bIkVtYmVkZGluZ3MiXQogICAgICAgIFBZWyJQeXRob24iXSAtLS0gR0xbIkdsdWUgQ29kZSJdCiAgICBlbmQKICAgIHN1YmdyYXBoIENUWFsiY3R4Z3JhcGgiXQogICAgICAgIGRpcmVjdGlvbiBUQgogICAgICAgIEJJTlsiU2luZ2xlIFJ1c3QgQmluYXJ5Il0KICAgICAgICBCSU4gLS0tIFNRWyJTUUxpdGUgRmlsZSJdCiAgICBlbmQKICAgIFRyYWRpdGlvbmFsIC0uICJ2cyIgLi0-IENUWAogICAgc3R5bGUgVHJhZGl0aW9uYWwgZmlsbDojZmVmMmYyLHN0cm9rZTojZGMyNjI2CiAgICBzdHlsZSBDVFggZmlsbDojZjBmZGY0LHN0cm9rZTojMTZhMzRhCiAgICBzdHlsZSBCSU4gZmlsbDojMTZhMzRhLGNvbG9yOiNmZmYsc3Ryb2tlOiMxNTgwM2QKICAgIHN0eWxlIFNRIGZpbGw6IzE2YTM0YSxjb2xvcjojZmZmLHN0cm9rZTojMTU4MDNk" alt="Traditional Stack vs ctxgraph" width="1130" height="274"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four services, two network dependencies, and a Docker compose file -- or one binary and one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bet: SQLite + Recursive CTEs = Graph Database
&lt;/h2&gt;

&lt;p&gt;The core insight is that a graph database is really two things: a storage format for nodes and edges, and a query engine that can walk those edges efficiently. SQLite handles the first part trivially. For the second part, recursive Common Table Expressions (CTEs) give you everything you need for multi-hop traversal.&lt;/p&gt;

&lt;p&gt;This isn't a new idea. What we found is that with proper indexing, it's fast enough for knowledge graphs in the tens-of-thousands-of-nodes range -- which covers most single-team or single-project use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Schema
&lt;/h2&gt;

&lt;p&gt;Here's the actual schema from our migration file. Three core tables, two junction tables, three FTS5 virtual tables, and eight indexes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Episodes: raw events (conversations, decisions, incidents)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;source&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recorded_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="nb"&gt;BLOB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Entities: extracted nodes (people, services, decisions)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;   &lt;span class="nb"&gt;BLOB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Edges: relationships between entities&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_id&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;target_id&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;relation&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fact&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recorded_at&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;  &lt;span class="nb"&gt;REAL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;episode_id&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Bi-Temporal Matters
&lt;/h3&gt;

&lt;p&gt;Look at the &lt;code&gt;edges&lt;/code&gt; table. It has two temporal dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;valid_from&lt;/code&gt; / &lt;code&gt;valid_until&lt;/code&gt;&lt;/strong&gt; -- when the fact was true &lt;em&gt;in the real world&lt;/em&gt;. "AuthService depends on Redis" was valid from March 1st until March 15th, when we migrated to Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;recorded_at&lt;/code&gt;&lt;/strong&gt; -- when the system &lt;em&gt;learned&lt;/em&gt; about the fact. We might record the Redis-to-Postgres migration on March 20th, five days after it happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction matters for debugging. "What did we &lt;em&gt;know&lt;/em&gt; about the system on March 10th?" is a different question from "What was &lt;em&gt;actually true&lt;/em&gt; about the system on March 10th?" Bi-temporal modeling lets you answer both.&lt;/p&gt;

&lt;p&gt;In practice, querying "current state" means filtering for &lt;code&gt;valid_until IS NULL&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;recorded_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invalidating an edge doesn't delete it -- it sets &lt;code&gt;valid_until&lt;/code&gt;, preserving the full history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same pattern Datomic and Graphiti use. The difference is we do it in a 2MB SQLite file instead of a JVM process or a Neo4j container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graph Traversal via Recursive CTE
&lt;/h2&gt;

&lt;p&gt;This is the core of making SQLite act as a graph database. Here's the actual traversal query from our codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;traversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Base case: start at the given entity&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt;

    &lt;span class="c1"&gt;-- Recursive step: walk edges in both directions&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_id&lt;/span&gt;
             &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;traversal&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;  &lt;span class="c1"&gt;-- only current edges&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;traversal&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what the traversal looks like on a small graph. Starting from "AuthService" at depth 0, the CTE walks outward one hop at a time:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBkMFsiRGVwdGggMCJdCiAgICAgICAgQVsiQXV0aFNlcnZpY2UiXQogICAgZW5kCiAgICBzdWJncmFwaCBkMVsiRGVwdGggMSJdCiAgICAgICAgQlsiSldUIl0KICAgICAgICBDWyJSZWRpcyJdCiAgICBlbmQKICAgIHN1YmdyYXBoIGQyWyJEZXB0aCAyIl0KICAgICAgICBEWyJQb3N0Z3JlcyJdCiAgICAgICAgRVsiU2Vzc2lvblN0b3JlIl0KICAgICAgICBGWyJUb2tlbkNhY2hlIl0KICAgIGVuZAogICAgQSAtLSAidXNlcyIgLS0-IEIKICAgIEEgLS0gImRlcGVuZHNfb24iIC0tPiBDCiAgICBCIC0tICJzdG9yZWRfaW4iIC0tPiBECiAgICBDIC0tICJiYWNrcyIgLS0-IEUKICAgIEMgLS0gImJhY2tzIiAtLT4gRgogICAgc3R5bGUgQSBmaWxsOiMyNTYzZWIsY29sb3I6I2ZmZixzdHJva2U6IzFlNDBhZgogICAgc3R5bGUgQiBmaWxsOiM3YzNhZWQsY29sb3I6I2ZmZixzdHJva2U6IzViMjFiNgogICAgc3R5bGUgQyBmaWxsOiM3YzNhZWQsY29sb3I6I2ZmZixzdHJva2U6IzViMjFiNgogICAgc3R5bGUgRCBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1NwogICAgc3R5bGUgRSBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1NwogICAgc3R5bGUgRiBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1Nw%3D%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBkMFsiRGVwdGggMCJdCiAgICAgICAgQVsiQXV0aFNlcnZpY2UiXQogICAgZW5kCiAgICBzdWJncmFwaCBkMVsiRGVwdGggMSJdCiAgICAgICAgQlsiSldUIl0KICAgICAgICBDWyJSZWRpcyJdCiAgICBlbmQKICAgIHN1YmdyYXBoIGQyWyJEZXB0aCAyIl0KICAgICAgICBEWyJQb3N0Z3JlcyJdCiAgICAgICAgRVsiU2Vzc2lvblN0b3JlIl0KICAgICAgICBGWyJUb2tlbkNhY2hlIl0KICAgIGVuZAogICAgQSAtLSAidXNlcyIgLS0-IEIKICAgIEEgLS0gImRlcGVuZHNfb24iIC0tPiBDCiAgICBCIC0tICJzdG9yZWRfaW4iIC0tPiBECiAgICBDIC0tICJiYWNrcyIgLS0-IEUKICAgIEMgLS0gImJhY2tzIiAtLT4gRgogICAgc3R5bGUgQSBmaWxsOiMyNTYzZWIsY29sb3I6I2ZmZixzdHJva2U6IzFlNDBhZgogICAgc3R5bGUgQiBmaWxsOiM3YzNhZWQsY29sb3I6I2ZmZixzdHJva2U6IzViMjFiNgogICAgc3R5bGUgQyBmaWxsOiM3YzNhZWQsY29sb3I6I2ZmZixzdHJva2U6IzViMjFiNgogICAgc3R5bGUgRCBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1NwogICAgc3R5bGUgRSBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1NwogICAgc3R5bGUgRiBmaWxsOiMwNTk2NjksY29sb3I6I2ZmZixzdHJva2U6IzA0Nzg1Nw%3D%3D" alt="Recursive CTE Traversal" width="822" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The CTE starts with "AuthService" (depth 0, blue), discovers "JWT" and "Redis" (depth 1, purple), then reaches "Postgres", "SessionStore", and "TokenCache" (depth 2, green). &lt;code&gt;UNION&lt;/code&gt; deduplicates, so if two paths reach the same node, it appears only once.&lt;/p&gt;

&lt;p&gt;Let's break down what this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Base case&lt;/strong&gt;: Seed the traversal with the starting entity at depth 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursive step&lt;/strong&gt;: For each entity already discovered, find all edges where it's either the source or the target. This gives you &lt;strong&gt;bidirectional traversal&lt;/strong&gt; -- you walk the graph regardless of edge direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;CASE&lt;/code&gt; expression&lt;/strong&gt;: Picks the &lt;em&gt;other&lt;/em&gt; end of the edge. If we arrived via &lt;code&gt;source_id&lt;/code&gt;, take &lt;code&gt;target_id&lt;/code&gt;, and vice versa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth limiting&lt;/strong&gt;: &lt;code&gt;WHERE t.depth &amp;lt; ?2&lt;/code&gt; caps how many hops you'll walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal filtering&lt;/strong&gt;: &lt;code&gt;AND e.valid_until IS NULL&lt;/code&gt; restricts to currently-valid edges. Remove this clause to traverse the full history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UNION&lt;/code&gt; (not &lt;code&gt;UNION ALL&lt;/code&gt;)&lt;/strong&gt;: Deduplicates. If entity B is reachable from A via two different paths, it appears once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After collecting traversed entities, we grab all edges between them in a second query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recorded_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;episode_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;recorded_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How This Compares to Cypher
&lt;/h3&gt;

&lt;p&gt;The equivalent Neo4j Cypher query would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;start:&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$id&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;neighbor&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ALL&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="nf"&gt;relationships&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r.valid_until&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;neighbor&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cypher is more concise, no question. But the SQL version is self-contained -- no external database process, no Bolt protocol, no connection pooling. And for knowledge graphs under ~50k entities, the performance difference is negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  3-Mode Search Fusion
&lt;/h2&gt;

&lt;p&gt;Search is where things get interesting. We have three retrieval modes, each with different strengths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;FTS5 keyword search&lt;/strong&gt; -- exact token matching via SQLite's built-in full-text search (BM25 ranking)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic similarity&lt;/strong&gt; -- cosine similarity against embeddings from &lt;code&gt;all-MiniLM-L6-v2&lt;/code&gt; (384 dimensions, runs locally via ONNX)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph traversal&lt;/strong&gt; -- walk edges from entities mentioned in the query&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's how data flows through the system, from ingestion to query:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRCCiAgICBzdWJncmFwaCBJbmdlc3Rpb25bIkluZ2VzdGlvbiBQaXBlbGluZSJdCiAgICAgICAgZGlyZWN0aW9uIExSCiAgICAgICAgSU5bIlRleHQgSW5wdXQiXSAtLT4gTkVSWyJHTGlORVIgTkVSIChPTk5YKSJdCiAgICAgICAgTkVSIC0tPiBSRUxbIkhldXJpc3RpYyBSZWxhdGlvbnMiXQogICAgICAgIFJFTCAtLT4gRU1CWyJFbWJlZGRpbmcgKE1pbmlMTSkiXQogICAgICAgIEVNQiAtLT4gREJbKCJTUUxpdGUgU3RvcmFnZSIpXQogICAgZW5kCiAgICBzdWJncmFwaCBRdWVyeVsiUXVlcnkgUGlwZWxpbmUiXQogICAgICAgIGRpcmVjdGlvbiBMUgogICAgICAgIFFbIlF1ZXJ5Il0gLS0-IEZUU1siRlRTNSBLZXl3b3JkIFNlYXJjaCJdCiAgICAgICAgUSAtLT4gU0VNWyJFbWJlZGRpbmcgQ29zaW5lIFNpbWlsYXJpdHkiXQogICAgICAgIFEgLS0-IENURVsiUmVjdXJzaXZlIENURSBHcmFwaCBXYWxrIl0KICAgICAgICBGVFMgLS0-IFJSRlsiUlJGIEZ1c2lvbiJdCiAgICAgICAgU0VNIC0tPiBSUkYKICAgICAgICBDVEUgLS0-IFJSRgogICAgICAgIFJSRiAtLT4gUkVTWyJSYW5rZWQgUmVzdWx0cyJdCiAgICBlbmQKICAgIEluZ2VzdGlvbiB-fn4gUXVlcnk%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRCCiAgICBzdWJncmFwaCBJbmdlc3Rpb25bIkluZ2VzdGlvbiBQaXBlbGluZSJdCiAgICAgICAgZGlyZWN0aW9uIExSCiAgICAgICAgSU5bIlRleHQgSW5wdXQiXSAtLT4gTkVSWyJHTGlORVIgTkVSIChPTk5YKSJdCiAgICAgICAgTkVSIC0tPiBSRUxbIkhldXJpc3RpYyBSZWxhdGlvbnMiXQogICAgICAgIFJFTCAtLT4gRU1CWyJFbWJlZGRpbmcgKE1pbmlMTSkiXQogICAgICAgIEVNQiAtLT4gREJbKCJTUUxpdGUgU3RvcmFnZSIpXQogICAgZW5kCiAgICBzdWJncmFwaCBRdWVyeVsiUXVlcnkgUGlwZWxpbmUiXQogICAgICAgIGRpcmVjdGlvbiBMUgogICAgICAgIFFbIlF1ZXJ5Il0gLS0-IEZUU1siRlRTNSBLZXl3b3JkIFNlYXJjaCJdCiAgICAgICAgUSAtLT4gU0VNWyJFbWJlZGRpbmcgQ29zaW5lIFNpbWlsYXJpdHkiXQogICAgICAgIFEgLS0-IENURVsiUmVjdXJzaXZlIENURSBHcmFwaCBXYWxrIl0KICAgICAgICBGVFMgLS0-IFJSRlsiUlJGIEZ1c2lvbiJdCiAgICAgICAgU0VNIC0tPiBSUkYKICAgICAgICBDVEUgLS0-IFJSRgogICAgICAgIFJSRiAtLT4gUkVTWyJSYW5rZWQgUmVzdWx0cyJdCiAgICBlbmQKICAgIEluZ2VzdGlvbiB-fn4gUXVlcnk%3D" alt="Architecture: Ingestion and Query Pipeline" width="1264" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three independent retrieval modes run in parallel, each producing a ranked list. Reciprocal Rank Fusion combines them without needing comparable scores.&lt;/p&gt;

&lt;p&gt;The problem: how do you combine ranked results from three systems with completely different scoring scales? BM25 scores, cosine similarities, and graph depth are not comparable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reciprocal Rank Fusion (RRF)
&lt;/h3&gt;

&lt;p&gt;The answer is &lt;a href="https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf" rel="noopener noreferrer"&gt;Reciprocal Rank Fusion&lt;/a&gt;. Instead of combining &lt;em&gt;scores&lt;/em&gt;, you combine &lt;em&gt;ranks&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rrf_score(d) = sum( 1 / (k + rank_i(d)) )  for each mode i where d appears
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With k=60 (the standard constant from the original paper), a document ranked #1 in one mode gets &lt;code&gt;1/61 = 0.0164&lt;/code&gt;. Ranked #10 gets &lt;code&gt;1/70 = 0.0143&lt;/code&gt;. The key property: a document appearing in &lt;em&gt;multiple&lt;/em&gt; modes gets scores from each, naturally boosting results that are relevant across different retrieval strategies.&lt;/p&gt;

&lt;p&gt;Here's the actual implementation from &lt;code&gt;graph.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;search_fused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FusedEpisodeResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HashMap&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;episodes_map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Episode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HashMap&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// --- FTS5 ranked list ---&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fts_pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.storage&lt;/span&gt;&lt;span class="nf"&gt;.search_episodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fts_pool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;episode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fts&lt;/span&gt;&lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.enumerate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;rrf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="nf"&gt;.entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;episode&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.or_insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;episodes_map&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;episode&lt;/span&gt;&lt;span class="py"&gt;.id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;episode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// --- Semantic (cosine similarity) ranked list ---&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;all_embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.get_embeddings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;all_embeddings&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="nf"&gt;.is_empty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;all_embeddings&lt;/span&gt;
            &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;)|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;vec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;semantic&lt;/span&gt;&lt;span class="nf"&gt;.sort_by&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="na"&gt;.1&lt;/span&gt;&lt;span class="nf"&gt;.partial_cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="na"&gt;.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ep_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_sim&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;semantic&lt;/span&gt;&lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.enumerate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;rrf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="nf"&gt;.entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ep_id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.or_insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;rrf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Sort by total RRF score descending, take top `limit`&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="nf"&gt;.sort_by&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="na"&gt;.1&lt;/span&gt;&lt;span class="nf"&gt;.partial_cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="na"&gt;.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="c1"&gt;// ... take(limit) and return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The FTS5 query underneath is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recorded_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;episodes_fts&lt;/span&gt; &lt;span class="n"&gt;fts&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;episodes_fts&lt;/span&gt; &lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FTS5's &lt;code&gt;rank&lt;/code&gt; column is the negative BM25 score (lower is better), so we negate it. But with RRF, the raw score doesn't matter -- only the position in the ranked list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not Just Use Vector Search?
&lt;/h3&gt;

&lt;p&gt;Pure semantic search misses exact matches. If someone searches for "Redis migration," you want episodes containing the literal string "Redis migration" to rank highly even if their embedding isn't the closest vector. FTS5 catches these. Conversely, semantic search catches rephrased mentions ("moved our caching layer to a different store") that keyword search would miss.&lt;/p&gt;

&lt;p&gt;RRF is the simplest way to get both without tuning weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: 8 Indexes and Why SQLite Scales
&lt;/h2&gt;

&lt;p&gt;Here are the eight indexes we maintain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_edges_source&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_edges_target&lt;/span&gt;     &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_edges_relation&lt;/span&gt;   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_edges_valid&lt;/span&gt;      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_until&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_entities_type&lt;/span&gt;    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_episode_entities&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;episode_entities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_episodes_source&lt;/span&gt;  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_episodes_recorded&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;episodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorded_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each optimizes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Optimizes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;idx_edges_source&lt;/code&gt; / &lt;code&gt;idx_edges_target&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Graph traversal JOIN on &lt;code&gt;edges.source_id&lt;/code&gt; and &lt;code&gt;edges.target_id&lt;/code&gt;. Without these, every recursive CTE step is a full table scan.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idx_edges_relation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filtering edges by relation type ("show me all &lt;code&gt;depends_on&lt;/code&gt; edges").&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idx_edges_valid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Temporal queries. The composite index on &lt;code&gt;(valid_from, valid_until)&lt;/code&gt; lets the planner efficiently filter current vs. historical edges.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idx_entities_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Listing/filtering entities by type. Used during deduplication (find all entities of type "Service" for fuzzy matching).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idx_episode_entities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reverse lookup: "which episodes mention this entity?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;idx_episodes_source&lt;/code&gt; / &lt;code&gt;idx_episodes_recorded&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Filtering episodes by source system and time-range queries.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of these, we have three FTS5 virtual tables (&lt;code&gt;episodes_fts&lt;/code&gt;, &lt;code&gt;entities_fts&lt;/code&gt;, &lt;code&gt;edges_fts&lt;/code&gt;) with triggers that keep them in sync on every INSERT, UPDATE, and DELETE. FTS5 maintains its own inverted index internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  SQLite Scales Further Than You Think
&lt;/h3&gt;

&lt;p&gt;Common objection: "SQLite can't handle large datasets." In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SQLite handles &lt;strong&gt;millions of rows&lt;/strong&gt; without issue when properly indexed. Our edge traversal is O(branching_factor^depth), bounded by &lt;code&gt;max_depth&lt;/code&gt;, not by total table size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAL mode&lt;/strong&gt; (&lt;code&gt;PRAGMA journal_mode = WAL&lt;/code&gt;) allows concurrent reads during writes. We set this on every connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page cache&lt;/strong&gt; scales with available RAM. SQLite's default 2MB cache can be bumped to 64MB+ for hot datasets.&lt;/li&gt;
&lt;li&gt;The semantic search scan (loading all embeddings for cosine similarity) is the bottleneck. At 10k episodes with 384-dim embeddings, that's ~15MB of data. Loads in milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When You'd Actually Need HNSW
&lt;/h3&gt;

&lt;p&gt;The brute-force cosine similarity scan works fine up to roughly 50-100k embeddings. Beyond that, you want an approximate nearest neighbor index. Options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;sqlite-vec&lt;/strong&gt; -- SQLite extension that adds HNSW indexing. Drop-in compatible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant/Milvus&lt;/strong&gt; -- if you need distributed vector search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We chose brute-force initially because it's zero dependencies and the accuracy is exact (no approximation error). For a single-team knowledge graph, you're unlikely to hit 100k episodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;From our benchmark against 50 gold-labeled episodes (ADRs, incident reports, PR descriptions, migration plans):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;ctxgraph (local)&lt;/th&gt;
&lt;th&gt;Graphiti + gpt-4o&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entity F1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.837&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.570&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relation F1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.763&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.104*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Combined F1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.800&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.337*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API calls&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~200+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per run&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$2-5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency (50 eps)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~2s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~8min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;* Graphiti's free-form relations mapped to ctxgraph's fixed taxonomy with generous keyword heuristics.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The extraction pipeline uses GLiNER Large v2.1 (INT8 ONNX) for NER and a heuristic keyword + proximity scorer for relation extraction. Everything runs locally. The ~2 second total for 50 episodes works out to roughly &lt;strong&gt;40ms per episode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why does a local ONNX model beat gpt-4o? It's not that gpt-4o is worse at understanding text. It's that Graphiti extracts &lt;em&gt;differently&lt;/em&gt;: multi-word descriptive phrases ("primary Postgres cluster") instead of canonical names ("Postgres"), free-form relation verbs instead of a fixed schema. For structured downstream querying, schema-typed extraction with a focused model wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to Use This
&lt;/h2&gt;

&lt;p&gt;SQLite-as-graph-database is the right call for a specific range of problems. Here's when it isn't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale: &amp;gt;100k entities with deep traversals.&lt;/strong&gt; The recursive CTE does a breadth-first search via SQL. At depth 4 with an average branching factor of 10, you're visiting 10,000 nodes per query. SQLite handles this in milliseconds with proper indexes, but at 500k entities with depth 6, you'll feel it. Neo4j's native graph storage format is genuinely faster for deep traversals on large graphs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency: multi-user concurrent writes.&lt;/strong&gt; SQLite's write lock is process-wide. WAL mode helps (concurrent reads are fine), but if you have 10 services writing to the same graph simultaneously, you'll hit contention. PostgreSQL with a graph schema, or Neo4j, are better choices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query expressiveness: when you need Cypher.&lt;/strong&gt; Cypher's pattern matching is more expressive than recursive CTEs. "Find all paths between A and B where every intermediate node is a Service" is natural in Cypher and painful in SQL. If your queries look like this regularly, use a graph database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distributed systems.&lt;/strong&gt; SQLite is a single-file embedded database. It doesn't replicate, shard, or cluster. If you need your knowledge graph available across multiple machines, this isn't the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;SQLite is not a graph database. But with recursive CTEs, FTS5, embeddings stored as BLOBs, and Reciprocal Rank Fusion to tie it all together, it's a remarkably capable one for the single-user, single-machine use case.&lt;/p&gt;

&lt;p&gt;The full stack -- entity extraction, relation extraction, graph storage, keyword search, semantic search, graph traversal -- runs as a single binary with no network dependencies. The database is a single file. Backup is &lt;code&gt;cp&lt;/code&gt;. Deployment is &lt;code&gt;cargo install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sometimes the best architecture is the one with the fewest moving parts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/rohansx/ctxgraph" rel="noopener noreferrer"&gt;ctxgraph&lt;/a&gt; is open-source (MIT). Star it on GitHub if this was useful. We're actively inviting &lt;a href="https://github.com/rohansx/ctxgraph/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;benchmark episode submissions&lt;/a&gt; to independently validate the extraction quality.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>graphdb</category>
      <category>database</category>
      <category>rust</category>
    </item>
    <item>
      <title>Your RAG Pipeline is Leaking - 4 Data Leak Points Nobody Talks About</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Fri, 06 Mar 2026 18:33:13 +0000</pubDate>
      <link>https://dev.to/rohansx/your-rag-pipeline-is-leaking-4-data-leak-points-nobody-talks-aboutpublished-false-1obm</link>
      <guid>https://dev.to/rohansx/your-rag-pipeline-is-leaking-4-data-leak-points-nobody-talks-aboutpublished-false-1obm</guid>
      <description>&lt;p&gt;Every enterprise running RAG today is doing what Samsung engineers did in 2023 — sending sensitive data to LLM providers. Except it's automated, at scale, thousands of times per day.&lt;/p&gt;

&lt;p&gt;Samsung's problem wasn't careless employees. &lt;strong&gt;It was architectural.&lt;/strong&gt; And your RAG pipeline has the same architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 Leak Points
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Documents (contracts, financials, HR, strategy)
        |
        v
   1. Chunking                  ✅ Local, safe
        |
        v
   2. Embedding API call         ❌ LEAK #1: raw text to provider
        |
        v
   3. Vector DB (cloud)          ❌ LEAK #2: invertible embeddings
        |
        v
   4. User query embedding       ❌ LEAK #3: query to embedding API
        |
        v
   5. Retrieved context          (your most sensitive chunks)
        |
        v
   6. LLM generation call        ❌ LEAK #4: query + context in plaintext
        |
        v
   Response to user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Six steps. Four leak points. Every single query.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your compliance team saw a box labeled "LLM" in the architecture diagram and assumed it was local. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But Embeddings Are Just Numbers"
&lt;/h2&gt;

&lt;p&gt;That was conventional wisdom until &lt;strong&gt;Zero2Text&lt;/strong&gt; (Feb 2026) — a zero-training inversion attack that reconstructs text from embedding vectors with only API access. 1.8x higher ROUGE-L scores vs all prior baselines.&lt;/p&gt;

&lt;p&gt;Patient records, legal docs, proprietary code — all recoverable from vectors alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Pinecone/Weaviate breach = full plaintext breach.&lt;/strong&gt; OWASP now classifies this as a &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/" rel="noopener noreferrer"&gt;Top 10 LLM vulnerability&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Existing Solutions Don't Work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Redaction kills utility:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: "Tata Motors reported Rs 3.4L Cr revenue in Q3 2025"
After:  "[REDACTED] reported [REDACTED] revenue in [REDACTED]"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good luck getting useful embeddings from that. Your vector search returns garbage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PII detectors (Presidio, LLM Guard):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50-200ms overhead per call (Python NER in hot path)&lt;/li&gt;
&lt;li&gt;Only catch names/emails — miss revenue figures, deal sizes, project codenames&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless&lt;/strong&gt; — different replacement each call breaks vector search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cloud-locked tools:&lt;/strong&gt; Bedrock guardrails = Bedrock only. Private AI = another SaaS middleman.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Consistent   Beyond    &amp;lt;10ms     Self-     Pipeline
                    mapping      PII       latency   hosted    aware
Presidio            ❌           ❌        ❌        ✅        ❌
LLM Guard           ❌           ❌        ❌        ✅        ❌
Bedrock Guardrails  ❌           ⚠️        ✅        ❌        ❌
CloakPipe           ✅           ✅        ✅        ✅        ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Fix: Consistent Pseudonymization
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't redact. Replace consistently.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Map "Tata Motors" → "ORG_7". Same token, every time, across every document and query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: "Tata Motors reported Rs 3.4L Cr revenue in Q3 2025, up 12%"
After:  "ORG_7 reported AMOUNT_12 revenue in DATE_3, up PCT_3"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Semantic structure preserved → embeddings still meaningful → vector search works → LLM responds with pseudonyms → rehydrate back to real values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"What was Tata Motors' revenue last quarter?"
        ↓
   Pseudonymize → "What was ORG_7's revenue last quarter?"
        ↓
   Embed + Search → retrieve pseudonymized chunks
        ↓
   LLM → "ORG_7 reported AMOUNT_12 in DATE_3..."
        ↓
   Rehydrate → "Tata Motors reported Rs 3.4L Cr in Q3 2025..."
        ↓
   ✅ User sees real answer. Provider never saw "Tata Motors."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Going Further: Kill 3/4 Leak Points
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vectorless tree search&lt;/strong&gt; builds a local JSON index and lets the LLM reason about relevance. No embedding API. No vector DB. No inversion risk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VECTOR RAG (4 leaks):              TREE-BASED RAG (1 leak):

Text → Embedding API  ❌           Tree index built locally  ✅
Vectors → Cloud DB    ❌           Tree stored locally       ✅
Query → Embedding API ❌           LLM navigates tree        ✅
Context → LLM         ❌           Pseudonymized → LLM      ⚠️ (protected)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PageIndex (VectifyAI) proved 98.7% accuracy on FinanceBench vs GPT-4o's ~31% for structured docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  CloakPipe — Drop-In Privacy Proxy
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/rohansx/cloakpipe" rel="noopener noreferrer"&gt;CloakPipe&lt;/a&gt; — a Rust-native proxy that sits between your app and any OpenAI-compatible API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your App  →  CloakPipe  →  LLM API
                |               |
          "Tata Motors"    Sees "ORG_1"
          → "ORG_1"            |
                |              |
          "ORG_1"         ←----+
          → "Tata Motors"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; change &lt;code&gt;OPENAI_BASE_URL&lt;/code&gt;. That's it. Your LangChain/LlamaIndex/OpenAI SDK code works unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v0.1 features:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-layer detection (API keys, JWTs, emails, IPs, financial amounts, fiscal dates, custom TOML rules)&lt;/li&gt;
&lt;li&gt;AES-256-GCM encrypted vault + &lt;code&gt;zeroize&lt;/code&gt; memory safety&lt;/li&gt;
&lt;li&gt;OpenAI-compatible proxy (&lt;code&gt;/v1/chat/completions&lt;/code&gt;, &lt;code&gt;/v1/embeddings&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;SSE streaming rehydration&lt;/li&gt;
&lt;li&gt;Single binary, &amp;lt;5ms overhead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Coming soon:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌳 &lt;strong&gt;CloakTree&lt;/strong&gt; — vectorless retrieval, eliminates 3/4 leak points&lt;/li&gt;
&lt;li&gt;🔐 &lt;strong&gt;CloakVector&lt;/strong&gt; — distance-preserving vector encryption&lt;/li&gt;
&lt;li&gt;🧠 ONNX-based NER&lt;/li&gt;
&lt;li&gt;🏗️ TEE support (AWS Nitro, Intel TDX)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The privacy-preserving AI market is $4.25B today, projected $40B by 2035. 75% of enterprise leaders cite security as #1 barrier to AI adoption.&lt;/p&gt;

&lt;p&gt;The era of sending raw enterprise data to LLM APIs in plaintext is ending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/rohansx/cloakpipe" rel="noopener noreferrer"&gt;github.com/rohansx/cloakpipe&lt;/a&gt;&lt;/strong&gt; — star it, try it, break it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/rohansx/cloakpipe" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Star CloakPipe on GitHub&lt;/a&gt;
&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rust</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Every AI Agent Tool Creates Git Worktrees. None of Them Make Worktrees Actually Work.</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Wed, 04 Mar 2026 23:41:08 +0000</pubDate>
      <link>https://dev.to/rohansx/every-ai-agent-tool-creates-git-worktrees-none-of-them-make-worktrees-actually-work-3ae9</link>
      <guid>https://dev.to/rohansx/every-ai-agent-tool-creates-git-worktrees-none-of-them-make-worktrees-actually-work-3ae9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flkzwdn79fx2hhgw7bl5l.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flkzwdn79fx2hhgw7bl5l.gif" alt=" " width="639" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been deep in the parallel AI agent ecosystem for months — Conductor, Claude Squad, Agent Deck, Claude Code's native agent teams. They all converge on the same architecture: spin up git worktrees, run an AI agent in each one, merge the results.&lt;/p&gt;

&lt;p&gt;And they all have the same problem: &lt;strong&gt;the worktree is empty.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git worktree add ../my-feature feature/auth
cd ../my-feature
# Where's my .env? Gone.
# Where's node_modules? Gone.
# Where's my Docker compose state? Gone.
# Where's my database config? Gone.
# Time to write 50-100 lines of bash. Again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Bash Scripts Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Every parallel agent workflow has a dirty secret: a setup script that does the actual work.&lt;/p&gt;

&lt;p&gt;Here's what Conductor's docs tell you to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ../.env .env
pnpm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks simple. Now here's what real Conductor users &lt;em&gt;actually&lt;/em&gt; write. A Phoenix developer published &lt;a href="https://nicholasjhenry.medium.com/building-isolated-phoenix-workspaces-for-ai-agents-with-conductor-a438d161f191" rel="noopener noreferrer"&gt;his setup script&lt;/a&gt; — it symlinks shared configs, copies build artifacts to avoid recompilation, reads Conductor's environment variables, generates workspace-specific &lt;code&gt;.env.local&lt;/code&gt; files with unique ports and Docker compose project names, and sets up isolated containers per workspace.&lt;/p&gt;

&lt;p&gt;Another team &lt;a href="https://www.10play.dev/blog/worktree-10x-development" rel="noopener noreferrer"&gt;published their scripts&lt;/a&gt; — they generate unique database names from workspace directories, allocate workspace-specific ports via a port registry, create PostgreSQL databases per workspace, generate &lt;code&gt;.env&lt;/code&gt; files with unique &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;PORT&lt;/code&gt;, assign iOS simulators per workspace, and do full cleanup on teardown.&lt;/p&gt;

&lt;p&gt;Someone even built &lt;a href="https://jsr.io/@wyattjoh/train-conductor" rel="noopener noreferrer"&gt;train-conductor&lt;/a&gt; — a standalone Deno tool that exists purely to do symlinks and scripts in worktrees, because no orchestration tool handles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same pattern repeats everywhere.&lt;/strong&gt; Claude Squad creates worktrees but doesn't install deps. Agent Deck creates worktrees but doesn't copy your &lt;code&gt;.env&lt;/code&gt;. Claude Code's native agent teams create worktrees but don't symlink &lt;code&gt;node_modules&lt;/code&gt;. Every tool assumes someone else will solve the environment problem.&lt;/p&gt;

&lt;p&gt;Nobody does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem: Code Isolation ≠ Environment Isolation
&lt;/h2&gt;

&lt;p&gt;Git worktrees give you code isolation. Your files are separate. Your branches don't conflict.&lt;/p&gt;

&lt;p&gt;But your &lt;strong&gt;runtime environment&lt;/strong&gt; is still shared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Port conflicts&lt;/strong&gt;: Two worktrees both run &lt;code&gt;npm run dev&lt;/code&gt; on port 3000. One fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database collisions&lt;/strong&gt;: Two agents write to the same dev database. Data corruption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker chaos&lt;/strong&gt;: Two worktrees share &lt;code&gt;docker compose&lt;/code&gt; project namespace. Containers clobber each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk explosion&lt;/strong&gt;: 5 worktrees × 2GB &lt;code&gt;node_modules&lt;/code&gt; = 10GB wasted. One Cursor user reported 9.82 GB consumed in 20 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing env files&lt;/strong&gt;: &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.envrc&lt;/code&gt;, &lt;code&gt;.npmrc&lt;/code&gt;, secrets — none of them are tracked by git, so none of them exist in new worktrees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One developer summed it up perfectly: "Git worktree gives you multiple working directories but they still share the same database, same ports, same Docker daemon — it solves code isolation, not environment isolation."&lt;/p&gt;

&lt;h2&gt;
  
  
  I Built the Missing Piece
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/rohansx/workz" rel="noopener noreferrer"&gt;workz&lt;/a&gt; is a Rust CLI that makes any git worktree a fully functional dev environment. One command. Zero config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;workz start feature/auth
&lt;span class="c"&gt;# ✓ Created worktree&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Detected Node.js project (pnpm-lock.yaml)&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Symlinked node_modules, .next, .turbo (saved 2.1 GB)&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Copied .env, .env.local, .envrc, .npmrc&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Installed dependencies (pnpm install --frozen-lockfile)&lt;/span&gt;
&lt;span class="c"&gt;# ✓ You're in the worktree. Ready to code.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it does automatically
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Smart project detection&lt;/strong&gt; — workz detects your project type (Node/Rust/Python/Go/Java) and only syncs relevant directories. No config file needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symlinks heavy directories&lt;/strong&gt; — &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;target/&lt;/code&gt;, &lt;code&gt;.venv&lt;/code&gt;, &lt;code&gt;vendor/&lt;/code&gt;, &lt;code&gt;.next&lt;/code&gt;, &lt;code&gt;.nuxt&lt;/code&gt;, &lt;code&gt;.angular&lt;/code&gt;, &lt;code&gt;.gradle&lt;/code&gt;, &lt;code&gt;build/&lt;/code&gt;, and 13 more. One copy on disk, symlinked everywhere. A typical Node project saves 1-3 GB per worktree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Copies env files&lt;/strong&gt; — 17 patterns: &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.*&lt;/code&gt;, &lt;code&gt;.envrc&lt;/code&gt;, &lt;code&gt;.tool-versions&lt;/code&gt;, &lt;code&gt;.node-version&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, &lt;code&gt;.npmrc&lt;/code&gt;, &lt;code&gt;.yarnrc.yml&lt;/code&gt;, &lt;code&gt;docker-compose.override.yml&lt;/code&gt;, secrets files. Everything you need, nothing lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-installs dependencies&lt;/strong&gt; — detects your lockfile (&lt;code&gt;bun.lockb&lt;/code&gt;, &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;, &lt;code&gt;yarn.lock&lt;/code&gt;, &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;Pipfile.lock&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;) and runs the right install command automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker support&lt;/strong&gt; — &lt;code&gt;workz start feature/api --docker&lt;/code&gt; runs &lt;code&gt;docker compose up -d&lt;/code&gt; in the new worktree. &lt;code&gt;workz done&lt;/code&gt; stops the containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI-agent ready&lt;/strong&gt; — &lt;code&gt;workz start feature/auth --ai&lt;/code&gt; launches Claude Code directly in the worktree. Also supports Cursor and VS Code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The new thing: &lt;code&gt;--isolated&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is what nobody else has. Not Conductor, not Claude Squad, not anyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;workz start feature/auth &lt;span class="nt"&gt;--isolated&lt;/span&gt;
&lt;span class="c"&gt;# Everything above, PLUS:&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Assigned PORT=3001 (unique, no conflict)&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Set DB_NAME=myapp_feature_auth&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Set COMPOSE_PROJECT_NAME=myapp-feature-auth&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Wrote workspace-specific .env.local&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Docker containers are namespaced&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can run 5 worktrees with 5 dev servers, 5 databases, and 5 Docker stacks — zero conflicts. And when you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;workz &lt;span class="k"&gt;done &lt;/span&gt;feature/auth
&lt;span class="c"&gt;# ✓ Stopped Docker containers&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Released port 3001&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Removed worktree&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Optionally deleted branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full cleanup. No orphaned containers. No stale port allocations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Works With Everything
&lt;/h2&gt;

&lt;p&gt;workz isn't an orchestration tool. It's the &lt;strong&gt;environment layer&lt;/strong&gt; that every orchestration tool is missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With Conductor&lt;/strong&gt; — replace your 50-line setup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"worktree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"workz sync --isolated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"setup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"workz sync --isolated"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With Claude Squad&lt;/strong&gt; — add to your session setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cs new &lt;span class="nt"&gt;--setup&lt;/span&gt; &lt;span class="s2"&gt;"workz sync --isolated"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With Claude Code agent teams&lt;/strong&gt; — use as a post-worktree hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or standalone&lt;/strong&gt; — workz works perfectly on its own. No orchestrator required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;For a typical Node.js project with 5 parallel worktrees:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Without workz&lt;/th&gt;
&lt;th&gt;With workz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disk usage&lt;/td&gt;
&lt;td&gt;~12 GB&lt;/td&gt;
&lt;td&gt;~3.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;3-5 min (npm install × 5)&lt;/td&gt;
&lt;td&gt;&amp;lt;10 sec (symlink + copy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port conflicts&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing .env&lt;/td&gt;
&lt;td&gt;Every time&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup effort&lt;/td&gt;
&lt;td&gt;Manual rm + prune + docker down&lt;/td&gt;
&lt;td&gt;&lt;code&gt;workz done&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Homebrew (macOS/Linux)&lt;/span&gt;
brew tap rohansx/tap &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew &lt;span class="nb"&gt;install &lt;/span&gt;workz

&lt;span class="c"&gt;# Cargo&lt;/span&gt;
cargo &lt;span class="nb"&gt;install &lt;/span&gt;workz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Single binary. No runtime dependencies. Works on Linux, macOS, and Windows.&lt;/p&gt;

&lt;p&gt;Add shell integration for auto-cd (like zoxide):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.zshrc or ~/.bashrc&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;workz init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Basic: create a worktree with full environment&lt;/span&gt;
workz start feature/login

&lt;span class="c"&gt;# With isolation: unique ports, Docker, DB naming&lt;/span&gt;
workz start feature/auth &lt;span class="nt"&gt;--isolated&lt;/span&gt;

&lt;span class="c"&gt;# With AI: launch Claude Code in the worktree&lt;/span&gt;
workz start feature/api &lt;span class="nt"&gt;--ai&lt;/span&gt; &lt;span class="nt"&gt;--isolated&lt;/span&gt; &lt;span class="nt"&gt;--docker&lt;/span&gt;

&lt;span class="c"&gt;# See all worktrees with status&lt;/span&gt;
workz list

&lt;span class="c"&gt;# Clean up&lt;/span&gt;
workz &lt;span class="k"&gt;done &lt;/span&gt;feature/login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Star the repo if this solves a pain point for you: &lt;a href="https://github.com/rohansx/workz" rel="noopener noreferrer"&gt;github.com/rohansx/workz&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;workz is open source (MIT). Built in Rust. Contributions welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>worktree</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I got tired of writing the same 80-line setup script for every AI worktree</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Tue, 03 Mar 2026 21:08:02 +0000</pubDate>
      <link>https://dev.to/rohansx/i-got-tired-of-writing-the-same-80-line-setup-script-for-every-ai-worktree-4hj9</link>
      <guid>https://dev.to/rohansx/i-got-tired-of-writing-the-same-80-line-setup-script-for-every-ai-worktree-4hj9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg63fx54h0bq5v0wpe651.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg63fx54h0bq5v0wpe651.gif" alt=" " width="639" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've been running parallel AI coding agents — Claude Code, Aider, Codex, anything — you've hit this wall.&lt;/p&gt;

&lt;p&gt;Each agent needs its own worktree. Each worktree needs its own environment. And that means every time you spin one up, you're either manually editing .env files or you've got a setup script that looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#!/bin/bash
# conductor.json worktree script — set up isolated environment
BRANCH_NAME="${CONDUCTOR_BRANCH_NAME}"
SLUG="${BRANCH_NAME//\//_}"
SLUG="${SLUG//-/_}"

BASE_PORT=3000
USED_PORTS=$(cat ~/.worktree-ports 2&amp;gt;/dev/null || echo "")
PORT=$BASE_PORT
while echo "$USED_PORTS" | grep -q "^$PORT$"; do
  PORT=$((PORT + 1))
done
echo "$PORT" &amp;gt;&amp;gt; ~/.worktree-ports

DB_NAME="myapp_${SLUG}"
COMPOSE_PROJECT="myapp_${SLUG}"
REDIS_PORT=$((PORT + 1000))

cat &amp;gt; .env.local &amp;lt;&amp;lt; EOF
PORT=$PORT
DB_NAME=$DB_NAME
DATABASE_URL=postgres://localhost/$DB_NAME
COMPOSE_PROJECT_NAME=$COMPOSE_PROJECT
REDIS_URL=redis://localhost:$REDIS_PORT
EOF

createdb "$DB_NAME" 2&amp;gt;/dev/null || true
echo "Set up: PORT=$PORT DB=$DB_NAME"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 30 lines of bash, no error handling, no cleanup when you tear the worktree down, and it breaks the moment two worktrees race to write to ~/.worktree-ports at the same time.&lt;/p&gt;

&lt;p&gt;I've seen this pattern everywhere. The Phoenix community wrote up their version. The folks at 10play wrote theirs. Everyone reinvents the same wheel.&lt;/p&gt;

&lt;p&gt;What I built instead&lt;br&gt;
I maintain workz — a Rust CLI for Git worktrees that handles zero-config dep syncing (symlinks node_modules, target, .venv automatically) and AI agent launching. Last week I shipped --isolated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workz start feature/auth --isolated

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;creating worktree for branch 'feature/auth'&lt;br&gt;
  worktree created at /home/you/myapp--feature-auth&lt;br&gt;
  symlinked node_modules&lt;br&gt;
  isolated environment:&lt;br&gt;
    PORT=3001                 → .env.local&lt;br&gt;
    DB_NAME=feature_auth&lt;br&gt;
    COMPOSE_PROJECT_NAME=feature_auth&lt;br&gt;
    REDIS_URL=redis://localhost:4001&lt;br&gt;
ready!&lt;br&gt;
That's it. It:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Picks the next available port starting from 3000 (atomic, no race conditions)&lt;br&gt;
Sanitizes the branch name into a safe slug (feature/auth → feature_auth)&lt;br&gt;
Writes .env.local with PORT, DB_NAME, DATABASE_URL, COMPOSE_PROJECT_NAME, REDIS_URL&lt;br&gt;
Registers the allocation in ~/.config/workz/ports.json so no two worktrees ever collide&lt;br&gt;
And when you're done:&lt;/p&gt;

&lt;p&gt;workz done feature/auth --cleanup-db&lt;br&gt;
Releases the port, removes the worktree, optionally drops the database. No orphaned ports. No stale registry entries.&lt;/p&gt;

&lt;p&gt;Running 3 isolated agents in parallel&lt;br&gt;
The reason I care about this: I run multiple Claude Code agents in parallel, each on a different task. Before --isolated, they'd all try to bind to port 3000. First one wins, the other two crash on startup.&lt;/p&gt;

&lt;p&gt;Now:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;workz fleet start \&lt;br&gt;
  --task "add OAuth2 login" \&lt;br&gt;
  --task "write integration tests" \&lt;br&gt;
  --task "refactor database layer" \&lt;br&gt;
  --agent claude \&lt;br&gt;
  --isolated&lt;br&gt;
Three isolated worktrees, three unique ports, three separate databases, three .env.local files — all spun up in parallel. Each agent gets a fully isolated environment without touching the others.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;workz status shows the full picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
  main                /home/you/myapp [clean]         342K  2h ago
  feature/auth        /home/you/myapp--feature-auth   89M   5m ago  PORT:3001
  fix/tests           /home/you/myapp--fix-tests       91M   5m ago  PORT:3002
  refactor/db         /home/you/myapp--refactor-db     88M   5m ago  PORT:3003
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Linux angle&lt;br&gt;
I built this partly because Conductor.build — the GUI for parallel Claude Code agents that's been getting a lot of attention — is Mac-only. Apple Silicon required. Linux support is "hopefully soon-ish, but not sure."&lt;/p&gt;

&lt;p&gt;Every Linux developer who wants to run parallel AI agents has no Conductor. And even on Mac, Conductor's worktree setup is a bash script you write yourself — there's no environment engine built in.&lt;/p&gt;

&lt;p&gt;workz is the answer for that gap:&lt;/p&gt;

&lt;p&gt;Linux, Mac (Windows planned)&lt;br&gt;
Open source — MIT, single Rust binary, cargo install workz&lt;br&gt;
Zero-config — auto-detects Node/Rust/Python/Go/Java projects, symlinks deps automatically&lt;br&gt;
--isolated — the environment engine Conductor doesn't have&lt;br&gt;
It's not trying to be a GUI. It's terminal-native, which is what Linux developers actually want.&lt;/p&gt;

&lt;p&gt;How the port registry works&lt;br&gt;
The implementation is straightforward. A JSON file at ~/.config/workz/ports.json:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;br&gt;
{&lt;br&gt;
  "base_port": 3000,&lt;br&gt;
  "allocations": {&lt;br&gt;
    "feature_auth": {&lt;br&gt;
      "port": 3001,&lt;br&gt;
      "branch": "feature/auth",&lt;br&gt;
      "db_name": "feature_auth",&lt;br&gt;
      "compose_project": "feature_auth",&lt;br&gt;
      "allocated_at": "2026-03-03T20:00:00Z"&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;/code&gt;&lt;br&gt;
On workz start --isolated: scan allocations for the next unused port, write the entry, write .env.local.&lt;br&gt;
On workz done: remove the entry. Atomic reads and writes, no race conditions between concurrent workz start calls.&lt;/p&gt;

&lt;p&gt;The branch slug sanitizer handles edge cases: feature/add-auth → feature_add_auth, collapses repeated separators, lowercases everything.&lt;/p&gt;

&lt;p&gt;Install&lt;/p&gt;

&lt;h1&gt;
  
  
  Homebrew
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;brew tap rohansx/tap&lt;br&gt;
&lt;/code&gt;brew install workz&lt;/p&gt;

&lt;h1&gt;
  
  
  Cargo
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;cargo install workz&lt;br&gt;
&lt;/code&gt;Add shell integration to your .zshrc / .bashrc:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eval "$(workz init zsh)"&lt;br&gt;
&lt;/code&gt;&lt;br&gt;
This gives you a workz shell function that auto-cds into the new worktree after workz start.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/rohansx/workz" rel="noopener noreferrer"&gt;https://github.com/rohansx/workz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're on Mac and already using Conductor — workz works as your setup script. Drop workz sync --isolated into your conductor.json and you get the environment engine without changing your workflow.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devtools</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built workz: The Zoxide for Git Worktrees That Finally Fixes .env + node_modules Hell in 2026</title>
      <dc:creator>Rohan Sharma</dc:creator>
      <pubDate>Fri, 27 Feb 2026 19:34:18 +0000</pubDate>
      <link>https://dev.to/rohansx/i-built-workz-the-zoxide-for-git-worktrees-that-finally-fixes-env-nodemodules-hell-in-2026-2dpj</link>
      <guid>https://dev.to/rohansx/i-built-workz-the-zoxide-for-git-worktrees-that-finally-fixes-env-nodemodules-hell-in-2026-2dpj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5liuco77etnpgfihc7gw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5liuco77etnpgfihc7gw.gif" alt=" " width="787" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been using git worktrees a lot lately — especially for running multiple Claude/Cursor AI agents in parallel without them stepping on each other's toes. The idea is great: fast branch switching, isolated dirs, no stashing mess.&lt;/p&gt;

&lt;p&gt;But the reality sucks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Untracked files like &lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;.npmrc&lt;/code&gt;, secrets, docker overrides get left behind every time.&lt;/li&gt;
&lt;li&gt;Heavy folders (&lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;.venv&lt;/code&gt;, caches, dist…) get duplicated → gigabytes wasted and 5–15 min waits for reinstalls.&lt;/li&gt;
&lt;li&gt;No clean, zero-config tool handles symlinking/copying + fuzzy navigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;workz&lt;/strong&gt; (Zoxide-inspired for worktrees) to fix exactly that.&lt;/p&gt;

&lt;h3&gt;
  
  
  What workz actually does
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Automatically symlinks 22+ heavy dependency dirs (smart detection via &lt;code&gt;package.json&lt;/code&gt; / &lt;code&gt;Cargo.toml&lt;/code&gt; / &lt;code&gt;pyproject.toml&lt;/code&gt; / &lt;code&gt;go.mod&lt;/code&gt; etc.)&lt;/li&gt;
&lt;li&gt;Copies env/config patterns (&lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;.secrets&lt;/code&gt;, &lt;code&gt;.tool-versions&lt;/code&gt;…)&lt;/li&gt;
&lt;li&gt;Fuzzy switcher with skim TUI → just type &lt;code&gt;w&lt;/code&gt; to search and &lt;code&gt;cd&lt;/code&gt; instantly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--ai&lt;/code&gt; flag to launch your AI coding agent directly in the new worktree&lt;/li&gt;
&lt;li&gt;Optional &lt;code&gt;.workz.toml&lt;/code&gt; for custom globs if needed&lt;/li&gt;
&lt;li&gt;Single Rust binary (clap + skim + git2), MIT licensed, cross-platform (macOS/Linux focus)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's fully open source, no tracking/telemetry, and designed to be extensible (Docker/podman hooks coming soon).&lt;/p&gt;

&lt;h3&gt;
  
  
  How it feels in real life (30-second workflow)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
bash
# Create new worktree + launch AI
ws feature/login --ai

# workz prints the cd command for you
cd /Users/rohan/projects/my-app--feature-login

Repo: https://github.com/rohansx/workz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>rust</category>
      <category>cli</category>
      <category>git</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
