<?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: Serhii Korniichuk</title>
    <description>The latest articles on DEV Community by Serhii Korniichuk (@__korniichuk).</description>
    <link>https://dev.to/__korniichuk</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%2F3969980%2Fa9bdbbec-ff88-469c-9983-59500e8287ae.jpg</url>
      <title>DEV Community: Serhii Korniichuk</title>
      <link>https://dev.to/__korniichuk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__korniichuk"/>
    <language>en</language>
    <item>
      <title>AI-Generated UI Tests in Business Central, or How to Check That "Passed" Actually Means Something</title>
      <dc:creator>Serhii Korniichuk</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:42:06 +0000</pubDate>
      <link>https://dev.to/__korniichuk/ai-generated-ui-tests-in-business-central-or-how-to-check-that-passed-actually-means-something-3609</link>
      <guid>https://dev.to/__korniichuk/ai-generated-ui-tests-in-business-central-or-how-to-check-that-passed-actually-means-something-3609</guid>
      <description>&lt;p&gt;&lt;em&gt;The test passed. The official bc-replay runner reports &lt;code&gt;passed&lt;/code&gt;, and the Page Scripting pane says "Script completed without errors". But the record isn't in the database.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem and the usual approaches
&lt;/h2&gt;

&lt;p&gt;These days, more and more processes are being automated with AI agents. It's becoming an essential part of how our work is changing. AI agents already write code, search for and check information, analyze an existing codebase, make plans, and draw conclusions from everything they've gathered. So a natural question comes up. How can we trust the result?&lt;/p&gt;

&lt;p&gt;Teams want to be sure their features work. That has always been true, and for good reason. The product's reputation depends on it. It matters even more in business systems, where the customers are businesses themselves and a mistake can be expensive. And when it comes to checking features, the obvious answer is testing.&lt;/p&gt;

&lt;p&gt;With AI agents, a developer can cover a new feature in detail with automated tests written in code. Manual QA can click through the feature and see how it works. Yes, it's slow, and you need to know exactly what was added (I describe how to make this part much better in &lt;a href="https://dev.to/__korniichuk/ai-chatbots-in-erp-or-effective-technique-for-transforming-al-source-code-into-a-semantic-knowledge-1c45"&gt;another article&lt;/a&gt;). But this way we see how the system works with our own eyes. Automation QA gives you a visible result too.&lt;/p&gt;

&lt;p&gt;Let's try to find an approach that doesn't need the statistics and metrics used to evaluate ML models, doesn't require you to know code syntax to read the tests, and doesn't make you check the feature by hand or write the same steps in code for an automated check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closer to the point
&lt;/h2&gt;

&lt;p&gt;To trust a result, it's best to see it in a way that's simple and clear to everyone who works with the system. That means the UI.&lt;/p&gt;

&lt;p&gt;Business Central already has a tool for replaying clicks. It's called Page Scripting. I record my clicks, the pane saves them to a YAML file, and from here on I'll call that file a plan. In CI, bc-replay runs the same plan. In the end, we get a ready-made answer.&lt;/p&gt;

&lt;p&gt;In my approach, an AI agent writes these scenarios from start to finish. I'll cover exactly how it does that in a separate article. Here the question is whether you can take such a test's result at face value, even when official Microsoft tools run it.&lt;/p&gt;

&lt;p&gt;I decided to check how reliable this approach is. During the experiments I saw, with my own eyes and on the same screen, both the green status "Script completed without errors" and a red BC error.&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%2F86skq6nq2quwoh678boz.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%2F86skq6nq2quwoh678boz.png" alt="The Page Scripting pane shows " width="800" height="515"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Page Scripting pane. On the right, "Script completed without errors" and green check marks on every step. On the left, the red banner "The length of the string is 15, but it must be less than or equal to 10 characters", and BC is also asking whether to discard the unsaved changes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let's look at the problem cases I found and try to pick methods that can protect a test setup from false &lt;code&gt;passed&lt;/code&gt; results. From here on, green means the test passed and red means it failed, the same colors you see in reports and CI. If you want more background on the topic, I've put it in the notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three cases where the test passed but the step didn't run
&lt;/h2&gt;

&lt;p&gt;I did the study on BC 28 and BC 29, on-prem and in a SaaS sandbox. I ran the plans with the official &lt;code&gt;npx replay&lt;/code&gt; runner from the &lt;a href="https://www.npmjs.com/package/@microsoft/bc-replay" rel="noopener noreferrer"&gt;&lt;code&gt;@microsoft/bc-replay&lt;/code&gt;&lt;/a&gt; package, which I'll just call the runner from here on, and with the Play button in the Page Scripting pane. Judging by the BC client code, both play the plan with the same player, so they give the same result. Fields with the &lt;code&gt;AUT&lt;/code&gt; prefix belong to my test extension.&lt;/p&gt;

&lt;p&gt;The first thing I had to add was a deterministic check that a new record really appeared in the database. On-prem, a separate check script with SQL access to the database did it. On SaaS, requests to the standard API did the same job. From here on I call this the independent data check, because it looks straight at the data, separately from the session that created it. I'll describe it in detail after the three cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server validation banner
&lt;/h3&gt;

&lt;p&gt;When the BC server rejects a value you entered, for example one that's too long for the field, it doesn't open a dialog and doesn't stop you from working with the page. Instead, a red bar with the error text appears above the form. I call it the server validation banner. A person sees it and fixes the value. A script just moves on.&lt;/p&gt;

&lt;p&gt;The plan creates a new Customer and enters 15 characters into a &lt;code&gt;Text[10]&lt;/code&gt; field. BC shows the red banner "The length of the string is 15, but it must be less than or equal to 10 characters" and saves the record without that value. The runner reports &lt;code&gt;passed&lt;/code&gt; every time, with no warning at all. The player itself, if you read its result directly, records no error (&lt;code&gt;error: null&lt;/code&gt;) and counts every step as replayed. You can see the banner in the screen recording the runner makes automatically for every run, but the field in the database is empty. The record was created, and Name was saved. Only the value that was too long never reached the database, and the "Not saved" status in the screenshot below refers to that value. The same plan with 9 characters saves the value. In the pane, the banner is on screen too, but what people read is the status, "Script completed without errors". Below is the shortened runner output for the first on-prem run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# npx replay console
  1 passed

# results.xml (JUnit)
&amp;lt;testsuites … tests="1" failures="0" skipped="0" errors="0" …&amp;gt;

# replay-log.yml, the step with the 15 characters, no error in it
  - type: input
    target:
      - page: Customer Card
      - field: AUT Loyalty Code
    value: ARTLEN15XXXXXXX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F08ek9byb4fwvo8vcfuqo.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%2F08ek9byb4fwvo8vcfuqo.png" alt="Customer Card with the length banner, played by the same player that npx replay uses" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same plan and the same player as in npx replay, but without the final step that closes the page, because that step opens a dialog on top of the banner. The runner doesn't open the Page Scripting pane, so there's no pane on the right. The player reported no error and counted every step, even though BC shows the length banner and the status "Not saved", and the value from the Loyalty Code field never reached the database.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  A filter with the value true
&lt;/h3&gt;

&lt;p&gt;The player silently fails to apply a filter on a Boolean field with the value &lt;code&gt;true&lt;/code&gt;. The list stays unfiltered, and the filter step is marked as done. In my case, the &lt;code&gt;true&lt;/code&gt; came from the AI agent that built the plan. In my test environment, a plan filtered the Vendor List this way. The runner gave &lt;code&gt;passed&lt;/code&gt;, and the last frame of the recording shows all six vendors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# the agent's plan, passed, but the filter isn't applied&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Vendor List&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filter&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AUT Preferred&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fdh8lghsi25luuso8bkz3.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%2Fdh8lghsi25luuso8bkz3.png" alt="Vendor List with a Preferred filter that has no value and a green filter step in the pane" width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A filter with the value true in the pane. The Preferred filter is added with no value, the list shows all vendors, and the pane marks the filter step green.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the same filter the recorder writes &lt;code&gt;value: 2&lt;/code&gt;, which is the option number in the list of values (0 empty, 1 No, 2 Yes). The pane again says "Script completed without errors", but now only one vendor is left in the list, as it should be. &lt;code&gt;value: "Yes"&lt;/code&gt; works too. And if you change just the &lt;code&gt;2&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; in the recorder's output, the filter silently stops working again, so for a Boolean filter, write the option number, the way the recorder does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# the recorder's output, the filter works&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Vendor List&lt;/span&gt;
        &lt;span class="na"&gt;runtimeRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;b8r&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filter&lt;/span&gt;
        &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AUT Preferred&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;            &lt;span class="c1"&gt;# 0 empty, 1 No, 2 Yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A step the player doesn't understand
&lt;/h3&gt;

&lt;p&gt;The player checks page and field names against the open page and fails honestly when they're wrong, for example "Field '…' was not found". But it doesn't check how the step itself is written, and it silently counts a step it doesn't understand as done. The easiest way to show this is with the &lt;code&gt;type:&lt;/code&gt; key. Every step in a plan starts with it, and it tells the player what to do. For example, &lt;code&gt;type: input&lt;/code&gt; means enter a value into a field. If you write &lt;code&gt;step:&lt;/code&gt; instead of &lt;code&gt;type:&lt;/code&gt;, the player doesn't run the step, but it doesn't fail either. The plan below creates an Item and fills in two fields. The step for the second field has the key &lt;code&gt;step:&lt;/code&gt; instead of &lt;code&gt;type:&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item Card&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Description&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;=Parameters.'Description'&lt;/span&gt;   &lt;span class="c1"&gt;# value from the plan's parameters&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;step&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;input&lt;/span&gt;            &lt;span class="c1"&gt;# should be type:&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item Card&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AUT Shelf No.&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;=Parameters.'ShelfNo'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Page Scripting recorder never writes it this way, and there's no official schema that would reject an unknown key. So this case and the previous one show up exactly when an agent writes the plan or someone edits it by hand. For generated plans, checking &lt;code&gt;passed&lt;/code&gt; becomes a required part of the approach.&lt;/p&gt;

