Oren Eini

CEO of RavenDB

a NoSQL Open Source Document Database

Get in touch with me:

oren@ravendb.net +972 52-548-6969

Posts: 7,644
|
Comments: 51,262
Privacy Policy · Terms
filter by tags archive
time to read 11 min | 2036 words

I've written before about why I think the “modern” approach of solving every problem by adding agents is a doomed path. The current instinct is to add more layers of agents, judges, reviewers, etc. - and hope the model will be smart enough to do the right thing and actually get something done.

The issue is that we already have a well-understood way to coordinate independent contributors working on a complex system. It's called software design & architecture, and we've been refining it for decades. The instinct to solve a coordination problem by adding a smarter message bus between your agents is exactly backwards.

When talking about multi-agent support in RavenDB, I want to be clear about what we did not build. We didn't build an orchestration framework. We didn't build a swarm. What we built is much more boring, and I mean that as the highest compliment I know how to give.

We built a way for you to solve problems in a predictable manner and without a lot of hassle.

If you haven't looked at RavenDB's AI Agents yet, the short version is this: an agent is a system prompt, a connection to an LLM, and a set of Query tools, which let the agent read specific data through RQL you define, and Action tools, which let it do something in your application, but only through the specific doors you've opened.

The note on “specific” is the whole point. Instead of giving the model the freedom to access any data it wants and execute anything it feels like, we place careful guardrails that it cannot escape.

The agent can only pull the levers you gave it, using the data you explicitly handed it. The database takes care of the tedious, error-prone plumbing (such as conversation state, message history, talking to the model provider, etc.) and stores every conversation as a real document in the @conversations collection. You define capabilities, and RavenDB takes care of all the rest.

Where one agent starts to hurt

A single agent like this will take you a genuinely long way. Right up until it doesn't. The issue is the slow creep of complexity. Let’s say that you start with a simple agent to deal with answering employee questions in the context of the HR department.

The next feature is to assist them in filing expense reports. Then someone wants it to handle time-off requests, and then to look up the org chart, and six months later you have one prompt trying to be an entire company, with thirty tools competing for the model's attention and a context window stuffed with things that have nothing to do with the question being asked.

That situation is problematic on multiple levels:

  • Your context window is full of a huge prompt (usually not relevant to the task at hand), a lot of tool descriptions and capabilities meant to cover every possible scenario under the sun.
  • Users’ questions are either squeezed into the remaining context window or you have to move to models with larger context windows, which also cost more.
  • You are also stuck with a single model for everything, instead of being able to pick the right model for each scenario. You overpay to run trivial chit-chat on your best model, or you cripple the hard tasks by forcing them onto a cheap one.

This is not a new problem. Scope is how you manage complexity, and it works exactly the same whether the thing on the other end is a compiler or a language model.

Multi-agents are just agents that talk to each other

A multi-agent system in RavenDB is not a special kind of agent. It's several ordinary agents, each built exactly the way you already know how to build one, where one of them happens to know that another exists and can hand work to it.

The way you connect them is almost anticlimactic. You add a SubAgents entry to the parent agent: an identifier and a description. That's the entire wiring job. The description matters because that's what the parent reads to decide whether a given request belongs to the specialist. There is no router you write, no dispatch table, no orchestration layer.

You made the subagent available to the root, and RavenDB will invoke it for you when it is needed. RavenDB will also handle all the logistical minutiae needed to accomplish this successfully.

Those include ensuring that the scope for the root agent is shared with the called subagents, managing memory and conversation history, allowing the subagent to invoke its own queries and actions, etc.

We ship a demo for this — an HR chatbot, samples-hr on GitHub if you want to run it yourself. An employee comes back from a conference, uploads the receipt, and types "submit this expense." From their side, it's one smooth conversation.

Behind the scenes, it's two agents. The HR Assistant faces the user and handles benefits, policies, and general questions. It does not know the first thing about filing an expense. What it does know is that there's an Expense Manager specialist, and — from that one-line description — that receipts belong to it. It hands the task over, the specialist analyzes the receipt and creates the BusinessTripBills document, and the answer flows back up.

Go look in the database afterward and you'll find two conversation documents, not one — the HR Manager's log and the Expense Manager's log, each with its own scoped history. That's not an implementation detail I'm mentioning for trivia's sake. It means you can open either conversation and see exactly what that agent received and how it responded. The audit trail falls out of the design for free.

Here's the parent agent, trimmed down to the part that matters:


public static Task Create(IDocumentStore store)
{
 return store.AI.CreateAgentAsync(
  new AiAgentConfiguration
  {
   Name = "HR Assistant",
   Identifier = AgentIdentifier,
   ConnectionStringName = ConnectionStringName,
   SystemPrompt = @"You are an HR assistant.
Provide info on benefits, policies, and departments.
Do not suggest actions that are not explicitly allowed by the tools available to you.
Do NOT discuss non-HR topics. Answer only for the current employee.",


   Parameters =
   [
    new AiAgentParameter(EmployeeIdParameter,
     "Employee ID; answer only for this employee")
   ],


   // The HR agent has no idea how to file an expense.
   // It just knows a specialist exists, and when to call it.
   SubAgents =
   [
    new AiAgentToolSubAgent
    {
     Identifier = ExpenseAgentIdentifier,
     Description = "Manages business trip expenses: analyzing " +
      "receipts/bills, reporting expenses, and retrieving " +
      "monthly expense summaries."
    }
   ],


   // Its own capabilities cover only HR concerns.
   Queries =
   [
    new AiAgentToolQuery
    {
     Name = "GetEmployeeInfo",
     Description = "Retrieve employee details",
     Query = $"from Employees where id() = ${EmployeeIdParameter}",
     ParametersSampleObject = "{}"
    }
   ]
  });
}

The Expense Manager, for its part, is defined in exactly the same way as any other agent. There is nothing special about it. It's a normal agent that happens to be pointed at by a SubAgents entry.

Notice what this buys you. The HR agent never sees the finance tooling. It doesn't integrate the expense system, doesn't swallow that domain, or deal with a BusinessTripBills document. It shells the call out to the specialist and stays in its lane. If tomorrow finance wants their own policy-checking agent in the loop, you add another SubAgents entry to the Expense Manager and the HR agent is none the wiser. Each piece keeps a small & independent scope.

