<?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: Aalishan</title>
    <description>The latest articles on DEV Community by Aalishan (@aalishan001).</description>
    <link>https://dev.to/aalishan001</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4245241%2F6bf5b378-5007-4065-afcc-324b44d5c0b2.jpg</url>
      <title>DEV Community: Aalishan</title>
      <link>https://dev.to/aalishan001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aalishan001"/>
    <language>en</language>
    <item>
      <title>Long AI Chats Drift. I Built a Harness on Claude Code Instead</title>
      <dc:creator>Aalishan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 02:50:38 +0000</pubDate>
      <link>https://dev.to/aalishan001/long-ai-chats-drift-i-built-a-harness-on-claude-code-instead-398h</link>
      <guid>https://dev.to/aalishan001/long-ai-chats-drift-i-built-a-harness-on-claude-code-instead-398h</guid>
      <description>&lt;p&gt;On the first day of a multi-day piece of work, the assistant knows every rule I gave it. By day three the thread is long, and the rules from that first morning sit somewhere in the middle of it. The middle of a long context is a bad place for anything you need a model to remember.&lt;/p&gt;

&lt;p&gt;So I stopped treating the chat as the memory. I built cockpit, a local-first command system that runs inside Claude Code, and published a generalized copy as &lt;a href="https://github.com/aalishan001/opencockpit" rel="noopener noreferrer"&gt;opencockpit&lt;/a&gt; (MIT license, set up as a template repository). Every claim about how the system works can be checked against that repo; the numbers and settings under "Lessons from daily use" come from my own copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why long threads and naive retrieval fall apart
&lt;/h2&gt;

&lt;p&gt;Two published results match what I kept running into. &lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;Lost in the Middle&lt;/a&gt; (Liu et al., TACL) found that models do best when the relevant information sits at the beginning or end of the input, and that performance degrades significantly when it sits in the middle of a long context, even for models built for long contexts. Chroma's &lt;a href="https://www.trychroma.com/research/context-rot" rel="noopener noreferrer"&gt;Context Rot&lt;/a&gt; report tested 18 models and found performance consistently degrading as input length grows, even on simple tasks.&lt;/p&gt;

&lt;p&gt;A multi-day thread is a long context that keeps growing, and the decisions that matter most are usually the old ones in the middle.&lt;/p&gt;

&lt;p&gt;Naive retrieval doesn't fix this. Similarity search returns what looks like the query, not what is current. A decision and the later decision that replaced it look almost the same to an embedding, so retrieval can hand back the stale one with full confidence.&lt;/p&gt;

&lt;p&gt;There is a quieter failure too: ledger bloat. If every change is appended to a log the model has to read, the log grows with every session, and the model has to work out which of many entries still holds. That is the same long-context problem, rebuilt by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What cockpit does instead
&lt;/h2&gt;

&lt;p&gt;Four habits, all visible in the repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Current state lives in small files, overwritten in place.&lt;/strong&gt; RUNWAY, the domain that ships my website, keeps its state in &lt;code&gt;data/runway/state.json&lt;/code&gt;, a single file the script rewrites whole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;save_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"utf-8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;History lives in git, not in the context window.&lt;/strong&gt; Every command that changes files ends with one commit and a push. When I need to know what changed last week, I read a git log, not an old thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where I keep an append-only log, a script can fold it.&lt;/strong&gt; The ideas backlog is an event log on purpose, because I want every version of an idea. The model doesn't read that raw log; &lt;code&gt;scripts/ideas.py&lt;/code&gt; derives the current state from it. The format, from &lt;code&gt;scripts/schemas.md&lt;/code&gt;:&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="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"idea_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"i-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"set"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"gist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&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="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"idea_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"i-0001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"developing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"conversation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;folder&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"urls"&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="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Skills stay small and load on demand.&lt;/strong&gt; Each domain is one skill file. Claude Code keeps a short description of each skill in context and loads the full instructions only when a command needs them.&lt;/p&gt;

&lt;h2&gt;
  
  
  State contracts: the files the model must obey
&lt;/h2&gt;