&lt;p&gt;If the plan doesn't check the skipped step, the runner gives &lt;code&gt;passed&lt;/code&gt; every time. The player treats the &lt;code&gt;step:&lt;/code&gt; step as replayed, even though nothing happens in the client. The runner fails a run only when the player returned an error or replayed no steps at all, and warnings don't affect the result. So the run is green, and in the variant from the snippet above the Item exists, but the field is empty.&lt;/p&gt;

&lt;p&gt;When every step is written this way, as in the screenshot below, no Item appears in the database. The Page Scripting pane shows this plainly, because every such step gets a green check mark. If the plan has a &lt;code&gt;validate&lt;/code&gt; on the skipped field itself, the run turns red.&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%2F3umfjk9hkdcehyyvmdsv.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%2F3umfjk9hkdcehyyvmdsv.png" alt="The Page Scripting pane finished a plan written with step and reported no errors, but the Items list is empty" width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every step of the plan is written with the key step. The pane says "Script completed without errors" and gives each step a green check mark, but the Items list filtered by the description from the plan is empty, so the Item was never created.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The player is just as quiet when it skips a step with a typo in the type (&lt;code&gt;type: imput&lt;/code&gt;) or in the value key (&lt;code&gt;vaule:&lt;/code&gt; instead of &lt;code&gt;value:&lt;/code&gt;). In both cases the runner gave &lt;code&gt;passed&lt;/code&gt;, the pane showed green check marks, and the field stayed empty. The same happens with check steps. More on that in the negative control section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methods that protect against a false passed
&lt;/h2&gt;

&lt;p&gt;All three cases lead to one conclusion. &lt;strong&gt;A test result has to be confirmed by a check that doesn't depend on the session that produced it.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Independent data check
&lt;/h3&gt;

&lt;p&gt;The independent data check reads the result straight from the database or through the API, not through the browser session that created it. It knows nothing about what the screen or the runner showed. Every plan writes a unique run tag into the record, for example into the customer's name, so the check looks for that exact record.&lt;/p&gt;

&lt;p&gt;But the check itself can be wrong too. There are two basic mistakes worth guarding against from the start, and doing so barely complicates the setup.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The check looks in the wrong place.&lt;/strong&gt; A query that returns "0 rows" may be looking at the wrong database or company, and then "no record" proves nothing. The fix is to count known data in the company before searching for the tag. This is the positive control, and that's what it's called in the code below. If it finds nothing, the check itself is broken, and we don't trust the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mistake in the query itself.&lt;/strong&gt; The positive control confirms the table is the right one, but it doesn't catch a mistake in the query's filter. What protects against that is a control plan, one where the data is guaranteed to be saved. For &lt;code&gt;step:&lt;/code&gt; it's the same plan with &lt;code&gt;type:&lt;/code&gt;, and for the banner it's the plan with 9 characters. In both cases, the check found the record with its value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is the diagram below, and the check's verdict only means something at its third step.&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%2F8cfi3ra0qu7scw3jzmmd.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%2F8cfi3ra0qu7scw3jzmmd.png" alt="The independent data check diagram in three steps" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The independent data check. If either of the first two steps fails, there's no conclusion about the run.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Back to how the check reads the data. The check itself is a script written in advance that can only read the data, and the agent can only run that script. How that access is set up depends on how your Business Central is deployed. As examples, here are the two approaches I used for the demo, SQL for on-prem and the API for a SaaS sandbox.&lt;/p&gt;

&lt;h4&gt;
  
  
  On-prem through SQL
&lt;/h4&gt;

&lt;p&gt;In a container, the check script runs a SQL query straight against the database. In the snippet below, the first query is the positive control. It counts all the records in the table for the right company. The second query looks for the record by its run tag.&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;-- 1. positive control: the check can see the right company's table&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 → the check is broken (wrong database / company / prefix), don't trust the run&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. result check (length banner)&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;No_&lt;/span&gt;&lt;span class="p"&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;Name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;AUT&lt;/span&gt; &lt;span class="n"&gt;Loyalty&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.[&lt;/span&gt;&lt;span class="n"&gt;No_&lt;/span&gt;&lt;span class="p"&gt;]&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;No_&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;WHERE&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;Name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;RunTag&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- after npx replay (passed): 1 row, AUT Loyalty Code empty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the check twice, once before the run and once after. Before the run, there must be no record with the tag yet, and the positive control shows how many records the table holds. After the run, the count must have grown by exactly the number of records the plan creates, and a search by tag must find exactly that record, with the value the plan entered. If there's no record with the tag, or the field is empty, but the runner gave &lt;code&gt;passed&lt;/code&gt;, that &lt;code&gt;passed&lt;/code&gt; is false. And if a record with the tag turns up before the run, the tag isn't unique and there's no conclusion.&lt;/p&gt;

&lt;h4&gt;
  
  
  SaaS through the API
&lt;/h4&gt;

&lt;p&gt;For the cloud version, which Microsoft calls Business Central online, the &lt;a href="https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-extract-data" rel="noopener noreferrer"&gt;documentation on extracting data&lt;/a&gt; says that "For Business Central online, the only supported option for reading data is using APIs". So on SaaS, the data check becomes a GET request to the standard API v2.0 on behalf of the test user. The first request in the snippet below is the positive control. It checks that the company returns at least one record. The second looks up the record by its run tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# 1. positive control: the company MUST return at least one record, otherwise the check is broken
GET https://api.businesscentral.dynamics.com/v2.0/&amp;lt;tenant&amp;gt;/&amp;lt;env&amp;gt;/api/v2.0/companies(&amp;lt;id&amp;gt;)/customers?$top=1&amp;amp;$select=number
Authorization: Bearer &amp;lt;test user's delegated token&amp;gt;

###
# 2. result check (length banner)
GET https://api.businesscentral.dynamics.com/v2.0/&amp;lt;tenant&amp;gt;/&amp;lt;env&amp;gt;/api/v2.0/companies(&amp;lt;id&amp;gt;)/customers?$filter=displayName eq '&amp;lt;run tag&amp;gt;'&amp;amp;$select=number,displayName,addressLine2
Authorization: Bearer &amp;lt;test user's delegated token&amp;gt;
# → 1 row, "addressLine2": ""   (runner: passed)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof here, as on-prem, is a missing record or an empty field after the positive control has passed. The check gets its token through its own app registration in Entra ID with delegated permissions, so it sees exactly what the test user sees.&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%2Fn24qz178rvtfurocpqhx.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%2Fn24qz178rvtfurocpqhx.png" alt="The Page Scripting pane on SaaS says " width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The pane says "Script completed without errors" and gives all six steps a green check mark, while above the form there's the red banner "The length of the string is 60, but it must be less than or equal to 50 characters" and the status "Not saved". On top of the page, BC also shows the dialog "A modal dialog is open in another window". After this run, the API returns a new Customer with an empty addressLine2.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pre-run plan check
&lt;/h3&gt;

&lt;p&gt;By pre-run plan check I mean an automated check of the YAML plan's text without running it, the way a linter checks code. It sits in front of the runner and the pane, because neither of them checks the plan. This check stops an unknown key, a typo in the step type, typos in &lt;code&gt;value:&lt;/code&gt; and &lt;code&gt;operation:&lt;/code&gt;, and the value &lt;code&gt;true&lt;/code&gt; in a filter. But it knows nothing about value length, so only the data check catches the banner. There's a minimal snippet at the end of the article.&lt;/p&gt;