Each of those agents is isolated from the others, so we can have the HR Agent use a pure textual model, maybe with high levels of reasoning. The Expense Manager agent, on the other hand, needs to be multimodal (to be able to read receipt images), but it doesn’t need to be smart. You can customize each for its own needs, without having to find the lowest common denominator.

The fact that each of those agents (even if they both participate in the same conversation) will use separate @conversations documents also means their contexts are isolated from one another. The fact that you just pushed the entire set of receipts from a two-week business trip to the Expense Manager agent doesn’t weigh down the HR agent when you ask about your remaining holiday balance.

When you should not do this

This shouldn’t be your default architecture. Like any advanced technique, you need a sufficient level of complexity to justify it. If the work fits in one sentence, it probably fits in one agent.

Answering from a knowledge base, generating a document from a template, running a linear sequence with no branching — a single well-built agent handles all of that, and reaching for sub-agents just to keep things tidy or to mirror your org chart is perfectionism. It's the same mistake as premature abstraction, and it costs you real latency and real tokens for the privilege.

Multi-agents earn their keep when one of the following is true:

  • You need to independently develop the agents (different teams are handling different agents).
  • You will use different models for different parts of the system.
  • You want to explicitly limit the sharing of context between different parts of the system.

For example, keeping with the HR agent theme, let’s say that we want to allow users to ask questions about our policies (an example of such an agent). The problem here is that we may have a lot of policies, and even when we limit ourselves to the right one, that is a lot of text.

Shoving all of the work of finding the right policy and extracting the specific elements to match the user’s question into an isolated agent can massively reduce the number of tokens that your agents will burn.

Summary

RavenDB’s multi-agents aren’t about orchestrating a robot army or an independent swarm of self-coordinating agents. It is far more prosaic than that. You're composing independent components with clear boundaries and letting each one own a scoped conversation, its own tools, and the model that fits its job.

That's not a new idea we invented for the age of AI. It’s bringing back the notion of independent components that are greater than the sum of their parts, in a way that is manageable, consistent, and hassle-free.

If you want to try it, grab a free Developer license or spin up a free Cloud database, and the multi-agent guide walks through the demo end to end.

time to read 7 min | 1391 words

Everyone talking about coding models fixates on the same number: how fast the thing generates code. This misses the point by a lot. The story isn't about how fast the model writes code I would have written anyway.

It's that the model lets me do things that I might have done before but were expensive enough that I didn’t bother. I had three separate interactions this week that led to this blog post.

We had a production problem on an instance and no clear idea what was going on. What we did have was the log: something like 25-30 MB of compressed text describing everything that happened. And the actual problem wasn't spotting an error: finding errors is easy. The problem was correlation. We needed to line up different events across the timeline and understand how they were related.

In the past, I would have to trawl through the log and hope that something would pop up. These days, we can try handing the whole thing to the model and let it figure it out. If the log file wasn’t that big, it might even work. At dozens of MB, it doesn’t work (and it is quite expensive to try).

I went the other way. I told the model: “Write me a script that looks at the structure of this log (I gave it the first ten rows). I want the script to extract and aggregate the parts I care about, and render the result in a nice table to make it easier to understand.”

I had the view in under a minute, then I could explore the log and iterate:

  • “Oh, I see that there are a lot of indexes. How many of them are for the same database?”
  • “Give me a histogram of index changes and their versions over time.”

The model wrote some code, produced a view, and I looked at it. Rinse & repeat until I had a pretty good idea what was going on.

The customer had several different versions of their application, each with its own set of indexes, and they kept overwriting one another, leading to a huge amount of indexing overhead. RavenDB actually has a dedicated feature for that scenario.

Here's the part that matters: I never read the code the model wrote. The moment the investigation was done, I threw all of it away. It's throwaway code whose entire purpose was to help me see, and once I had seen enough, I discarded it.

Without the model to write this code, I could have written it myself, but it is enough of a chore that it probably wouldn’t make sense. Doing that manually would have taken roughly the same amount of time.


The second interaction is the opposite kind of work. I'm doing a fairly significant refactor of how a particular query executes in Corax, and that code is going into the product and staying there for a decade or two.

Here, the model writes and I drive. I tell it the overall direction, it goes somewhere, and then I decide if I like the result. I find it genuinely easier to react to something than to produce it from a blank page — having a first draft to push against is faster than writing it all myself. Nevertheless, this is my code. I went over every single line, and I know exactly what's in there.

That last part takes real discipline, and it's worth being honest about why. When you're in the zone chasing a change (try something out, revert, try something else, etc.), it is very easy to surface a few hours later staring at two thousand lines of changes you never actually wrote. You went through a dozen iterations, and somewhere in there the code stopped being something you authored and became something that merely happened. Guarding against that is really important, because otherwise that isn’t your code.

How do I make sure it's still mine? I lean on tests, of course — regression tests to prove I didn't break the old behavior, and new tests built alongside the change to pin down the new behavior. That's the baseline for anything long-lived.

The technique I found most useful for confirming that the change is really mine is a little unusual. I had it build a harness that runs a set of scenarios against both the old version and the new one. It's a small app that issues queries and operations to the database and visualizes the results.

Here is what this looked like:

You can see that I have a bunch of scenarios that I’m testing, and it is very easy for me to track progress and know where I need to pay attention. The actual app had a lot more capabilities: what got faster, what got slower, the ranges, the memory used, everything and the kitchen sink went into that, in a format that made sense for the sort of work I was doing.

Each time I had a new direction, it was either driven by this application or I asked the model to add it to the application, so I could keep working on it. I kept working until nothing in the new version was slower than the old, and the headline paths were dramatically faster.

As an example of what this looked like most of the time, I ran a query, and then I inspected the structure we got back. Here is what some of that looked like:

And as I went, I kept changing the harness itself — show me this instead, group it that way. Trivial to do, because the harness is also throwaway. I'm not carrying it forward. I don't care about its code quality. I never even looked at its code. It exists to make a point, and once it's made the point, it's gone.

