<?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: Agave Information Soultions, LLC</title>
    <description>The latest articles on DEV Community by Agave Information Soultions, LLC (@agave_info_solutions).</description>
    <link>https://dev.to/agave_info_solutions</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%2F3965683%2F98aaa7fd-e15b-4d78-a06d-74459ecd4390.jpg</url>
      <title>DEV Community: Agave Information Soultions, LLC</title>
      <link>https://dev.to/agave_info_solutions</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agave_info_solutions"/>
    <language>en</language>
    <item>
      <title>We Tested 300 Phoenix and Scottsdale Small-Business Websites. Most Fail on a Phone.</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:49:43 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/we-tested-300-phoenix-and-scottsdale-small-business-websites-most-fail-on-a-phone-18o8</link>
      <guid>https://dev.to/agave_info_solutions/we-tested-300-phoenix-and-scottsdale-small-business-websites-most-fail-on-a-phone-18o8</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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdt0pcsixcn159cmhroqr.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdt0pcsixcn159cmhroqr.jpg" alt="14 of 297 Phoenix and Scottsdale small-business websites scored good on Google's mobile speed test" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your website is usually the first thing a customer sees, and they see it on a phone. So we measured how local business websites hold up there.&lt;/p&gt;

&lt;p&gt;On October 2, 2026 we took 300 independent small businesses from six chamber of commerce member directories, 150 in Scottsdale and 150 across Phoenix, Mesa, Tempe, Gilbert and Chandler, and ran each homepage through Google's PageSpeed Insights mobile test. We also read each homepage for the basics a local customer and Google both look for. No chains or franchises, and no business is named here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Headline: 14 of 297 Scored Good
&lt;/h2&gt;

&lt;p&gt;Google's test scores a page from 0 to 100 on a phone. 90 and up is good, 50 to 89 needs improvement, and under 50 is poor. Of the 297 sites the test could score:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;14 scored good (5%).&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;177 needed improvement (60%).&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;106 scored poor (36%).&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The median score was 56.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scottsdale and the Phoenix side came out the same.&lt;/strong&gt; Both had a median of 56, and both had exactly 53 sites in the poor band. A Scottsdale address does not buy a faster website.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wait Is the Problem
&lt;/h2&gt;

&lt;p&gt;The number that matters most to a customer is how long until the main content appears. Google calls it Largest Contentful Paint and wants it under 2.5 seconds.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;287 of 297 sites (97%) took longer than 2.5 seconds&lt;/strong&gt; in Google's phone test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;261 (88%) took longer than 4 seconds&lt;/strong&gt;, the point Google calls poor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The median was 9.7 seconds. 141 sites (47%) took longer than 10.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google's test simulates a mid-range phone on a slower mobile connection, so it is harder than a new phone on good signal. That is on purpose: it is what a customer sees in a parking lot with two bars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real visitors tell a similar story where Google has data.&lt;/strong&gt; Google collects real-visitor speed only for sites with enough traffic. Only 92 of the 300 (31%) had any. Of the 35 homepages with complete real-visitor data of their own, 21 passed Google's Core Web Vitals and 14 did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Basics Are Missing, Too
&lt;/h2&gt;

&lt;p&gt;Speed is not the only gap. On the homepage alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;179 of 300 (60%) had no LocalBusiness structured data&lt;/strong&gt;, the code that tells Google and AI assistants the business name, address, phone and hours in a form they read directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;117 (39%) had no tap-to-call link.&lt;/strong&gt; A customer on a phone has to copy the number by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;70 (23%) had no meta description&lt;/strong&gt;, so Google writes the search snippet itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;22 (7%) had no mobile viewport tag&lt;/strong&gt;, so the page renders as a shrunken desktop page on a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15 (5%) did not use HTTPS&lt;/strong&gt;, and browsers mark them "Not secure".&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;29 of the 194 sites that show a copyright year show 2024 or earlier.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which Platforms We Found
&lt;/h2&gt;

&lt;p&gt;The page code names the platform on most sites. WordPress ran 143 (48%), Wix 21, Squarespace 19, GoDaddy's builder 12, Shopify 8 and Duda 5; 81 could not be identified. Median phone scores by platform: Duda 64 (5 sites), Wix 63, WordPress 56, GoDaddy 45.5, Squarespace 42, Shopify 39.5. The smaller groups are small, so read those as a direction, not a ranking. What decides the score is how a site is built and what it loads, more than which builder it sits on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Fix First
&lt;/h2&gt;

&lt;p&gt;If you own one of these sites, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the test yourself.&lt;/strong&gt; Google's PageSpeed Insights is free. Use the Mobile tab, and run it twice, because single runs vary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink what loads first.&lt;/strong&gt; On most slow sites the biggest cost is a large hero image or video, plus scripts for sliders, chat widgets and trackers. Compress the image, size it for a phone and load the rest later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add tap-to-call.&lt;/strong&gt; A phone link is one line of code and the most useful button a local site has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add LocalBusiness structured data&lt;/strong&gt; with your name, address, phone, hours and service area, matching your Google Business Profile exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a meta description&lt;/strong&gt; for the homepage and each service page, in the words a customer would search.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you would rather have someone look, our &lt;a href="https://agaveis.com/website-check" rel="noopener noreferrer"&gt;free website check&lt;/a&gt; reviews your site's speed and local search basics and tells you what to fix first. We build &lt;a href="https://agaveis.com/webdesign" rel="noopener noreferrer"&gt;fast local websites&lt;/a&gt; for businesses in &lt;a href="https://agaveis.com/phoenix-web-design" rel="noopener noreferrer"&gt;Phoenix&lt;/a&gt; and &lt;a href="https://agaveis.com/scottsdale-web-design" rel="noopener noreferrer"&gt;Scottsdale&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Did It
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample:&lt;/strong&gt; 300 independent small businesses with their own website, taken from the member directories of the Scottsdale, Mesa, Greater Phoenix, Tempe, Chandler and Gilbert chambers of commerce: 150 in Scottsdale and 150 on the Phoenix side (Mesa 58, Phoenix 47, Tempe 32, Gilbert 7, Chandler 6). Trades as each directory lists them, led by health care, restaurants, retail, real estate, personal services, legal, contractors and professional services. Chains and franchises, businesses with no website or only a social or directory page, and sites that failed to load twice were left out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; Google PageSpeed Insights API, mobile strategy, performance category, one run per homepage, on 2026-10-02 between 12:29 and 13:20 UTC. Three sites returned no score and are left out of the speed figures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-visitor data:&lt;/strong&gt; Google's Chrome UX Report as returned by the same API, counted only at the page's own level (not site-wide), and never filled in from the lab test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Basics:&lt;/strong&gt; one plain fetch of each homepage, reading the HTML as served. A feature added later by scripts, or present only on an inner page, counts as absent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; the generator tag or the platform's own asset paths only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Agave Information Solutions builds &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom software&lt;/a&gt;, &lt;a href="https://agaveis.com/webdesign" rel="noopener noreferrer"&gt;local-first websites&lt;/a&gt;, &lt;a href="https://agaveis.com/seo" rel="noopener noreferrer"&gt;SEO&lt;/a&gt;, and &lt;a href="https://agaveis.com/ai-search" rel="noopener noreferrer"&gt;private AI search solutions&lt;/a&gt; for organizations that want to be found. Based in Scottsdale, serving all of Arizona.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>When to Rewrite .NET in Rust (and When Not To)</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sun, 27 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/when-to-rewrite-net-in-rust-and-when-not-to-42f7</link>
      <guid>https://dev.to/agave_info_solutions/when-to-rewrite-net-in-rust-and-when-not-to-42f7</guid>
      <description>&lt;p&gt;Rewriting a working application in a new language is one of the most expensive decisions an engineering team can make, and one of the most frequently made for the wrong reasons. We modernize .NET systems for a living, so when the question came up for a production &lt;a href="https://agaveis.com/blog/lto-tape-storage-cheapest-data-retention" rel="noopener noreferrer"&gt;LTO tape archive application&lt;/a&gt; written in .NET 9, we did the unglamorous thing and actually ran the experiment. We ported the core of it to Rust: a four-crate workspace covering the catalog, the tape read and write engine, a command-line tool, and a desktop shell.&lt;/p&gt;

&lt;p&gt;The interesting finding was not "Rust is faster." It is that &lt;strong&gt;the rewrite question is almost never about which language is better.&lt;/strong&gt; Here is the honest scorecard from doing it for real: where Rust earned its keep, where it hurt, and the four questions that actually decide whether you should follow us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Rust Earned Its Keep
&lt;/h2&gt;

&lt;p&gt;Several of the wins were immediate and structural, not matters of taste.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline that was hardest in C# became the easiest in Rust.&lt;/strong&gt; Our archive writer streams a file into a TAR container while computing a SHA-256 checksum in the same pass. In .NET that means nesting a TarOutputStream around a CryptoStream around a FileStream, and the disposal order matters: get it wrong and you get truncated writes on the error path. In Rust the same thing is a small generic wrapper, a "hashing writer" built on the standard Write trait, and the language's cleanup guarantees mean the flush happens correctly even when something fails halfway through. The Rust version was shorter than the C# version and harder to misuse. That is the good kind of win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading our legacy data took zero glue code.&lt;/strong&gt; The old catalog ships as JSON with PascalCase keys and a scattering of missing and nullable fields. One attribute on the Rust type handled the casing for every field at once, and a one-line default handled the missing keys. The C# equivalent is a per-field annotation marathon. We imported years of legacy archives without writing a single line of mapping code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency management is simply better.&lt;/strong&gt; A Cargo workspace pins every crate version in one place, and each member project opts in with a single line. After living with NuGet's per-project package references plus a central props file to keep them aligned, this felt like an obvious upgrade. Adding a whole new crate to the workspace was four lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whole categories of bug became visible at compile time.&lt;/strong&gt; An unknown status code coming out of the database is a case the compiler forces you to handle, instead of silently mapping to zero the way a C# enum does. Error handling that would have been try/catch noise collapsed into clean, linear code. None of this is exotic Rust. It is the boring, everyday safety that adds up over a codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it is less code.&lt;/strong&gt; The Rust port reproduces the same functionality in roughly a third to a half of the lines, partly because Rust collapses the plain-object, data-transfer-object, and validator triad that a .NET codebase spreads across three files into one struct with a couple of derived traits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Costs Nobody Puts in the Estimate
&lt;/h2&gt;

&lt;p&gt;None of that makes the rewrite free. The costs that sink these projects are rarely the ones in the proposal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no "just install the SDK."&lt;/strong&gt; .NET's single-installer setup is an underrated superpower. Standing up the Rust toolchain on a locked-down Windows machine cost us 40 minutes fighting antivirus file-locks, a manual toolchain extraction, and hunting down a C linker the standalone download did not bundle. On one developer's laptop that is a one-time annoyance. Across a team and a continuous-integration pipeline, it is real recurring friction you have to own with a documented bootstrap script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some constraints are structural, not stylistic.&lt;/strong&gt; Our SQLite handle is safe to move between threads but not to share across them, so the moment it sat behind a UI boundary we had to wrap it in a lock. That is the correct design for a single-writer catalog, but it is the kind of thing you only learn by hitting it, and a team new to the language will hit a dozen such walls before the patterns become second nature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The biggest cost is not the language at all.&lt;/strong&gt; The engine port went quickly. What does not port quickly is the years of productized ergonomics wrapped around it: the desktop chrome, the settings and credential storage, the installer, the code signing, the auto-update channel, the offline runtime bootstrapping for customers who are not online. Our desktop shell compiled and opened a real window in 25 minutes. Getting from "a window opens" to "a non-technical customer can install, trust, and update this" is the multi-week mountain. The language was never the hard part. The product around the language was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So we sized it honestly:&lt;/strong&gt; roughly 9 to 14 weeks for one competent-but-not-expert engineer to reach feature parity with the .NET app. A Rust veteran who has shipped a desktop application before would do it in 6 to 8. Either way, that is real time you are spending instead of shipping features your customers asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Questions That Actually Decide It
&lt;/h2&gt;