&lt;p&gt;There is no single spec file. The contract is spread over a few files, each with one job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt;: the router, the folder contract, the global rules and the git rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;COMMANDS.md&lt;/code&gt;: every command name. A skill change updates it in the same commit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/skills/&amp;lt;domain&amp;gt;/SKILL.md&lt;/code&gt;: each command step by step, including how it ends and what it commits.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scripts/schemas.md&lt;/code&gt;: the format of every data file.&lt;/li&gt;
&lt;li&gt;State files such as &lt;code&gt;data/runway/state.json&lt;/code&gt; and &lt;code&gt;data/fourthwall/state.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The folder contract: &lt;code&gt;inbox/&lt;/code&gt; is the drop zone, &lt;code&gt;raw/&lt;/code&gt; keeps originals and is never deleted, &lt;code&gt;data/&lt;/code&gt; holds structured truth that only skills write, and &lt;code&gt;out/&lt;/code&gt; holds renders a script can always regenerate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I lean on most is the fourth one in &lt;code&gt;CLAUDE.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4. Code does exact work. Arithmetic, dates, sorting, deadlines, hashes, rendering, and validation are done by scripts/. Never compute these in your head. Run the script and report its output.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every command takes the same path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;you: chat, files, phone photos
 -&amp;gt; CLAUDE.md      routes the request, applies the global rules
 -&amp;gt; a skill        the command's steps, from start to its end condition
 -&amp;gt; scripts/       every number, date, hash, render and check
 -&amp;gt; data/          structured records, validated after every edit
 -&amp;gt; git            one commit and push per command
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The model makes the judgment calls. The harness does everything that must be exact.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Domains and boundaries
&lt;/h2&gt;

&lt;p&gt;Every domain except HELP has its own skill, its own folder under &lt;code&gt;data/&lt;/code&gt;, its own section in &lt;code&gt;scripts/schemas.md&lt;/code&gt;, and a script for its exact work. Among them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EXTROVERT&lt;/strong&gt; interviews me about an idea and edits the transcript into posts in my own voice. It also keeps the ideas backlog and versioned profile copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RUNWAY&lt;/strong&gt; ships content and code to my own site repo: a preview branch first, live only after I approve it, then live tests against production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FOURTHWALL&lt;/strong&gt; documents the system itself and exports the public copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CHASSIS&lt;/strong&gt; is an example domain: vehicle maintenance from the owner's manual and service slips, tracked against odometer readings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HELP&lt;/strong&gt; prints the command sheet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The boundaries that matter most are enforced by code, not by the model's good intentions. A Claude Code hook runs the validator after every edit, from &lt;code&gt;.claude/settings.json&lt;/code&gt;:&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="nl"&gt;"PostToolUse"&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="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Edit|Write|MultiEdit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"hooks"&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"python \"$CLAUDE_PROJECT_DIR\"/scripts/validate.py --hook"&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A disclosure lint per audience (internal, resume, public) gates every resume render and every site publish, and a voice lint (banned phrases, emoji, British spellings, em dashes) runs on every post before I see it. FOURTHWALL's export runs five numbered leak checks before a public copy can ship, and any finding blocks it. The last two, from &lt;code&gt;scripts/fourthwall.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"4 deny list"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deny&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"5 em dashes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Posts that make claims about my work can go to a claim checker before they go out: a read-only subagent with only the Read, Grep and Glob tools that labels every claim supported, stretched or unsupported against my own records.&lt;/p&gt;