I also used the model to add introspection hooks and visibility into what was going on inside the system, surfacing stuff that you would usually have to scratch your head and debug to understand. That meant that I was able to look at a problematic query, then just look at its query plan and the timing in it. I usually knew where I needed to pay attention from there.

To be honest, that part feels a lot like cheating.


In the cases of the log analyzer and the comparison harness, the code is literally disposable. It’s scaffolding that would be thrown away after the work is done. I didn’t pay any attention to that code (I never read it), and it was never meant to be useful for anything else.

In the case of the production code, I went over each line of code so many times, I dreamt of it. A lot of the code there consists of annoying building blocks (building a visualization of the query plan as a graph, for example), which were sped up enormously by asking the model to build it for us. A lot of other code there is hand-crafted to say exactly what I needed it to.

But the fact that I can get good scaffolding from the model for cheap changes a lot of the usual considerations. Because scaffolding is literally disposable code, I don’t have to worry about the usual code quality concerns. The log analyzer would probably take two or three hours to write (without the pretty graphics, which were helpful for easily identifying what was going on).

The comparison harness would be multiple weeks of effort and would probably be a non-interactive ASCII table. In fact, I don’t need to guess. Scaffolding isn’t something that is new, I do that all the time. Here is an example of one, written about a decade ago:

In the image above, you can find the internal structure of a B+Tree inside RavenDB. Contrast that with the following scaffolding for query plans. That one, by the way, is actually staying in the product.

Compare that to the level of insight that you can derive from the query plan higher up in this post. The B+Tree scaffolding, by the way, is essential to understanding the more complex scenarios. It paid for the time it took to write it many times over.

The ability to now effectively do the same at very little cost means that the act of building software itself is now easier. Not because someone else is writing the core code, but because everything else that we need to do is also easier.

time to read 8 min | 1546 words

Deep in the heart of Corax (RavenDB’s querying engine), everything deals with something called a Posting List. Posting Lists are a way for the engine to say “all of those documents have the term Fast for the field Speed”.  Conceptually, a Posting List is an ordered set of document ids. In this case (and this is important, the ids in question are numeric, not RavenDB’s document id, which is a string).

An interesting problem with Posting Lists is that a term can be unique (such as a GUID) - only a single document will ever have this value. They can have a small set of values (for example, CreationDate, where only the items created on that day will share the same value). Or they can have many values (for example, Status = ‘Shipped’ for Orders).

The reason those details matter is that we can deal with the three (very) different modes in a distinct manner to reduce the cost of storage that Corax uses for Posting Lists. Internally, Corax has the notion of a PostingListId, which is just a 64-bit number. We use the least significant 2 bits to tell us what kind of Posting List this is.

Typical code to consume this looks like this:


while ((read = provider.FillPostingListIds(plIds)) > 0)
{
    for (int i = 0; i < read; i++)
    {
        var postingListId = plIds[i];
        var termType = (TermIdMask)postingListId & TermIdMask.EnsureIsSingleMask;


        switch (termType)
        {
            case TermIdMask.Single:
            {
                // Accumulate the single decoded entry ID into the entryBuffer
                // Flush to bitmap if the buffer is full
                break;
            }


            case TermIdMask.SmallPostingList:
            {
                // Decode inline via FastPForBufferedReader into the entryBuffer
                // Flush to bitmap if the buffer is full
                break;
            }


            case TermIdMask.PostingList:
            {
                // Flush any accumulated entries from the buffer first
                // Iterate through the full large posting list via FillFromPostings
                break;
            }


            default:
                throw new InvalidOperationException($"Unknown TermIdMask type: {termType}");
        }
    }
}

The code here iterates through a list of Posting List ids, handling each one of the options. This is part of handling queries such as: where CreatedAt > $lastQuarter. We have to get all the distinct dates in the range, and for each date, we need to get all the matching documents for it.

The code here is pretty simple, right? It is fairly obvious what it does, but it has a pretty big problem. It processes each one of the Posting Lists manually & separately. There are two problems with this approach:

  • We end up doing a lot of branching, based on which Posting List type we are processing.
  • We lose the ability to handle the Posting Lists in bulk.

Because we use the lowest two bits to store the type of the Posting List, we can do better. The insight behind the new approach is that (id & EnsureIsSingleMask) naturally yields 0, 1, or 2 — a perfect index into an array of buckets. Instead of a switch statement, we partition the batch in one pass with no per-item branches:


var buckets = new List<long>[3];
while ((read = provider.FillPostingListIds(plIds)) > 0)
{
   for (int i = 0; i < read; i++)
   {
       buckets[(int)(plIds[i] & 0b11)].AddUnsafe(pid);
   }
}

Of course, you’ll note that we aren’t actually processing those, just putting them in buckets. The fact that we are able to split them into groups in a branchless manner is a fun optimization task, but it doesn’t matter as much as the next stage… we can now deal with a list of Posting Lists that are already divided by type.

For example, for the list holding only a single unique value, I can run the following:


var singlesSpan = CollectionMarshal.AsSpan(buckets[0]);
EntryIdEncodings.DecodeAndDiscardFrequency(singlesSpan, singlesSpan.Length);
var singlesLen = Sorting.SortAndRemoveDuplicates(singlesSpan);
bitmap.AddRange(singlesSpan[..singlesLen]);

This is great, because now I can process the entire list using SIMD in a highly efficient manner. But things get better when we look at the other lists. In the case of the small Posting Lists, for example, the ids that I’m reading are not the actual values, but point to where the values actually reside.

This gives me the chance to actually load them from disk in an optimal, batched manner, like so:


var smallsSpan = buckets[1].ToSpan();
EntryIdEncodings.DecodeAndDiscardFrequency(smallsSpan, smallsSpan.Length);
var smallLen = Sorting.SortAndRemoveDuplicates(smallsSpan);
Container.GetAll(llt, smallsSpan[..smallLen], containerItems.ToSpan(), long.MinValue, pageLocator);


for (int i = 0; i < smallLen; i++)
{
   var item = containerItems[i];
   // read the small posting list and deal with it
}

The key here is the Container.GetAll() call, which takes the list of unique Postings List locations and loads them in an optimized manner. In the previous code style, that was actually pretty hard to do. In this manner, it falls naturally from the way we write the code.