&lt;h3&gt;
  
  
  A negative control for every &lt;code&gt;validate&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Not every change can be checked in the database. A lot of functionality doesn't change data at all. It only shows the data differently, like a filter on a list. For that, Page Scripting has &lt;code&gt;validate&lt;/code&gt;, a check step you put in the plan alongside the actions. My measurements showed that &lt;code&gt;validate&lt;/code&gt; works when it checks exactly the thing that broke. A check on the skipped field turned the &lt;code&gt;step:&lt;/code&gt; run red ("Was expecting 'A1-01' but got ''"), and a check on the first row after the filter with the value &lt;code&gt;true&lt;/code&gt; gave "Was expecting '20000' but got '10000'".&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%2Fx5rbmgdx3e4t4m5z74mk.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%2Fx5rbmgdx3e4t4m5z74mk.png" alt="The pane turns red on the validate of the Shelf No. field, which stayed empty" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same skipped step, but the plan checks the Shelf No. field with validate. The field is empty, and the pane turns red on exactly that check.&lt;/em&gt;&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%2Fn52nycf4jl5ujw8cv51a.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%2Fn52nycf4jl5ujw8cv51a.png" alt="After the filter with the value true, the validate on the first row fails in the pane" width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After the filter with the value true, the validate on the first row expects vendor 20000 but sees 10000. Here the pane honestly says "Script failed".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But a &lt;code&gt;validate&lt;/code&gt; that has never failed is still a hypothesis. My measurements showed that the player silently skips a check with a typo in the type (&lt;code&gt;type: validat&lt;/code&gt;) or in the &lt;code&gt;operation:&lt;/code&gt; key, just as it skips actions. If you deliberately break the expected value in such a check, the run is still green, so the check might as well not be in the plan.&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%2Fgbnz8yuif9pmdifqnwwc.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%2Fgbnz8yuif9pmdifqnwwc.png" alt="The pane says Script completed without errors, even though the check step has a red icon" width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A check with a typo in the operation key and a deliberately wrong expected value, A1-02. The pane says "Script completed without errors", even though the last step has a red icon and the field holds A1-01.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So every such step needs a negative control. That's what I call a copy of the plan with the expected value broken on purpose. A run of that copy must fail on exactly that step and nowhere earlier. If it's green, the check doesn't work. This is the counterpart of the positive control in the data check. There we make sure the check sees known data. Here we make sure &lt;code&gt;validate&lt;/code&gt; can fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item Card&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AUT Shelf No.&lt;/span&gt;
    &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;="&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;=Parameters.'ShelfNo'&lt;/span&gt;   &lt;span class="c1"&gt;# A1-01&lt;/span&gt;
    &lt;span class="c1"&gt;# negative control: value: A1-02 → the run must fail on exactly this step&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my test environment I made a negative control for each of the two &lt;code&gt;validate&lt;/code&gt; steps in the control plan (the one where the data is saved correctly) and ran each of them with the runner twice. All four runs failed on exactly the broken step, and every step before it passed. The error was the one you'd expect, "Was expecting 'A1-02' but got 'A1-01'". Meanwhile, the data check found a new Item with the correct value every time, and the same plan without the break came up green. So the red came from the check itself, not from the data. The player stops at the first red &lt;code&gt;validate&lt;/code&gt;, so one negative control tests exactly one check.&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%2Fpjeh0v402osbgerc3lr1.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%2Fpjeh0v402osbgerc3lr1.png" alt="The negative control in the pane fails on the last check step" width="800" height="594"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The negative control in the pane. The expected value is deliberately changed to A1-02, the field holds the correct A1-01, and the last check step is the one that fails, while every step before it is green.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This technique has a limit. A negative control proves that the check runs and can fail, but it doesn't show whether the value depends on the behavior the requirement describes. So it's a cheap minimum. Mutating data or AL code is the stronger tool. dubcrab describes a similar technique, inverting the assertion, in &lt;a href="https://dev.to/dubcrab/your-green-tests-are-lying-5h5m"&gt;Your green tests are lying&lt;/a&gt;, and the same author covers data mutation &lt;a href="https://dmitriiev.dev/posts/passing-tests-never-talk-to-the-server/" rel="noopener noreferrer"&gt;on his blog&lt;/a&gt;. LethAL does AL code mutation for BC. More on it in the notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to apply this to your tests
&lt;/h2&gt;

&lt;p&gt;The scripts from my test environment are tied to my setup, but the principle carries over to any Page Scripting run.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the result directly with a database query and a positive control, so the check fails when it can't see a known record.&lt;/li&gt;
&lt;li&gt;Make a deliberately red run for every &lt;code&gt;validate&lt;/code&gt;. Break the expected value, and that exact step must fail.&lt;/li&gt;
&lt;li&gt;Keep the video of the green run too, next to the data check result.&lt;/li&gt;
&lt;li&gt;Check the plan before running it, so an unknown key or the value &lt;code&gt;true&lt;/code&gt; in a filter never reaches the runner or the pane.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal plan check for item 4 can look like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// plan = the YAML plan, parsed with any parser (e.g. js-yaml)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoke&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close-page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="cm"&gt;/* + the types you actually use */&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: no 'type:' (keys: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;KNOWN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: unknown type '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: input without 'value:'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;operation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: validate without 'operation:'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`step &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="s2"&gt;: the player will silently fail to apply filter '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' with the value &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; for a Boolean write Yes / No or the option number (2 = Yes)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// non-empty → don't start the runner&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And write the task description you give to an agent or a person so that there's something to prove it with. It helps when the task describes&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;who acts, which data and preconditions, and where it starts and ends;&lt;/li&gt;
&lt;li&gt;the observable outcome, meaning a document or a ledger entry. "The field was saved" isn't enough for this;&lt;/li&gt;
&lt;li&gt;for each requirement, whether you check the UI or the data, which data check with a positive control covers it, and where the negative controls are;&lt;/li&gt;
&lt;li&gt;what is out of scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to practice and check this yourself, you could, for example, use the ready-made scenarios from the how-to articles on MS Learn and the test names in &lt;code&gt;microsoft/BCApps&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;A green status from the runner or the pane proves only that the player reached the end of the plan without an error. The independent data check with a positive control proves that the record really is in the database. The negative control proves that &lt;code&gt;validate&lt;/code&gt; really checks something. The pre-run plan check proves that the plan reaches the player intact.&lt;/p&gt;

&lt;p&gt;These methods work today on plain Page Scripting. The limit is that its player itself doesn't see BC's banners and dialogs, it doesn't check how a step is written, and you can't put a data check or a BC error check between the steps of a plan. More generally, I'd like to see something closer to how a person does the steps, as if they were walking through them in the browser themselves, with every stage and nuance. So let's try bringing in a browser automation tool. I'll share what came of it in the next article.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've run Page Scripting and seen green where there was no data, I'd like to hear how it went for you.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Notes. What Microsoft and the community already have
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AL tests don't see the UI.&lt;/strong&gt; &lt;code&gt;TestPage&lt;/code&gt; runs inside BC, without a browser, so real dialogs are out of its reach. The author of LethAL, a tool that deliberately breaks AL code, writes that "Tests run without a GUI, so code behind a Confirm or a dialog may be unmeasurable" (&lt;a href="https://github.com/SShadowS/LethAL" rel="noopener noreferrer"&gt;LethAL README&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page Scripting is meant not only for developers.&lt;/strong&gt; Microsoft writes that "Customers, consultants, and testers can create repeatable user acceptance tests … without writing AL code" (&lt;a href="https://www.microsoft.com/en-us/microsoft-365/roadmap?id=573384" rel="noopener noreferrer"&gt;roadmap entry RM573384&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page Scripting in CI.&lt;/strong&gt; bc-replay runs the same plan in CI (&lt;a href="https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-page-scripting#run-page-scripts-in-pipelines" rel="noopener noreferrer"&gt;MS Learn&lt;/a&gt;), and AL-Go for GitHub supports it starting with v6.1. For a green run, AL-Go publishes no details. "If the page scripting tests succeed - the details are not published" (&lt;a href="https://github.com/microsoft/AL-Go/blob/main/RELEASENOTES.md" rel="noopener noreferrer"&gt;AL-Go release notes&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plan without the recorder.&lt;/strong&gt; Erik Hougaard built a plan from Page Metadata back in 2024 (&lt;a href="https://www.hougaard.com/hacking-the-new-page-scripting-tool/" rel="noopener noreferrer"&gt;hougaard.com&lt;/a&gt;), Andy Wingate generates AI variants of a recorded script (&lt;a href="https://github.com/andywingate/D365BC-vibe-page-scripting" rel="noopener noreferrer"&gt;D365BC-vibe-page-scripting&lt;/a&gt;), and in &lt;a href="https://github.com/rf9000/CreateScriptsForVideos" rel="noopener noreferrer"&gt;rf9000/CreateScriptsForVideos&lt;/a&gt; an agent writes the plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The player's silent modes.&lt;/strong&gt; FBakkensen described them in his bc-replay format reference, for example "no error, the row silently never inserts" (&lt;a href="https://github.com/FBakkensen/bc-agentic-dev-tools-marketplace/blob/main/plugins/al-agentic-dev/skills/al-page-script/references/bc-replay-yaml-format.md" rel="noopener noreferrer"&gt;format reference&lt;/a&gt;). Checking data outside the UI and deliberately failing a check are both known techniques on their own. My part here is only to put them together for the BC UI and measure them on the runner, the pane, and two BC versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blind spot of a UI scenario.&lt;/strong&gt; FBakkensen writes that "bc-replay re-reads the bound &lt;code&gt;Rec&lt;/code&gt; exactly as a TestPage does, so its oracle is blind to that fault class" (&lt;a href="https://github.com/FBakkensen/bc-agentic-dev-tools-marketplace/blob/main/plugins/al-agentic-dev/skills/al-page-script/SKILL.md" rel="noopener noreferrer"&gt;al-page-script skill&lt;/a&gt;). In other words, if a UI scenario re-reads the same record that &lt;code&gt;TestPage&lt;/code&gt; does, it will miss the same bug that &lt;code&gt;TestPage&lt;/code&gt; misses. That's one more reason to check the result outside the session that created it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same thing is noticed outside BC too.&lt;/strong&gt; ACCELQ, a test tool vendor, writes about Dynamics 365 that "A passing UI test only proves the interface behaved correctly" (&lt;a href="https://www.accelq.com/blog/multi-layer-test-validation-dynamics-365/" rel="noopener noreferrer"&gt;ACCELQ article&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>AI Chatbots in ERP or Effective Technique for Transforming AL Source Code into a Semantic Knowledge Base via RAG</title>
      <dc:creator>Serhii Korniichuk</dc:creator>
      <pubDate>Fri, 12 Jun 2026 15:33:03 +0000</pubDate>
      <link>https://dev.to/__korniichuk/ai-chatbots-in-erp-or-effective-technique-for-transforming-al-source-code-into-a-semantic-knowledge-1c45</link>
      <guid>https://dev.to/__korniichuk/ai-chatbots-in-erp-or-effective-technique-for-transforming-al-source-code-into-a-semantic-knowledge-1c45</guid>
      <description>&lt;h4&gt;
  
  
  &lt;em&gt;What if an exhaustive description of how your solution works had been at hand all along: complete, up to date, with a full history of changes? The only paradox is that using it directly is nearly impossible. Here's how I tried to get around that with modest, cheap, local tools — and what came of it.&lt;/em&gt;
&lt;/h4&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;AI chatbots are everywhere these days, from online marketplaces to assistants in professional software that act through the UI as full-blown agents. But what about something as narrowly specialized as an ERP system — Business Central?&lt;/p&gt;