&lt;p&gt;To be precise about one word: this is not a multi-agent system. &lt;code&gt;CLAUDE.md&lt;/code&gt; says that "agent" here means a division of duties, not a separate process. There is one Claude Code session plus one read-only subagent. There is no telemetry either. The record is git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons from daily use
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Latency comes from the model, not the harness.&lt;/strong&gt; The validator runs after every single edit, and on my own data it takes about 0.4 seconds (365 ms, the median of five runs, Python startup included). I never notice it. What costs time is the model reading, so the cheapest speedup is giving it less to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the context small on purpose.&lt;/strong&gt; Scripts print short summaries and the model reports them. A status command prints a few lines a script computed, not the files behind them. A skill loads only when its command runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deterministic checks beat careful prompting.&lt;/strong&gt; opencockpit ships 66 synthetic tests in one file. By default, every RUNWAY deploy waits for production and then runs the site's live tests against it, and a test that passed only on its retry still counts as passed, but the report names it as flaky. When a rule matters, it becomes a check that fails loudly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The human stays at the gates that can't be undone.&lt;/strong&gt; Nothing reaches my live site until I approve its preview. By default, a public copy of cockpit ships only after I've seen the diff and the leak report and said yes. I've since recorded a standing rule in my own copy (&lt;code&gt;auto_publish&lt;/code&gt;): when an export is clean and its own tests pass, Claude approves and publishes it without asking and tells me afterwards. The publish script still refuses anything but a clean, approved export. And the permission rules deny the plain forms of force push, hard reset and global config change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Create your own private repo from the template, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;your private repo&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; cockpit
&lt;span class="nb"&gt;cd&lt;/span&gt; cockpit
pip install -r requirements.txt
python tests/test_suite.py
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships with empty data and a fictional example user. Copy &lt;code&gt;examples/persona/data/&lt;/code&gt; into &lt;code&gt;data/&lt;/code&gt; and run &lt;code&gt;/cockpit&lt;/code&gt; to try every command before adding your own records. The whole thing is about 4,000 lines of Python, scripts and tests together, small enough to read before you trust it.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/aalishan001/opencockpit" rel="noopener noreferrer"&gt;github.com/aalishan001/opencockpit&lt;/a&gt;. If you build a domain of your own, I'd like to see it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I Took a Memory Leak Arena to Google Ananta. It Got Picked</title>
      <dc:creator>Aalishan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 02:48:49 +0000</pubDate>
      <link>https://dev.to/aalishan001/i-took-a-memory-leak-arena-to-google-ananta-it-got-picked-5d1n</link>
      <guid>https://dev.to/aalishan001/i-took-a-memory-leak-arena-to-google-ananta-it-got-picked-5d1n</guid>
      <description>&lt;p&gt;A pool of developers waiting for registration to pick up the esteemed, albeit temporary, ID tags, the ceiling about as high as the ambitions in the room. Possibly the only place I felt like I belonged, and had yearned for, for so long.&lt;/p&gt;

&lt;p&gt;That was Saturday, 24 October 2026, at Google Ananta in Bengaluru, for FORK. BUILD. MERGE., hosted by GDG Cloud Bengaluru. I brought two open-source tools aimed at one problem: AI agents that share long-term memory, and leak it to each other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m43midumpdcfhyvkykx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1m43midumpdcfhyvkykx.webp" alt="my photo at Google Ananta" width="800" height="1063"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, in one hotel
&lt;/h2&gt;

&lt;p&gt;Picture a hotel where every room key opens every door, and the only thing keeping you out of the next room is a polite sign on it. That is how a lot of agent memory works today. Every user's memories sit in one shared vector table, and the boundary between them is a WHERE clause the application has to remember to write, on every query, in every code path, for as long as the product lives.&lt;/p&gt;

&lt;p&gt;Similarity search doesn't read the sign. It ranks by distance, not by owner, so a perfectly innocent question about your own roadmap can pull up a colleague's private note that happens to sound like it, and no attacker is needed for that to happen. The &lt;a href="https://arxiv.org/abs/2610.04195" rel="noopener noreferrer"&gt;MemLeak paper&lt;/a&gt; measured this kind of leak in multi-tenant agent memory with no attacker involved at all. Add the ordinary bugs on top: a filter someone forgot, a user id read from the prompt instead of the session, a sub-agent reading memos above its clearance.&lt;/p&gt;