There are also advantages to the fact that we run tight loops with the same code, instead of branching for each type. We also ended up with less code overall, which is also nice.

time to read 8 min | 1542 words

I have run into this post by John Rush, which I found really interesting, mostly because I so vehemently disagree with it. Here are the points that I want to address in John’s thesis:

1. Open Source movement gonna end because AI can rewrite any oss repo into a new code and commercially redistribute it as their own.

2. Companies gonna use AI to generate their none core software as a marketing effort (cloudflare rebuilt nextjs in  a week).

Can AI rewrite an OSS repo into new code? Let’s dig into this a little bit.

AI models today do a great job of translating code from one language to another. We have good testimonies that this is actually a pretty useful scenario, such as the recent translation of the Ladybird JS engine to Rust.

At RavenDB, we have been using that to manage our client APIs (written in multiple languages & platforms). It has been a great help with that.

But that is fundamentally the same as the Java to C# converter that shipped with Visual Studio 2005. That is 2005, not 2025, mind you. The link above is to the Wayback Machine because the original link itself is lost to history.

AI models do a much better job here, but they aren’t bringing something new to the table in this context.

Claude C Compiler

Now, let’s talk about using the model to replicate a project from scratch. And here we have a bunch of examples. There is the Claude C Compiler, an impressive feat of engineering that can compile the Linux kernel.

Except… it is a proof of concept that you wouldn’t want to use. It produces code that is significantly slower than GCC, and its output is not something that you can trust. And it is not in a shape to be a long-term project that you would maintain over the years.

For a young project, being slower than the best-of-breed alternative is not a bad thing. You’ve shown that your project works; now you can work on optimization.

For an AI project, on the other hand, you are in a pretty bad place. The key here is in terms of long-term maintainability. There is a great breakdown of the Claude C Compiler from the creator of Clang that I highly recommend reading.

The amount of work it would require to turn it into actual production-level code is enormous. I think that it would be fair to say that the overall cost of building a production-level compiler with AI would be in the same ballpark as writing one directly.

Many of the issues in the Claude C Compiler are not bugs that you can “just fix”. They are deep architectural issues that require a very different approach.

Leaving that aside, let’s talk about the actual use case. The Linux kernel’s relationship with its compiler is not a trivial one. Compiler bugs and behaviors are routine issues that developers run into and need to work on.

See the occasional “discussion” on undefined behavior optimizations by the compiler for surprisingly straightforward code.

Cloudflare’s vinext

So Cloudflare rebuilt Next.js in a week using AI. That is pretty impressive, but that is also a lie. They might have done some work in a week, but that isn’t something that is ready. Cloudflare is directly calling this highly experimental (very rightly so).

They also have several customers using it in production already. That is awesome news, except that within literal days of this announcement, multiple critical vulnerabilities have been found in this project.

A new project having vulnerabilities is not unexpected. But some of those vulnerabilities were literal copies of (fixed) vulnerabilities in the original Next.js project.

The issue here is the pace of change and the impact. If it takes an agent a week to build a project and then you throw that into production, how much real testing has been done on it? How much is that code worth?

John stated that this vinext project for Cloudflare was a marketing effort. I have to note that they had to pay bug bounties as a result and exposed their customers to higher levels of risk. I don’t consider that a plus. There is also now the ongoing maintenance cost to deal with, of course.

The key here is that a line of code is not something that you look at in isolation. You need to look at its totality. Its history, usage, provenance, etc. A line of code in a project that has been battle-tested in production is far more valuable than a freshly generated one.

I’ll refer again to the awesome “Things You Should Never Do” from Spolsky. That is over 25 years old and is still excellent advice, even in the age of AI-generated code.

NanoClaw’s approach

You’ve probably heard about the Clawdbot ⇒ Moltbot ⇒ OpenClaw, a way to plug AI directly into everything and give your CISO a heart attack. That is an interesting story, but from a technical perspective, I want to focus on what it does.

A key part of what made OpenClaw successful was the number of integrations it has. You can connect it to Telegram, WhatsApp, Discord, and more. You can plug it into your Gmail, Notes, GitHub, etc.

It has about half a million lines of code (TypeScript), which were mostly generated by AI as well.

To contrast that, we have NanoClaw with ~500 lines of code. Not a typo, it is roughly a thousand times smaller than OpenClaw. The key difference between these two projects is that NanoClaw rebuilds itself on the fly.

If you want to integrate with Telegram, for example, NanoClaw will use the AI model to add the Telegram integration. In this case, it will use pre-existing code and use the model as a weird plugin system. But it also has the ability to generate new code for integrations it doesn’t already have. See here for more details.

On the one hand, that is a pretty neat way to reduce the overall code in the project. On the other hand, it means that each user of NanoClaw will have their own bespoke system.

Contrasting the OpenClaw and NanoClaw approaches, we have an interesting problem. Both of those systems are primarily built with AI, but NanoClaw is likely going to show a lot more variance in what is actually running on your system.

For example, if I want to use Signal as a communication channel, OpenClaw has that built in. You can integrate Signal into NanoClaw as well, but it will generate code (using the model) for this integration separately for each user who needs it.

A bespoke solution for each user may sound like a nice idea, but it just means that each NanoClaw is its own special snowflake. Just thinking about supporting something like that across many users gives me the shivers.

For example, OpenClaw had an agent takeover vulnerability (reported literally yesterday) that would allow a simple website visit to completely own the agent (with all that this implies). OpenClaw’s design means that it can be fixed in a single location.

NanoClaw’s design, on the other hand, means that for each user, there is a slightly different implementation, which may or may not be vulnerable. And there is no really good way to actually fix this.

Summary

The idea that you can just throw AI at a problem and have it generate code that you can then deploy to production is an attractive one. It is also by no means a new one.

The notion of CASE tools used to be the way to go about it. The book Application Development Without Programmers was published in 1982, for example. The world has changed since then, but we are still trying to get rid of programmers.

Generating code quickly is easy these days, but that just shifts the burden. The cost of verifying code has become a lot more pronounced. Note that I didn’t say expensive. It used to be the case that writing the code and verifying it were almost the same task. You wrote the code and thus had a human verifying that it made sense. Then there are the other review steps in a proper software lifecycle.