&lt;p&gt;As a Microsoft product, Business Central has spent the last few years quietly growing built-in AI under the Copilot brand: from simple helpers that generate item descriptions or suggest bank statement matches, to full chat assistants right into the interface. The approaches vary too. In some places a human stays the main hero and only accepts suggestions; in others, autonomous agents like the Sales Order Agent carry out multi-step tasks on their own.&lt;/p&gt;

&lt;p&gt;To understand how and why all of this applications built into Business Central, you should look at the system more broadly. It's not just a convenient, standardized Microsoft platform with its own programming language (AL) and ready-made infrastructure. Business Central is also a living ecosystem where developers and partners build their own extensions for specific customer needs, adding new features on top of a core product. And that's exactly what makes the question of AI bots here more interesting than it seems. It's not just "another Copilot button from Microsoft" but an open field for experiments: how do you build an assistant that understands the context and logic of a specific solution more deeply — one that becomes part of the system itself and knows its business logic?&lt;/p&gt;

&lt;p&gt;Let's see what we can find here.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How to read this.&lt;/strong&gt; I've left short asides for readers with different backgrounds. 🟦 explains things for those not from Business Central. 🟩 skip those paragraphs if you already know RAG basics. ⚙️ is purely technical depth.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Code as a source of context
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;🟦 &lt;strong&gt;For readers not from Business Central:&lt;/strong&gt; this section explains why the code turned out to be the best source of context for the assistant. There's a bit of BC specifics here, but you can catch the point from the examples without digging into AL syntax.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If we picture the assistant as a chatbot living inside Business Central, the very first question is: what do we ground it on? The model by itself knows nothing about a specific solution, its entities and rules; everything depends on the context we hand it. The most obvious solution is documentation: Microsoft even encourages keeping it close to the code (contextual help, help URLs, ToolTips). But let's be honest: how often do we actually have that famously well-written documentation? which someone also keeps up to date while the functionality changes very fast. There are other anchors too. The database is a live picture of what's happening in the system, but put the words "company data", "sending it somewhere" and "AI" side by side, and any customer instantly tenses up, and rightly so. Telemetry shows how processes actually run; policies and work instructions describe the company's logic from the top down. Each of these sources gives a piece of the picture. None of them, on its own, makes the assistant truly at home in a specific solution.&lt;/p&gt;