&lt;p&gt;My position is that the boundary belongs in the database, not in the app. To be fair, an application filter is simple, and it is right almost every time. Almost is the problem, because the one query that forgets it is the one that leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I brought
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;adk-memshield&lt;/strong&gt; is a drop-in memory service for Google's Agent Development Kit (ADK) that moves the ownership check into PostgreSQL. It uses &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;, a database role that cannot bypass it, and a per-transaction identity: the user from the ADK session, the tenant and clearance from the service's own config. The design goal: a query with a missing or wrong filter returns fewer rows, not someone else's rows. It is on PyPI as &lt;code&gt;adk-memshield&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;adk-leak-arena&lt;/strong&gt; is the red-team harness that tries to break it. It runs two real ADK Runners side by side on the same seeded data, one on a plain pgvector table guarded by application filters and one on adk-memshield, attacks both with five cross-boundary scenarios that go from incidental overlap all the way to a query with no WHERE clause at all, plus one same-tier boundary scenario, and shows the rows each side returned with the PostgreSQL telemetry underneath, so you can watch the boundary hold or fail query by query. Its GitHub repo dates from event day, and it reached PyPI the day after, as &lt;code&gt;adk-leak-arena&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4874i1xo85vwvvn3nlz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4874i1xo85vwvvn3nlz.webp" alt=" the arena console, scenario S5 with no WHERE clause" width="800" height="1024"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say, and how far they go
&lt;/h2&gt;

&lt;p&gt;The arena's benchmark has six scenarios plus a benign control, 54 queries in all. Forty of them are cross-boundary attacks, eight in each of the scenarios S1 to S5, asked as one analyst with clearance 1, top three results per query.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unprotected pgvector:&lt;/strong&gt; 39 of the 40 attack queries returned at least one row the caller is not allowed to read (97.5%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;adk-memshield:&lt;/strong&gt; 0 of 40.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The legitimate answer&lt;/strong&gt;, where the caller had one, made the top three in 19 of 22 queries on the unprotected table and in 22 of 22 with adk-memshield. Leaked rows pushed it out once, and a spoofed owner filter returned only the victim's rows twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rerun with gemma3:4b through Ollama gave the same leak and recall numbers. Good. They are measured on what the search returns, not on what the model says.&lt;/p&gt;

&lt;p&gt;At 2,000 rows, the query plan shows where the work happens. RLS filters 2,000 rows down to the 45 the caller may read before any distance is computed; with fresh statistics, the scope index narrows to the tenant's 978 rows first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkroynhg6b2bo3armplqy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkroynhg6b2bo3armplqy.webp" alt="the benchmark table" width="800" height="812"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the limits, because a security number without its limits is just marketing. The dataset is synthetic and hand-written, 36 rows across three tenants, which means these percentages show you failure modes you can reproduce on your own machine and argue with, not real-world leak rates. One scenario is a known boundary, too. If someone at your own clearance writes a poisoned note into shared memory you are entitled to read, RLS returns it, because the read is authorized. Row-level security decides who may read a row, not whether the row is true, and that part needs write approval or moderation in the application.&lt;/p&gt;

&lt;p&gt;Every number above has its command, commit and raw output in the arena's &lt;a href="https://github.com/aalishan001/adk-leak-arena/blob/main/BENCHMARKS.md" rel="noopener noreferrer"&gt;BENCHMARKS.md&lt;/a&gt;. Both test suites pass on those commits: 8 integration tests for adk-memshield, 45 end-to-end tests for the arena.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that looked like a pass
&lt;/h2&gt;

&lt;p&gt;Two days before the event, an adversarial review I'd set my coding agents to run threw malformed requests at the arena's API. One of them hid a NUL byte inside a spoofed user id, in the scenario where the unprotected side takes the user id straight from a tool argument. It came back HTTP 200, with zero rows.&lt;/p&gt;

&lt;p&gt;Zero rows is a plausible answer for a mangled user id, so on its face nothing looked wrong. Why was it a fake pass, then? Because the telemetry under the pane was empty and the container log held an exception: three reasonable layers had lined up. The database driver, psycopg, refused the parameter before it ever reached PostgreSQL, since text fields cannot hold a NUL byte. ADK's PreloadMemoryTool caught that exception, logged a warning and let the turn continue with no memory. The arena then classified an empty result and counted zero leaked rows. A crash, reported as a security pass.&lt;/p&gt;