&lt;p&gt;Notice that nothing above is "which language is better." A rewrite is a business decision wearing an engineering costume. Before you start one, answer these in order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What specific problem does the current codebase have?&lt;/strong&gt; "I want to learn Rust" is a fine reason to start a side project and a terrible reason to rewrite a product. If you cannot name the concrete pain (a class of bug, a performance floor you keep hitting, a deployment story you cannot stand behind), stop here. You are about to spend three months solving a problem you have not defined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the rewrite cheaper than the friction it removes?&lt;/strong&gt; Our number was 9 to 14 weeks. If the friction you are escaping is structural and would otherwise persist for a decade, that trade can pencil out. If it is accidental complexity that one focused refactor in C# would fix, it does not. Most of the time, it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How strong is your team in the new language?&lt;/strong&gt; One expert paired with someone learning produces good Rust. Three beginners produce code that is worse than the equivalent .NET team would have written, because the rate of subtle mistakes per week is higher while everyone is still climbing the curve. Be honest about which team you actually have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does the customer notice?&lt;/strong&gt; A rewrite done under a feature freeze looks, from the outside, like the product was broken for six months and then magically became the same. That is the worst possible experience for someone paying you. If you rewrite, ship the new version before you retire the old one. Never freeze your customers in place while you rebuild.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  So When Should You Actually Do It?
&lt;/h2&gt;

&lt;p&gt;Rust earned the bet for us on this one product because the answers lined up. The friction was structural: a single self-contained binary and compile-time memory safety genuinely matter when you are &lt;a href="https://agaveis.com/data-archiving" rel="noopener noreferrer"&gt;writing irreplaceable archives to an 18-terabyte tape&lt;/a&gt; that no one will read back for a decade. The domain let the type system delete whole classes of bug. And we had the Rust depth to carry it. When those things are true, Rust is not hype. It is leverage that compounds for years.&lt;/p&gt;

&lt;p&gt;But for most of the .NET systems we are brought in to assess, the answers do not line up. The pain is accidental complexity a refactor away. The team is all-.NET. The application is interface-heavy, where the chrome is the bulk of the value and the language underneath it is almost incidental. In those cases the right move is not a rewrite at all. It is &lt;a href="https://agaveis.com/dotnet-modernization" rel="noopener noreferrer"&gt;modernizing the .NET you already have&lt;/a&gt;: the current runtime, ahead-of-time compilation, trimming, and a cleanup of the actual hotspots. That gets you most of the benefit for a fraction of the cost and none of the customer-facing freeze.&lt;/p&gt;

&lt;p&gt;The language comparison is the easy, fun part of this conversation, which is exactly why it gets all the attention. The decision underneath it is harder and more boring: what does your codebase actually cost you today, and is twelve weeks the cheapest way to fix it? Answer that first. The language almost always sorts itself out once you have.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/when-to-rewrite-dotnet-in-rust" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>rust</category>
      <category>csharp</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Your Company Does Not Need Another AI Chatbot. It Needs a Knowledge Layer.</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 14:46:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/your-company-does-not-need-another-ai-chatbot-it-needs-a-knowledge-layer-9f3</link>
      <guid>https://dev.to/agave_info_solutions/your-company-does-not-need-another-ai-chatbot-it-needs-a-knowledge-layer-9f3</guid>
      <description>&lt;p&gt;Every company adopting AI eventually runs into the same problem.&lt;/p&gt;

&lt;p&gt;An employee opens ChatGPT, Claude, or another AI assistant and asks a question about the business:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do we deploy this application?&lt;/li&gt;
&lt;li&gt;What did we decide about the payment provider?&lt;/li&gt;
&lt;li&gt;Where is the current policy?&lt;/li&gt;
&lt;li&gt;Has this production error happened before?&lt;/li&gt;
&lt;li&gt;Which database table owns this field?&lt;/li&gt;
&lt;li&gt;What process should the support team follow?&lt;/li&gt;
&lt;li&gt;Why was this architecture chosen?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model usually does not know.&lt;/p&gt;

&lt;p&gt;The employee then copies documents, source code, email threads, meeting notes, or internal wiki pages into the conversation. Another employee does the same thing the next day. A developer starts every coding session by explaining the application architecture again. Important lessons remain buried in old chat sessions that nobody else can access.&lt;/p&gt;

&lt;p&gt;The company may be paying for powerful AI models, but every session starts with organizational amnesia.&lt;/p&gt;

&lt;p&gt;That is not primarily a chatbot problem.&lt;/p&gt;

&lt;p&gt;It is a knowledge architecture problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem Is Not Missing Information
&lt;/h2&gt;

&lt;p&gt;Most established companies already have plenty of information.&lt;/p&gt;

&lt;p&gt;They have too much of it.&lt;/p&gt;

&lt;p&gt;Knowledge is scattered across SharePoint, Google Drive, Git repositories, Confluence, Teams or Slack, ticketing systems, email, network drives, databases, employee laptops, Markdown files, old PDF manuals, meeting notes, chat histories, and the memory of the one employee who has been there for 17 years.&lt;/p&gt;

&lt;p&gt;The difficult question is not:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Can an AI model read documents?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It can.&lt;/p&gt;

&lt;p&gt;The difficult questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which document is current?&lt;/li&gt;
&lt;li&gt;Which document is approved?&lt;/li&gt;
&lt;li&gt;Which version was superseded?&lt;/li&gt;
&lt;li&gt;Which employees are allowed to see it?&lt;/li&gt;
&lt;li&gt;What information should never leave the company?&lt;/li&gt;
&lt;li&gt;How can the answer cite its sources?&lt;/li&gt;
&lt;li&gt;How do we know the retrieved information is correct?&lt;/li&gt;
&lt;li&gt;What happens when a document is deleted or its permissions change?&lt;/li&gt;
&lt;li&gt;Can the company change AI providers without rebuilding everything?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Uploading folders into a chatbot does not solve those questions.&lt;/p&gt;

&lt;p&gt;It usually hides them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why We Are Building This at Agave Information Solutions
&lt;/h2&gt;

&lt;p&gt;We began with a smaller version of the problem inside a busy development workflow.&lt;/p&gt;

&lt;p&gt;Over time, we built an Obsidian knowledge vault containing project documentation, deployment procedures, architecture decisions, production incidents, database notes, infrastructure details, troubleshooting history, runbooks, lessons learned, and agent instructions.&lt;/p&gt;

&lt;p&gt;The vault follows a pattern similar to Andrej Karpathy's idea of an LLM-maintained wiki: durable knowledge should live between raw source material and individual AI conversations.&lt;/p&gt;

&lt;p&gt;That model works.&lt;/p&gt;

&lt;p&gt;Instead of explaining an application from scratch every time we open Codex or Claude Code, an agent can start with the current project state, accepted architecture decisions, and known deployment traps.&lt;/p&gt;

&lt;p&gt;But the vault grew.&lt;/p&gt;

&lt;p&gt;It passed 1,000 Markdown files. Historical logs, archived plans, active runbooks, and project notes began competing for attention.&lt;/p&gt;

&lt;p&gt;The problem was no longer whether the information existed. The problem was whether an agent could reliably answer:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Which five documents should I trust for this task?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That question led us beyond a personal Obsidian vault and toward a broader architecture that could work for other companies.&lt;/p&gt;

&lt;p&gt;We are now building a governed organizational knowledge platform using PostgreSQL, pgvector, full-text search, an ASP.NET Core API, and the Model Context Protocol.&lt;/p&gt;

&lt;p&gt;Our vault is tenant zero.&lt;/p&gt;

&lt;p&gt;The long-term goal is a reusable system that connects approved company knowledge to the AI tools employees already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  A "Shared Brain" Is the Wrong Mental Model
&lt;/h2&gt;

&lt;p&gt;People often describe this kind of system as a company-wide shared brain.&lt;/p&gt;

&lt;p&gt;The phrase is catchy, but technically dangerous.&lt;/p&gt;

&lt;p&gt;A company does not need a machine pretending to be its brain. It needs a controlled knowledge service that can answer three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What information is relevant?&lt;/li&gt;
&lt;li&gt;Is the employee allowed to access it?&lt;/li&gt;
&lt;li&gt;What source supports the answer?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The AI model should not become the source of truth.&lt;/p&gt;

&lt;p&gt;The model is a consumer.&lt;/p&gt;

&lt;p&gt;The source systems remain authoritative: the current runbook, the accepted architecture decision, the approved policy, the live application code, the production database, and the document with the correct access controls.&lt;/p&gt;

&lt;p&gt;The knowledge platform sits between those sources and the AI assistant.&lt;/p&gt;

&lt;p&gt;Its job is to retrieve the right evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The system we are building follows this flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Company knowledge sources
        ↓
Ingestion and normalization
        ↓
Metadata, ownership, permissions, and versioning
        ↓
Structural document chunking
        ↓
Embeddings and full-text indexing
        ↓
PostgreSQL + pgvector
        ↓
Permission-aware hybrid retrieval
        ↓
REST API and MCP tools
        ↓
Codex, Claude, ChatGPT, internal portals, and agents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL becomes the retrieval and governance layer.&lt;/p&gt;

&lt;p&gt;It does not replace the original documents.&lt;/p&gt;

&lt;p&gt;If the source is an Obsidian vault, employees continue editing Markdown. If the source is SharePoint, SharePoint remains authoritative. If the source is GitHub, the repository remains authoritative.&lt;/p&gt;

&lt;p&gt;The database stores a normalized and searchable representation that can be rebuilt.&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;A generated search index can be deleted and recreated. A company knowledge system that silently becomes the only copy of the truth is another future migration project wearing an AI hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PostgreSQL and pgvector
&lt;/h2&gt;

&lt;p&gt;There are many specialized vector databases available.&lt;/p&gt;

&lt;p&gt;We chose PostgreSQL because company knowledge is not just a pile of vectors.&lt;/p&gt;

&lt;p&gt;A production system also needs tenants, users, groups, document ownership, access-control lists, source identifiers, version history, lifecycle status, effective dates, retention rules, retrieval logs, user feedback, and evaluation results.&lt;/p&gt;

&lt;p&gt;That information is relational.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://agaveis.com/database-architecture" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; can store the organizational metadata, perform native full-text search, and use pgvector for vector similarity in the same system. The pgvector project specifically supports combining vector retrieval with PostgreSQL full-text search, including fusion methods such as Reciprocal Rank Fusion.&lt;/p&gt;

&lt;p&gt;For our first implementation, local PostgreSQL is intentional. The entire retrieval stack runs in a single Docker container on a developer machine: PostgreSQL 17 with pgvector, costing nothing and touching no production infrastructure. That is not a compromise. It is the fastest honest way to answer the only question that matters at this stage: does retrieval return the right evidence?&lt;/p&gt;

&lt;p&gt;The same local-first rule applies to embeddings. The first slice generates them with a small open-source model running in-process, which means indexing sends nothing to any external API. Providers sit behind an interface, and the store records which model produced each vector, so upgrading to a commercial embedding model later is a deliberate re-index, not a silent drift.&lt;/p&gt;

&lt;p&gt;Starting local does not trap the system on a laptop. Because the store is rebuildable from the sources by design, promoting it to managed infrastructure later is a re-ingest, not a migration. Amazon RDS for PostgreSQL ships pgvector on every currently offered PostgreSQL major version (as of mid-2026, all of them carry pgvector 0.8 or newer), and enabling it is a single &lt;code&gt;CREATE EXTENSION vector;&lt;/code&gt;. The schema that proves retrieval quality on one machine is the same schema that will eventually serve multiple employees, multiple applications, and multiple AI providers from managed AWS infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Not Use Vector Search Alone
&lt;/h2&gt;

&lt;p&gt;The typical RAG tutorial works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split documents into chunks.&lt;/li&gt;
&lt;li&gt;Convert each chunk into an embedding.&lt;/li&gt;
&lt;li&gt;Store the embeddings.&lt;/li&gt;
&lt;li&gt;Convert the user's question into an embedding.&lt;/li&gt;
&lt;li&gt;Return the nearest chunks.&lt;/li&gt;
&lt;li&gt;Ask an LLM to write an answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a useful demonstration.&lt;/p&gt;

&lt;p&gt;It is not enough for company knowledge.&lt;/p&gt;

&lt;p&gt;Semantic similarity can retrieve a document that sounds right while being completely wrong operationally.&lt;/p&gt;

&lt;p&gt;Imagine that the database contains the current production deployment runbook, a deployment plan abandoned two years ago, a postmortem describing a temporary emergency fix, a draft migration strategy, and an archived version of the old application.&lt;/p&gt;

&lt;p&gt;All five documents may be semantically similar to the question:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How do we deploy the application?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The nearest vector is not necessarily the correct answer.&lt;/p&gt;

&lt;p&gt;This is why the retrieval process must begin with metadata and authority.&lt;/p&gt;

&lt;p&gt;Before semantic search runs, the system should filter by tenant, user permissions, project, document type, lifecycle status, authority, classification, effective date, and archive status.&lt;/p&gt;

&lt;p&gt;Only then should it compare embeddings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authority Must Be Explicit
&lt;/h2&gt;