When we can drop 15,000 lines of code in a few minutes of prompting, the entire story changes. The value of a line of code on its own approaches zero. The value of a reviewed line of code, on the other hand, hasn’t changed.

A line of code from a battle-tested, mature project is infinitely more valuable than a newly generated one, regardless of how quickly it was produced. The cost of generating code approaches zero, sure.

But newly generated code isn’t useful. In order for me to actually make use of that, I need to verify it and ensure that I can trust it. More importantly, I need to know that I can build on top of it.

I don’t see a lot of people paying attention to the concept of long-term maintainability for projects. But that is key. Otherwise, you are signing up upfront to be a legacy system that no one understands or can properly operate.

Production-grade software isn’t a prompt away, I’m afraid to say. There are still all the other hurdles that you have to go through to actually mature a project to be able to go all the way to production and evolve over time without exploding costs & complexities.

time to read 5 min | 897 words

Modern coding agents can generate a lot of code very quickly.What once consumed days or weeks of a person’s time is now a simple matter of a prompt and a coffee break.The question is whether this changes any of the fundamental principles ofsoftware development.

A significant portion of software engineering (beyond pure algorithms and data structure work) is not about the code itself, but about managing the social aspects of building and evolving the software over time.

Our system's architecture inherently mirrors the structure of the organization that builds it, as stated by Conway's Law.Therefore, software engineering deals a lot with how a software project is structured to ensure that a (human) team can deliver, make changes, and maintain it over time.

That is why maintainability is such a high-value target: an unmaintainable project quickly becomes one no one can safely change. A good example is OpenSSL circa Heartbleed, or your bank’s COBOL-based core systems.

Does this still apply in the era of coding agents?If a new feature is needed, and I can simply ask a model to regenerate the whole thing from scratch, bypassing technical debt and re-incorporating all constraints, do I still need to worry about maintainability?

My answer in this regard is emphatically yes.There is immense value in ensuring the maintainability of projects, even in the age of AI agents.

One of the most obvious answers is that a maintainable project minimizes the amount of code you must review and touch to make a change.Translating this into the language of Large Language Models, this means you are fundamentally reducing the required context needed to execute a change.

It isn’t just about saving our token budget. Even assuming an essentially unlimited budget, the true value extends beyond mere computation cost.

The maintainability of a software project remains critical because you cannot trust a model to act with absolute competence.You do not have the option of simply telling a model, "Make this application secure," and blindly expecting a perfect outcome. It will give you a thumbs-up and place your product API key in the client-side code.

Furthermore, in a mature software project, even one built entirely with AI, making substantial changes using an AI agent is incredibly risky.Consider the scenario where you spend a week with an agent, carefully tweaking the system's behavior, reviewing the code, and directing its output into the exact shape required.

Six months later, you return to the same area for a change.If the model rewrites everything from scratch, because it can, the entire context and history of those days and weeks of careful guidancewill be lost.This lost context is far more valuable than the code itself.

Remember Hyrum’s Law: "With a sufficient number of users of an API, it does not matter what you promise in the contract:all observable behaviors of your system will be depended on by somebody."

The "sufficient number of users" is surprisingly low, and observable behaviors include non-obvious factors like performance characteristics, the order of elements in a JSON document, the packet merging algorithm in a router you weren’t even aware existed, etc.

The key is this: if a coding agent routinely rewrites large swaths of code, you are not performing an equivalent exchange.

Even if the old code had been AI-generated, it was subsequently subjected to human review, clarification, testing, and verification by users, then deployed - and it survived the production environment and production loads.

The entirely new code has no validated quality yet.You must still expend time and effort to verify its correctness.That is the difference between the existing code and the new one.

Over 25 years ago, Joel Spolsky wrote Things You Should Never Do about the Netscape rewrite. That particular article has withstood the test of time very well. And it is entirely relevant in the age of coding agents as well.

Part of my job involves reviewing code on a project that is over fifteen years old with over a million lines of code. The past week,I've reviewed pull requests ranging from changes of a few hundred lines to one that changed over 10,000 lines of code.

The complexity involved in code review scales exponentially with the amount of code changed, because you must understand not just the changed code, but all its interactions with the rest of the system.

That 10,000+ lines of code pull request is something that is applicable for major features, worth the time and effort that it takes to properly understand and evaluate the change.

Thinking that you can just have a coding agent throw big changes on a project fundamentally misunderstands how projects thrive. And assuming you can have one agent write the code and another review it is a short trip to madness.

In summary, maintainability in the age of coding agents looks remarkably like it did before.The essential requirements remain: clear boundaries, a consistent architecture, and the ability to go into a piece of code and understand exactly what it's doing.

Funnily enough, the same aspects of good software engineering discipline also translate well into best practices for AI usage: limiting the scope of change, reducing the amount of required context, etc.

You should aim to modify a single piece of code, or better yet, create new code instead of modifying existing, validated code (Open/Closed Principle).

Even with AI, the human act of reviewing code is still crucial.And if your proposed solution is to have one AI agent review another, you have simply pushed the problem one layer up, as you are still faced with the necessity of specifying exactly what the system is supposed to be doing in a way that is unambiguous and clear.

There is already a proper way to do that, we call it coding 🙂.

time to read 7 min | 1362 words

A really interesting problem for developers building agentic systems is moving away from chatting with the AI model. For example, consider the following conversation:

This is a pretty simple scenario where we need to actually step out of the chat and do something else. This seems like an obvious request, right? But it turns out to be a bit complex to build.

The reason for that is simple. AI models don’t actually behave like you would expect them to if your usage is primarily as a chat interface. Here is a typical invocation of a model in code:


class MessageTuple(NamedTuple):
    role: str
    content: str


def call_model(
    message_history: List[MessageTuple],
    tools: List[Callable] = None
):
   pass # redacted

In other words, it is the responsibility of the caller to keep track of the conversation and send the entire conversation to the agent on each round. Here is what this looks like in code:


conversation_history = [
    {
        "role": "user",
        "content": "When do I get my anniversary gift?"
    },
    {
        "role": "agent",
        "content": "Based on our records, your two-year anniversary is in three days. This milestone means you're eligible for a gift card as part of our company's recognition program.\nOur policy awards a $100 gift card for each year of service. Since you've completed two years, a $200 gift card will be sent to you via SMS on October 1, 2025."
    },
    {
        "role": "user",
        "content": "Remind me to double check I got that in a week"
    }
]

Let’s assume that we have a tool call for setting up reminders for users. In RavenDB, this looks like the screenshot below (more on agentic actions in RavenDB here):

And in the backend, we have the following code:


conversation.Handle<CreateReminderArgs>("CreateReminder", async (args) =>
{
    using var session = _documentStore.OpenAsyncSession();
    var at = DateTime.Parse(args.at);
    var reminder = new Reminder
    {
        EmployeeId = request.EmployeeId,
        ConversationId = conversation.Id,
        Message = args.msg,
    };
    await session.StoreAsync(reminder);
    session.Advanced.GetMetadataFor(reminder)["@refresh"] = at;
    await session.SaveChangesAsync();


    return $"Reminder set for {at} {reminder.Id}";
});

This code uses several of RavenDB’s features to perform its task. First we have the conversation handler, which is the backend handling for the tool call we just saw. Next we have the use of the @refresh feature of RavenDB. I recently posted about how you can use this feature for scheduling.

In short, we set up a RavenDB Subscription Task to be called when those reminders should be raised. Here is what the subscription looks like:


from Reminders as r
where r.'@metadata'.'@refresh' != null

And here is the client code to actually handle it:


async Task HandleReminder(Reminder reminder)
{
        var conversation = _documentStore.AI.Conversation(
                agentId: "smartest-agent",
                reminder.ConversationId,
                creationOptions: null
       );
     conversation.AddArtificialActionWithResponse(
"GetRaisedReminders", reminder);
     var result = await conversation.RunAsync();
     await MessageUser(conversation, result);
}

The question now is, what should we do with the reminder?

Going back to the top of this post, we know that we need to add the reminder to the conversation. The problem is that this isn’t part of the actual model of the conversation. This is neither a user prompt nor a model answer. How do we deal with this?

We use a really elegant approach here: we inject an artificial tool call into the conversation history. This makes the model think that it checked for reminders and received one in return, even though this happened outside the chat. This lets the agent respond naturally, as if the reminder were part of the ongoing conversation, preserving the full context.

Finally, since we’re not actively chatting with the user at this point, we need to send a message prompting them to check back on the conversation with the model.

Summary

This is a high-level post, meant specifically to give you some ideas about how you can take your agentic systems to a higher level than a simple chat with the model. The reminder example is a pretty straightforward example, but a truly powerful one. It transforms a simple chat into a much more complex interaction model with the AI.

RavenDB’s unique approach of "inserting" a tool call back into the conversation history effectively tells the AI model, "I've checked for reminders and found a reminder for this user." This allows the agent to handle the reminder within the context of the original conversation, rather than initiating a new one. It also allows the agent to maintain a single, coherent conversational thread with the user, even when the system needs to perform background tasks and re-engage with them later.

You can also use the same infrastructure to create a new conversation, if that makes sense in your domain, and use the previous conversation as “background material”, so to speak. There is a wide variety of options available to fit your exact scenario.

time to read 5 min | 849 words

I ran into this tweet from about a month ago:

dax @thdxr

programmers have a dumb chip on their shoulder that makes them try and emulate traditional engineering there is zero physical cost to iteration in software - can delete and start over, can live patch our approach should look a lot different than people who build bridges

I have to say that I would strongly disagree with this statement. Using the building example, it is obvious that moving a window in an already built house is expensive. Obviously, it is going to be cheaper to move this window during the planning phase.

The answer is that it may be cheaper, but it won’t necessarily be cheap. Let’s say that I want to move the window by 50 cm to the right. Would it be up to code? Is there any wiring that needs to be moved? Do I need to consider the placement of the air conditioning unit? What about the emergency escape? Any structural impact?

This is when we are at the blueprint stage - the equivalent of editing code on screen. And it is obvious that such changes can be really expensive. Similarly, in software, every modification demands a careful assessment of the existing system, long-term maintenance, compatibility with other components, and user expectations.This intricate balancing act is at the core of the engineering discipline.

A civil engineer designing a bridge faces tangible constraints: the physical world, regulations, budget limitations, and environmental factors like wind, weather, and earthquakes.While software designers might not grapple with physical forces, they contend with equally critical elements such as disk usage, data distribution, rules & regulations, system usability, operational procedures, and the impact of expected future changes.

Evolving an existing software system presents a substantial engineering challenge.Making significant modifications without causing the system to collapse requires careful planning and execution.The notion that one can simply "start over" or "live deploy" changes is incredibly risky.History is replete with examples of major worldwide outages stemming from seemingly simple configuration changes.A notable instance is the Google outage of June 2025, where a simple missing null check brought down significant portions of GCP. Even small alterations can have cascading and catastrophic effects.

I’m currently working on a codebase whose age is near the legal drinking age. It also has close to 1.5 million lines of code and a big team operating on it. Being able to successfully run, maintain, and extend that over time requires discipline.

In such a project, you face issues such as different versions of the software deployed in the field, backward compatibility concerns, etc. For example, I may have a better idea of how to structure the data to make a particular scenario more efficient. That would require updating the on-disk data, which is a 100% engineering challenge. We have to take into consideration physical constraints (updating a multi-TB dataset without downtime is a tough challenge).

The moment you are actually deployed, you have so many additional concerns to deal with. A good example of this may be that users are used to stuff working in a certain way. But even for software that hasn’t been deployed to production yet, the cost of change is high.

Consider the effort associated with this update to a JobApplication class:

This looks like a simple change, right? It just requires that you (partial list):

  • Set up database migration for the new shape of the data.
  • Migrate the existing data to the new format.
  • Update any indexes and queries on the position.
  • Update any endpoints and decide how to deal with backward compatibility.
  • Create a new user interface to match this whenever we create/edit/view the job application.
  • Consider any existing workflows that inherently assume that a job application is for a single position.
  • Can you be partially rejected? What is your status if you interviewed for one position but received an offer for another?
  • How does this affect the reports & dashboard?