&lt;p&gt;The arena now reports that case as a retrieval error instead of a verdict, and rejects NUL bytes at the API before they get near SQL. Upstream, I opened &lt;a href="https://github.com/google/adk-python/pull/7723" rel="noopener noreferrer"&gt;google/adk-python#7723&lt;/a&gt;. It adds a &lt;code&gt;raise_on_error&lt;/code&gt; option and an &lt;code&gt;on_error&lt;/code&gt; callback to PreloadMemoryTool, so an application can fail closed, or at least find out. The defaults keep today's behavior, and the PR is open, waiting for review. One honest gap remains in my own tool: its leak percentages still count an errored query as not leaking, and the CLI and Markdown tables don't show the separate error count (the JSON report and the web console do). Every published run had zero errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The people
&lt;/h2&gt;

&lt;p&gt;Krishna Mehta hosted and led the organizing. Pratik Singh, one of the judges, from GitLab, watched the leak-arena demo with good humor and stories from his own work, and we swapped notes on the week's CVE news.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;At the end, the organizers announced 10 projects, in no particular order, with goodie bags. Mine was one of them: one of 10 projects picked, out of 211 PRs raised that day (by my count).&lt;/p&gt;

&lt;p&gt;adk-leak-arena had zero GitHub stars when I checked this morning, so for now this post is its entire marketing department.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you run agents that share memory, add your own retrieval pattern to the arena as a target and attack it before someone else does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;adk-memshield: &lt;a href="https://github.com/aalishan001/adk-memshield" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://pypi.org/project/adk-memshield/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;adk-leak-arena: &lt;a href="https://github.com/aalishan001/adk-leak-arena" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://pypi.org/project/adk-leak-arena/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The upstream PR: &lt;a href="https://github.com/google/adk-python/pull/7723" rel="noopener noreferrer"&gt;google/adk-python#7723&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The deep-dive on why the gate belongs in PostgreSQL: &lt;a href="https://medium.com/@aalishan001/shared-agent-memory-leaks-enforce-the-boundary-in-postgresql-1695bed0d928" rel="noopener noreferrer"&gt;Shared Agent Memory Leaks. Enforce the Boundary in PostgreSQL&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>security</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Shared Agent Memory Leaks. Enforce the Boundary in PostgreSQL</title>
      <dc:creator>Aalishan</dc:creator>
      <pubDate>Sun, 15 Nov 2026 02:42:09 +0000</pubDate>
      <link>https://dev.to/aalishan001/shared-agent-memory-leaks-enforce-the-boundary-in-postgresql-228n</link>
      <guid>https://dev.to/aalishan001/shared-agent-memory-leaks-enforce-the-boundary-in-postgresql-228n</guid>
      <description>&lt;p&gt;Agents with long-term memory often keep it in one vector store shared by many users, and often by many agents. Ownership is a metadata column, and the boundary is a WHERE clause in application code. This post argues that the boundary belongs in the database instead. Why the database? Because it is the one layer every query has to pass through, including the ones nobody remembers writing. The post shows the PostgreSQL that does it, and the two open-source tools I built around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the research measured
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2610.04195" rel="noopener noreferrer"&gt;MemLeak&lt;/a&gt; (Mudgal et al., October 2026) studies multi-tenant personal agents that share one memory store. Under pooled same-team retrieval, incidental leakage reached 70–100% with no attacker involved. Adversarially crafted memories reached 90–100% top-k placement. Of three mitigations it tested (metadata filtering, ownership-aware embeddings, post-retrieval ownership gating), only the hard ownership gate, which drops every hit not owned by the querying user, consistently restored the clean baseline. It added about 1.4 ms of mean latency per query (12.4 ms to 13.8 ms, measured against a 10,000-memory pgvector HNSW index). The authors flag the scale themselves: 10 queries per condition on synthetic fixtures, so read the rates as a failure mode, not prevalence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2602.11510" rel="noopener noreferrer"&gt;AgentLeak&lt;/a&gt; (El Yagoubi et al.) looks inside multi-agent systems. It defines seven leakage channels, including C1 (final output), C2 (inter-agent messages) and C5 (shared memory), and runs 1,000 scenarios across five production LLMs. In multi-agent mode, final-output leakage was 27.2%, but 68.9% of traces leaked through at least one of C1, C2 or C5: a 41.7-point gap that an audit of final outputs alone would miss. The paper reports that none of the four frameworks it evaluated offers a way to intercept inter-agent messages.&lt;/p&gt;