&lt;p&gt;But there is one source that holds complete information about our ERP system. It carries the bulk of the up-to-date details, including the nuances nobody even suspects, and it comes with a preserved history of changes. That source is, of course, the code. The only question is what we can do with it in the context of Business Central. The programming language here is AL (and CAL before it; I'll talk about AL, but the reasoning holds for CAL too).&lt;/p&gt;

&lt;p&gt;Let's just look at AL syntax one more time. Take the &lt;code&gt;Sales/Customer/&lt;/code&gt; domain area and walk through three object types.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The table — what people mean when they talk about the "customer master".&lt;/strong&gt; &lt;code&gt;Customer.Table.al&lt;/code&gt;. Notice it's not just field names: there's &lt;code&gt;Caption&lt;/code&gt;, &lt;code&gt;ToolTip&lt;/code&gt;, &lt;code&gt;AdditionalSearchTerms&lt;/code&gt; — a human description right in the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;field(1; "No."; Code[20])
{
    Caption = 'No.';
    OptimizeForTextSearch = true;
    ToolTip = 'Specifies the number of the customer. ...';
}
field(2; Name; Text[100])
field(22; "Currency Code"; Code[10])
field(39; Blocked; Enum "Customer Blocked")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The page — the "Customer Card", as the end user knows it.&lt;/strong&gt; &lt;code&gt;CustomerCard.Page.al&lt;/code&gt;. It even contains natural-language synonyms — essentially a list of the ways real people search for this entity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;page 21 "Customer Card"
{
    SourceTable = Customer;
    AdditionalSearchTerms = 'Customer Profile, Client Details, Buyer Information, ...';
    AboutText = 'With the **Customer Card** you manage information about a customer ...';
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Procedures — the verbs people use to describe functionality.&lt;/strong&gt; From the same &lt;code&gt;Customer.Table.al&lt;/code&gt;. The names read like a functional spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;procedure CalcAvailableCredit(): Decimal
procedure CalcOverdueBalance(): Decimal
procedure CheckBlockedCustOnDocs(...)
procedure DisplayMap()
procedure CustBlockedErrorMessage(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the body of &lt;code&gt;CalcOverdueBalance&lt;/code&gt; shows the second layer — those very nuances "nobody suspects". You can see the &lt;code&gt;OnBeforeCalcOverdueBalance&lt;/code&gt; extension point, and the concrete definition of what exactly the system considers "overdue":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;procedure CalcOverdueBalance() OverDueBalance: Decimal
begin
    OnBeforeCalcOverdueBalance(Rec, OverDueBalance, IsHandled);    // the nuance: extensibility
    ...
    CustLedgEntryRemainAmtQuery.SetFilter(Due_Date, '&amp;lt;%1', Today); // the nuance: what counts as "overdue"
end;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why source code is a completely different Beast?
&lt;/h2&gt;

&lt;p&gt;Looking at this as an AL developer, I noticed a simple but important thing. The names of procedures, tables and pages are the same words we use in everyday life when we discuss functionality. That's no coincidence: Microsoft's architectural guidelines and conventions (with all the inevitable exceptions) push toward procedure names that match what they do, and table names that match the entity they store. &lt;code&gt;Customer&lt;/code&gt; is a table of customers, not a cryptic &lt;code&gt;DB1&lt;/code&gt; or &lt;code&gt;DB2&lt;/code&gt;. &lt;code&gt;CalcOverdueBalance&lt;/code&gt; calculates the overdue balance; &lt;code&gt;DisplayMap&lt;/code&gt; shows a map. So even someone with zero knowledge of AL syntax can read this code and roughly get what it's about: recognizing two or three keywords is enough.&lt;/p&gt;

&lt;p&gt;More than that, Microsoft built a separate natural-language layer into the code specifically for search and help: &lt;code&gt;ToolTip&lt;/code&gt; explains every field in simple words, &lt;code&gt;AboutText&lt;/code&gt; describes what a page is for, and &lt;code&gt;AdditionalSearchTerms&lt;/code&gt; literally lists the synonyms a user might search by. That's what makes the code such an attractive anchor for AI: it's not just complete, actual and versioned — it's also, as a result, semi-self-documenting. If you want a textbook example, &lt;code&gt;Sales/Customer/&lt;/code&gt; is it: one domain folder holding the full triad (table + page + business procedures), self-documenting names, and ready-made natural-language context for embeddings.&lt;/p&gt;

&lt;p&gt;But it's not all smooth. You will not always guess the exact word used in the code. The same concept can be phrased in different ways: a user asks how to "deactivate" or "disable" a customer, while the code calls it &lt;code&gt;Blocked&lt;/code&gt;. Someone searches for "client"; the system has &lt;code&gt;Customer&lt;/code&gt;. That's exactly why Microsoft added &lt;code&gt;AdditionalSearchTerms&lt;/code&gt;: exact word matches don't happen in real life. Add plain old typos and naming inconsistencies. And on top of that, to reach the code at all you first have to pull it from the repository, find the right fragments among thousands of objects, and then make sense of them. Doing that without knowing the syntax, without basic developer skills, is genuinely hard.&lt;/p&gt;

&lt;p&gt;So we have a paradox that despite of clarity we can not deal with it with ordinary tools. A simple keyword search won't help. We need a way to search by meaning, not by exact text match. And this is where an approach built for exactly this problem enters the scene...&lt;/p&gt;




&lt;h2&gt;
  
  
  RAG: searching by meaning
&lt;/h2&gt;

&lt;p&gt;The approach is called RAG — Retrieval-Augmented Generation. Sounds fancy, but the idea behind it is simple, and I'll retell it the way I understood it myself, with no claim to academic rigor.&lt;/p&gt;

&lt;p&gt;Imagine that instead of comparing words letter by letter, we learned to compare them &lt;strong&gt;by meaning&lt;/strong&gt;. Not "does the text match" but "is this about the same thing". Then the query "how do I disable a customer" will find &lt;code&gt;Blocked&lt;/code&gt; in the code, even though the word "disable" appears nowhere near it. That's exactly the bridge we were missing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🟩 &lt;strong&gt;AI/RAG primer — skip if you know this:&lt;/strong&gt; the next two paragraphs are a quick refresher on embeddings and cosine similarity. If you're already familiar, jump straight to the &lt;code&gt;king − man + woman&lt;/code&gt; example.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It works through so-called &lt;strong&gt;embeddings&lt;/strong&gt;. In the simplest terms: every piece of text gets turned into a long row of numbers (coordinates in a high-dimensional space). The trick is that texts close in meaning end up close in that space, and unrelated ones far apart. "Customer" and "client" will be neighbors, while "Customer" and "weather" sit on opposite ends. To measure how much two such rows of numbers "point the same way", we use &lt;strong&gt;cosine similarity&lt;/strong&gt; — essentially a measure of how closely the directions of two vectors match. I'm deliberately staying out of the math: the image "close in meaning = close in space" is all you need.&lt;/p&gt;

&lt;p&gt;Demonstration of this property—frequently cited across ML literature—pertains to natural language semantics rather than source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;king − man + woman ≈ queen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the vector for "king", subtract "man", add "woman" — and the nearest vector turns out to be "queen". The most striking part: nobody trained the model to do this. The property emerges on its own, as a side effect of semantics being encoded geometrically. I won't try to explain &lt;em&gt;why&lt;/em&gt; it works (here I'm more an enthusiastic observer than an expert), but as an illustration of "searching by meaning, not by letters" the example is perfect.&lt;/p&gt;

&lt;p&gt;This is where the puzzle clicks together. Remember what's good about Business Central code? Self-documenting names, &lt;code&gt;ToolTip&lt;/code&gt; in plain language, &lt;code&gt;AdditionalSearchTerms&lt;/code&gt; with synonyms. All of that is a ready-made natural-language layer that seems to have been waiting to be turned into embeddings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Semantic search fits the nature of AL code remarkably well: we are literally searching by the same words the user thinks in, not by whatever words happened to end up in an object's name.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fy9udb76c494cr0w0wy2j.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.amazonaws.com%2Fuploads%2Farticles%2Fy9udb76c494cr0w0wy2j.png" alt="Simplified RAG pipeline: from user query to answer" width="798" height="154"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A simplified RAG pipeline: the query is processed on the host, becomes an embedding, the search compares it against the pre-built index of code fragments by cosine similarity — and only a handful of relevant chunks reach the LLM at the very end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;LLM is the last block of the diagram. And this deserves a separate mention. The code chunks the search finds are not an answer by themselves; they need to be pulled together, trimmed of noise, and phrased in plain language. That's what the large language model does. Let's park the details for now (which model and why comes later); the one thing to register is that there's no way around it: the LLM is the final link that turns "a pile of relevant code" into final piece of puzzle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ground rules: cost and time
&lt;/h2&gt;

&lt;p&gt;Before going further, I should be upfront about the constraints I was building under, because they shaped every decision that followed.&lt;/p&gt;

&lt;p&gt;The first and biggest one: I &lt;strong&gt;deliberately didn't want a heavy model&lt;/strong&gt;. The kind that shines in a demo but eats tokens by the handful and keeps you waiting. In a real assistant that people use daily, every call means money plus delay. Multiply that by dozens of queries a day, by several users, by months of operation — and a "slightly pricier model" turns into a noticeable line item. So the bet was on a &lt;strong&gt;relatively modest, partly local solution&lt;/strong&gt;: maybe not the smartest in the world, but predictable in cost and fast.&lt;/p&gt;

&lt;p&gt;The word "local" is the key one here, and here's why. Part of the work (semantic search)  can be moved to your own host and run there with no paid calls at all. Which means this part can be &lt;strong&gt;self-hosted&lt;/strong&gt;, on very modest resources, no expensive hardware. Fewer external calls means fewer bills and less dependence on someone else's API.&lt;/p&gt;

&lt;p&gt;And one more point I'll keep coming back to, because it matters to me: all of this is &lt;strong&gt;hand-built and free&lt;/strong&gt;. This isn't some ready-made SaaS wrapper, not a subscription to someone's service. Assembled from freely available components. A nice side effect of that approach is &lt;strong&gt;broad reusability&lt;/strong&gt;, building RAG over a new corpus is more a matter of repeating the procedure than writing everything from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Working with the code: preparing the corpus
&lt;/h2&gt;

&lt;p&gt;So the idea is clear: turn the code into embeddings and search by meaning. But between "take the code" and "get embeddings" lies a whole stage that, honestly, turned out to be just about the most important one: preparing the &lt;strong&gt;corpus&lt;/strong&gt;, which is what I'll call the full body of code the search works over.&lt;/p&gt;

&lt;p&gt;The problem is that you can't just feed the code to the model in one giant piece. It has to be cut into fragments — &lt;strong&gt;chunks&lt;/strong&gt; (chunking). And here's where the tradeoff begins. Chunk too small and a fragment loses its context: the search pulls back a stub that means nothing on its own. Chunk too large and huge blocks pour into the answers, inflating tokens and diluting relevance. You have to get three things right at once: &lt;strong&gt;how&lt;/strong&gt; to split (where to draw the boundaries), &lt;strong&gt;which&lt;/strong&gt; pieces to take, and &lt;strong&gt;what metadata&lt;/strong&gt; to attach, so it stays clear where a fragment came from and what it belongs to.&lt;/p&gt;

&lt;p&gt;I'm deliberately leaving the details of my implementation out of this article: that part deserves a separate write-up — especially if the approach itself is to change substantially — and in a stricter, closer-to-scientific format. The general principle, though: split along the code's natural boundaries (not blindly by character count), make sure logically whole pieces don't get torn down the middle, and see that every fragment carries a hint about its origin. One detail matters a lot for search quality: each fragment gets a short contextual header — where it's from, which object it belongs to. Thanks to that, even a small piece "remembers" what it's part of and doesn't get lost among thousands of others.&lt;/p&gt;

&lt;p&gt;The number of fragments depends on the version and keeps changing; it's not essential here. What matters more: there are &lt;strong&gt;a lot&lt;/strong&gt; of them, and finding the right ones among them is a separate task — the search's job (how many pieces come back, i.e. the top-K, is something we'll get to when we reach the search itself).&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.amazonaws.com%2Fuploads%2Farticles%2Fh10xak1jas092gf4wso6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh10xak1jas092gf4wso6.jpg" alt="Indexing run: processed AL files and produced fragments" width="800" height="615"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;An indexing run over the Base Application (one of the earlier tuning iterations): 91,522 fragments embedded batch by batch at ~13 fragments per second; the full pass finished in 118 minutes on a free Colab GPU.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For now, one technical detail to pin down (I'll come back to it): at this stage the embeddings are stored in a plain text format (JSON). It's the simplest "just make it work" option, and it's perfectly fine to start with. But — getting ahead of myself — I'll abandon it later. Why, and what for, is a story of its own, waiting in the subsection on scale and optimization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Infrastructure for testing and demo
&lt;/h2&gt;

&lt;p&gt;A few words about the infrastructure, so it's clear where all of this runs. For testing I set up a separate cloud function (an Azure Function): it accepts the query, calls the search, talks to the model, and returns the answer. And to show it to a person, not just drive it from scripts, I built a simple visual chat.&lt;/p&gt;

&lt;p&gt;For now the chat lives as a &lt;strong&gt;separate page&lt;/strong&gt; — a deliberately temporary choice. It could be attached to some Business Central page, kept fully standalone, or embedded some other way; at this stage the form doesn't matter. What matters is that it works and you can see it.&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.amazonaws.com%2Fuploads%2Farticles%2Fgvcx7hxq8k7wtt0adqfl.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.amazonaws.com%2Fuploads%2Farticles%2Fgvcx7hxq8k7wtt0adqfl.png" alt="Demo chat UI with a sample question and answer" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The demo chat: the user asks "Where can I see how much money a client still owes us?" — and the assistant points to the exact fields on the Customer Card page and the Posted Sales Invoices page.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One note for later. The assistant's form could be anything, from a custom window of your own to integration into existing tools. Microsoft, by the way, leaves an official door open for this: Business Central has the means to embed your own AI experience into the native Copilot flow — a dedicated Copilot page type and a system AI module that connects BC to external intelligence. So in theory an assistant like this isn't doomed to stay a "separate button on the side": it can realistically become part of the familiar interface. I haven't walked that path to the end yet, so I mention it as a possibility, not a done fact.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🟦 &lt;strong&gt;For readers not from Business Central:&lt;/strong&gt; Copilot is the brand for Microsoft's built-in AI assistant across their products. In BC, partners can add their own AI capabilities through official extensibility. The point of the paragraph is simple: an assistant like this can realistically be embedded into the system's native interface, not just shown as a separate page on the side.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Choosing the models
&lt;/h2&gt;

&lt;p&gt;Now for the most delicate part, where it's easy to tangle everything up, so let me go carefully. There are &lt;strong&gt;two different models with two different roles&lt;/strong&gt; in this setup, and mixing them up is not an option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first is the embedding model.&lt;/strong&gt; Its only job is to turn text (both the code and the user's query) into the vectors we search by. It doesn't "chat" and doesn't "reason"; it just lays meaning out into coordinates. I took &lt;code&gt;BAAI/bge-m3&lt;/code&gt;: 1024-dimensional vectors, relatively compact (about 568 million parameters), a permissive MIT license. These parameters was decisive for me, so it also &lt;strong&gt;runs locally, with no API at all&lt;/strong&gt;. That maps straight onto the ground rules from earlier: it's free, it never goes over the network (so it can't leak anything either). As a unnecessary plus, it's multilingual, it's understands queries in different languages, a nice bonus for a non-English-speaking team.&lt;/p&gt;

&lt;p&gt;One honest note about "an ordinary CPU". For the &lt;em&gt;search&lt;/em&gt; itself, a CPU is more than enough. At runtime only the user's short query gets embedded, and that's instant. Indexing the full app is different. I ran it on free Google Colab with a basic GPU (T4), because there were many tuning iterations and I didn't feel like waiting hours every time. On the Colab GPU, a full pass over the Base Application took me about an hour and change; On a home CPU, of cource depending on the code volume and fragment count, it's an overnight job, so it is possible to start it in the evening and have your index by morning. Either way it's a one-off, entirely manageable procedure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second is the large language model (LLM).&lt;/strong&gt; This is that final link from the diagram: it takes the found code fragments and shapes a coherent answer. It also has a second duty, which we'll come back to: rephrasing the query for another round of search. Here I took &lt;code&gt;gemini-2.5-flash&lt;/code&gt; — deliberately a &lt;strong&gt;light and cheap&lt;/strong&gt; model, not a flagship. It's &lt;strong&gt;remote&lt;/strong&gt; (an external paid service), but it is remarkably cost-effective; because we never load the whole codebase into it, only a handful of relevant fragments. Better yet: until you start running benchmarks with huge set of questions, you can pay nothing at all. The model has a perfectly adequate free API tier, which is what I used early on &lt;em&gt;(Google revises the limits from time to time; check the current ones before you start)&lt;/em&gt;. The same deliberate bet again: not "the smartest", but "sufficient + cheap + fast".&lt;/p&gt;