&lt;p&gt;Every company already has an authority hierarchy, whether it documents one or not.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Verified production state
    ↓
Approved policy or current runbook
    ↓
Accepted architecture decision
    ↓
Active project documentation
    ↓
Reference material
    ↓
Historical logs
    ↓
Archived or superseded documents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retrieval system must understand those distinctions.&lt;/p&gt;

&lt;p&gt;We model lifecycle metadata such as &lt;code&gt;canonical&lt;/code&gt;, &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;reference&lt;/code&gt;, &lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;historical&lt;/code&gt;, &lt;code&gt;superseded&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;, and &lt;code&gt;expired&lt;/code&gt;. The first slice enforces the core buckets today, and the finer authority tiers arrive with the hardening phase.&lt;/p&gt;

&lt;p&gt;A canonical runbook receives a strong ranking advantage.&lt;/p&gt;

&lt;p&gt;A superseded document is excluded.&lt;/p&gt;

&lt;p&gt;An archived incident may be available when the user explicitly asks for history, but it should not appear as current guidance.&lt;/p&gt;

&lt;p&gt;Age alone is not enough.&lt;/p&gt;

&lt;p&gt;A five-year-old architecture decision may still be valid. A note written yesterday may still be an unreviewed guess.&lt;/p&gt;

&lt;p&gt;The system must model authority, not just freshness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions Must Be Applied Before Retrieval
&lt;/h2&gt;

&lt;p&gt;This is the point where a personal RAG system becomes an enterprise system.&lt;/p&gt;

&lt;p&gt;Suppose an employee asks:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What did leadership decide about the acquisition?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The database may contain relevant documents, but the employee may not be authorized to see them.&lt;/p&gt;

&lt;p&gt;The wrong architecture performs vector search across everything and removes unauthorized results afterward.&lt;/p&gt;

&lt;p&gt;That is dangerous.&lt;/p&gt;

&lt;p&gt;Titles, snippets, similarity scores, or generated summaries may already reveal sensitive information.&lt;/p&gt;

&lt;p&gt;The correct sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Authenticate the user
    ↓
Resolve company and group membership
    ↓
Determine the permitted document set
    ↓
Apply classification and lifecycle rules
    ↓
Run lexical and vector retrieval
    ↓
Return permitted evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL provides Row-Level Security policies that can restrict which rows are visible to particular database roles or session contexts. Once enabled, policies can apply expressions that determine whether a row is visible or writable.&lt;/p&gt;

&lt;p&gt;Application-level permission checks are still necessary, but database-level enforcement provides defense in depth.&lt;/p&gt;

&lt;p&gt;For a multi-company platform, every relevant table should carry a tenant identifier from the beginning.&lt;/p&gt;

&lt;p&gt;Adding tenancy after the data model is complete is how architecture teams accidentally invent a six-month rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid Search Works Better Than Semantic Search Alone
&lt;/h2&gt;

&lt;p&gt;Company questions often contain exact technical language: error codes, database table names, API routes, customer identifiers, product names, class names, server names, and policy numbers.&lt;/p&gt;

&lt;p&gt;Embeddings are good at meaning.&lt;/p&gt;

&lt;p&gt;They are not always good at exact identifiers.&lt;/p&gt;

&lt;p&gt;PostgreSQL full-text search can retrieve exact and lexical matches. pgvector can retrieve semantically similar passages. A fusion algorithm can combine both result sets.&lt;/p&gt;

&lt;p&gt;Our pipeline uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Metadata and ACL filters
        ↓
PostgreSQL full-text retrieval
        +
pgvector semantic retrieval
        ↓
Reciprocal Rank Fusion
        ↓
Authority adjustment
        ↓
Optional reranking
        ↓
Cited evidence
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We are starting with exact vector search rather than immediately adding approximate indexes.&lt;/p&gt;

&lt;p&gt;Our first corpus is small enough that query quality matters more than shaving milliseconds off a benchmark.&lt;/p&gt;

&lt;p&gt;Optimization without a measured problem is just architecture cosplay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documents Should Be Chunked by Structure
&lt;/h2&gt;

&lt;p&gt;Another common RAG mistake is chopping documents every fixed number of characters.&lt;/p&gt;

&lt;p&gt;That can produce chunks such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;
...restart the production server.

&lt;span class="gu"&gt;## Do Not Restart the Production Server&lt;/span&gt;

Under no circumstances...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first chunk now contains advice that appears to say the exact opposite of the document.&lt;/p&gt;

&lt;p&gt;Markdown already contains useful structure: document titles, headings, subheadings, lists, tables, code blocks, and wikilinks.&lt;/p&gt;

&lt;p&gt;We parse the heading structure and create chunks around meaningful sections. The parser is code-fence aware, so a &lt;code&gt;#&lt;/code&gt; comment inside a code block is never mistaken for a heading.&lt;/p&gt;

&lt;p&gt;Each stored chunk retains context such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="na"&gt;Document&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hiberden Deployment Runbook&lt;/span&gt;
&lt;span class="na"&gt;Project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hiberden&lt;/span&gt;
&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Runbook&lt;/span&gt;
&lt;span class="na"&gt;Authority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Canonical&lt;/span&gt;
&lt;span class="na"&gt;Heading&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backups &amp;gt; Cloud Destinations &amp;gt; Resume&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That improves retrieval and makes citations understandable.&lt;/p&gt;

&lt;p&gt;It also allows the system to re-process only the documents that changed instead of paying to re-embed the entire corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Database Is an Index, Not the Author
&lt;/h2&gt;

&lt;p&gt;The source document remains the editable artifact.&lt;/p&gt;

&lt;p&gt;For our first implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
Obsidian Markdown + Git = source of truth
PostgreSQL = searchable representation
Embeddings = ranking signal
Full-text search = ranking signal
Metadata = authority and security boundary
MCP/API = controlled agent access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ingestion process will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scan approved folders.&lt;/li&gt;
&lt;li&gt;Exclude archives, credentials, caches, and unsupported files.&lt;/li&gt;
&lt;li&gt;Read YAML frontmatter.&lt;/li&gt;
&lt;li&gt;Parse Markdown structure.&lt;/li&gt;
&lt;li&gt;Calculate content hashes.&lt;/li&gt;
&lt;li&gt;Skip unchanged documents.&lt;/li&gt;
&lt;li&gt;Create stable chunks.&lt;/li&gt;
&lt;li&gt;Reuse unchanged embeddings.&lt;/li&gt;
&lt;li&gt;Insert new versions transactionally.&lt;/li&gt;
&lt;li&gt;Reconcile deleted and moved documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A filesystem watcher may provide fast updates, but periodic full reconciliation is still necessary. Watchers miss events. Production systems should assume that eventually something will fail while nobody is watching the watcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Codex and Claude Through MCP
&lt;/h2&gt;

&lt;p&gt;We do not want every AI client to implement its own database access.&lt;/p&gt;

&lt;p&gt;The platform exposes a controlled retrieval API and an MCP server.&lt;/p&gt;

&lt;p&gt;MCP allows compatible AI clients to access defined tools and contextual data. Codex supports MCP servers through both its CLI and IDE integration.&lt;/p&gt;

&lt;p&gt;The first tool is already live: &lt;code&gt;search_knowledge&lt;/code&gt;, a read-only retrieval tool that Claude Code picks up from a one-file repository configuration. The next tools on the roadmap are narrow in the same way:&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;search_knowledge&lt;/span&gt;
&lt;span class="n"&gt;get_document_section&lt;/span&gt;
&lt;span class="n"&gt;get_current_project_context&lt;/span&gt;
&lt;span class="n"&gt;submit_retrieval_feedback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server will not expose:&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;run_arbitrary_sql&lt;/span&gt;
&lt;span class="n"&gt;browse_every_customer&lt;/span&gt;
&lt;span class="n"&gt;update_company_policy&lt;/span&gt;
&lt;span class="n"&gt;execute_deployment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version is intentionally read-only.&lt;/p&gt;

&lt;p&gt;The agent can retrieve evidence. It cannot silently rewrite the company's operating procedures because it had a particularly confident Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smallest Useful Implementation, Built
&lt;/h2&gt;

&lt;p&gt;We did not start with every company source and a polished chat interface.&lt;/p&gt;

&lt;p&gt;The first complete vertical slice is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
One approved Markdown folder
    ↓
Structural parsing
    ↓
Chunking
    ↓
Embeddings
    ↓
PostgreSQL storage
    ↓
Hybrid search
    ↓
Cited API response
    ↓
One MCP tool
    ↓
Ten evaluation questions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We built that slice against a working vault of real project notes before publishing this post.&lt;/p&gt;

&lt;p&gt;The first full run ingested 30 approved documents into 179 heading-scoped chunks and passed all ten golden evaluation questions, with mean retrieval latency around 50 milliseconds on a local container. The set includes a trap case where an archived near-duplicate of a current page must lose to the current page, and a case where the correct behavior is admitting the corpus does not contain the answer.&lt;/p&gt;

&lt;p&gt;That proves the important parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ingestion pipeline works.&lt;/li&gt;
&lt;li&gt;The database model works.&lt;/li&gt;
&lt;li&gt;Retrieval works.&lt;/li&gt;
&lt;li&gt;Citations work.&lt;/li&gt;
&lt;li&gt;Agents can use the service.&lt;/li&gt;
&lt;li&gt;Quality can be measured.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after that works should the system expand into multiple tenants, source permissions, SharePoint, Google Drive, GitHub, Confluence, administrative portals, usage analytics, and customer onboarding.&lt;/p&gt;

&lt;p&gt;Building six connectors before proving retrieval quality is a reliable way to create six pipelines that all return the wrong document.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Know Whether It Works
&lt;/h2&gt;

&lt;p&gt;A RAG system cannot be judged by whether the answer sounds intelligent.&lt;/p&gt;

&lt;p&gt;Language models are exceptionally good at making weak retrieval look convincing.&lt;/p&gt;

&lt;p&gt;We maintain an evaluation set containing real questions and expected evidence.&lt;/p&gt;

&lt;p&gt;An evaluation case defines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;
&lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;How do we deploy this application to production?&lt;/span&gt;

&lt;span class="na"&gt;expected_documents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;current deployment runbook&lt;/span&gt;

&lt;span class="na"&gt;forbidden_documents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;archived deployment plan&lt;/span&gt;

&lt;span class="na"&gt;required_authority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;canonical&lt;/span&gt;

&lt;span class="na"&gt;expected_facts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;publish location&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deployment verification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Whether the correct document appears in the top results&lt;/li&gt;
&lt;li&gt;Whether the canonical source ranks above historical notes&lt;/li&gt;
&lt;li&gt;Whether archived documents appear when they should not&lt;/li&gt;
&lt;li&gt;Whether citations point to the correct section&lt;/li&gt;
&lt;li&gt;Whether permission rules leak restricted content (once the permission model lands)&lt;/li&gt;
&lt;li&gt;Whether the system correctly returns no answer&lt;/li&gt;
&lt;li&gt;Retrieval latency&lt;/li&gt;
&lt;li&gt;Embedding costs&lt;/li&gt;
&lt;li&gt;User feedback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most important test is not:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Did the model produce a good paragraph?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Did the platform give the model the correct evidence?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Companies Adopting AI
&lt;/h2&gt;

&lt;p&gt;Companies do not need to standardize every employee on one AI model.&lt;/p&gt;

&lt;p&gt;Models will continue to change.&lt;/p&gt;

&lt;p&gt;Some teams will use ChatGPT. Developers may use Codex or Claude Code. Other employees may use Microsoft Copilot, internal applications, or models running through Amazon Bedrock.&lt;/p&gt;

&lt;p&gt;The durable investment is the knowledge layer behind them.&lt;/p&gt;

&lt;p&gt;A well-designed platform allows the company to control sources, permissions, authority, retrieval, citations, logging, and evaluation.&lt;/p&gt;

&lt;p&gt;The LLM becomes replaceable.&lt;/p&gt;

&lt;p&gt;That is a healthier architecture than uploading the same employee handbook into six AI products and hoping each one remembers which version is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where We Are Starting
&lt;/h2&gt;

&lt;p&gt;The first tenant is a working Obsidian knowledge vault of real project notes.&lt;/p&gt;

&lt;p&gt;That gives us a realistic test corpus containing years of application architecture, AWS infrastructure, deployment procedures, database design, production incidents, operational lessons, project history, and both current and obsolete documentation.&lt;/p&gt;

&lt;p&gt;It also gives us the exact problem many companies face: valuable knowledge mixed with historical clutter.&lt;/p&gt;