&lt;p&gt;MemLeak's fix is an ownership gate, and my whole argument is about where that gate should run, because a gate that lives in application code only guards the code paths that remember to call it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat 1: cross-user semantic bleed
&lt;/h2&gt;

&lt;p&gt;Similarity search ranks by distance, not by owner. Three ordinary paths turn that into a leak:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Filters forgotten on secondary endpoints.&lt;/strong&gt; The main search path filters by user. The "related memories" endpoint, the export job or the debug tool written later does not. In &lt;a href="https://github.com/aalishan001/adk-leak-arena" rel="noopener noreferrer"&gt;adk-leak-arena&lt;/a&gt;'s incidental-overlap scenario, where the filter checks the tenant but not the user, leaked rows pushed the caller's own answer out of the top 3 on 1 of the 5 queries that had one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt-injected tool parameters.&lt;/strong&gt; If a memory tool accepts a &lt;code&gt;target_user_id&lt;/code&gt; and the model fills it in, injected text can make it &lt;code&gt;target_user_id='cfo'&lt;/code&gt;. The WHERE clause is correct; the value is the attacker's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planted lures.&lt;/strong&gt; A co-tenant writes private memories shaped like a victim's questions. In the arena's lure scenario (8 queries, top 3, synthetic data), lures that mirror the victim's question, which assumes an attacker who knows what will be asked, took rank 1 on 3 of 8 queries against a filter on tenant and clearance that skips the owner check.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Threat 2: lateral movement between agents
&lt;/h2&gt;

&lt;p&gt;Multi-agent frameworks often share state freely. AgentLeak describes inter-agent messages passed along unfiltered and shared state visible to every agent. OpenAI's Swarm (experimental and educational by its own README, now replaced by the OpenAI Agents SDK) documents that on a handoff "the system prompt will change, but the chat history will not". The next agent inherits the whole conversation, and no access control between agents is documented.&lt;/p&gt;

&lt;p&gt;That makes one compromised low-clearance agent dangerous in two directions. It can &lt;strong&gt;read up&lt;/strong&gt;, pulling a clearance-3 memo out of shared memory. It can also &lt;strong&gt;write poison up&lt;/strong&gt;, planting a memory labeled clearance 3 that higher-clearance agents will trust.&lt;/p&gt;

&lt;p&gt;The classic models cover both. Bell-LaPadula's simple security property is no read up. Its write rule, no write down, protects confidentiality, which is a different goal. Poison written upward is an integrity problem, and the rule for that is Biba's: no write up. A memory table needs both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: make PostgreSQL the gate
&lt;/h2&gt;

&lt;p&gt;Four pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The memory service connects as a &lt;code&gt;NOSUPERUSER NOBYPASSRLS&lt;/code&gt; role that does not own the table.&lt;/li&gt;
&lt;li&gt;The table has &lt;code&gt;ENABLE&lt;/code&gt; and &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;, so even its owner is subject to the policies.&lt;/li&gt;
&lt;li&gt;Identity is bound per transaction with &lt;code&gt;set_config(name, value, true)&lt;/code&gt;. The &lt;code&gt;true&lt;/code&gt; makes the value local to the transaction, so a pooled connection can't carry one user's identity into the next request.&lt;/li&gt;
&lt;li&gt;Policies encode the rules: no read up on &lt;code&gt;SELECT&lt;/code&gt;, no write up on &lt;code&gt;INSERT&lt;/code&gt;. &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; need policies of their own; without one, RLS lets them touch no rows.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="no"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="no"&gt;LEVEL&lt;/span&gt; &lt;span class="no"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="no"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="no"&gt;LEVEL&lt;/span&gt; &lt;span class="no"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Read: your own rows, plus shared rows at or below your clearance (no read up).&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;mem_select&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'tenant_shared'&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;min_clearance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.clearance'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;int&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Write: only as yourself, and shared rows only at or below your clearance (no write up).&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;mem_insert&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'private'&lt;/span&gt;
       &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;min_clearance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nb"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.clearance'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;int&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NULLIF&lt;/code&gt; is there because once a custom setting has been used on a connection, PostgreSQL reads it back as an empty string, not NULL. For the same reason, the identity columns carry &lt;code&gt;CHECK (tenant_id &amp;lt;&amp;gt; '')&lt;/code&gt; and &lt;code&gt;CHECK (user_id &amp;lt;&amp;gt; '')&lt;/code&gt;, so an empty setting can't match an empty owner. This is a minimal standalone demo; &lt;a href="https://github.com/aalishan001/adk-memshield" rel="noopener noreferrer"&gt;adk-memshield&lt;/a&gt; uses the same pattern with its own setting and role names, plus an app_name scope and owner-only &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; policies.&lt;/p&gt;

