<?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>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>