&lt;p&gt;We will document the implementation as we build it, including why we chose PostgreSQL and pgvector, how we model authority, how we chunk Markdown, how hybrid retrieval performs, how we connect Codex and Claude, how we test permissions, what the AWS infrastructure costs when we promote the store to RDS, what fails, and what turns out to be unnecessary.&lt;/p&gt;

&lt;p&gt;The objective is not to produce another RAG tutorial that works against five clean PDFs.&lt;/p&gt;

&lt;p&gt;The objective is to build a system that can survive real company data, real permissions, stale documents, changing AI tools, and employees who need the correct answer before the production deployment, not three days later during the postmortem.&lt;/p&gt;

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

&lt;p&gt;Most organizations do not need another standalone AI chatbot.&lt;/p&gt;

&lt;p&gt;They need an organizational knowledge layer that can answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What information is current?&lt;/li&gt;
&lt;li&gt;Who is allowed to see it?&lt;/li&gt;
&lt;li&gt;What source supports the answer?&lt;/li&gt;
&lt;li&gt;How do we know retrieval is working?&lt;/li&gt;
&lt;li&gt;Can the same system serve multiple AI tools?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our implementation uses Obsidian as the initial source, PostgreSQL and pgvector as the governed retrieval store, hybrid search for relevance, authority metadata for trust, and MCP for controlled agent access. All of it starts local: one container, one folder of approved documents, zero cloud spend, and nothing leaving the building until the company decides it should.&lt;/p&gt;

&lt;p&gt;The first milestone is simple to describe:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An authenticated employee or AI agent can retrieve the correct, current, permitted company evidence with citations, and the company can prove why those results were returned.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The retrieval half of that milestone, correct, current, cited, and measured, is running today against that vault. The authenticated, permission-aware half is the next phase.&lt;/p&gt;

&lt;p&gt;That is not a shared brain.&lt;/p&gt;

&lt;p&gt;It is something more useful: company knowledge that employees and AI systems can finally use without losing control of it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Agave Information Solutions builds &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom software&lt;/a&gt;, &lt;a href="https://agaveis.com/database-architecture" rel="noopener noreferrer"&gt;database systems&lt;/a&gt;, AWS infrastructure, and &lt;a href="https://agaveis.com/ai-search" rel="noopener noreferrer"&gt;private AI search solutions&lt;/a&gt; for organizations with complicated data. This series will document the platform from the first Markdown scan through a production-ready, permission-aware knowledge service.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/company-ai-knowledge-layer-postgres-rag" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>postgres</category>
      <category>rag</category>
      <category>architecture</category>
    </item>
    <item>
      <title>pgvector Without Embeddings: When a Feature Vector Beats Semantic Search</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:45:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/pgvector-without-embeddings-when-a-feature-vector-beats-semantic-search-12ob</link>
      <guid>https://dev.to/agave_info_solutions/pgvector-without-embeddings-when-a-feature-vector-beats-semantic-search-12ob</guid>
      <description>&lt;p&gt;Almost every pgvector tutorial starts the same way. Take some text, run it through an embedding model, store the resulting vector, and search it with natural language. That is a real and useful pattern, and it is also why most engineers walk away thinking pgvector is a tool for one job: &lt;a href="https://agaveis.com/ai-search" rel="noopener noreferrer"&gt;semantic search over text&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is not. pgvector is a tool for finding the nearest vectors to a target vector. Where those vectors come from is entirely up to you. An embedding model is one source. A column of numbers you computed yourself is another, and for a large class of problems it is the better one.&lt;/p&gt;

&lt;p&gt;We learned this building a "find similar players" feature for a baseball side project, and the lesson generalizes to almost any similarity problem over structured data. Here is the distinction nobody draws clearly, and how to know which side of it you are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pgvector actually does
&lt;/h2&gt;

&lt;p&gt;Strip away the AI framing and pgvector adds three things to &lt;a href="https://agaveis.com/database-architecture" rel="noopener noreferrer"&gt;Postgres&lt;/a&gt;. A column type, &lt;code&gt;vector(N)&lt;/code&gt;, that stores an array of N floats. A set of distance operators between two of those columns: &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; for L2 (Euclidean) distance, &lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; for cosine distance, and &lt;code&gt;&amp;lt;#&amp;gt;&lt;/code&gt; for negative inner product. And approximate-nearest-neighbor indexes (HNSW and IVFFlat) so that finding the closest vectors to a target stays fast as the table grows.&lt;/p&gt;

&lt;p&gt;That is the whole job. Given a target vector, return the rows whose vectors are closest to it, ranked, quickly. pgvector has no opinion about whether your 384 numbers came out of a transformer or out of a &lt;code&gt;SELECT&lt;/code&gt; and some arithmetic. The math is identical.&lt;/p&gt;

&lt;p&gt;This matters because the moment you stop thinking "embedding column" and start thinking "any vector I can construct," a different design space opens up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embeddings versus feature vectors
&lt;/h2&gt;

&lt;p&gt;An embedding is a vector produced by a model. You hand a chunk of text or an image to something like a sentence transformer or CLIP, and it returns a few hundred floats that encode the meaning of the input in a way you did not design and cannot fully interpret. Embeddings shine when the input is unstructured and you do not know in advance which features matter. You could never write down by hand what makes two paragraphs "similar in meaning." The model learned that for you.&lt;/p&gt;

&lt;p&gt;A feature vector is a vector you build deliberately. You decide what the dimensions are, you compute each one from your own data, and you normalize them so they live on a comparable scale. Every dimension means something you can name. Feature vectors shine when the input is structured and you already know what makes two things similar.&lt;/p&gt;

&lt;p&gt;The trap is treating these as the same thing because they both end up in a &lt;code&gt;vector&lt;/code&gt; column. They are not. One is a learned, opaque representation of unstructured data. The other is an explicit, interpretable description of structured data. Calling a hand-built feature vector an "embedding" because it sounds more sophisticated is the kind of overstatement that falls apart the moment someone reads your code and finds a &lt;code&gt;SELECT AVG(...)&lt;/code&gt; where they expected a model call. Name it for what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worked example: comparable players
&lt;/h2&gt;

&lt;p&gt;We wanted to answer a coach's question: "show me pitchers similar to this one." Similarity here is not one number. A pitcher is a bundle of tendencies. What does he throw and how often. Where he locates each pitch. How hard he throws it. How his behavior changes with the count. No single SQL &lt;code&gt;WHERE&lt;/code&gt; clause captures "pitches like this guy," which is the signal that you are in vector territory rather than filter territory.&lt;/p&gt;

&lt;p&gt;So we built a feature vector per pitcher straight from the pitch-by-pitch data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arsenal mix:&lt;/strong&gt; the percentage of each pitch type (fastball, slider, changeup, and so on). One dimension per type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location:&lt;/strong&gt; the mean and spread of the horizontal and vertical location for each pitch type, computed from normalized zone coordinates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Velocity:&lt;/strong&gt; average and range, where it exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count tendencies:&lt;/strong&gt; how the pitch mix shifts in hitter-friendly versus pitcher-friendly counts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a plain aggregate over the pitcher's rows. Concatenate them, normalize, and you have a vector that describes how a pitcher actually operates. Two pitchers who attack the zone the same way land near each other in that space, and pgvector's nearest-neighbor search finds them.&lt;/p&gt;

&lt;p&gt;The schema is unremarkable:&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="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&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;pitcher_profiles&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;feature_vec&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pitcher_profiles&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature_vec&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the query is the part people expect to be hard and is not:&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;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pitcher_profiles&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;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;target_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;feature_vec&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;target_vec&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a .NET app with EF Core and the Pgvector provider, building the target vector and running that order-by is &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;a few lines&lt;/a&gt;. The intelligence is not in the query. It is in the feature design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that decide whether it works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Normalization.&lt;/strong&gt; Your dimensions start on wildly different scales. A pitch-mix percentage lives between 0 and 1. A velocity average might be 88. If you drop both into a vector raw, velocity dominates the distance and the mix percentages become noise. Standardize every dimension (z-score across the population, or min-max into a fixed range) before you store it, or your "similarity" just measures whichever feature happens to be biggest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weighting, which is the feature vector's superpower.&lt;/strong&gt; Because you built the dimensions, you can decide that arsenal mix matters twice as much as location by scaling those dimensions before the distance is computed. You cannot do that with a black-box embedding. You get whatever the model decided was important. When you know your domain, that control is worth more than any learned representation, and it is the single best argument for the hand-built approach.&lt;/p&gt;

&lt;p&gt;One caveat from the data: velocity was frequently missing, because the entry UI had no radar input. A vector with a sparse dimension behaves badly, since "missing" is not the same as "zero." Design the vector so it degrades gracefully when a feature is absent, either by imputing the population mean or by dropping the dimension and renormalizing. Real structured data is messier than the tutorial version, and the feature approach at least lets you see and handle that, because you can read every dimension.&lt;/p&gt;

&lt;h2&gt;
  
  
  When embeddings still win, and when to use neither
&lt;/h2&gt;

&lt;p&gt;Feature vectors are the right tool when the input is structured and you can name what matters. Flip either of those and you want something else.&lt;/p&gt;

&lt;p&gt;If the input is unstructured, embeddings win. Free-text scouting notes, a coach's written report, an image: you cannot hand-engineer features for those, so &lt;a href="https://agaveis.com/local-ai" rel="noopener noreferrer"&gt;let a model do it&lt;/a&gt;. The strongest systems often run both. Use a feature vector for the structured tendencies and an embedding for the prose notes, then combine the two scores. pgvector holds both columns happily.&lt;/p&gt;

&lt;p&gt;And sometimes the answer is no vector at all. If "similar" really comes down to one or two numeric columns, you do not need pgvector, you need a &lt;code&gt;WHERE&lt;/code&gt; clause and an &lt;code&gt;ORDER BY&lt;/code&gt;. Reaching for vector search there is theater. It looks advanced and adds a dependency to do what an index on a column already does. The gut check we use: vectors earn their place only when similarity spans many dimensions at once and no single filter captures it. Below that bar, plain SQL is not just adequate, it is better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;pgvector is a nearest-neighbor engine, not a semantic-search feature. Once you internalize that, "do I need an embedding model here" becomes a real question with a real answer instead of a reflex. For unstructured data where the important features are unknowable, embed. For structured data where you know exactly what makes two things alike, build the feature vector yourself, normalize it, weight it, and let pgvector do the search. You will ship something simpler, faster, and fully explainable, and you will be able to answer the only question that matters in a code review: why are these two results considered similar.&lt;/p&gt;

&lt;p&gt;The honest version of that answer is worth more than the impressive-sounding one.&lt;/p&gt;

&lt;p&gt;Agave Information Solutions builds &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom software&lt;/a&gt;, &lt;a href="https://agaveis.com/database-architecture" rel="noopener noreferrer"&gt;data architecture&lt;/a&gt;, and &lt;a href="https://agaveis.com/local-ai" rel="noopener noreferrer"&gt;on-premises AI systems&lt;/a&gt; out of Scottsdale, Arizona. If you have a similarity, search, or retrieval problem and are not sure whether you need a model or just better-shaped data, &lt;a href="https://agaveis.com/about" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/pgvector-without-embeddings" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>vectordatabase</category>
      <category>ai</category>
    </item>
    <item>
      <title>MCP Server Security: Match the Guardrails to the Blast Radius</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 03:31:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/mcp-server-security-match-the-guardrails-to-the-blast-radius-5a2b</link>
      <guid>https://dev.to/agave_info_solutions/mcp-server-security-match-the-guardrails-to-the-blast-radius-5a2b</guid>
      <description>&lt;p&gt;We run two Model Context Protocol servers in production. One of them can create, move, and delete things. The other cannot change a single byte, and never returns a person's name.&lt;/p&gt;

&lt;p&gt;Both are correct. That is the point of this post.&lt;/p&gt;

&lt;p&gt;Most MCP security writing worries about the wrong thing. It focuses on the malicious server: somebody publishes a poisoned tool, an assistant calls it, secrets leak. That is a real risk and it deserves the attention it gets. But if you operate a server rather than install one, it is not your daily problem. Your daily problem is that &lt;strong&gt;the assistant on the other end is not malicious, it is confidently wrong&lt;/strong&gt;, and it is about to call a tool you gave it.&lt;/p&gt;

&lt;p&gt;The useful question is therefore not "is this server secure." It is "when this call is a mistake, who absorbs the damage." Answer that and the guardrails design themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three doors, three postures
&lt;/h2&gt;