&lt;p&gt;The test that matters: a vector search with no WHERE clause, run as alice at clearance 1. Without RLS, the top 3 would be alice's note, another tenant's identical row and the CFO's private note; a clearance-3 board memo is just as close.&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="nf"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.clearance'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_clearance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;memories&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;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.9, 0.1, 0.0]'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- result (set_config's own row omitted):&lt;/span&gt;
 &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;     &lt;span class="n"&gt;scope&lt;/span&gt;     &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;min_clearance&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;          &lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="c1"&gt;---------+---------------+---------------+---------------------------&lt;/span&gt;
 &lt;span class="n"&gt;alice&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;       &lt;span class="o"&gt;|&lt;/span&gt;             &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;my&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;1&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
 &lt;span class="n"&gt;alice&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;       &lt;span class="o"&gt;|&lt;/span&gt;             &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;
 &lt;span class="n"&gt;hr&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;tenant_shared&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;             &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;hr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;holiday&lt;/span&gt; &lt;span class="n"&gt;calendar&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the injected argument (&lt;code&gt;WHERE user_id = 'cfo'&lt;/code&gt;), alice gets zero rows. With no identity bound, the query sees zero rows. An attempt to write a shared clearance-3 memory as alice, or a row owned by the CFO, fails with &lt;code&gt;new row violates row-level security policy for table "memories"&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A query with a missing or wrong filter should return fewer rows, never someone else's rows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is MemLeak's ownership gate, plus a clearance-labeled shared scope, moved out of application code and into the database. Anything that connects as the app role gets the same gate, including the forgotten endpoint and next year's debug script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the trust boundary really is
&lt;/h2&gt;

&lt;p&gt;All of this holds only if trusted middleware sets the identity from the authenticated session, never from a tool argument, and the agent never gets raw SQL. &lt;code&gt;agent_app&lt;/code&gt; can call &lt;code&gt;set_config&lt;/code&gt; itself. In the same run, a transaction that claimed to be the CFO at clearance 3 read the CFO's rows without complaint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated session (user, tenant, clearance)
        |
        v
trusted middleware      binds identity with set_config(..., true), per transaction
        |
        v
memory service          role agent_app: NOSUPERUSER NOBYPASSRLS, not the table owner
        |               (agent tools pass parameters, never SQL)
        v
PostgreSQL              FORCE ROW LEVEL SECURITY decides every row
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Know the bypasses too: superusers and roles with &lt;code&gt;BYPASSRLS&lt;/code&gt;; the table owner, unless the table has &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;; &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions, which run with their owner's rights; and views without &lt;code&gt;security_invoker&lt;/code&gt;, which also run with their owner's rights. In the demo, a view created by a superuser showed alice all 6 rows. The same view with &lt;code&gt;security_invoker = true&lt;/code&gt; showed her 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Planner and performance
&lt;/h2&gt;