&lt;p&gt;⚙️ &lt;strong&gt;Technical depth (optional):&lt;/strong&gt;&lt;br&gt;
I also want to tell a short story about &lt;strong&gt;one more role&lt;/strong&gt;, because it shows well how the approach evolved, and why. Early on, between the search and the answer, I had a so-called &lt;strong&gt;reranker&lt;/strong&gt;, a model that re-reads the found candidates together with the query and re-scores which of them are actually relevant. The logic: the search casts a wide net first (takes many candidates), and the reranker keeps the best.&lt;/p&gt;

&lt;p&gt;This is the right moment to say where all of this physically runs. I hosted (and still host) the search service on a free Hugging Face Space: 2 vCPUs, 16 GB of RAM, no GPU. That configuration is available to anyone for free, so every speed I quote below can be reproduced one to one. Everything works the same locally; I picked the Space for simplicity, and for a showcase you can share as a link.&lt;/p&gt;

&lt;p&gt;So, at first I took a heavy multilingual model for the reranking (&lt;code&gt;bge-reranker-v2-m3&lt;/code&gt;, the same 568 million parameters as the embedding model) and hit a wall. On those two free vCPUs, reranking fifty candidates took over a hundred seconds and kept timing out. The classic price of a "smarter but heavier" model: exactly what I wanted to avoid. So I &lt;strong&gt;dropped down&lt;/strong&gt; to a tiny reranker (&lt;code&gt;ms-marco-MiniLM-L-12-v2&lt;/code&gt;, 33 million parameters): results in ~5 seconds, and it coped fine; the queries were in English, so the heavy multilinguality was wasted there anyway. And then came the most interesting part: once I got the chunking itself and the contextual headers right, the needed fragments started landing at the top &lt;strong&gt;even without the reranker&lt;/strong&gt; — at least on my questions I could no longer see a difference. Let me be straight: I don't have a measured "with reranker versus without"; this is an observation, not a proven fact. But there's a weighty indirect argument in its favor: the entire benchmark discussed below ran without the reranker — and produced zero failures. So the current version drops it, leaving a simple selection of the few best fragments by similarity.&lt;/p&gt;