&lt;p&gt;Between two products we ended up with three distinct surfaces, and each one landed somewhere different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Door one: read and write, on the user's own machine
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hiberden.app" rel="noopener noreferrer"&gt;Hiberden&lt;/a&gt; is desktop software for LTO tape archiving, and its MCP server ships inside the application. It exposes around thirty tools and plenty of them write: create a project, assign a policy, move an archive, retire a destination, delete a copy.&lt;/p&gt;

&lt;p&gt;Letting an assistant delete something sounds reckless until you work out who is exposed. The person driving the assistant owns the machine, owns the catalog, and could delete the same copy through the application's own interface in two clicks. Routing that through an assistant does not widen the blast radius. It is the same person, the same computer, the same file.&lt;/p&gt;

&lt;p&gt;So the guardrails here are not about restricting capability. They are about making consent explicit and keeping receipts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server runs locally, over stdio, as a subprocess on the same machine as the data. There is no listening port and no inbound network surface.&lt;/li&gt;
&lt;li&gt;Permissions are a setting inside the application, not something the assistant can grant itself.&lt;/li&gt;
&lt;li&gt;Read-only is the default. Destructive operations sit behind their own separate switch that a human has to turn on deliberately.&lt;/li&gt;
&lt;li&gt;Every action is written to an audit trail, so "what did it actually do" is answerable after the fact.&lt;/li&gt;
&lt;li&gt;Credentials for the customer's own cloud storage stay in the operating system keyring. They are never returned by a tool and never pass through the assistant.&lt;/li&gt;
&lt;li&gt;The catalog is a local file. There is no hosted service in the path, no account, and no telemetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape of that list is worth noticing. When the user absorbs the damage, you guard the &lt;em&gt;decision&lt;/em&gt;: default to safe, make the dangerous thing an explicit choice, and write down what happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Door two: read only, on a shared service
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://easy2257.com" rel="noopener noreferrer"&gt;Easy2257&lt;/a&gt; is a hosted service holding 18 U.S.C. 2257 records for producers in a federally regulated vertical. Its MCP server exposes sixteen tools, and not one of them can create, change, sign, delete, or pay for anything.&lt;/p&gt;

&lt;p&gt;Run the same test and you get a different answer. A mistaken write here does not ruin the operator's afternoon. It puts a false state change into a legal record about a third party who is not in the room and never consented to an assistant touching their file. The person absorbing the damage is not the person driving.&lt;/p&gt;

&lt;p&gt;When you cannot afford a mistaken write, do not try to make writes safe. Remove them. The write path stays on the website, performed by a human, on purpose. Paid actions and legal attestations are somebody's to make deliberately, not an assistant's to infer.&lt;/p&gt;

&lt;p&gt;Having removed what it can do, the remaining question is what it can see. Read-only is not automatically safe: a read-only tool that returns a date of birth is still a data breach waiting for an audience. So the second half of the posture is to shrink the payload until there is nothing worth stealing in it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No names, no dates of birth, no ID numbers, no images, no addresses, no document contents, no file paths. Not redacted on the way out. Never in the response shape to begin with.&lt;/li&gt;
&lt;li&gt;What comes back is compliance state: record codes, statuses, counts, dates, and content hashes. Records are referred to by public codes rather than by the people in them.&lt;/li&gt;
&lt;li&gt;Every credential is scoped to exactly one account, and lookups of records outside it are throttled rather than merely denied, so the surface cannot be walked.&lt;/li&gt;
&lt;li&gt;Keys are hashed at rest and shown exactly once, capped per account, and revoked automatically on a password reset. The interactive route uses short-lived tokens that disconnect in one click.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last cluster matters more than it looks. The realistic failure is not somebody breaking the cryptography. It is a key that got pasted into a chat window eight months ago and nobody remembers. Caps, one-time display, and automatic revocation on password reset are all designed for that failure, not for a determined attacker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Door three: write, for somebody else's software
&lt;/h3&gt;

&lt;p&gt;The same service also has a partner API, and it is the genuinely dangerous door: it can provision an account, open a scene, and deliver a certificate, called by platforms we do not control.&lt;/p&gt;

&lt;p&gt;Here the damage lands on third parties &lt;em&gt;and&lt;/em&gt; the caller is a machine, so the guardrails go back up and change character again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live keys only, and no sandbox.&lt;/strong&gt; Every call touches real records, real people, and real email, so there is no pretend mode that lulls anyone into treating it as low stakes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The human who creates a live key verifies their identity first.&lt;/strong&gt; Authentication attaches to a person, not just to a string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys are shown once and stored only as a hash.&lt;/strong&gt; Lose it and you rotate it, because we cannot show it to you again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every webhook is signed with a per-endpoint secret that rotates on its own.&lt;/strong&gt; Verify the signature before you trust the body. A shared signing secret means one compromised integration compromises all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificates are signed tokens verifiable offline against public keys.&lt;/strong&gt; A platform never has to take our word for anything, and verification does not require calling us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Records are consent-gated.&lt;/strong&gt; A platform never receives a person's records without that person's consent, regardless of what the API key is entitled to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys and rate limits,&lt;/strong&gt; so a retry can never double-write. Assume every call happens more than once, because eventually it will.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The threat model people skip
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the assistant means well. That is the case worth designing for, because it is the common one.&lt;/p&gt;

&lt;p&gt;A well-meaning assistant misreads intent constantly. It sees "clean up the old stuff," decides that means the archive from 2019, and reaches for a delete tool because you gave it one. It sees a job that failed and helpfully retries it four times. It reads a tool result containing text somebody else wrote and treats that text as an instruction, which is the part most teams have not internalised yet: &lt;strong&gt;a tool result is untrusted input&lt;/strong&gt;, exactly like a form field, even when your own server produced it, because your server may be relaying content a customer typed.&lt;/p&gt;

&lt;p&gt;Three habits follow from that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Name tools for what they do, not for what they enable.&lt;/strong&gt; A tool called &lt;code&gt;delete_copy&lt;/code&gt; gets called deliberately. A tool called &lt;code&gt;cleanup&lt;/code&gt; gets called speculatively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make destructive operations narrow and specific.&lt;/strong&gt; Deleting one identified copy is recoverable reasoning. A tool that takes a filter and deletes everything matching it is a loaded weapon with a natural-language trigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make retries free.&lt;/strong&gt; Idempotency is a security control, not just a reliability one, because the assistant is going to retry and will not tell you it did.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The principle, in one line
&lt;/h2&gt;

&lt;p&gt;Match the security to the blast radius. The door that can act authenticates people and signs what it sends. The door that can only read carries nothing worth stealing. And the door that runs on somebody's own machine, against their own files, can afford to do more, provided the dangerous parts are a deliberate choice and everything is written down.&lt;/p&gt;

&lt;p&gt;What you should not do is pick one posture and apply it everywhere. A read-only server with an over-generous payload is more dangerous than a write-capable server with an explicit permission switch. Capability and exposure are separate axes, and it is worth deciding each one on purpose.&lt;/p&gt;

&lt;p&gt;The desktop connector is documented at &lt;a href="https://hiberden.app/mcp" rel="noopener noreferrer"&gt;hiberden.app/mcp&lt;/a&gt; and the partner API at &lt;a href="https://docs.easy2257.com" rel="noopener noreferrer"&gt;docs.easy2257.com&lt;/a&gt;, both including exactly what each surface can and cannot do. If you are wiring an assistant into something that matters, &lt;a href="https://agaveis.com/local-ai" rel="noopener noreferrer"&gt;local AI integration&lt;/a&gt; and &lt;a href="https://agaveis.com/api" rel="noopener noreferrer"&gt;API design&lt;/a&gt; are both work we take on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/mcp-server-security-blast-radius" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Shipping an MCP Server: Desktop App vs Hosted Web App</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 02:29:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/shipping-an-mcp-server-desktop-app-vs-hosted-web-app-1hk7</link>
      <guid>https://dev.to/agave_info_solutions/shipping-an-mcp-server-desktop-app-vs-hosted-web-app-1hk7</guid>
      <description>&lt;p&gt;We ship two Model Context Protocol servers. One runs inside a desktop application, on the machine where the data already lives. The other runs as a hosted service behind an account, in front of a multi-tenant database. They speak the same protocol and share almost none of the same decisions.&lt;/p&gt;

&lt;p&gt;Most writing about MCP stops at the hello-world tutorial: register a tool, return a string, done. The interesting part starts after that, when you have to decide where the server runs, how it reaches users, how you update it, what it is allowed to touch, and what happens when the assistant calling it is confidently wrong. Those answers are not a matter of taste. They fall out of the deployment model almost mechanically.&lt;/p&gt;

&lt;p&gt;Here is the comparison, decision by decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two products
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hiberden.app" rel="noopener noreferrer"&gt;Hiberden&lt;/a&gt; is desktop software for LTO tape archiving. It keeps a local catalog of everything it has archived: every project, every copy on tape, disk, NAS and cloud, every file path, every checksum, and every overnight job. The MCP server ships inside the application.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://easy2257.com" rel="noopener noreferrer"&gt;Easy2257&lt;/a&gt; is a hosted service that keeps 18 U.S.C. 2257 records for producers in a federally regulated vertical: who appeared in a scene, that identity was verified, which paperwork is signed, and a certificate proving the record existed at a point in time. Its MCP server runs as part of the service. Nothing is installed.&lt;/p&gt;

&lt;p&gt;One catalog sits on your desk. The other sits in a database shared by every customer. Nearly everything below follows from that one difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Where the server runs decides everything else
&lt;/h2&gt;

&lt;p&gt;The desktop server is a local process. The transport is stdio: the assistant launches the server as a subprocess on the same machine as the data. There is no network hop, no listening port, and no inbound surface to attack. The catalog it reads is an ordinary local file the user already owns.&lt;/p&gt;

&lt;p&gt;The hosted server is a network service. The assistant connects across the internet to something holding many companies' records at once. Every single request has to answer "who is asking, and what are they entitled to see" before it can answer anything else.&lt;/p&gt;

&lt;p&gt;The consequence is not subtle. On the desktop, authentication is mostly solved before you write a line of server code: the person is sitting at the machine, and the operating system already decided they can open that file. On a hosted server, authentication &lt;em&gt;is&lt;/em&gt; the product surface. It is the first thing you build and the thing you keep rebuilding.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Distribution: an installer problem or a documentation problem
&lt;/h2&gt;

&lt;p&gt;A desktop server has to physically arrive on the machine. A well-built one ships inside the installer, and it is also published to the official MCP registry as a versioned, checksummed bundle running over stdio, plus third-party directories like Glama, which is increasingly how assistants and their users discover servers at all.&lt;/p&gt;

&lt;p&gt;A hosted server has nothing to install. The endpoint exists the moment the account exists. Distribution stops being an engineering problem and becomes a documentation problem: the work is making the connection instructions short enough that somebody finishes them.&lt;/p&gt;

&lt;p&gt;Each model quietly inherits a different environment. Ship on the desktop and you inherit the user's machine: operating system versions, assistant clients that each store their config somewhere different, antivirus software with opinions about unsigned subprocesses. Ship hosted and you inherit the network: timeouts, retries, and clients that will absolutely call the same tool four times in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Versioning: pinned forever, or changed for everyone at once
&lt;/h2&gt;

&lt;p&gt;This is where the two models diverge hardest, and it is the one people underestimate.&lt;/p&gt;

&lt;p&gt;On the desktop, the server version is pinned to the application version. A customer running last spring's build is running last spring's tools, and they may run them for years. You cannot fix a tool for everybody at once, and you cannot remove one either, because somebody who never updates will keep calling it forever. Deprecation is a release-notes problem measured in years, not sprints.&lt;/p&gt;

&lt;p&gt;On the hosted service there is exactly one version and everyone is on it. A fix reaches every customer the moment it deploys. The flip side is that a breaking change breaks every customer simultaneously, and the software calling your tools is not your software. It is somebody's assistant, wired up months ago, that you cannot test against.&lt;/p&gt;

&lt;p&gt;What that works out to in practice: on the desktop, make changes additive and never alter a tool signature that already shipped. On hosted, you can iterate faster, but treat the tool list as a published API contract, because that is what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Credentials: keep them out of the conversation
&lt;/h2&gt;

&lt;p&gt;The desktop application already holds credentials it needs for its own work, such as the keys for the customer's own cloud storage account. Those live in the operating system keyring. The rule we settled on is simple and worth stating plainly: &lt;strong&gt;credentials never pass through the assistant&lt;/strong&gt;. The assistant asks the catalog questions and the catalog answers them. Keys are not part of the conversation, are never returned by a tool, and are never needed to call one.&lt;/p&gt;