This is a simple change, no? Just a few characters on the screen. No physical cost. But it is also a full-blown Epic Task for the project - even if we aren’t in production, have no data to migrate, or integrations to deal with.

Software engineersoperate under constraints similar to other engineers, including severe consequences for mistakes (global system failure because of a missing null check). Making changes to large, established codebases presents a significant hurdle.

The moment that you need to consider more than a single factor, whether in your code or in a bridge blueprint, there is a pretty high cost to iterations. Going back to the bridge example, the architect may have a rough idea (is it going to be a Roman-style arch bridge or a suspension bridge) and have a lot of freedom to play with various options at the start. But the moment you begin to nail things down and fill in the details, the cost of change escalates quickly.

Finally, just to be clear, I don’t think that the cost of changing software is equivalent to changing a bridge after it was built. I simply very strongly disagree that there is zero cost (or indeed, even low cost) to changing software once you are past the “rough draft” stage.

time to read 9 min | 1730 words

We got an interesting use case from a customer - they need to verify that documents in RavenDB have not been modified by any external party, including users with administrator credentials for the database.

This is known as the Rogue Root problem, where you have to protect yourself from potentially malicious root users. That is not an easy problem - in theory, you can safeguard yourself using various means, for example the whole premise of SELinux is based on that.

I don’t really like that approach, since I assume that if a user has (valid) root access, they also likely have physical access. In other words, they can change the operating system to bypass any hurdles in the way.

Luckily, the scenario we were presented with involved detecting changes made by an administrator, which is significantly easier. And we can also use some cryptography tools to help us handle even the case of detecting malicious tampering.

First, I’m going to show how to make this work with RavenDB, then we’ll discuss the implications of this approach for the overall security of the system.

The implementation

The RavenDB client API allows you to hook into the saving process of documents, as you can see in the code below. In this example, I’m using a user-specific ECDsa key (by calling the GetSigningKeyForUser() method).


store.OnBeforeStore += (sender, e) =>
{
    using var obj = e.Session.JsonConverter.ToBlittable(e.Entity, null);
    var date = DateTime.UtcNow.ToString("O");
    var data = Encoding.UTF8.GetBytes( e.DocumentId + date + obj);
    
    using ECDsa key = GetSigningKeyForUser(CurrentUser);
    var signData = key.SignData(data, HashAlgorithmName.SHA256);


    e.DocumentMetadata["DigitalSignature"] = new Dictionary<string, string>
    {
        ["User"] = CurrentUser,
        ["Signature"] = Convert.ToBase64String(signData),
        ["Date"] = date,
        ["PublicKey"] = key.ExportSubjectPublicKeyInfoPem()
    };
};

What you can see here is that we are using the user’s key to generate a signature that is composed of:

  • The document’s ID.
  • The current signature time.
  • The JSON content of the entity.

After we generate the signature, we add it to the document’s metadata. This allows us to verify that the entity is indeed valid and was signed by the proper user.

To validate this afterward, we use the following code:


bool ValidateEntity<T>(IAsyncDocumentSession session,T entity)
{
    var metadata = session.Advanced.GetMetadataFor(entity);
    var documentId = session.Advanced.GetDocumentId(entity);
    var digitalSignature = metadata.GetObject("DigitalSignature") ??
          throw new IOException("Signature is missing for " + documentId);
    var date = digitalSignature.GetString("Date");
    var user = digitalSignature.GetString("User");
    var signature = digitalSignature.GetString("Signature");
    using var key = GetPublicKeyForUser(user);
    using var obj = session.Advanced.JsonConverter.ToBlittable(entity, null);
    var data = Encoding.UTF8.GetBytes(documentId + date + obj);
    var bytes = Convert.FromBase64String(signature);
    return key.VerifyData(data, bytes, HashAlgorithmName.SHA256);
}

Note that here, too, we are using the GetPublicKeyForUser() to get the proper public key to validate the signature. We use the specified user from the metadata to get the key, and we verify the signature against the document ID, the date in the metadata, and the JSON of the entity.

We are also saving the public key of the signing user in the metadata. But we haven’t used it so far, why are we doing this?

The reason we use GetPublicKeyForUser() in the ValidateEntity() call is pretty simple: we want to get the user’s key from the same source. This assumes that the user’s key is stored in a safe location (a secure vault or a hardware key like YubiKey, etc.).

The reason we want to store the public key in the metadata is so we can verify the data on the server side. I created the following index:


from c in docs.Companies
let unverified = Crypto.Verify(c)
where unverified is not null
select new 
{ 
    Problem = unverified
}

I’m using RavenDB’s additional sources feature to add the following code to the index. This exposes the Crypto.Verify() call to the index, and the code uses the public key in the metadata (as well as the other information there) to verify that the document signature is valid.

The index code above will filter all the documents whose signature is valid, so you can easily get all the problematic documents. In other words, it is a quick way of saying: “Find me all the documents whose verification failed”. For compliance, that is quite important and usually requires going over the entire dataset to answer it.

The implications

Let’s consider the impact of such a system. We now have cryptographic verification that the document was modified by a specific user. Any tampering with the document will invalidate the digital signature (or require signing it with your key).

Combine that with RavenDB’s revisions, and you have an immutable log that you can verify using modern cryptography. No, it isn’t a blockchain, but it will put a significant roadblock in the path of anyone trying to just modify the data.

The fact that we do the signing on the client side, rather than the server, means that the server never actually has access to the signing keys (only the public keys). The server’s administrator, in the same manner, doesn’t have a way to get those signing keys and forge a document.

In other words, we solved the Rogue Root problem, and we ensured that a user cannot repudiate a document they signed. It is easy to audit the system for invalid documents (and, combined with revisions, go back to a valid one).

Escape hatch design

If you need this sort of feature for compliance only, you may want to skip the ValidateEntity() call. That would allow an administrator to manually change a document (thus invalidating the digital signature) and still have the rest of the system work. That goes against what we are trying to do, yes, but it is sometimes desirable.

That isn’t required for the normal course of operations, but it can be required for troubleshooting, for example. I’m sure you can think of a number of reasons why it would make things a lot easier to fix if you could just modify the database’s data.