&lt;p&gt;When all of this first came together, it was a real pleasure: you ask in plain language and get a meaningful answer with references to specific places in the code. But the very first runs honestly exposed the weak spots. Some answers were incomplete, sometimes the search latched onto the wrong thing — and those pains dictated everything that came next. To treat them, I added tracing (to see what's happening inside), made the search run in several passes, and compressed the embeddings with quantization (the subsection on scale covers what that is and what it bought). I also went through a few models, but you've just read that story. The remaining steps, one by one, below.&lt;/p&gt;




&lt;h2&gt;
  
  
  Observability: "I can't see what's happening"
&lt;/h2&gt;

&lt;p&gt;Very quickly I ran into a simple but irritating problem. The assistant produces an answer, but what happened inside is anyone's guess. Which fragments did the search find? Were they relevant? How did the components talk to each other? From the outside it's a black box: a query goes in, text comes out, and in between, fog.&lt;/p&gt;

&lt;p&gt;The answer is &lt;strong&gt;tracing&lt;/strong&gt;. I plugged in Langfuse, a tool that records the whole journey of a request as a tree: what the user asked, what search ran, which chunks came back and with what similarity scores, what went into the model and what it replied. Roughly speaking, I got an X-ray of the entire process — and could finally see what was actually going on under the hood.&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.amazonaws.com%2Fuploads%2Farticles%2Fl0hl9hbzk3ysg2gx22fq.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.amazonaws.com%2Fuploads%2Farticles%2Fl0hl9hbzk3ysg2gx22fq.png" alt="Langfuse trace of a single request" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A Langfuse trace of a single request: on the left, the tree — the semantic search span (&lt;code&gt;search_code:initial&lt;/code&gt;) and the model call; on the right, the question, the answer, and the exact cost of the whole journey (2,952 tokens, ~4.7 s, ~$0.001).&lt;/em&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fghvfdy93qkvmplrncm6m.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fghvfdy93qkvmplrncm6m.jpg" alt="Langfuse trace of an index-free request working through file tools" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The same X-ray pointed at a different setup — the model wandering the files with &lt;code&gt;grep&lt;/code&gt;/&lt;code&gt;read_file&lt;/code&gt; instead of the semantic index (we'll meet this mode properly in the benchmark below): four model calls, 33.9k tokens and ~29 s for a single answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In fact, several of the observations and screenshots later in this article come exactly from there. Once a process is visible, it's much easier to explain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Core
&lt;/h2&gt;

&lt;p&gt;This is the core of the whole construction. The thing the observability above was set up for.&lt;/p&gt;

&lt;p&gt;It quickly became clear that &lt;strong&gt;a single search pass is often not enough&lt;/strong&gt;. Two reasons. First, the fragments found on the first try can be incomplete, because something important gets left behind. Second, the &lt;strong&gt;user's query itself can be "not great"&lt;/strong&gt; for search, because people phrase questions in their own words, and those don't always map well onto what should be looked up in the code.&lt;/p&gt;

&lt;p&gt;The fix is to make the process &lt;strong&gt;iterative&lt;/strong&gt;. This is where we introduce a concept that the industry has worn thoroughly threadbare by now—yet. Instead of executing a single search pass with the raw user query, we give possibility to &lt;strong&gt;the model&lt;/strong&gt; to drive it itself and refine the retrieval process. It works like this: the model receives the original query and the first found fragments, looks at them and, if it lacks context, &lt;strong&gt;formulates a refined sub-query of its own&lt;/strong&gt; — essentially rewriting the question in the words that will find the right code better. That refined query goes back into the search — becomes an embedding — finds new fragments by similarity — they return to the model. And so on, a few times, until the model decides it has enough context to answer. By the way, here's the promised answer about top-K. On each pass the search hands the model only a handful of the best fragments by similarity; in my case, but the number isn't universal it depends directly on how the corpus is chunked and how big the fragments are. My typical fragment is about 160–200 tokens, so 8 fragments is only ~1.5k tokens of code context per pass. Enough to form a picture, too little to inflate the cost.&lt;/p&gt;

&lt;p&gt;In other words, the model acts not only as the author of the final text but as the search's "navigator". It actively digs down to what it needs instead of settling for the first attempt. I cap the number of passes: no endless loops, no runaway costs. As a rule, a few iterations are enough.&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.amazonaws.com%2Fuploads%2Farticles%2F0gtdq8p96ppnidth4v42.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.amazonaws.com%2Fuploads%2Farticles%2F0gtdq8p96ppnidth4v42.png" alt="One iteration: the model refines the sub-query and searches again" width="799" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One iteration of the loop: the model reviews the retrieved fragments and, if the context is not enough yet, writes a refined sub-query of its own — which goes back into the semantic search as a new embedding. The number of passes is capped.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Scale and optimization
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;⚙️ &lt;strong&gt;Technical depth (optional):&lt;/strong&gt; this subsection is about the internal optimization of embedding storage. If you only want the bottom line: the index became several times smaller, loads faster, and eats fewer tokens per query. Details below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While I was playing with a small number of objects, everything flew. The real test is the Base Application: a huge codebase with a great many objects, many of them large. At that scale, problems surfaced that the toy example never showed.&lt;/p&gt;

&lt;p&gt;The first wall was embedding storage. Remember I mentioned keeping them in plain JSON at first? On the full corpus, that file grew to almost two gigabytes — and that became a hard stop. I wanted to host the search on a free tier (recall the refrain: free and local), and the limit there is one gigabyte. This is the same free Hugging Face Space (2 vCPUs, 16 GB RAM, no GPU): the gigabyte is the limit on the Space's repository, covering everything you put into it, index included. A two-gigabyte JSON simply &lt;strong&gt;didn't fit&lt;/strong&gt;, and on top of that it stalled the startup for good. Why does the index have to sit in memory whole in the first place? It's a consequence of my basic approach to search: every user query also becomes a vector, and that vector is compared against &lt;em&gt;all&lt;/em&gt; the vectors of the corpus — so the entire index has to sit ready in RAM, not lie on disk. More advanced RAG setups have cleverer schemes that relax this requirement, but the simpler route was enough for me. Optimization here was a matter of survival.&lt;/p&gt;

&lt;p&gt;The path came in stages. First I moved from text JSON to a &lt;strong&gt;compact binary format&lt;/strong&gt;: the embedding model outputs vectors as numbers (float32) anyway, so now they're written straight into a binary index. The numbers themselves instead of their textual spelling, no intermediate giant file. Much better already: almost two gigabytes shrank to ~481 MB. Then — as a separate step, with no re-embedding whatsoever — I applied &lt;strong&gt;scalar quantization to 8 bits (SQ8)&lt;/strong&gt;: each vector coordinate is compressed from 32 bits down to 8, with its own scale for each of the 1024 dimensions. It sounds like quality has to suffer, but in practice it barely does: the vectors are normalized, and this compression barely perturbs them; besides, the user's query stays at full precision, and the compressed vectors are unpacked on the fly during search; we're saving memory and disk, not computation accuracy. The result: vectors that took ~481 MB in float32 weigh ~120 MB after quantization, roughly four times less. Together with the fragment texts and metadata, the whole index is about 354 MB: it fits comfortably into the one-gigabyte limit of the free hosting and loads in a matter of seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vector storage format&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Index loading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON (numbers as text)&lt;/td&gt;
&lt;td&gt;~1.9 GB&lt;/td&gt;
&lt;td&gt;exceeds the storage limit; loading never finished&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary float32&lt;/td&gt;
&lt;td&gt;~481 MB&lt;/td&gt;
&lt;td&gt;stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scalar quantization SQ8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~120 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3–6 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Together with fragment texts and metadata, the full index is about 354 MB — well within the one-gigabyte limit of the free hosting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's a second win here, just as important: no longer about disk but about &lt;strong&gt;tokens and response time&lt;/strong&gt;. Once I made the fragments smaller and tidier, each of them started taking up little room in the model's context. Which means the same context window now fits &lt;strong&gt;more relevant pieces&lt;/strong&gt; — while the total token count per query &lt;strong&gt;drops&lt;/strong&gt;. Fewer tokens means cheaper and faster. A pleasant chain: careful chunking — small fragments — more useful context for fewer tokens — lower cost and faster response time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Efficiency results: the index versus index-free search
&lt;/h2&gt;

&lt;p&gt;Here I don't want to just declare how well (or how badly) it works and want show it with an honest comparison. Because you could fairly ask: why build RAG with embeddings at all if there's a simpler route,the ones we work with daily anyway, I mean just give the model direct access to the code files themselves and let it search them by keywords?&lt;/p&gt;

&lt;p&gt;To have something to compare against, I built a second mode — &lt;strong&gt;index-free&lt;/strong&gt;. Same model, same code, but instead of semantic search the model gets simple file tools (&lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;list_files&lt;/code&gt;) and has to wander the &lt;code&gt;.al&lt;/code&gt; files on its own: find a word match, read a file, search again. It's the same "ordinary keyword search" whose limits we discussed above, just handed over to an agent.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A small but important disclaimer about the name.&lt;/strong&gt; In the code I called this mode &lt;code&gt;mcp&lt;/code&gt; for high-level understanding, but I never stood up a real MCP protocol (Model Context Protocol: a separate server, JSON-RPC, handshake, tool discovery). It's simple grep/read over local files, with no index of any kind. It seems like an agent with a filesystem MCP server, so as a &lt;strong&gt;cost proxy&lt;/strong&gt; the comparison is fair (tokens are driven by how much code the model pull into the prompt, not by the protocol wrapper). A real &lt;em&gt;filesystem&lt;/em&gt; MCP server would cost about the same in tokens — and would even be &lt;strong&gt;slower&lt;/strong&gt;, adding network delays. For full honesty, there's also a third player my benchmark doesn't cover. Microsoft has already shipped an official &lt;strong&gt;AL MCP Server&lt;/strong&gt;: a separate process with language-level tools (symbol search, diagnostics, build), essentially grown out of the same language server that powers the AL extension for VS Code. A "smart" server like that would search noticeably better than naive word matching. Then again, that's no longer "index-free": it's just a &lt;em&gt;different&lt;/em&gt; index (a symbol one) on the server side, and a fair fight with it would take a separate, third benchmark mode. And my RAG in this comparison isn't "on steroids" either: a single dense retriever, no reranker, no hybrid search. So the numbers below isolate exactly one axis: a semantic index versus its absence.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ran two question sets through both modes on the same app (Base Application) and the same model, and looked at cost (tokens), speed (response time) and, most importantly, whether an answer was found at all. Up front: the head start went &lt;strong&gt;to index-free search&lt;/strong&gt;: I gave it twice as many attempt-iterations (6 versus 3 for RAG) so as not to build a straw man; more than that, the 3 RAG iterations were a limit I raised for the benchmark, while in the working configuration I plan for one or two, so in daily use it's even more modest than these numbers. Even with that head start, index-free search came out more expensive, slower and less reliable. By how much, exactly? Let's go through the numbers.&lt;/p&gt;

&lt;p&gt;When it comes &lt;strong&gt;to cost and latency&lt;/strong&gt;, RAG demonstrates superior efficiency: it requires 5–6× fewer tokens and delivers answers 2.5–3.5× faster (technical set breakdown: 5,834 tokens vs. 34,384; 7.5 s vs. 25.1 s). In money terms, roughly four times cheaper per query. The reason is simple: RAG brings the model a handful of on-target fragments, while index-free search forces it to wander the code, pulling in piece after piece and burning tokens all the way.&lt;/p&gt;

&lt;p&gt;But the most interesting part isn't the averages — it's the &lt;strong&gt;tails&lt;/strong&gt;. An average hides the worst case: on individual questions the index-free mode burned &lt;strong&gt;130–170 thousand tokens&lt;/strong&gt; on a single answer (versus 3–4 thousand for RAG: 30–60+ times more expensive), and sometimes, after all that, it &lt;strong&gt;still&lt;/strong&gt; came back with "couldn't find it". RAG behaved predictably: its most expensive question was about 18 thousand tokens.&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.amazonaws.com%2Fuploads%2Farticles%2F8hq60i0ms0r60a9bhqiu.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.amazonaws.com%2Fuploads%2Farticles%2F8hq60i0ms0r60a9bhqiu.png" alt="Average tokens per question by category: semantic index vs index-free search" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Average tokens per question across the five business question categories. The multiplier above each pair shows how much more the index-free mode burned; the "scenario" category — a situation described in plain words, with no obvious keyword — shows the biggest gap.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Break the business questions down by type and a pattern emerges: on &lt;strong&gt;scenario&lt;/strong&gt; questions (the user describes a situation — "a customer returned the goods" — with no keyword at all) the token gap reaches &lt;strong&gt;×15&lt;/strong&gt;; on &lt;strong&gt;direct keyword questions&lt;/strong&gt; it's smallest (about ×2.5), because there's an obvious word for grep. The careful conclusion: the advantage of semantic search grows exactly with how &lt;strong&gt;human&lt;/strong&gt; the phrasing is. And that's fit nice to the real-world case: users ask in their own words, not in terms from the source files.&lt;/p&gt;

&lt;p&gt;The most important point? &lt;strong&gt;Reliability&lt;/strong&gt;. Index-free search kept simply &lt;strong&gt;failing to find the answer&lt;/strong&gt;: 15 failures out of 60 technical questions (and 4 out of 30 business ones) versus &lt;strong&gt;zero&lt;/strong&gt; for RAG. Almost all the failures sit where the logic hides inside a huge table file: &lt;code&gt;grep&lt;/code&gt; by field name returns noise or nothing, and the model gives up. Sometimes the outcome is even worse than an honest "not found": on the question about Quantity field validation, the index-free mode confidently declared that the OnValidate trigger &lt;strong&gt;doesn't exist&lt;/strong&gt; — although it's literally there in the code, and RAG quoted it with accuracy, down to the procedure names. So without an index the model sometimes doesn't just fold — it &lt;strong&gt;errs with confidence&lt;/strong&gt;. RAG, thanks to small fragments with a contextual header, pulls exactly the right piece.&lt;/p&gt;

&lt;p&gt;Time to face the main possible objection head on: "maybe the questions were rigged for RAG — phrased to dodge the exact words of the code?" I checked this separately, by counting how often each question's keyword actually occurs in the app. It turned out that &lt;strong&gt;none&lt;/strong&gt; of the 19 index-free failures happened because a word was missing: the words were there, often thousands of times. The mode lost not because the questions hid the vocabulary, but because it &lt;strong&gt;choked on the volume of large files&lt;/strong&gt;. That makes RAG's advantage honest.&lt;/p&gt;

&lt;p&gt;For full fairness, the flip side too. On some of the questions, where there's a recognizable object name and a few files to 'walk', index-free search gave a deeper, more detailed answer (more references to specific lines of code). It's far from obsolete — it simply serves a specific use case. You just pay for it with several times the tokens, and RAG answers those questions correctly as well. And one more check: do the two modes "invent" file and procedure names? No — almost all the cited entities really exist in the code, on both sides. So RAG's win is a win on cost and reliability.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(All numbers are recomputed from the full run logs; a selection of the most telling questions, with both modes side by side, is in the note at the end of the article.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is a good fit — and what it's part of
&lt;/h2&gt;

&lt;p&gt;Now it's time to wrap up why this solution makes sense and where it actually fits in&lt;/p&gt;

&lt;p&gt;First, briefly, why it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Up-to-date context.&lt;/strong&gt; RAG is built over the code itself, so it updates automatically with every release. As a result, the assistant knows the current logic. No separate documentation that someone has to keep writing.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security and privacy.&lt;/strong&gt; All the code and the embeddings stay local; what leaves for the external model is not the whole codebase but a handful of relevant fragments for one specific query. For many customers, the fear of "handing everything over somewhere" was the main barrier — and here it's largely removed. As an added bonus, due to the fact that the LLM is supplied with high-signal, low-noise data. It doesn't need native knowledge of the platform. Its workload shifts entirely to structuring the output, meaning you can offload the job to a much smaller, lightweight model without losing any quality. Looking ahead, this opens up the possibility of running a fully local model with absolutely no external calls. Sure, you'll still need high-spec computing resources, but the infrastructure requirements become far less intimidating.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reusability.&lt;/strong&gt; This isn't a one-off built for a single project. I mean that the same mechanism can be pointed at another codebase — another app in Business Central. One approach for many applications within the system.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Output flexibility.&lt;/strong&gt; Since the LLM phrases the answer according to an instruction, the output is easy to set up for the audience — with code or without, brief or detailed. Additionally, it is possible to specify a given 'role'. The instruction can also be made strict: a classic prompt-engineering move is to lock the model into a frame of "answer only within the provided context; politely decline everything else", and it will obediently ignore off-topic questions. From there it's one step to an idea that fits Business Central especially well: filter not the answer format but the &lt;em&gt;context itself&lt;/em&gt;, by the user's role. Every fragment in the index already has metadata about its object (type and name), and a permission set in BC is essentially a list of allowed objects; match one against the other, and the assistant will show a user only the code their role can actually access. For now this is an idea, not an implementation, but I do not see technical blockers.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;More interesting than the advantages themselves is &lt;strong&gt;who actually benefits from this, and where&lt;/strong&gt;. A few roles where I see the point (from the most obvious to the most distant):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The customer (end user).&lt;/strong&gt; The first thing that comes to mind is a chat directly for the client, which is particularly valuable for new or feature-heavy functionality. Thanks to semantic search, a person gets an answer even when they only &lt;em&gt;roughly&lt;/em&gt; know what the thing they need is called. They can describe it in their own words and the system finds it by meaning. The business benefit is direct: the routine "where do I see…" questions stop landing on consultants and support, an answer as for the system component detailed in the article costs pennies and arrives in seconds. In general, such an assistant helps users understand functionality faster and deeper; as a result, it helps them use the solution more effectively. Of course, we're talking about a full chat product here; RAG is only one part of it, though quite an essential part, as the results showed us.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;QA / tester.&lt;/strong&gt; A chat tuned for testing. No more guessing which table or procedure a given action leads to. The output isn't code fragments as-is but a technically detailed explanation in plain language (what does what). Also you don't have to hold the implementation in your head. System will help you with such task: "how does this button work/ what does it affect/ which fields and tables does it touch". And the same answers make a convenient draft of test documentation. A useful side effect is communication: a tester can put the exact procedure name or code fragment into a task right under their description, and the developer doesn't have to translate "from business language into technical".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Developer.&lt;/strong&gt; A chat with a RAG component become powerful when a task is described "business-style". Just a business process, but no procedure or object names, and no screenshots to reproduce from. The assistant then helps get oriented in the module and the functionality faster, moving from the general description down to specific places in the code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Part of something bigger.&lt;/strong&gt; The chat itself is only the most visible showcase for a simple example. This identical RAG framework can be integrated as a modular component within a broader architecture, serving as a retrieval step for autonomous agents handling vague queries, or acting as a baseline for next-step execution. RAG usually lives in big systems as one of the components, not the showcase. The special thing here is that in Business Central, thanks to the nature of the AL language, this component — approached right — works remarkably well, as we've just seen in the numbers.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the end, it all comes down to the one thing we started with: the system was taught to &lt;strong&gt;understand its own logic by meaning, not by word match&lt;/strong&gt;. Everything else is just different ways of putting that to use.&lt;/p&gt;

&lt;p&gt;Here's how I see the next directions. First, toward smarter RAG: linking the code fragments to each other and to the database into a single picture, so the assistant sees not isolated pieces but the connections between them — who calls whom, which table stands behind which process. Second, toward broader tasks on the same mechanism: understanding and writing test codeunits, navigating solutions made of several communicating apps, checking reports against the logic that produces them. None of this is science fiction — it's the same search by meaning, just directed to new tasks.&lt;/p&gt;

&lt;p&gt;I won't pretend this is a scientific discovery — it's more of a curious thing I explored in my off-hours and wanted to share. The quality of the RAG system hinges entirely on your chunking strategy, which must align with the inherent design of AL code: pinpointing its semantic core, respecting natural architectural boundaries, and keeping atomic logical blocks intact. Standard text search holds its ground for clear keyword queries, while output formatting remains a static design-time decision rather than a dynamic runtime feature. However, establishing these precise boundaries ensures architectural viability—allowing you to maximize efficiency in its spot and bypass edge cases where it underperforms.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note: ten benchmark questions up close
&lt;/h2&gt;

&lt;p&gt;Rather than dump the full run logs here, I've gathered a small, honest sample from the business set. The questions are given verbatim, exactly as they went into the benchmark, in English. I deliberately included the rows where index-free search did the job cheaper; they're part of the picture too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question (verbatim from the benchmark)&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Semantic index&lt;/th&gt;
&lt;th&gt;Index-free search&lt;/th&gt;
&lt;th&gt;Bottom line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"A customer sent goods back to us — how does the system handle returns?"&lt;/td&gt;
&lt;td&gt;scenario&lt;/td&gt;
&lt;td&gt;2.8k tok · 5 s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;173k tok&lt;/strong&gt; · 26 s&lt;/td&gt;
&lt;td&gt;×62 on tokens — the set's record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do we record that we only shipped part of what was ordered?"&lt;/td&gt;
&lt;td&gt;scenario&lt;/td&gt;
&lt;td&gt;2.5k · 5 s&lt;/td&gt;
&lt;td&gt;48k · 19 s&lt;/td&gt;
&lt;td&gt;×19; index-free wandered the files for a long time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How does the company remind customers who haven't paid on time?"&lt;/td&gt;
&lt;td&gt;scenario&lt;/td&gt;
&lt;td&gt;2.9k · 6 s&lt;/td&gt;
&lt;td&gt;18k · 23 s&lt;/td&gt;
&lt;td&gt;×6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How does the warehouse know which shelf to take an item from?"&lt;/td&gt;
&lt;td&gt;scenario&lt;/td&gt;
&lt;td&gt;9.8k · 7 s&lt;/td&gt;
&lt;td&gt;72.5k · 23 s&lt;/td&gt;
&lt;td&gt;×7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Where can I see how much money a client still owes us?"&lt;/td&gt;
&lt;td&gt;synonym&lt;/td&gt;
&lt;td&gt;3.0k · 7 s&lt;/td&gt;
&lt;td&gt;19k · 15 s, &lt;strong&gt;no answer found&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;index-free: a failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do I deactivate a supplier so we stop buying from them?"&lt;/td&gt;
&lt;td&gt;cross-term&lt;/td&gt;
&lt;td&gt;4.2k · 7 s&lt;/td&gt;
&lt;td&gt;40k · 53 s&lt;/td&gt;
&lt;td&gt;×9; the word "deactivate" isn't in the code — it's &lt;code&gt;Blocked&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How does an approval flow move a sales document from draft to approved?"&lt;/td&gt;
&lt;td&gt;cross-file&lt;/td&gt;
&lt;td&gt;2.9k · 9 s&lt;/td&gt;
&lt;td&gt;45k · 29 s&lt;/td&gt;
&lt;td&gt;×15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do we charge a customer in a foreign currency but still report in our own?"&lt;/td&gt;
&lt;td&gt;control&lt;/td&gt;
&lt;td&gt;3.1k · 11 s&lt;/td&gt;
&lt;td&gt;33.9k · 32 s&lt;/td&gt;
&lt;td&gt;×11 — even with clear terms in the question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How does the system work out the date by which a customer has to pay?"&lt;/td&gt;
&lt;td&gt;synonym&lt;/td&gt;
&lt;td&gt;10.9k · 10 s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.8k&lt;/strong&gt; · 19 s&lt;/td&gt;
&lt;td&gt;honestly: index-free is cheaper here — the term is obvious&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"customer credit limit"&lt;/td&gt;
&lt;td&gt;control&lt;/td&gt;
&lt;td&gt;5.6k · 8 s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;4.0k&lt;/strong&gt; · 16 s&lt;/td&gt;
&lt;td&gt;on an exact keyword, plain search holds its own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is the same as in the averages: the more "human" the phrasing, the bigger the index's lead; on exact keywords, plain search keeps up just fine. &lt;em&gt;(The numbers are total tokens per question and response time, rounded; I keep the full logs.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And so the numbers don't stay faceless: two live answers from the logs, verbatim.&lt;/p&gt;

&lt;p&gt;The first is one I'm not ashamed of. The question: &lt;em&gt;"How is a product/article record set up in the system?"&lt;/em&gt;. Note: the code contains neither "product" nor "article" — the system had to figure out on its own that this is about &lt;strong&gt;Item&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Semantic index&lt;/strong&gt; (13.3k tokens · 10 s): "Product or article records, referred to as "Items" in the system, are set up and managed through the "Item Card" page. This page allows users to define various details about an item, including: * &lt;strong&gt;Description and Pricing:&lt;/strong&gt; Information that appears in sales and purchase documents, such as line description and price. * &lt;strong&gt;Replenishment and Stocking:&lt;/strong&gt; Settings for how an item is replenished and stocked. * &lt;strong&gt;Costing and Posting:&lt;/strong&gt; Rules for how costing and posting are handled for the item. * &lt;strong&gt;Item Type:&lt;/strong&gt; An item can be of type "Inventory" (for physical units) or "Service" (for labor time units). Items can be created in a few ways: * &lt;strong&gt;Manually from an "Item List":&lt;/strong&gt; Users can access the "Item List" page and choose to create a "New Item," which opens a blank "Item Card." * &lt;strong&gt;From a Template:&lt;/strong&gt; When creating a new item, a user can choose to insert an item from a pre-defined template, which pre-fills many fields. * &lt;strong&gt;From a Description:&lt;/strong&gt; In some cases, if an item doesn't exist, the system might try to create an item from its description, especially if the "Create Item from Description" setting is enabled in the sales setup. Once an item is created, users can also perform related actions such as: * &lt;strong&gt;Create Stockkeeping Unit:&lt;/strong&gt; Create an instance of the item at each location. * &lt;strong&gt;Calculate Counting Period:&lt;/strong&gt; Prepare for a physical inventory by determining which items need to be counted. The "Item Card" serves as a central hub for managing all the detailed information related to a product, impacting how it's handled throughout the sales, purchasing, and inventory processes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second, for contrast, is what an index-free failure looks like on &lt;em&gt;"Where can I see how much money a client still owes us?"&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Index-free search&lt;/strong&gt; (19k tokens · 15 s): "I could not find a direct table named "CustomerLedgerEntry.Table.al" in the immediate search. However, the system widely uses the concept of a "Customer Ledger Entry" and refers to a "Remaining Amount" associated with it. This "Remaining Amount" is likely where you can see how much money a client still owes. I will try to find the definition of the "Customer Ledger Entry" table to confirm this." — the answer breaks off at "I will try to find": six times the cost of the index's correct answer to the same question (3k tokens).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(A small aside on why the answer text looks rough: the benchmark stored each answer as a single raw log line, with no rendering; the asterisks and colons are markdown list markup, which a user in the live chat sees as proper bullets and subheadings.)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