&lt;p&gt;The hosted server has the opposite problem, because the connection itself needs a credential. There are two routes in. A customer can generate an API key, which is hashed at rest and displayed exactly once, capped at ten per account, with every key revoked automatically if the account password is reset. Or they can sign in interactively and connect with short-lived tokens, which can be disconnected in one click. Either way, a credential is scoped to exactly one account, and attempts to look up records belonging to somebody else are throttled rather than merely refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What the tool surface can afford to do
&lt;/h2&gt;

&lt;p&gt;Now the interesting part. The two servers made opposite choices, and both are right.&lt;/p&gt;

&lt;p&gt;The desktop server exposes about thirty tools, and a good number of them write. They can create a project, assign a policy, move an archive between destinations, retire a destination, or delete a copy. That is defensible because the person driving the assistant already owns the data and the machine. Deleting a copy through an assistant is not more dangerous than deleting it through the application's own interface: same person, same computer, same file.&lt;/p&gt;

&lt;p&gt;What it does require is that consent is explicit and the work leaves a record. Permissions are a setting inside the application rather than something an assistant can grant itself. Read-only is the default. Destructive operations sit behind their own separate switch. Every action is written to an audit trail.&lt;/p&gt;

&lt;p&gt;The hosted compliance server exposes sixteen tools and &lt;em&gt;none&lt;/em&gt; of them can create, change, sign, delete, or pay for anything. That is not timidity. On a shared compliance service, a write tool means an assistant can hallucinate a state change into somebody's federal records. The blast radius is not a ruined afternoon, it is a legal record about a real person. So the write path stays where it belongs: on the website, performed by a human being, on purpose.&lt;/p&gt;

&lt;p&gt;The rule that falls out of the comparison: &lt;strong&gt;how much a tool is allowed to do should track who absorbs the damage when it is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What the tool surface is allowed to see
&lt;/h2&gt;

&lt;p&gt;The second divergence is quieter and, we think, more important.&lt;/p&gt;

&lt;p&gt;The desktop server returns file paths, file names, checksums, and tape barcodes. Every bit of that is already sitting on the user's own disk. Handing it to an assistant running as a subprocess on the same machine leaks nothing that was not already there to read.&lt;/p&gt;

&lt;p&gt;The hosted compliance server deliberately returns none of the identity data it holds. No names, no dates of birth, no ID numbers, no images, no document contents, no file paths. What comes back is compliance state: record codes, statuses, counts, dates, and content hashes. Records are referred to by public codes rather than by the people in them.&lt;/p&gt;

&lt;p&gt;That is not a gap in the feature set. It is the reason the surface is safe to point at a shared assistant at all. There is nothing in the payload worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Discovery works differently too
&lt;/h2&gt;

&lt;p&gt;A desktop server is discovered the way software is discovered: registries, directories, and the installer the user already ran. A hosted server is discovered the way an API is discovered: documentation, a setup page, and a connect button inside the product. If you are building one, budget real time for whichever of those applies. A server nobody can find is indistinguishable from a server that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would tell someone starting
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide where the server runs before you write a single tool.&lt;/strong&gt; Transport, authentication, versioning strategy, and tool design all fall out of that one choice, and reversing it later means rewriting all four.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know which set of hard problems you just signed up for.&lt;/strong&gt; On the desktop they are distribution, versioning, and permission design. On hosted they are identity, tenancy, and blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep credentials out of the conversation.&lt;/strong&gt; No tool should return one, and no tool should require the assistant to hold one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the tool list as a public API contract either way.&lt;/strong&gt; On the desktop because you can never take a tool back. On hosted because when you take it back, you take it back from everyone at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the blast radius decide the capability.&lt;/strong&gt; Not the other way around, and not whatever the framework made easy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both servers are live. The desktop connector is documented at &lt;a href="https://hiberden.app/mcp" rel="noopener noreferrer"&gt;hiberden.app/mcp&lt;/a&gt;, including exactly what it can and cannot do. If you are building something similar and want a second pair of eyes on the architecture, that is the kind of work we do in &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom development&lt;/a&gt; and &lt;a href="https://agaveis.com/local-ai" rel="noopener noreferrer"&gt;local AI integration&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/mcp-server-desktop-app-vs-hosted-web-app" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>LTFS and LTO Across Windows, macOS, and Two Flavors of Linux</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 00:29:14 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/ltfs-and-lto-across-windows-macos-and-two-flavors-of-linux-12e7</link>
      <guid>https://dev.to/agave_info_solutions/ltfs-and-lto-across-windows-macos-and-two-flavors-of-linux-12e7</guid>
      <description>&lt;p&gt;LTFS sounds like a solved cross-platform problem. There is one specification, one reference implementation on GitHub, and the same three letters appear in every vendor's datasheet. Write to the standard once and ship everywhere.&lt;/p&gt;

&lt;p&gt;That is not how it goes. We build archiving software that targets Windows, macOS, and Linux from one Rust workspace, and the tape layer is the single place where the three operating systems refuse to converge. Not because of small API differences, but because &lt;strong&gt;the word LTFS names two genuinely different architectures depending on which machine you are standing at.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is what actually differs, written from the platform backends rather than from the specification. It includes a macOS build defect that is not documented anywhere we could find, and which will cost you a day the first time you meet it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One name, two architectures
&lt;/h2&gt;

&lt;p&gt;On Linux and macOS, LTFS is a &lt;strong&gt;userspace filesystem&lt;/strong&gt;. You install a binary called &lt;code&gt;ltfs&lt;/code&gt;, it links against FUSE, and you mount a cartridge at a path the same way you would mount anything else. The tape becomes a directory. Everything downstream is ordinary file I/O.&lt;/p&gt;

&lt;p&gt;On Windows, LTFS is a &lt;strong&gt;command suite plus a filesystem driver&lt;/strong&gt;. IBM ships a family of executables, and you drive the tape by calling them in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LtfsCmdDrives.exe      enumerate attached drives
LtfsCmdAssign.exe      bind a drive to a letter
LtfsCmdLoad.exe        load the cartridge
LtfsCmdFormat.exe      write a fresh LTFS volume
LtfsCmdEject.exe       take the cartridge offline
LtfsCmdUnassign.exe    release the letter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cartridge does not appear at a path you chose. It appears at a drive letter, and the thing you check to confirm you are looking at tape rather than at somebody's USB stick is the volume's reported filesystem name.&lt;/p&gt;

&lt;p&gt;That difference propagates through everything. A mount on Linux is a path that either has a marker in it or does not. A mount on Windows is a letter that may or may not currently be bound to what you think it is bound to. Any abstraction over both has to be honest that it is hiding two different failure modes, not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool inventory, side by side
&lt;/h2&gt;

&lt;p&gt;Here is what each backend actually shells out to. This is the clearest single picture of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Windows   LtfsCmdDrives / Assign / Unassign / Load / Eject / Format
Linux     ltfs, mkltfs, umount, fusermount, mt, df
macOS     ltfs, mkltfs, umount, mount, df
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the third line twice. &lt;strong&gt;macOS is missing two tools that Linux has, and one of them is load bearing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  macOS has no way to eject a tape
&lt;/h2&gt;

&lt;p&gt;On Linux, taking a cartridge offline is &lt;code&gt;mt -f /dev/nst0 offline&lt;/code&gt;. &lt;code&gt;mt&lt;/code&gt; comes from mt-st, it is packaged everywhere, and it has been the answer for decades.&lt;/p&gt;

&lt;p&gt;macOS ships no tape control tool at all. There is no &lt;code&gt;mt&lt;/code&gt;, and no system equivalent. So the eject verb, which is trivial on the other two platforms, has no implementation available to it.&lt;/p&gt;

&lt;p&gt;What you do about that is a design decision rather than a coding one. Our answer was to make the failure honest and typed rather than silent: unmount first, which flushes the index to tape and leaves the cartridge complete and readable on another machine, then return a specific "eject unsupported" result telling the operator the cartridge is safe to remove with the drive's own button. The caller that matters here is a spanning writer whose operator is already standing at the drive with the next cartridge in hand, so it needs to tell "I could not eject" apart from "something went wrong" without matching on error strings.&lt;/p&gt;

&lt;p&gt;There is a second route. The FUSE layer can be asked at mount time to eject on unmount, using the IOKit backend and no external tool. But it is a &lt;em&gt;mount-time&lt;/em&gt; option, which means committing at mount to whether the session ends in an eject. That turns eject from a verb into a mode, and it is a change to the whole spanning flow rather than a one-line swap. Worth knowing it exists before you design around its absence.&lt;/p&gt;

&lt;h2&gt;
  
  
  FUSE cleanup differs too
&lt;/h2&gt;