For example, an Order contains a ZipCode with the value "02116" (note the leading zero), which a downstream system turns into the integer 02116. An administrator can change the value to be " 02116", with a leading space, preventing this problem (the downstream system will not convert this to a number, thus keeping the leading 0). Silly, yes - but it happens all the time.

Even though we are invalidating the digital signature, we may want to do that anyway. The index we defined would alert on this, but we can proceed with processing the order, then fix it up later. Or just make a note of this for compliance purposes.

Summary

This post walks you through building a cryptographic solution to protect document integrity within a RavenDB environment, addressing the Rogue Root problem. The core mechanism is a client-side OnBeforeStore hook that generates an ECDsa digital signature for each document. This design ensures that the private keys are never exposed on the server, preventing a database administrator from forging signatures and providing true non-repudiation.

A RavenDB index is used to automatically and asynchronously verify every document's signature against its current content. This index filters for any documents where the digital signature is valid, providing an efficient server-side audit mechanism to find all the documents with invalid signatures.

The really fun part here is that there isn’t really a lot of code or complexity involved, and you get strong cryptographic proof that your data has not been tampered with.

time to read 7 min | 1290 words

I got a question from one of our users about how they can use RavenDB to manage scheduled tasks. Stuff like: “Send this email next Thursday” or “Cancel this reservation if the user didn’t pay within 30 minutes.”

As you can tell from the context, this is both more straightforward and more complex than the “run this every 2nd Wednesday" you’ll typically encounter when talking about scheduled jobs.

The answer for how to do that in RavenDB is pretty simple, you use the Document Refresh feature. This is a really tiny feature when you consider what it does. Given this document:


{
   "Redacted": "Details",
   "@metdata": {
      "@collection": "RoomAvailabilities",
      "@refresh": "2025-09-14T10:00:00.0000000Z"
   }
}

RavenDB will remove the @refresh metadata field at the specified time. That is all this does, nothing else. That looks like a pretty useless feature, I admit, but there is a point to it.

The act of removing the @refresh field from the document will also (obviously) update the document, which means that everything that reacts to a document update will also react to this.

I wrote about this in the past, but it turns out there are a lot of interesting things you can do with this. For example, consider the following index definition:


from RoomAvailabilitiesas r
where true and not exists(r."@metadata"."@refresh")
select new { 
  r.RoomId,
  r.Date,
  // etc...
}

What you see here is an index that lets me “hide” documents (that were reserved) until that reservation expires.

I can do quite a lot with this feature. For example, use this in RabbitMQ ETL to build automatic delayed sending of documents. Let’s implement a “dead-man switch”, a document will be automatically sent to a RabbitMQ channel if a server doesn’t contact us often enough:


if (this['@metadata']["@refresh"]) 
    return; // no need to send if refresh didn't expire


var alertData = {
    Id: id(this),
    ServerId: this.ServerId,
    LastUpdate: this.Timestamp,
    LastStatus: this.Status || 'ACTIVE'
};


loadToAlertExchange(alertData, 'alert.operations', {
    Id: id(this),
    Type: 'operations.alerts.missing_heartbeat',
    Source: '/operations/server-down/no-heartbeat'
});

The idea is that whenever a server contacts us, we’ll update the @refresh field to the maximum duration we are willing to miss updates from the server. If that time expires, RavenDB will remove the @refresh field, and the RabbitMQ ETL script will send an alert to the RabbitMQ exchange. You’ll note that this is actually reacting to inaction, which is a surprisingly hard thing to actually do, usually.

You’ll notice that, like many things in RavenDB, most features tend to be small and focused. The idea is that they compose well together and let you build the behavior you need with a very low complexity threshold.

The common use case for @refresh is when you use RavenDB Data Subscriptions to process documents. For example, you want to send an email in a week. This is done by writing an EmailToSend document with a @refresh of a week from now and defining a subscription with the following query:


from EmailToSend as e
where true and not exists(e.'@metadata'.'@refresh')

In other words, we simply filter out those that have a @refresh field, it’s that simple. Then, in your code, you can ignore the scheduling aspect entirely. Here is what this looks like:


var subscription = store.Subscriptions
    .GetSubscriptionWorker<EmailToSend>("EmailToSendSubscription");


await subscription.Run(async batch =>
{
    using var session = batch.OpenAsyncSession();
    foreach (var item in batch.Items)
    {
        var email = item.Result;
        await EmailProvider.SendEmailAsync(new EmailMessage
        {
            To = email.To,
            Subject = email.Subject,
            Body = email.Body,
            From = "no-reply@example.com"
        });


        email.Status = "Sent";
        email.SentAt = DateTime.UtcNow;
    }
    await session.SaveChangesAsync();
});

Note that nothing in this code handles scheduling. RavenDB is in charge of sending the documents to the subscription when the time expires.

Using @refresh + Subscriptions in this manner provides us with a number of interesting advantages:

  • Missed Triggers: Handles missed schedules seamlessly, resuming on the next subscription run.
  • Reliability: Automatically retries subscription processing on errors.
  • Rescheduling: When @refresh expires, your subscription worker will get the document and can decide to act or reschedule a check by updating the @refresh field again.
  • Robustness: You can rely on RavenDB to keep serving subscriptions even if nodes (both clients & servers) fail.
  • Scaleout: You can use concurrent subscriptions to have multiple workers read from the same subscription.

You can take this approach really far, in terms of load, throughput, and complexity. The nice thing about this setup is that you don’t need to glue together cron, a message queue, and worker management. You can let RavenDB handle it all for you.

FUTURE POSTS

No future posts left, oh my!

RECENT SERIES

  1. API Design (10):
    29 Jan 2026 - Don't try to guess
  2. Recording (20):
    05 Dec 2025 - Build AI that understands your business
  3. Webinar (8):
    16 Sep 2025 - Building AI Agents in RavenDB
  4. RavenDB 7.1 (7):
    11 Jul 2025 - The Gen AI release
  5. Production postmorterm (2):
    11 Jun 2025 - The rookie server's untimely promotion
View all series

Syndication

Main feed ... ...
Comments feed   ... ...