&lt;p&gt;From adk-leak-arena's published runs, with the scale stated. Every command, commit and raw output is in its &lt;a href="https://github.com/aalishan001/adk-leak-arena/blob/main/BENCHMARKS.md" rel="noopener noreferrer"&gt;BENCHMARKS.md&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;36 rows, 40 cross-boundary attack queries, top 3:&lt;/strong&gt; the unprotected table returned at least one row the caller may not read on 39 of 40 queries; adk-memshield on 0 of 40. Latency was measured on Docker Desktop and is noisy, so no latency number from these runs is a claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,000 rows per table:&lt;/strong&gt; RLS filters 2,000 rows down to the 45 the caller may read before any distance is computed; with fresh statistics, the scope index narrows to the tenant's 978 rows first. Straight after the arena's bulk load, before &lt;code&gt;ANALYZE&lt;/code&gt;, the same search is a sequential scan with the RLS filter, and it still computes distances for those 45 rows only. The unprotected search computes them for all 2,000 either way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With no vector index, the policy predicate is just another filter, applied before the sort that computes distances, and with fresh statistics the planner can serve the tenant part of it from a B-tree index.&lt;/p&gt;

&lt;p&gt;At large scale with an approximate index, this changes, and I haven't benchmarked it yet, so no numbers here. The &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector README&lt;/a&gt; says that with approximate indexes, filtering is applied after the index scan, and an HNSW scan returns at most &lt;code&gt;hnsw.ef_search&lt;/code&gt; candidates (40 by default). RLS is such a filter, so a query can return fewer than k rows. The answers are pgvector's iterative index scans (0.8.0 and later, &lt;code&gt;SET hnsw.iterative_scan = strict_order&lt;/code&gt; or &lt;code&gt;relaxed_order&lt;/code&gt;, bounded by &lt;code&gt;hnsw.max_scan_tuples&lt;/code&gt;), or partitioning by tenant, so each partition's index holds only that tenant's rows and the post-index filter discards far fewer. Either way, measure recall at your own scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;p&gt;RLS stops cross-boundary reads and writes. It does nothing about &lt;strong&gt;same-tier poisoning&lt;/strong&gt;: a clearance-1 writer can still plant a lure in a clearance-1 shared scope, and the demo accepts that insert. adk-leak-arena reports this as a boundary scenario of its own, outside the attack numbers. It needs content-level defenses, such as provenance on shared memories and review or quarantine before a shared write is trusted.&lt;/p&gt;

&lt;p&gt;It also can't see &lt;strong&gt;memory-preload tools that swallow exceptions&lt;/strong&gt;. Google ADK's preload memory tool catches any exception from the memory search, logs a warning and lets the turn continue without memory. Nothing leaks, but a broken identity binding looks like an agent with no memory instead of an error. I learned this the embarrassing way, when my own harness reported a crash as a clean security pass. I've since opened &lt;a href="https://github.com/google/adk-python/pull/7723" rel="noopener noreferrer"&gt;google/adk-python#7723&lt;/a&gt;, which would let an application fail closed (&lt;code&gt;raise_on_error&lt;/code&gt;) or get a callback (&lt;code&gt;on_error&lt;/code&gt;); the defaults keep today's behavior, and the PR is open and waiting for review. Until then, alert on that warning, but know that a missing identity returns zero rows with no error, so it logs nothing. Test the binding itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ecosystem needs
&lt;/h2&gt;

&lt;p&gt;The failure modes are now measured. What the ecosystem needs is reproducible red-team harnesses and memory backends where the database enforces ownership, for frameworks like Google ADK and for local Gemma deployments. I've open-sourced one of each: &lt;a href="https://github.com/aalishan001/adk-memshield" rel="noopener noreferrer"&gt;adk-memshield&lt;/a&gt;, a drop-in ADK memory service built on the same pattern as the policies above, and &lt;a href="https://github.com/aalishan001/adk-leak-arena" rel="noopener noreferrer"&gt;adk-leak-arena&lt;/a&gt;, which attacks a plain pgvector table and adk-memshield side by side in real ADK Runners. A rerun with gemma3:4b through Ollama gave the same leak and recall numbers as the scripted agent. Both are on PyPI, and numbers at approximate-index scale come next.``&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>postgres</category>
      <category>database</category>
    </item>
  </channel>
</rss>