&lt;p&gt;When an &lt;code&gt;ltfs&lt;/code&gt; mount hangs and you kill it, you are left with a stale FUSE endpoint that will refuse the next mount attempt. Clearing it is not the same command on both unix platforms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Linux    fusermount &lt;span class="nt"&gt;-u&lt;/span&gt; &amp;lt;mountpoint&amp;gt;
macOS    umount &amp;lt;mountpoint&amp;gt;     &lt;span class="o"&gt;(&lt;/span&gt;or diskutil unmount force&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS has no &lt;code&gt;fusermount&lt;/code&gt;. If your recovery path assumes it, your retry works in development and wedges on a Mac, and the symptom is a mount failure that looks like a hardware problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeouts are the other thing everyone gets wrong
&lt;/h2&gt;

&lt;p&gt;Tape operations do not run on human timescales, and a single global timeout will either kill legitimate work or hide a wedged controller for hours.&lt;/p&gt;

&lt;p&gt;Formatting a fresh LTO-9 cartridge is the extreme case. The first load of a new cartridge can take anywhere from forty minutes to two and a half hours before the drive will even talk to you, and the format itself runs long after that. A mount-class timeout of a few minutes will reliably kill a perfectly healthy format.&lt;/p&gt;

&lt;p&gt;So the timeout has to be a property of the operation class, not of the session. The implementation here settles at three floors, applied identically on all three platforms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;probe             30 seconds     is the drive there
index flush        1 hour        unmount, writing the index to tape
long operations    4 hours       format, and first-load waits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The floor matters more than the number. A caller can configure a longer timeout, but it cannot configure one &lt;em&gt;shorter&lt;/em&gt; than the class floor, because a caller that shortens a format timeout is not expressing a preference, it is expressing a misunderstanding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two flavors of Linux, and they are not the two you expect
&lt;/h2&gt;

&lt;p&gt;Ask which Linux distributions a desktop application supports and you expect an answer like "Ubuntu and Fedora". For a tape tool the meaningful split is not by distribution brand. It is &lt;strong&gt;packaged versus portable&lt;/strong&gt;, and each carries a different set of problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  The .deb: integrated, and honest about it
&lt;/h3&gt;

&lt;p&gt;A Debian package declares its dependencies, and the package manager enforces them. A well-built one names the GUI toolkit and the dialog helper explicitly, and CI fails the build if either declaration goes missing from the produced package. That is the useful property: on a Debian or Ubuntu machine, a missing dependency is a clear install-time error naming the package, not a mystery crash on first launch.&lt;/p&gt;

&lt;p&gt;The cost is a hard glibc floor. Linux binaries get built on Ubuntu 22.04 rather than on whatever "latest" currently means, because a binary linked against a newer glibc simply will not start on an older machine, and archive operators run old machines on purpose. Building on latest is the single easiest way to ship something that works on every developer's laptop and on none of the customer's servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  The AppImage: portable, and it moves the problem
&lt;/h3&gt;

&lt;p&gt;An AppImage carries its own userland and runs on distributions you never tested. That is genuinely useful for a tool whose users are on RHEL derivatives, Arch, or something a facility built itself in 2019 and will not touch.&lt;/p&gt;

&lt;p&gt;What it does not carry is the kernel side. FUSE is a kernel feature, the tape device nodes are the host's, and the permissions on them are the host's. So an AppImage moves the dependency problem from install time to run time, and converts a clear package-manager error into a runtime failure inside the sandbox. It broadens reach and narrows diagnosability at the same time. Ship both, and know which one a given support conversation is about before you start debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building LTFS on macOS, which is where the day goes
&lt;/h2&gt;

&lt;p&gt;This section is the reason we wrote the post. Building the reference LTFS implementation on Apple Silicon is not hard in the sense of being intellectually difficult. It is hard in the sense that six separate things go wrong in sequence, each with an error message pointing somewhere other than the cause.&lt;/p&gt;

&lt;p&gt;In order, on macOS 15 on an M2:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;macFUSE needs Reduced Security.&lt;/strong&gt; It is a third-party kernel extension, and Apple Silicon ships with Full Security, under which the kext cannot load at all. Changing that requires booting into Recovery. It cannot be done over SSH, it cannot be scripted, and it cannot be done for a customer. Budget a person at the keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autogen.sh&lt;/code&gt; skips libtool silently.&lt;/strong&gt; No error, no warning, and configure then fails later for a reason that has nothing to do with libtool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;icu4c is keg-only and versioned.&lt;/strong&gt; Plain &lt;code&gt;icu4c&lt;/code&gt; now returns a 404 from the Homebrew API. You need the versioned formula, and because it is keg-only it never lands on PATH, so configure dies claiming a tool called &lt;code&gt;genrb&lt;/code&gt; was not found. Nothing in that message mentions ICU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;configure demands a uuid pkg-config file.&lt;/strong&gt; It asks for &lt;code&gt;uuid &amp;gt;= 1.6&lt;/code&gt;. macOS does have UUID, in libSystem, with no &lt;code&gt;.pc&lt;/code&gt; file anywhere, so the check fails on a platform that has the functionality. Installing ossp-uuid satisfies the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SNMP is on by default and cannot build.&lt;/strong&gt; configure calls &lt;code&gt;net-snmp-config --cflags&lt;/code&gt;, which is not there, and the resulting error is truncated in a way that makes it look like a FUSE problem. It is not. Pass &lt;code&gt;--disable-snmp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macFUSE's framework is not where the compiler looks.&lt;/strong&gt; It lives under &lt;code&gt;/Library/Filesystems/macfuse.fs/Contents/Frameworks/&lt;/code&gt;, which needs an explicit &lt;code&gt;-F&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Work through all six and it compiles. Roughly thirty seconds on an M2. And then you run the one command that matters and it dies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The link defect nobody documents
&lt;/h2&gt;

&lt;p&gt;Enumerating drives is &lt;code&gt;ltfs -o device_list&lt;/code&gt;. On a freshly built macOS LTFS with no drive attached, that segfaults. Exit 139, with the crash inside &lt;code&gt;libsystem_malloc&lt;/code&gt; on what looks like recursive allocation.&lt;/p&gt;

&lt;p&gt;Every instinct says hardware, or macFUSE, or the missing drive. None of those is it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a link defect.&lt;/strong&gt; Upstream's IOKit plugin leaves its IOKit and CoreFoundation symbols to dynamic lookup, and nothing in the build actually links the frameworks that define them. The binary links clean, loads, and then dies the moment it reaches for a symbol that was never bound. Adding the frameworks fixes it:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LDFLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-L&lt;/span&gt;&lt;span class="nv"&gt;$ICU&lt;/span&gt;&lt;span class="s2"&gt;/lib -framework IOKit -framework CoreFoundation"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, &lt;code&gt;ltfs -o device_list&lt;/code&gt; behaves correctly: it exits 1 and prints an empty device list, which is the right answer when no drive is attached. A clean negative result instead of a crash.&lt;/p&gt;

&lt;p&gt;The trap underneath the trap is what a caller does with the crash. Any code that treats "device_list failed" as "no drives present" will render an empty drive picker and tell the user they have no tape hardware, when the truth is the enumerator died. A segfault and an empty list are not the same fact and should never collapse into the same UI state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs you, and what to do about it
&lt;/h2&gt;

&lt;p&gt;Five things we would tell anyone starting a cross-platform tape project:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not abstract over Windows and unix too early.&lt;/strong&gt; They are a command suite against a drive letter and a FUSE mount against a path. An interface that pretends otherwise hides the failure modes you most need to see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make timeouts a property of the operation class&lt;/strong&gt;, with floors a caller cannot lower. Formatting is a four-hour operation on hardware that is behaving perfectly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let a platform say "I cannot do this" in a typed way.&lt;/strong&gt; macOS cannot eject. That is better expressed as a specific result the caller can branch on than as a generic failure it has to parse a string out of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let a crashed probe read as an empty result.&lt;/strong&gt; Enumeration failing and enumeration returning nothing are different, and only one of them should be shown to a user as "no hardware found".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix a build recipe everywhere at once.&lt;/strong&gt; A typical recipe lives in a runbook, a knowledge base article, and a CI workflow. A fix that lands in one of three is a fix that will be un-learned by whoever reads the other two.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest scope of this post
&lt;/h2&gt;

&lt;p&gt;Everything above is about the software layer: build systems, tool inventories, mount semantics, timeout policy, and packaging. It is drawn from platform backends we wrote and a macOS build we did on a real machine.&lt;/p&gt;

&lt;p&gt;It is not a hardware compatibility report, and we are not going to imply otherwise. Our current Rust implementation has not yet run against a physical tape drive on any operating system. Coverage today comes from a virtual drive and a simulated backend, and the first run on real hardware is a scheduled validation window covering all three platforms. The cross-platform differences described here are real and cost real time, but the claim stops precisely at the software boundary, because a tape tool that overstates what it has tested is the exact thing this industry does not need more of.&lt;/p&gt;

&lt;p&gt;If you are building on tape across operating systems and want to compare notes, or you want the archiving software this came out of, &lt;a href="https://hiberden.app" rel="noopener noreferrer"&gt;Hiberden&lt;/a&gt; is where that work lives. Adjacent problems we take on: &lt;a href="https://agaveis.com/nas-infrastructure" rel="noopener noreferrer"&gt;NAS infrastructure&lt;/a&gt;, &lt;a href="https://agaveis.com/data-archiving" rel="noopener noreferrer"&gt;data archiving&lt;/a&gt;, and &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom development&lt;/a&gt; when the tool you need does not exist yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/ltfs-lto-across-windows-macos-linux" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>backup</category>
    </item>
    <item>
      <title>Permission Filters and pgvector: Why Your Most Restricted Users Get the Fewest Answers</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Sat, 26 Sep 2026 00:15:23 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/permission-filters-and-pgvector-why-your-most-restricted-users-get-the-fewest-answers-2e63</link>
      <guid>https://dev.to/agave_info_solutions/permission-filters-and-pgvector-why-your-most-restricted-users-get-the-fewest-answers-2e63</guid>
      <description>&lt;p&gt;If you build AI search over documents that not everyone may read, you filter by permission. In PostgreSQL with pgvector, that usually means a WHERE clause on the caller's groups and an ORDER BY on vector distance. With no vector index, that query is exact and always complete. Add an HNSW index for speed, and something quiet happens: the people allowed to read the least start getting the fewest answers. Nothing errors. Nothing leaks, because the filter still holds. The list just comes back short.&lt;/p&gt;

&lt;p&gt;Here is the mechanism, measured, and the two fixes. Everything below ran on PostgreSQL 17.10 with pgvector 0.8.4, and the script is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query
&lt;/h2&gt;

&lt;p&gt;Chunks of documents carry embeddings. Documents carry the groups allowed to read them. A search for one caller looks like this:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; operator is array overlap: a document qualifies if the caller holds any one of its groups. With no vector index, pgvector does exact nearest neighbor search, which its documentation describes as providing perfect recall. This query returns 20 rows whenever 20 readable chunks exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an HNSW index changes
&lt;/h2&gt;

&lt;p&gt;pgvector's README says it plainly: with approximate indexes, filtering is applied after the index is scanned. The scan produces a fixed list of candidates, sized by &lt;code&gt;hnsw.ef_search&lt;/code&gt;, which is 40 by default, and the WHERE clause runs on that list. If the caller may read 10 percent of the rows, about 4 of the 40 survive.&lt;/p&gt;

&lt;p&gt;We measured it. 100,000 chunks across 10,000 documents: one document in ten readable by &lt;code&gt;group:hr&lt;/code&gt;, the other nine by &lt;code&gt;group:staff&lt;/code&gt;. Random 64-dimension vectors, an HNSW index with default settings, the same 40 random queries for each caller, LIMIT 20:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Can read&lt;/th&gt;
&lt;th&gt;Rows returned, of 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;group:staff&lt;/td&gt;
&lt;td&gt;90 percent&lt;/td&gt;
&lt;td&gt;20 on every query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;group:hr&lt;/td&gt;
&lt;td&gt;10 percent&lt;/td&gt;
&lt;td&gt;median 4, as few as 1, never more than 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same query, same index, same data. The only difference is who asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why permissions make it worse
&lt;/h2&gt;

&lt;p&gt;A category filter is the same for everyone, so a short result shows up the first time anyone tests it. A permission filter depends on who is asking. Developers test with broad accounts, and a broad account gets full results. The short list only appears for the restricted user: the new hire, the contractor, the three-person team with its own folder.&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgix0czv6u0dhphtn8xa.png" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgix0czv6u0dhphtn8xa.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And a short list does not look like an error. An AI assistant handed 4 passages instead of 20 answers from the 4. If the passage that mattered was number 5, it can tell a new hire there is no policy on something that has one, and the new hire has no reason to doubt it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: iterative index scans
&lt;/h2&gt;

&lt;p&gt;pgvector 0.8.0 added iterative index scans. When the filter leaves too few rows, the scan keeps going through the index instead of stopping at the candidate list:&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;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strict_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- the search query&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&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;SET LOCAL&lt;/code&gt; ends with the transaction that runs the search, so a pooled connection cannot carry the setting anywhere else. Outside a transaction it does nothing. The other mode, &lt;code&gt;relaxed_order&lt;/code&gt;, can return rows slightly out of distance order in exchange for better recall, and pgvector's README shows a materialized CTE that puts them back in order.&lt;/p&gt;

&lt;p&gt;On the same 40 queries, the HR caller got 20 of 20 every time, in either mode. It costs time, because the scan now looks at more of the index. The median query went from 1.7 ms to 27.8 ms with &lt;code&gt;strict_order&lt;/code&gt;, and to 18.1 ms with &lt;code&gt;relaxed_order&lt;/code&gt;, on one desktop. For comparison, exact search, with the index turned off, took 47.5 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: give narrow permissions an exact path
&lt;/h2&gt;

&lt;p&gt;Iterative scans have limits. A scan stops after visiting about &lt;code&gt;hnsw.max_scan_tuples&lt;/code&gt; entries, 20,000 by default, or when it reaches a memory cap of &lt;code&gt;work_mem&lt;/code&gt; times &lt;code&gt;hnsw.scan_mem_multiplier&lt;/code&gt;. A caller who may read almost nothing can hit those limits before 20 matches turn up.&lt;/p&gt;

&lt;p&gt;So we added a third group, &lt;code&gt;group:legal&lt;/code&gt;, readable on one document in a thousand: 0.1 percent of the chunks. Left alone, the planner did the right thing. It used the GIN index on &lt;code&gt;allowed_principals&lt;/code&gt; to find the ten readable documents, joined their chunks, and sorted them by exact distance: 20 of 20 on every query, at a median of 33 ms.&lt;/p&gt;

&lt;p&gt;Then we forced the query through the HNSW index, to see what happens when the planner picks it. With iterative scans on and the default limits, the median was 16 of 20, and the worst query returned 10. Raising &lt;code&gt;hnsw.max_scan_tuples&lt;/code&gt; to 100,000 alone changed nothing. Raising &lt;code&gt;hnsw.scan_mem_multiplier&lt;/code&gt; to 4 alone changed nothing. Raising both got 20 of 20, at a median of 732 ms: about 22 times slower than the exact plan the planner had chosen on its own.&lt;/p&gt;

&lt;p&gt;So the second fix is not a setting. It is an ordinary index on the permission column. pgvector's README calls an index on the filter column "a good place to start" and notes that exact indexes work well for conditions that match a low percentage of rows. With one in place, the planner has a fast, complete path for your narrowest callers. Confirm it with EXPLAIN as your most restricted real user, not as yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that catches it
&lt;/h2&gt;

&lt;p&gt;Every number in this post came from asking as a specific group. That is the test that finds this problem, and a test run as an administrator never will. If your retrieval has a test set, give it cases that run as a restricted group and expect a specific document back. When an index change starves that group, the case fails before a user notices.&lt;/p&gt;

&lt;p&gt;The retrieval engine we build for clients works this way. Its vector search is exact, with no approximate index, until a corpus is large enough to need one and the recall cost has been measured. Its test sets run questions as a named group, and a case fails when an expected document is missing from the top five results or a forbidden one appears in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own documents
&lt;/h2&gt;

&lt;p&gt;Random vectors keep the arithmetic clean: what a caller loses tracks what fraction of the rows they can read. Real embeddings cluster, and permissions often follow topic, so an HR user asking an HR question may lose less, and the same user asking about something outside their documents may lose more. The timings come from one desktop and will differ on yours. Both are reasons to measure your own documents, as your own restricted users.&lt;/p&gt;

&lt;p&gt;If you are putting AI in front of documents that not everyone may read, this is the kind of detail we work through with organizations that keep their data in-house: &lt;a href="https://agaveis.com/arizona-private-ai" rel="noopener noreferrer"&gt;private AI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;Needs PostgreSQL 17 with pgvector 0.8.0 or later; run it with psql. Each count comes from one random query, so yours will differ from the medians above. The index build may print a notice that it no longer fits in &lt;code&gt;maintenance_work_mem&lt;/code&gt;; that only makes the build slower.&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;-- PostgreSQL 17 with pgvector 0.8.0 or later. Run with psql.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 10,000 documents: one in ten readable by group:hr, one in a thousand also by group:legal.&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;document&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;int&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;allowed_principals&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[]&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'group:legal'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
               &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
               &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:staff'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&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;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;g&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="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 100,000 chunks, ten per document, random 64-dimension embeddings.&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;chunk&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;int&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;document_id&lt;/span&gt; &lt;span class="nb"&gt;int&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;document&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;embedding&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10000&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&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;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&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;100000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- One random query vector.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&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;64&lt;/span&gt;&lt;span class="p"&gt;))::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;qv&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;gset&lt;/span&gt;

&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'No vector index (exact search). Rows returned of 20, for group:hr:'&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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;FROM&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&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="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'qv'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&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="c1"&gt;-- Single process build, so it fits a small container's shared memory.&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;max_parallel_maintenance_workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'HNSW index, defaults. group:staff (reads 90 percent), then group:hr (reads 10 percent):'&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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;FROM&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:staff'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&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="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'qv'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&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;FROM&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&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="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'qv'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&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="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'Iterative scans on. group:hr again:'&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iterative_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strict_order&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;count&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;FROM&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;d&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_principals&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'group:hr'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&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="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'qv'&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Agave Information Solutions builds &lt;a href="https://agaveis.com/local-ai" rel="noopener noreferrer"&gt;on-premises AI systems&lt;/a&gt;, &lt;a href="https://agaveis.com/database-architecture" rel="noopener noreferrer"&gt;data architecture&lt;/a&gt;, and &lt;a href="https://agaveis.com/custom-development" rel="noopener noreferrer"&gt;custom software&lt;/a&gt; out of Scottsdale, Arizona. If your AI search has to respect who may read what, &lt;a href="https://agaveis.com/about" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agaveis.com/blog/pgvector-permission-filters-restricted-users" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>EF6 to EF Core: 5 query translations that quietly break in production</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:08:00 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/ef6-to-ef-core-5-query-translations-that-quietly-break-in-production-1ghm</link>
      <guid>https://dev.to/agave_info_solutions/ef6-to-ef-core-5-query-translations-that-quietly-break-in-production-1ghm</guid>
      <description>&lt;p&gt;Most teams plan an EF6 to EF Core migration as "swap the package, fix some namespaces, ship it." For straightforward CRUD codebases that's mostly true. For anything with real query complexity, you will hit a handful of translation differences that the upgrade tooling does not warn you about. The queries compile fine, the unit tests pass against an in-memory provider, and then production explodes the first time someone hits the page that runs the report.&lt;/p&gt;

&lt;p&gt;These are the five that bit us hardest on real migrations. None of them are obvious from reading the official "what's new" page, and a couple of them are silent regressions in behavior rather than visible errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Client-side evaluation does not silently rescue you anymore
&lt;/h2&gt;

&lt;p&gt;EF Core 1.x and 2.x had a feature called automatic client evaluation. If part of your LINQ query could not be translated to SQL, EF Core would happily run that part in memory after fetching the rest of the data from the database. It logged a warning, but the query worked.&lt;/p&gt;

&lt;p&gt;EF Core 3.0 changed the default to throw instead of silently falling back. Code that ran fine for two years suddenly throws &lt;code&gt;InvalidOperationException: The LINQ expression could not be translated.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The worst version of this is a query like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;csharp&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Customers&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NormalizeForSearch&lt;/span&gt;&lt;span class="p"&gt;()&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="nf"&gt;NormalizeForSearch&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NormalizeForSearch() is a custom C# extension method. EF6 happily fetched every row, ran the method on each one in memory, and filtered. The query was a full table scan and stayed quietly slow for years. After the EF Core upgrade it throws at runtime.&lt;/p&gt;

&lt;p&gt;The remediation is not "add .AsEnumerable() before the Where" even though that "works." That just preserves the original full-table-scan behavior with the original O(n) memory cost. The right fix is to push the normalization into the query itself using EF.Functions.Like or a normalized column maintained at write time. Either of those produces real SQL with real index usage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GroupBy translates much less aggressively&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EF6 translated almost any GroupBy you threw at it. EF Core is far more conservative — for years, it required your GroupBy to fit a narrow shape (group key + aggregate functions) and would throw if you tried to project the group elements directly.&lt;/p&gt;

&lt;p&gt;This works in both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Amount&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="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked in EF6 but did not in EF Core 3-5, and only partially works in EF Core 7+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GroupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&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="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is asking for "give me each group along with the list of items in that gr# LINQ but hard to express in standard SQL. EF Core 7 added partial support via SQL Server window functions; older versions throw.&lt;/p&gt;

&lt;p&gt;For migration purposes, audit every GroupBy in the codebase before you flip the switch. The shape that breaks most often is grouping followed by projecting the actual group elements rather than aggregations.&lt;/p&gt;

&lt;p&gt;Restructuring usually means doing the grouping client-side after a flat query, or rewrifor aggregates.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;String comparisons changed collation behavior&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EF6 ran string comparisons through SQL Server with the database's default collation, whie accent-insensitive on most installations. EF Core leaves the comparison semantics up tothe provider, and the defaults are different across providers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"foo@bar.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;against a Latin1_General_CI_AS database returns rows where Email equals &lt;a href="mailto:FOO@BAR.COM"&gt;FOO@BAR.COM&lt;/a&gt; because the SQL collation is case-insensitive. The same query against PostgreSQL&lt;br&gt;
returns zero rows because PostgreSQL is case-sensitive by default. If your application wver provision a PostgreSQL instance, you will spend a confused afternoon.&lt;/p&gt;

&lt;p&gt;EF Core 5+ exposes EF.Functions.Collate for explicit collation control, and EF.Functionsttern matching that works the same across providers. Use those instead of relying on thedatabase default if your code might run against more than one provider.&lt;/p&gt;

&lt;p&gt;A subtler form: string.Equals(c.Name, query, StringComparison.OrdinalIgnoreCase) translated to ordinary = in EF6 (the comparison enum was just ignored), but in EF Core it can throw "could not be translated" depending on version. Same shape of bug as #1.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;DateTime.Now lands client-side in confusing ways&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DateTime.Now evaluated in the context of a LINQ query does not have a consistent transla and providers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;recent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDays&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF6 translated this to WHERE CreatedAt &amp;gt; DATEADD(day, -7, GETDATE()) on SQL Server, whicery time. EF Core sometimes evaluates DateTime.Now once on the client at query construction time, then sends a constant to SQL. If your app builds the query at 12:00 and executes it at 12:30, EF6 uses 12:30, EF Core often uses 12:00.&lt;/p&gt;

&lt;p&gt;For most queries this 30-minute drift does not matter. For session expiry, rate limiting, scheduled job pickups, or audit windows, it matters a lot.&lt;/p&gt;

&lt;p&gt;The deterministic pattern: capture the time explicitly in a local variable and pass it as a parameter. Or use EF.Functions.GetUtcDate() to force server-side evaluation. Or move the time calculation into a&lt;br&gt;
database function and call it explicitly. Any of those produces predictable behavior.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lazy loading is off by default and the fix is not what you think&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EF6 enabled lazy loading by default if your navigation properties were marked virtual. Ty fetches it.&lt;/p&gt;

&lt;p&gt;EF Core does not enable lazy loading by default. The migration path that the documentatirosoft.EntityFrameworkCore.Proxies and call UseLazyLoadingProxies() on the options. That technically restores lazy loading, but it makes every navigation access into a separate database round trip — exactly the N+1 problem the rest of the EF Core team is trying to push you away from.&lt;/p&gt;

&lt;p&gt;The better migration path is to audit where your code was relying on lazy loading and convert those sites to either explicit Include calls or projection into DTOs. It is more work, but the resulting queries&lt;br&gt;
are faster, the N+1 patterns become visible at code review time instead of in production table.&lt;/p&gt;

&lt;p&gt;If you do install the lazy loading proxies as a "get the migration unblocked" bridge, to put a real plan in place to remove it. The longer you ship on lazy proxies, the more code grows to depend on the implicit fetch behavior and the harder removal gets.&lt;/p&gt;

&lt;p&gt;The pattern across all five&lt;/p&gt;

&lt;p&gt;Every one of these breakages comes from the same root: EF6 was very permissive about hiding the gap between LINQ semantics and SQL semantics, and EF Core is less so. EF Core fails loudly (or silently&lt;br&gt;
differently) in places where EF6 would have happily run something inefficient.&lt;/p&gt;

&lt;p&gt;That sounds like a regression. It is actually the right tradeoff. The EF6 queries that "l table scans, N+1 patterns, or subtly wrong results that nobody noticed because the database was small enough that performance didn't expose them. EF Core's stricter posture forces those decisions to surface during migration instead of in production a year later when the data has grown.&lt;/p&gt;

&lt;p&gt;The practical migration playbook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on LogTo for Microsoft.EntityFrameworkCore.Query.QueryCompilationStarting events during integration tests so you can see what each query actually translates to.&lt;/li&gt;
&lt;li&gt;Run a load test against representative data volume before declaring the migration don the dev database often fail on production volume after the EF Core change.&lt;/li&gt;
&lt;li&gt;Audit every GroupBy, every Where with custom methods, every navigation property access, and every DateTime.Now reference.&lt;/li&gt;
&lt;li&gt;Treat Microsoft.EntityFrameworkCore.Proxies as a bridge, not a destination.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The "swap the package and ship it" plan turns into a multi-week project for non-trivial modernization is one of the things we work on most often at Agave Information Solutions(&lt;a href="https://agaveis.com/dotnet-modernization" rel="noopener noreferrer"&gt;https://agaveis.com/dotnet-modernization&lt;/a&gt;). If you are heading into one of these migrations and want a sanity check on your pattern audit, the .NET Modernization (&lt;a href="https://agaveis.com/dotnet-modernization" rel="noopener noreferrer"&gt;https://agaveis.com/dotnet-modernization&lt;/a&gt;)&lt;br&gt;
page has more on how we approach it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>efcore</category>
      <category>csharp</category>
      <category>database</category>
    </item>
    <item>
      <title>Agave Information Solutions</title>
      <dc:creator>Agave Information Soultions, LLC</dc:creator>
      <pubDate>Wed, 03 Jun 2026 04:58:25 +0000</pubDate>
      <link>https://dev.to/agave_info_solutions/agave-information-solutions-d7n</link>
      <guid>https://dev.to/agave_info_solutions/agave-information-solutions-d7n</guid>
      <description>&lt;p&gt;30 years on the Microsoft stack. Started writing .NET when the framework was still in beta in 2001.&lt;/p&gt;

&lt;p&gt;Founded &lt;a href="https://tempestcom.com" rel="noopener noreferrer"&gt;Tempest Telecom&lt;/a&gt; in 1996. We built the first Internet roaming service to provide access on all 7 continents, and a unified AAA platform that combined voice, dialup, WiFi, and satellite into one experience. The 2+ petabyte video platform that came out of that work is where I learned that query plans behave non-linearly at scale, and why backup strategy is architecture.&lt;/p&gt;

&lt;p&gt;Founded &lt;a href="https://agaveis.com" rel="noopener noreferrer"&gt;Agave Information Solutions&lt;/a&gt; in Scottsdale in 2008. Today we modernize legacy .NET Framework applications onto modern .NET 10/9/8 with AI-ready architecture, design databases that need to handle real volume, and ship the kind of senior-engineering work most boutique shops outsource.&lt;/p&gt;

&lt;p&gt;I write here about .NET migrations, EF Core gotchas, SQL Server query plans, AI integration into legacy stacks, and the lessons from running production systems at scale for a long time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find me elsewhere:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://agaveis.com" rel="noopener noreferrer"&gt;agaveis.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.linkedin.com/in/jasonjacoby/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Agaveis" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://x.com/AgaveInfo" rel="noopener noreferrer"&gt;X&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>dotnet</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
