<?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: Oleksandr Sainchuk</title>
    <description>The latest articles on DEV Community by Oleksandr Sainchuk (@oleksandr_sainchuk).</description>
    <link>https://dev.to/oleksandr_sainchuk</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%2F4182234%2Fbc397cbc-9f1c-4c89-8039-a5785bd44932.png</url>
      <title>DEV Community: Oleksandr Sainchuk</title>
      <link>https://dev.to/oleksandr_sainchuk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandr_sainchuk"/>
    <language>en</language>
    <item>
      <title>Your SaaS Says “Saved”? Refresh the Page.</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Tue, 10 Nov 2026 21:58:36 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/your-saas-says-saved-refresh-the-page-4emn</link>
      <guid>https://dev.to/oleksandr_sainchuk/your-saas-says-saved-refresh-the-page-4emn</guid>
      <description>&lt;p&gt;A client of ours vibe-coded their product with AI tools and brought it to a demo. The forms worked, the screens looked finished, and the app said Saved after every action. During that demo it turned out the data had never been stored.&lt;/p&gt;

&lt;p&gt;Nothing on the screen hinted at it. From the user's side it looks like this. You type a task, press Save, the task appears in the list, the input clears, and a small green "Saved" shows up under the form. Then you refresh the page and the task is gone, because the server never stored it.&lt;/p&gt;

&lt;p&gt;The task appears with a Saved message, then disappears after a page refresh&lt;/p&gt;

&lt;p&gt;A false success state is a screen that tells the user an action worked before the server confirmed it, or after the server refused it. This article grew out of that demo. When I review a save flow now, I don't take the green message as proof. I create the record through the UI, reload the page, read the record back through the authenticated API, try the same ID as a different user, and then break the request on purpose to see what the UI says.&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%2Fidcqd1v88dwwk4zogrom.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%2Fidcqd1v88dwwk4zogrom.png" alt="checks"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To show all four, I built a small React app with an Express API, SQLite and Playwright, then broke it in three different ways. The results surprised me a little. A server that answered 201 and wrote nothing passed the test for failed saves, and a server that leaked tasks between users passed both persistence tests. Every bug got past at least one check, so a single test was never enough.&lt;/p&gt;

&lt;p&gt;People write these bugs by hand too. With AI-built features the risk is higher, because an agent can produce a whole flow in minutes and it's easy to accept code you haven't read line by line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handler that lies
&lt;/h2&gt;

&lt;p&gt;This is the broken path in the React app. It runs inside the form's submit handler.&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="nf"&gt;setTasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Saved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/api/tasks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React adds the row right away, the API answers 503 and writes nothing, and the UI never looks at the answer.&lt;/p&gt;

&lt;p&gt;Two things go wrong here. Saved appears before the request finishes, and the code never checks the HTTP status. As &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch" rel="noopener noreferrer"&gt;MDN explains&lt;/a&gt;, &lt;code&gt;fetch()&lt;/code&gt; resolves on a 503 the same way it resolves on a 200, so the &lt;code&gt;.catch()&lt;/code&gt; fires only for network errors and the 503 goes by unnoticed.&lt;/p&gt;

&lt;p&gt;Showing a pending task right away is a fair design choice. The trouble starts when that pending row gets the word Saved, because at that moment the app is confirming something it never checked.&lt;/p&gt;

&lt;p&gt;For this small app I picked a simpler contract. The text stays in the input while the request is in flight, the row appears after the server confirms the write, and if confirmation fails, the input keeps the text and an error message shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every test its own account
&lt;/h2&gt;

&lt;p&gt;The tests use cookie sessions. A page fixture creates a fresh user for each test and deletes that user's records afterwards. The &lt;code&gt;/__test/*&lt;/code&gt; endpoints exist only when &lt;code&gt;TEST_MODE=1&lt;/code&gt; is set and skip real login, so they have no place in a deployed app. In your own project, use seeded test accounts and your real login flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"@playwright/test"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:crypto"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;use&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/__test/session"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/__test/session"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I create a new user per test because a new browser context gives you fresh cookies and storage, and that's all it gives you. Two tests logged in as the same user still write to the same rows on the server. Playwright's &lt;a href="https://playwright.dev/docs/auth" rel="noopener noreferrer"&gt;authentication guide&lt;/a&gt; makes the same point and recommends separate accounts for tests that change server state.&lt;/p&gt;

&lt;p&gt;The shared save function starts listening for the POST response before it clicks, so a fast reply can't slip past it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/tasks"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Task name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&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="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"/api/tasks"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;"Save"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Saved"&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="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Test 1. Reload the page
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"saved task survives reload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`Release check ${randomUUID()}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;"Tasks"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;"Tasks"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the broken handler, the first assertion passes because the task lives in React state. After the reload the app asks the API for the list, the API returns what it stored, and the task isn't there. This is the same thing the client saw in the demo, caught by a test in a few seconds.&lt;/p&gt;

&lt;p&gt;A passing reload test proves less than it seems. An app can rebuild its list from localStorage, IndexedDB, a service worker or an HTTP cache, and then the task survives a refresh with no database row behind it. In this demo the list always comes from the API, responses send &lt;code&gt;Cache-Control: no-store&lt;/code&gt;, and the Playwright config blocks service workers. Even so, I want to hear from the server itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2. Read the record back through the API
&lt;/h2&gt;

&lt;p&gt;The POST response carries the new record's ID. I use it for a separate authenticated GET and compare the fields I meant to save.&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"saved task is readable through the authenticated API"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`API check ${randomUUID()}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&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="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`/api/tasks/${created.id}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;page.request&lt;/code&gt; shares cookies with the browser context (see &lt;a href="https://playwright.dev/docs/api/class-apirequestcontext" rel="noopener noreferrer"&gt;APIRequestContext&lt;/a&gt;), so this GET runs as the same user who filled in the form. If your app uses bearer tokens, set the Authorization header yourself. There's no mocked response anywhere in the persistence tests, they talk to the real local API.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;fake201&lt;/code&gt; server mode shows why this test exists. It answers 201 with a believable ID and skips the insert. The status assertion passes, the follow-up GET returns 404, and the test fails.&lt;/p&gt;

&lt;p&gt;In this app the GET reads from SQLite. In your architecture it might read a cache or a replica, so check where the read path goes before you call this a persistence test. Async APIs need a different assertion too. A 202 says the server accepted the work and nothing more, so follow the operation ID and poll for the completed state with a timeout.&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%2F6m4lr8rzndoflv6my1qm.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%2F6m4lr8rzndoflv6my1qm.png" alt="An illustrative Network panel shows a task creation request returning 201 Created, followed by a GET for the same task ID returning 404 Not Found"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 3. Request the same ID as another user
&lt;/h2&gt;

&lt;p&gt;A record can be saved perfectly and still be readable by the wrong person. This test seeds a private task, checks that its owner can read it, then logs in a second user in a separate browser context and asks for that task by its ID.&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%2Fcn8psj0undqjnsyokrrn.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%2Fcn8psj0undqjnsyokrrn.png" alt="A private task owned by Maya appears in Noah’s account when he requests the same task ID."&gt;&lt;/a&gt;&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"another user cannot read a known task ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;}&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/__test/seed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Private ${randomUUID()}`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`/api/tasks/${task.id}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="n"&gt;outsider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"http://127.0.0.1:4173"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outsider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/__test/session"&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="n"&gt;forbidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outsider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`/api/tasks/${task.id}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forbidden&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&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="n"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outsider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/api/tasks"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outsider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/__test/session"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outsider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This API answers 404 for a task the caller doesn't own. If your contract says 403, assert 403. A redirect to the login page, a 500, or a loose &lt;code&gt;!response.ok()&lt;/code&gt; check would let a real problem pass here. The owner read at the start is a positive control, because if the task was never created, a 404 for the second user would prove nothing.&lt;/p&gt;

&lt;p&gt;The fixed lookup filters by both the ID and the logged-in owner, and &lt;code&gt;req.user&lt;/code&gt; comes from the server's session store, never from the request body.&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT id, title FROM tasks WHERE id = ? AND owner = ?"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A workspace product has real roles, and a teammate may be allowed to see the record. There I'd test someone from another tenant, a read-only member, a member who was removed, and the write endpoints as well as the reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 4. Make the failed save visible
&lt;/h2&gt;

&lt;p&gt;This one checks what the user sees when the save fails. It intercepts only the POST, answers with a controlled 503, and lets the GET go through to the real server.&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%2Fhp1w5mwk4jlm1nmovw7v.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%2Fhp1w5mwk4jlm1nmovw7v.png" alt="A SaaS task form retains the user’s draft and displays an unconfirmed-save warning after a failed request"&gt;&lt;/a&gt;&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"rejected save keeps the draft and never says Saved"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`Rejected ${randomUUID()}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"**/api/tasks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&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="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'{"error":"Write rejected"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/tasks"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Task name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;"Save"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"alert"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"Save was not confirmed. Your draft is still here."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBeEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Task name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;"Tasks"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This mock tests the frontend's error handling and tells you nothing about the database, so I keep it apart from the real-server tests.&lt;/p&gt;

&lt;p&gt;The fixed handler checks the status before it adds the row or clears the draft.&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="nf"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/api/tasks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Save was not confirmed."&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="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setTasks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Saved"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Save was not confirmed. Your draft is still here."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rejected save and a lost response are two different situations. The network can drop after the server has already committed the write, and then the client has no way of knowing what happened. So the message says the save wasn't confirmed, which is true in both cases. Keeping the draft is safe, and resending it blindly can create a duplicate, so before I add automatic retries to a real create flow, I give each operation a stable idempotency key and deduplicate on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests caught
&lt;/h2&gt;

&lt;p&gt;I ran the same five tests against all four modes in Chromium, with two workers and retries turned off. The fifth test, not shown above, checks that an anonymous request can't list tasks.&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%2F3arxd37qx8iyfdjbq3m1.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%2F3arxd37qx8iyfdjbq3m1.png" alt="Modes"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Playwright HTML report with the failed reload test in broken mode&lt;/p&gt;

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

&lt;p&gt;The companion project has the React app, the server, the tests, the Playwright config and a lockfile. I tested it on Node 24, React 19.2.0 and Playwright 1.56.1. From the saved-demo folder on Linux or macOS, these commands run the fixed version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
npx playwright install --with-deps chromium
npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see the bugs, run each mode on its own. These runs are supposed to exit with failures.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;broken npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;fake201 npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;leaky npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI, give every job a throwaway database and keep test auth state out of git. The project has a CI workflow for the fixed mode, and the numbers in the table above come from my local run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd start in a bigger app
&lt;/h2&gt;

&lt;p&gt;In a real product I wouldn't try to test every form at once. I'd pick the save flow where a lost record costs the most and add one real-server create-and-read test, one access test with a specific expected status, and one controlled failure test.&lt;/p&gt;

&lt;p&gt;If an AI agent built the feature, you can hand it this prompt and then review the tests it writes. Replace the parts in square brackets with your own names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write Playwright tests for the [create task] flow in this app.

1. Create a [task] through the UI, reload the page, and assert it is still in the [Tasks] list.
2. Take the ID from the POST response, GET [/api/tasks/:id] with the same session, and assert status 200 and the saved fields.
3. Log in a second user in a new browser context, request the same ID, and assert [404].
4. Make the POST return 503 with page.route, then assert that "Saved" never appears, the draft stays in the input, and an error is shown.

Give every test its own user account. Do not mock the API in tests 1 to 3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four green tests don't make an app production-ready. They do show whether one specific Saved message holds up after a reload, an API read, a second user and a failed request. Before your next demo, save one record and read it back as the person who will need it tomorrow.&lt;/p&gt;

&lt;p&gt;What's the strangest fake success you've seen in an AI-built app? Tell me in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does a task disappear after refreshing an AI-built app?
&lt;/h3&gt;

&lt;p&gt;The frontend may have updated local state even though the write failed, or the reload may query a different account or data source. Look at the POST response and the GET that follows it. A disappearing record on its own doesn't tell you which cause you have.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test database persistence with Playwright?
&lt;/h3&gt;

&lt;p&gt;Create the record through the UI, then read it back by ID through an authenticated API request and compare the stored fields. Confirm that the endpoint reads the database you care about, since a refresh test can pass on browser-stored data.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test data isolation between SaaS accounts?
&lt;/h3&gt;

&lt;p&gt;Create a record for its owner and confirm the owner can read it. Then log in a second user in a separate browser context, request the same ID, and assert the documented denial status. Extend the test to write endpoints and to the tenant roles your product has.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I mock API calls in a persistence test?
&lt;/h3&gt;

&lt;p&gt;Mock a failed response when you test how the UI reacts to failure. Use the real write and read endpoints when you check saved state, because a mocked create response passes even when the backend is completely broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thanks everybody!&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'm Lead Full Stack Developer at &lt;a href="https://uitop.design/" rel="noopener noreferrer"&gt;UITOP&lt;/a&gt;. This check is one small piece of taking a vibe-coded prototype to production 🤝&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>backend</category>
      <category>frontend</category>
      <category>saas</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>How we keep AI agent actions under server side control in rails</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Thu, 05 Nov 2026 21:36:56 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/how-we-keep-ai-agent-actions-under-server-side-control-in-rails-387e</link>
      <guid>https://dev.to/oleksandr_sainchuk/how-we-keep-ai-agent-actions-under-server-side-control-in-rails-387e</guid>
      <description>&lt;p&gt;Here is an endpoint that looks fine in a first agent prototype.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:tool&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;constantize&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:arguments&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model returns a tool name and some arguments. Rails finds the class and runs it. It's short, and it works in a demo.&lt;/p&gt;

&lt;p&gt;It also gives the model the final say. If the agent reads a poisoned document, picks the wrong target or carries context over from another tenant, this endpoint runs the bad decision as it is.&lt;/p&gt;

&lt;p&gt;In a security product the side effects are big. An agent can end a session, disable an account, isolate a machine, rotate a credential or change a cloud policy. I don't want the model to authorize any of that.&lt;/p&gt;

&lt;p&gt;So in our product the agent only proposes. It sends a typed action request. Rails takes the tenant and the user from the authenticated session, looks the action up in its own list, validates the arguments, applies policy and saves the request. If a person has to approve it, the approval is tied to the payload they reviewed. Right before execution, Rails checks everything again.&lt;/p&gt;

&lt;p&gt;I'm Oleksandr Sainchuk, a Lead Full Stack Developer at &lt;a href="https://uitop.design/" rel="noopener noreferrer"&gt;UITOP&lt;/a&gt;. For three years we've worked with a client on an enterprise cybersecurity platform. It uses role-scoped AI agents for investigation, sensitive-data protection, remediation and audit across AWS and Azure. We design the product, build the interface, write the Rails backend and the agent logic, and maintain it. &lt;/p&gt;

&lt;p&gt;I covered the life of one account-disable request in an &lt;a href="https://dev.to/oleksandr_sainchuk/an-ai-agent-wants-to-disable-an-account-what-should-the-rails-app-do-next-3pjc"&gt;earlier article&lt;/a&gt;. This one is about the Rails layer underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case the whole design is built around
&lt;/h2&gt;

&lt;p&gt;An agent investigating a suspicious login finds a leaked session and proposes disabling the identity for 60 minutes. An analyst opens the request, reads the evidence and approves it.&lt;/p&gt;

&lt;p&gt;A minute later someone edits the request. The duration becomes 240 minutes, or the target becomes another account. The first approval must not cover the edited version.&lt;/p&gt;

&lt;p&gt;Most of the code below exists for that moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a registry the server owns
&lt;/h2&gt;

&lt;p&gt;The model picks from the tool descriptions you give it. That list helps generation. It doesn't decide what can run, so Rails needs its own allowlist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;AgentActions&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Registry&lt;/span&gt;
    &lt;span class="no"&gt;ACTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"collect_identity_evidence"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;CollectIdentityEvidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"revoke_session"&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;RevokeSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"disable_identity"&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;DisableIdentity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"isolate_endpoint"&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;IsolateEndpoint&lt;/span&gt;
    &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="no"&gt;ACTIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;KeyError&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;UnknownAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;name&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I never call &lt;code&gt;constantize&lt;/code&gt;, &lt;code&gt;public_send&lt;/code&gt;, a shell command or a URL that the model supplied. The registry maps a string from the model to one command class that we wrote. An unknown name raises and nothing runs.&lt;/p&gt;

&lt;p&gt;It also gives reviewers a single file that lists every action the agent can request. Nobody has to dig through prompts or provider settings to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each action an input contract
&lt;/h2&gt;

&lt;p&gt;Strong Parameters filter which attributes reach mass assignment. The Rails guide describes them as an explicit allowlist. They don't know whether an identity belongs to the current tenant or whether a duration is safe. Agent actions need a contract that checks meaning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;AgentActions&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DisableIdentity&lt;/span&gt;
    &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;ActiveModel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Model&lt;/span&gt;
    &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;ActiveModel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Attributes&lt;/span&gt;

    &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:identity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;
    &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;
    &lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:duration_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:integer&lt;/span&gt;

    &lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="ss"&gt;:identity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;presence: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="ss"&gt;:duration_minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;inclusion: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;in: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_target!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
      &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;risk_level&lt;/span&gt;
      &lt;span class="ss"&gt;:high&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can send &lt;code&gt;identity_id&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt; and a duration from a fixed list. If it sends &lt;code&gt;tenant_id&lt;/code&gt; or &lt;code&gt;approved_by&lt;/code&gt;, &lt;code&gt;ActiveModel::UnknownAttributeError&lt;/code&gt; is raised, because the contract has no such attribute. I treat that as an invalid proposal. Risk level, policy version and status come from server code.&lt;/p&gt;

&lt;p&gt;The target lookup is scoped to the tenant. &lt;code&gt;Identity.find(identity_id)&lt;/code&gt; would accept a valid ID from any tenant. &lt;code&gt;tenant.identities.find(identity_id)&lt;/code&gt; keeps the scope inside the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save the proposal before doing any work
&lt;/h2&gt;

&lt;p&gt;An approval flow has to survive a page refresh, a deploy and a background job that starts an hour later. So I save the proposal as application data before any connector runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProposeAgentAction&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;raw_call&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="n"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tenant&lt;/span&gt;

    &lt;span class="n"&gt;command_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;InvalidArguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full_messages&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valid?&lt;/span&gt;

    &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve_target!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;tenant: &lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActionPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;tenant: &lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;actor: &lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;agent: &lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;command: &lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;AgentAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;tenant: &lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;requested_by: &lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;proposed_by_agent_id: &lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;action_type: &lt;/span&gt;&lt;span class="n"&gt;raw_call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;arguments: &lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;risk_level: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;expires_at: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy returns one of three statuses: &lt;code&gt;denied&lt;/code&gt;, &lt;code&gt;awaiting_approval&lt;/code&gt; or &lt;code&gt;approved_for_execution&lt;/code&gt;. A read-only evidence lookup can run on its own. Disabling a production identity needs a security administrator. When policy context is missing, the answer is a denial or an approval request, never a guess in favor of running.&lt;/p&gt;

&lt;p&gt;The prompt can tell the model about these rules, and Rails is what applies them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie the approval to what the person reviewed
&lt;/h2&gt;

&lt;p&gt;An Approve button means little on its own. Say an administrator reviews this request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disable_identity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"idn_1842"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"duration_minutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the target changes to &lt;code&gt;idn_9011&lt;/code&gt; after the review, the old approval must not cover it. The same goes for a changed action type, duration, tenant, policy version or any other argument that matters for security.&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%2Ff0o3lc0jh0fhbjbvmpnu.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%2Ff0o3lc0jh0fhbjbvmpnu.png" alt="Rails server-side approval validation for AI agent actions: a 60-minute identity disable request is approved, while an edited 240-minute request is blocked." width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We build an approval payload from normalized server data and run it through a versioned canonical serializer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approval_payload&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;schema_version: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;tenant_id: &lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;action_type: &lt;/span&gt;&lt;span class="n"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;target_type: &lt;/span&gt;&lt;span class="n"&gt;target_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;target_id: &lt;/span&gt;&lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;arguments: &lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;policy_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;expires_at: &lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iso8601&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_digest&lt;/span&gt;
  &lt;span class="no"&gt;AgentActions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The approval record stores the digest, the approver, the decision time and the expiry. At execution time Rails recomputes the digest and compares it with the approved one. An edit changes the digest, so the old approval stops matching.&lt;/p&gt;

&lt;p&gt;OWASP's &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html#high-impact-action-integrity-controls" rel="noopener noreferrer"&gt;AI Agent Security Cheat Sheet&lt;/a&gt; recommends the same for high-impact actions. It says to bind the approval to the actor, tool, target resource and normalized parameters, and to record a timestamp and expiry. It also asks for replay protection and idempotent actions where possible.&lt;/p&gt;

&lt;p&gt;Frameworks can pause a run and resume it after a decision. In RubyLLM 2.0, a tool marked &lt;code&gt;requires_approval&lt;/code&gt; stays pending, and &lt;a href="https://dev.to/serpapi/rubyllm-2x-whats-new-for-your-agents-9fp"&gt;the pending call and the decision live in your database&lt;/a&gt;. Your application still decides who may approve, what they approved and whether that approval is valid now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check authorization again at execution time
&lt;/h2&gt;

&lt;p&gt;Policy can change while an action waits. The target can move to another state. The user who started the request can lose access. The approval can expire. So the job loads current records and evaluates current policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExecuteAgentActionJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue_after_transaction_commit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:always&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved?&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;past?&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved_digest&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_digest&lt;/span&gt;

      &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActionPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_for_execution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed?&lt;/span&gt;

      &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"executing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;execution_key: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execution_key&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;SecureRandom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execution_key&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;action: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;idempotency_key: &lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"verifying"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;external_reference: &lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reference&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Net&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ReadTimeout&lt;/span&gt;
    &lt;span class="c1"&gt;# Use whichever timeout error your connector raises.&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"execution_unknown"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;ReconcileAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row lock stops two workers from claiming the same approved action. Rails supports both optimistic and pessimistic locking, and the &lt;a href="https://guides.rubyonrails.org/active_record_querying.html#locking-records-for-update" rel="noopener noreferrer"&gt;Active Record guide&lt;/a&gt; covers them.&lt;/p&gt;

&lt;p&gt;The connector call runs after the short lock is released. A database transaction can't make a cloud API call atomic with a local update, and Rails' &lt;a href="https://guides.rubyonrails.org/active_record_callbacks.html#transaction-callbacks" rel="noopener noreferrer"&gt;transaction callbacks&lt;/a&gt; exist for work that happens outside the database. So the connector receives a stable idempotency key.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;enqueue_after_transaction_commit = :always&lt;/code&gt; was added in Rails 7.2. It holds the job back until the surrounding transaction commits, so a worker never looks for a record that isn't saved yet. The &lt;a href="https://guides.rubyonrails.org/active_job_basics.html#transactional-integrity-on-jobs" rel="noopener noreferrer"&gt;Active Job guide&lt;/a&gt; explains the setting.&lt;/p&gt;

&lt;p&gt;If the connector times out, I don't mark the action as failed and send a new request. The remote system may have finished the work. &lt;code&gt;execution_unknown&lt;/code&gt; is a real state in our model. A reconciliation job asks the remote system about the operation reference, or inspects the target, before anyone tries again.&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%2Fgnwtkjgo4vyqknf66uuu.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%2Fgnwtkjgo4vyqknf66uuu.png" alt="Rails AI agent timeout handling: the first request is marked execution_unknown, a duplicate retry is blocked, and reconciliation checks the remote state." width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass record IDs to jobs
&lt;/h2&gt;

&lt;p&gt;Jobs get the action ID and nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;ExecuteAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker reloads the proposal, target, approval and policy context from our database. It never receives the model's original arguments as trusted input.&lt;/p&gt;

&lt;p&gt;This matters when a request waits hours for a decision. The chat transcript explains why the agent proposed an action. The database record decides whether the action runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep audit events apart from the chat log
&lt;/h2&gt;

&lt;p&gt;A chat log gives context, but it's a poor security record. For each action I store structured events:&lt;/p&gt;

&lt;p&gt;who and which agent proposed it, and which tenant, incident, target and evidence were involved&lt;br&gt;
which action contract and policy version were evaluated, and which arguments Rails accepted&lt;br&gt;
who approved, rejected or edited it, which digest they approved and when the approval expired&lt;br&gt;
which worker claimed it, with the idempotency key and the external reference&lt;br&gt;
whether verification confirmed the result, and whether a rollback followed&lt;/p&gt;

&lt;p&gt;Events hold evidence references and short summaries. They never hold credentials, full sensitive files or hidden model reasoning.&lt;/p&gt;

&lt;p&gt;OWASP's &lt;a href="https://genai.owasp.org/resource/agent-control-standard-acs/" rel="noopener noreferrer"&gt;Agent Control Standard&lt;/a&gt;, now part of the OWASP GenAI Security Project, says agents must be inspectable, traceable and instrumentable. A durable event history gives an application a concrete way to meet that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt injection is an authorization problem too
&lt;/h2&gt;

&lt;p&gt;Injected text can still convince a model to request &lt;code&gt;disable_identity&lt;/code&gt;. That request then meets the same registry, contract, tenant scope, policy, approval, expiry, lock and audit checks as any other proposal. It can't skip a step because the model was persuaded.&lt;/p&gt;

&lt;p&gt;Filtering untrusted content and isolating tools still matter. The Rails boundary limits what a successful injection can do. OWASP's &lt;a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/" rel="noopener noreferrer"&gt;prompt injection guidance&lt;/a&gt; recommends least privilege and human approval for high-risk actions for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before an agent changes customer state
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Every action the agent can run sits in a server-owned registry with its own input contract.&lt;/li&gt;
&lt;li&gt;Tenant, user, roles and policy version come from server state, and targets are found through tenant-scoped queries.&lt;/li&gt;
&lt;li&gt;Policy runs when the proposal is saved and again before execution.&lt;/li&gt;
&lt;li&gt;An approval covers one normalized action, expires, and stops matching when someone edits the action.&lt;/li&gt;
&lt;li&gt;Workers get record IDs, take a lock and reload current state.&lt;/li&gt;
&lt;li&gt;External calls carry an idempotency key, and a timeout leads to a reconciliation step.&lt;/li&gt;
&lt;li&gt;Audit events record the decision and the outcome and keep secrets out.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  More questions:
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I stop an AI agent from running actions nobody approved in a Rails app?
&lt;/h3&gt;

&lt;p&gt;Keep a server-owned registry of allowed actions and give each one an input contract. Let Rails take the tenant and user from the session, apply policy, and store any approval against a digest of the normalized action. Check policy and the digest again in the job that executes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Strong Parameters enough to validate AI tool call arguments?
&lt;/h3&gt;

&lt;p&gt;No. Strong Parameters filter which keys reach mass assignment. They don't check that a record belongs to the current tenant or that a value is safe. Use an ActiveModel contract per action and a tenant-scoped lookup for the target.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should a Rails app do when an agent action times out?
&lt;/h3&gt;

&lt;p&gt;Mark the action execution_unknown and don't send a new request yet. The remote system may have finished the work. Pass a stable idempotency key to the connector, and run a reconciliation job that queries the operation reference or the target before any retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I keep agent approvals in the chat transcript?
&lt;/h3&gt;

&lt;p&gt;No. Store the approval in its own table with the approver, the decision time, the expiry and the digest of the action they reviewed. The transcript explains why the agent proposed something. The approval record decides whether it can run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should agent authorization live in the AI framework or the Rails app?
&lt;/h3&gt;

&lt;p&gt;Keep the final authorization decision in Rails. The AI framework can pause a tool call and resume the run after approval. Rails checks the actor, tenant, target, current policy and approval before the executor reaches a real service.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop the same approved action from running twice?
&lt;/h3&gt;

&lt;p&gt;Claim the action under a row lock and give the external request a stable idempotency key. If the result is uncertain, move the action to execution_unknown and reconcile the remote state before another attempt.&lt;/p&gt;

&lt;p&gt;If your team builds an agent that can touch customer accounts, cloud resources or sensitive data, I'd like to hear where you put the execution boundary and how you handle an unknown result after a retry 🤝&lt;/p&gt;

</description>
      <category>backend</category>
      <category>rails</category>
      <category>ruby</category>
      <category>security</category>
    </item>
    <item>
      <title>An AI Agent Wants to Disable an Account. What Should the Rails App Do Next?</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Thu, 29 Oct 2026 22:09:11 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/an-ai-agent-wants-to-disable-an-account-what-should-the-rails-app-do-next-3pjc</link>
      <guid>https://dev.to/oleksandr_sainchuk/an-ai-agent-wants-to-disable-an-account-what-should-the-rails-app-do-next-3pjc</guid>
      <description>&lt;p&gt;An incident response agent connects several alerts to the same user session. Someone opened a sensitive file, started an outbound transfer to an unauthorized destination, and used the same token from an unfamiliar endpoint.&lt;/p&gt;

&lt;p&gt;The agent recommends revoking the session, disabling the account, and isolating the endpoint.&lt;/p&gt;

&lt;p&gt;At this point, the model has done useful work. It has grouped the evidence and proposed a response. It still hasn't changed anything in the customer's environment.&lt;/p&gt;

&lt;p&gt;The next decision belongs to the application.&lt;/p&gt;

&lt;p&gt;The Rails backend has to check the agent's permissions, the target account, the current policy, the risk of each action, and the person allowed to approve it. If any part of that check fails, the action stops.&lt;/p&gt;

&lt;p&gt;UITOP has been working on an enterprise cybersecurity product through an ongoing three-year product partnership. The platform uses role-scoped sub-agents for security investigation, sensitive-data protection, remediation, and audit. Our work includes product design, interface implementation, Ruby on Rails development, agent logic, and product maintenance.&lt;/p&gt;

&lt;p&gt;I've changed the product name and left out client-specific architecture. The control model in this article reflects the engineering decisions we worked through in the real product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Save the proposal before calling a security tool
&lt;/h2&gt;

&lt;p&gt;I treat an agent response as untrusted input at the authorization boundary. Valid JSON doesn't make an operation safe to run.&lt;/p&gt;

&lt;p&gt;The application first turns the recommendation into a durable action record. That record stays with the operation during policy evaluation, approval, execution, verification, and audit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:agent_actions&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt; &lt;span class="ss"&gt;:incident&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;foreign_key: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:proposed_by_agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:action_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:target_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jsonb&lt;/span&gt; &lt;span class="ss"&gt;:parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:risk_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:policy_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="s2"&gt;"proposed"&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt; &lt;span class="ss"&gt;:approved_by_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;foreign_key: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;to_table: :users&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:approved_fingerprint&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:approved_at&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:expires_at&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:execution_reference&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jsonb&lt;/span&gt; &lt;span class="ss"&gt;:verification_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:verified_at&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:lock_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:agent_actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:fingerprint&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:agent_actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:incident_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;action_type&lt;/code&gt; comes from an application-owned allowlist. In this case, it may contain &lt;code&gt;revoke_session&lt;/code&gt;, &lt;code&gt;disable_account&lt;/code&gt;, and &lt;code&gt;isolate_endpoint&lt;/code&gt;. A model-generated tool name never becomes an executable operation on its own.&lt;/p&gt;

&lt;p&gt;The fingerprint ties approval to one version of the action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentAction&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;before_validation&lt;/span&gt; &lt;span class="ss"&gt;:set_fingerprint&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_fingerprint&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ActionFingerprint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;action_type: &lt;/span&gt;&lt;span class="n"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target_type: &lt;/span&gt;&lt;span class="n"&gt;target_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target_id: &lt;/span&gt;&lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;policy_version&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ActionFingerprint&lt;/code&gt; serializes parameters in a canonical order. Two equivalent payloads should produce the same value even if their keys arrive in a different sequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let Rails assign the effective risk
&lt;/h2&gt;

&lt;p&gt;The agent may suggest that an action is Low, Medium, High, or Critical. Rails makes the final decision.&lt;/p&gt;

&lt;p&gt;In the product we worked on, each sub-agent had its own role, data scope, tools, and permissions. Access to identity evidence did not grant permission to disable an identity. Any operation outside an explicit policy was blocked.&lt;/p&gt;

&lt;p&gt;The policy service receives the proposing agent, the initiating user, the incident, and the requested action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActionPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;actor: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;agent: &lt;/span&gt;&lt;span class="n"&gt;proposing_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;incident: &lt;/span&gt;&lt;span class="n"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;action: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outcome&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:denied&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: :policy_denied&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:approval_required&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: :awaiting_approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;risk_level: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;expires_at: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="ss"&gt;:automatic&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: :approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;risk_level: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;approved_fingerprint: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;ExecuteAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision can depend on the environment, data sensitivity, destination, target type, and current incident state. A production account suspension should not inherit the same path as a read-only evidence lookup.&lt;/p&gt;

&lt;p&gt;We kept automatic execution narrow. A policy could allow evidence collection or an immediate block on a forbidden outbound transfer. Disabling an account, isolating production infrastructure, rotating credentials, and changing IAM or firewall rules required an authorized person.&lt;/p&gt;

&lt;p&gt;OWASP recommends a similar split in its &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;AI Agent Security Cheat Sheet&lt;/a&gt;. The agent proposes an operation. Application-controlled policy and execution layers validate scope, privilege, and approval before a high-impact side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the approval specific
&lt;/h2&gt;

&lt;p&gt;Imagine that an administrator reviews the proposed account suspension. The original plan disables the account indefinitely and revokes every active session. The administrator changes it to a two-hour freeze and revokes only the suspicious session.&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%2Fsjvy2t2v3zwns89pktvj.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%2Fsjvy2t2v3zwns89pktvj.png" alt="Approval applies to one specific action. If its target or parameters change, the action must be reviewed again" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The old approval is no longer valid because the operation has changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApproveAgentAction&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;approver&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;expected_lock_version&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;StaleAction&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock_version&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected_lock_version&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;InvalidState&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaiting_approval?&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;ExpiredAction&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;past?&lt;/span&gt;

      &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentActionPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;actor: &lt;/span&gt;&lt;span class="n"&gt;approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;agent: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;proposing_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;incident: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;action: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;Forbidden&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approver?&lt;/span&gt;
      &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;PolicyChanged&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;

      &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;status: :approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;approved_by_user: &lt;/span&gt;&lt;span class="n"&gt;approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;approved_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;approved_fingerprint: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="no"&gt;AuditEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;incident: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;actor: &lt;/span&gt;&lt;span class="n"&gt;approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;event_type: &lt;/span&gt;&lt;span class="s2"&gt;"agent_action.approved"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;subject: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="ss"&gt;fingerprint: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;policy_version: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;policy_version&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="no"&gt;ExecuteAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rails gives us &lt;a href="https://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html" rel="noopener noreferrer"&gt;&lt;code&gt;with_lock&lt;/code&gt;&lt;/a&gt; for the row-level lock. It prevents two approval requests from consuming the same pending action at the same time. &lt;code&gt;lock_version&lt;/code&gt; also catches a reviewer acting on an older version.&lt;/p&gt;

&lt;p&gt;The browser sends the action ID, the decision, and the version shown to the reviewer. The server loads its own record, authenticates the person, checks authorization again, and stores the decision atomically.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://openai.github.io/openai-agents-js/guides/human-in-the-loop/" rel="noopener noreferrer"&gt;OpenAI Agents SDK human-in-the-loop guide&lt;/a&gt; makes the same boundary clear. Approval state should live in server-controlled storage. A run ID or decision ID cannot prove that the person submitting it has permission to approve the tool call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recheck every edited remediation plan
&lt;/h2&gt;

&lt;p&gt;Administrators in the product could remove a proposed action, change its target, or submit a different response. The revised plan went through policy evaluation again. It also required a second confirmation before execution.&lt;/p&gt;

&lt;p&gt;I would clear the previous approval inside the edit transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign_attributes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="n"&gt;revised_parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:proposed&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved_by_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved_fingerprint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save!&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;EvaluateAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback creates a new fingerprint. Policy evaluation may assign another risk level or block the edited action. The audit history still shows the earlier proposal, while execution accepts only the current fingerprint.&lt;/p&gt;

&lt;p&gt;This behavior has to match the approval interface. If the administrator changes a target and presses confirm, the backend must not reuse a decision attached to the old parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim the action once
&lt;/h2&gt;

&lt;p&gt;The execution job uses a short transaction to claim the approved action. It calls the external security connector after releasing the database lock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExecuteAgentActionJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue_after_transaction_commit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AgentAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_lock&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved?&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approved_fingerprint&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt;
      &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;past?&lt;/span&gt;

      &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :executing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="kp"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SecurityConnector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;action_type: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;target_id: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;idempotency_key: &lt;/span&gt;&lt;span class="s2"&gt;"agent-action-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fingerprint&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;status: :verifying&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;execution_reference: &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reference&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;VerifyAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;SecurityConnector&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Timeout&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :execution_unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;ReconcileAgentActionJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency key represents one approved operation. A timeout leaves us with uncertainty. The connector may have disabled the account even though Rails never received the response.&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%2Fo269gb6rufrr3tx1swr2.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%2Fo269gb6rufrr3tx1swr2.png" alt="1. When execution is uncertain, block duplicate attempts and verify the real downstream state first" width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Blindly repeating the request can create a second ticket, rotate a credential twice, or apply another policy change. I prefer an honest execution_unknown state. A reconciliation job can query the downstream system by operation ID or inspect the target before it retries anything.&lt;/p&gt;

&lt;p&gt;Rails supports &lt;a href="https://guides.rubyonrails.org/active_job_basics.html#transactional-integrity-on-jobs" rel="noopener noreferrer"&gt;enqueue_after_transaction_commit&lt;/a&gt; for jobs that need committed data. The worker cannot pick up an approval record that is still inside an open transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the security outcome
&lt;/h2&gt;

&lt;p&gt;Now return to the original incident. Session revocation succeeds. The account is disabled. Endpoint isolation times out.&lt;/p&gt;

&lt;p&gt;The remediation plan is still open because the endpoint may still have access.&lt;/p&gt;

&lt;p&gt;The execution worker stores the connector response. A separate worker checks the downstream state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SecurityConnector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;action_type: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;target_id: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;execution_reference: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execution_reference&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmed?&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: :completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;verified_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;verification_data: &lt;/span&gt;&lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_metadata&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;status: :failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;verification_data: &lt;/span&gt;&lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_metadata&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each action needs its own verification rule. Session revocation checks that the session no longer exists. Account suspension reads the current account state. Endpoint isolation confirms the containment policy or network state.&lt;/p&gt;

&lt;p&gt;The incident closes after its required actions reach an accepted terminal state. A partial failure stays visible with its owner and remaining exposure. Rollback, when supported, becomes another policy-checked action with its own approval and verification record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep an audit trail people can use
&lt;/h2&gt;

&lt;p&gt;For a high-risk action, I want the audit history to answer practical questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which incident produced the proposal?&lt;/li&gt;
&lt;li&gt;Which agent proposed it?&lt;/li&gt;
&lt;li&gt;Which evidence supported it?&lt;/li&gt;
&lt;li&gt;Which policy version evaluated it?&lt;/li&gt;
&lt;li&gt;Who approved, rejected, or edited it?&lt;/li&gt;
&lt;li&gt;Which fingerprint did that person approve?&lt;/li&gt;
&lt;li&gt;What did the connector attempt?&lt;/li&gt;
&lt;li&gt;What did the downstream system report?&lt;/li&gt;
&lt;li&gt;Did verification pass?&lt;/li&gt;
&lt;li&gt;Did someone roll the action back later?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would leave raw secrets, access tokens, full sensitive files, and hidden model reasoning out of the payload. Stable evidence references and application-written summaries are enough to preserve the basis of the decision and the resulting action.&lt;/p&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/security/fundamentals/shared-responsibility-ai-agent" rel="noopener noreferrer"&gt;shared responsibility model for AI agents&lt;/a&gt; keeps per-action authorization, approval for high-impact operations, agent identity, and audit logging with the application owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the uncomfortable paths before release
&lt;/h2&gt;

&lt;p&gt;I would run one incident through the following cases before letting the agent disable a real account.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent proposes an allowed action for a permitted target.&lt;/li&gt;
&lt;li&gt;The same agent requests a tool outside its role.&lt;/li&gt;
&lt;li&gt;An unauthorized employee tries to approve the action.&lt;/li&gt;
&lt;li&gt;An administrator edits the target after reviewing the plan.&lt;/li&gt;
&lt;li&gt;Two approval requests arrive almost together.&lt;/li&gt;
&lt;li&gt;The connector finishes after Rails times out.&lt;/li&gt;
&lt;li&gt;Execution succeeds and verification fails.&lt;/li&gt;
&lt;li&gt;A different authorized administrator requests rollback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For every run, I would compare the server state, the interface, and the audit history. They should describe the same operation.&lt;/p&gt;

&lt;p&gt;An enterprise AI security agent becomes useful when it can move an incident forward. It becomes safe enough to operate when the surrounding product limits what it can see, what it can propose, what it can execute, and whose approval it needs.&lt;/p&gt;

&lt;p&gt;That work spans the full product. The interface has to show evidence, risk, scope, and current state clearly. The Rails application has to enforce the policy even if the model, the browser, or the connector behaves unexpectedly.&lt;/p&gt;

&lt;p&gt;I lead product development at UITOP. If you're building an enterprise AI agent that can touch company accounts, cloud resources, or sensitive data, feel free to message me. I'd be interested to hear how your team handles edited approvals, uncertain execution, and verification after a security action.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>ai</category>
      <category>rails</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>Replacing an ERP interface? Define API contracts, keep business rules and permissions on the server</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Wed, 28 Oct 2026 21:19:39 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/replacing-an-erp-interface-define-api-contracts-keep-business-rules-and-permissions-on-the-server-hf9</link>
      <guid>https://dev.to/oleksandr_sainchuk/replacing-an-erp-interface-define-api-contracts-keep-business-rules-and-permissions-on-the-server-hf9</guid>
      <description>&lt;p&gt;An ERP frontend migration can keep every endpoint and still break the business. Picture a new release where the stock table labels on-hand inventory as "available." A new form skips a validation that used to live in the old browser code. And the submit button shows a nice green toast, and the operator still has no idea whether the transfer posted.&lt;/p&gt;

&lt;p&gt;None of these show up in an API diff. So when you modernize an ERP frontend, the API schema is only half the agreement. The other half is the workflow: which records people act on, what the numbers on screen mean, and which server response confirms that an action happened.&lt;/p&gt;

&lt;p&gt;To keep this concrete, we'll follow one small workflow the whole way: a warehouse operator reviews stock and posts a transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one complete workflow
&lt;/h2&gt;

&lt;p&gt;Pick a workflow with a clear start and a visible business result. In our example, the operator finds an item in a source warehouse, checks availability, picks a destination and submits a quantity. The ERP either accepts the transfer or says why it can't.&lt;/p&gt;

&lt;p&gt;Before you design anything, watch that workflow in the current app. Ask an operator to show you a normal transaction and one that needs intervention. Open the browser's network tab and look at the code that builds each request. Note any spreadsheet or manual step people use to finish the job.&lt;/p&gt;

&lt;p&gt;You'll find rules that look strange. A waiting period might enforce an approval policy, or it might be left over from a batch job nobody runs anymore. To tell them apart, find the business owner and ask what the rule is for. Keep the behavior until that person agrees on a change and how to accept it.&lt;/p&gt;

&lt;p&gt;A short note per uncertain rule is enough to start: what you saw, where it's implemented, who owns it, why it exists, and what you decided. Now engineering has something concrete to verify.&lt;/p&gt;

&lt;p&gt;At UITOP we redesigned &lt;a href="https://uitop.design/case-studies/erp-platform-for-the-construction-industry/" rel="noopener noreferrer"&gt;Slabstack, a CRM for heavy construction materials producers&lt;/a&gt;, around how companies, projects and daily operations connect. The same habit helps in ERP work: map those operational relationships first, and the scope of each workflow becomes much clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the contract around the workflow
&lt;/h2&gt;

&lt;p&gt;For the stock transfer, write down these agreements:&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%2Fhop159ztztx1tpahab9j.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%2Fhop159ztztx1tpahab9j.png" alt="Transfer contract: The data, permissions, and outcome rules to agree on before building an inventory transfer workflow" width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Settle these with the people who own the ERP rules. A frontend team can only show a useful distinction if it knows what the backend values mean.&lt;/p&gt;

&lt;p&gt;Describe the HTTP side with &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;OpenAPI&lt;/a&gt;: parameters, request bodies, response schemas and known errors. Add field descriptions for units and business meaning. Keep the workflow acceptance scenarios next to the spec, including the document or ledger state each one should produce.&lt;/p&gt;

&lt;p&gt;One trap to watch: a response can keep the same shape while a field quietly changes meaning. That's still a contract change, so review it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the rules hiding in the old frontend
&lt;/h2&gt;

&lt;p&gt;Old screens do more than display data. They prepare quantities, fill hidden fields, pick a document type and run validation before sending a request. New markup doesn't bring any of that along by itself.&lt;/p&gt;

&lt;p&gt;So list the client scripts and the events that trigger them. Trace what each one calculates, what it only displays, and which rules the server also checks. Don't skip the rare paths, like cancelling a document or reopening a rejected request.&lt;/p&gt;

&lt;p&gt;Frappe is a good real-world example. Its docs note that &lt;a href="https://docs.frappe.io/framework/user/en/desk/scripting/client-script" rel="noopener noreferrer"&gt;Client Script validations run through the standard browser form&lt;/a&gt;. If a rule must also apply to API access, it needs server-side validation. A separate React frontend skips that browser form entirely, so it needs a plan for every rule that lived there.&lt;/p&gt;

&lt;p&gt;As you go, give each rule an owner and a home. Presentation logic can stay in the frontend. Business validation and authorization belong on the server too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put an adapter between the ERP and your components
&lt;/h2&gt;

&lt;p&gt;Keep the ERP's field names at one defined boundary. Your components then work with a small model whose names match the interface.&lt;/p&gt;

&lt;p&gt;Below is the full adapter for our example. The contract sends quantities as plain decimal strings. Strings keep the precision the ERP supplied, and the adapter does no stock math of its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StockSnapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Readonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;warehouseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onHand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;text&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;unknown&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Expected a non-empty string: &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;`&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;value&lt;/span&gt;&lt;span class="p"&gt;;&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;quantity&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;unknown&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;text&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;field&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="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^-&lt;/span&gt;&lt;span class="se"&gt;?\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?:\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Expected a decimal string: &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;`&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;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeStockSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;StockSnapshot&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;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected a stock response object&lt;/span&gt;&lt;span class="dl"&gt;"&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;item_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;item_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;warehouseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;warehouse_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warehouse_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;onHand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty_on_hand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qty_on_hand&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;qty_available&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock_uom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stock_uom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;record_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record_version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say the response has &lt;code&gt;qty_on_hand: "148.000"&lt;/code&gt; and &lt;code&gt;qty_available: "118.000"&lt;/code&gt;. The interface gets both values and shows the ERP's availability as is. The frontend doesn't calculate it, because the ERP's number can include rules beyond "on hand minus reserved," and the frontend doesn't know them.&lt;/p&gt;

&lt;p&gt;If availability is missing, the adapter throws. Showing zero would be worse: it turns a broken contract into wrong inventory data that someone might act on. The caller should catch the error and show a loading error that fits the workflow.&lt;/p&gt;

&lt;p&gt;The example tests cover decimal precision, missing and malformed fields, identifiers, revision handling and extra response fields. All ten local tests pass. Keep their scope in mind, though: they test this response boundary. They don't call a running ERP or prove that a transfer posts.&lt;/p&gt;

&lt;p&gt;Use whatever validation your codebase already has. A schema library or a generated client can replace these small helpers. What matters is that runtime response checking has a clear place in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep authorization on the server, at every entry point
&lt;/h2&gt;

&lt;p&gt;A simpler interface can hide advanced modules and show fewer actions. That's fine for the UI. The API still has to check what the current user can do.&lt;/p&gt;

&lt;p&gt;Test warehouse access through the list, the record detail, the transaction endpoint and reports. Include a user who can view a warehouse but can't move its stock, and a user who belongs to several departments. A filtered dropdown can't enforce any of this. &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP's authorization guidance&lt;/a&gt; recommends checking permissions on every request.&lt;/p&gt;

&lt;p&gt;If the new frontend runs on a separate origin, write down how it authenticates and keeps the session alive. Check the ERP's supported browser auth flow, its cookie and CSRF requirements, and the allowed origins. Remember that CORS only controls which browsers can read responses. It doesn't give a user permission to any business record. And keep privileged integration credentials on the server, never in browser code.&lt;/p&gt;

&lt;p&gt;Add session expiry to your workflow tests too. If auth expires in the middle of a transfer, the operator needs a clear way to recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide on stale data and retries before release
&lt;/h2&gt;

&lt;p&gt;Two operators can work with the same stock while a transfer form sits open. The quantity that was available when the form loaded may be gone by the time someone clicks submit.&lt;/p&gt;

&lt;p&gt;If the API supports version checks, send the version the operator reviewed and let the server check it in the same atomic step as the write. HTTP APIs can do this with an ETag and &lt;code&gt;[If-Match](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/If-Match)&lt;/code&gt;, and a failed precondition returns &lt;code&gt;412&lt;/code&gt;. Other systems use an explicit version field. Either way, the header only works if the server supports it.&lt;/p&gt;

&lt;p&gt;A timeout is a different problem. The server may have committed the transfer even though the browser never got a confirmation. So decide how the client looks up the outcome. If the API supports idempotent writes, reuse the same operation ID when you retry, and let the server handle duplicates. AWS walks through this in &lt;a href="https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/" rel="noopener noreferrer"&gt;its guide to idempotent APIs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disabling the submit button helps with double clicks. It doesn't replace retry and duplicate rules on the write API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the business result after each action
&lt;/h2&gt;

&lt;p&gt;Use representative, access-controlled test records, and check what the ERP recorded after each action. Check the resulting document and stock state along with the success message, because that state shows what happened.&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%2Fywxlj69qjbvwo4g2n6uc.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%2Fywxlj69qjbvwo4g2n6uc.png" alt="Transfer regression checks: Scenarios to test so transfers handle approvals, stock limits, access, conflicts, and uncertain outcomes correctly" width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://playwright.dev/docs/api-testing" rel="noopener noreferrer"&gt;Playwright can run API checks&lt;/a&gt; alongside browser steps, including checks on server-side results. Run integration tests against the ERP for anything a mocked response can't prove. Keep UI-state and adapter tests for their narrower jobs.&lt;/p&gt;

&lt;p&gt;Compare old and new outputs for a stable set of inputs. When they differ, take it to the business owner. If it's an intentional fix, it gets its own decision and acceptance criteria. Just don't keep a known bug alive only to make the outputs match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roll out one workflow and keep a way back
&lt;/h2&gt;

&lt;p&gt;Decide which users and routes see the new interface, what stays in the old app, and who can pause the rollout. Check deep links, navigation to related records, shared auth and existing exports.&lt;/p&gt;

&lt;p&gt;A route or feature flag lets people switch back to the old interface. Make sure the old app can still read records created in the new one. And remember that rolling back a frontend release doesn't undo posted transfers or reverse a schema change. Those need their own recovery plan.&lt;/p&gt;

&lt;p&gt;Your first scope should name one workflow, its dependencies, the checks it must pass and the evidence you need before expanding. This is also a good test for a modernization partner: ask how they'll find the existing rules and how they'll prove the result in the working system.&lt;/p&gt;

&lt;p&gt;At UITOP we designed and built &lt;a href="https://uitop.design/case-studies/logicore/" rel="noopener noreferrer"&gt;LogiCore, a logistics and warehouse application&lt;/a&gt; that connects shipment monitoring, warehouse structures and inventory, with a React/TypeScript frontend on a REST backend. The lesson carries over to ERP work: interface decisions and operational data are tied together, and when the frontend changes, those ties need to stay visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  You could wonder right now:
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can we add a modern frontend while keeping the existing ERP backend?
&lt;/h3&gt;

&lt;p&gt;Yes, if the backend already exposes the operations and access controls the new interface needs. Start with one bounded workflow and verify reads, writes, authentication and business validation. If the API has gaps, scope the server or integration work together with the frontend.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do we tell real business rules from historical workarounds?
&lt;/h3&gt;

&lt;p&gt;Walk through the behavior with an operator and the business owner responsible for the outcome. Check the code, integrations and downstream reports. Write down why you keep, change or remove it. At UITOP, the design question we ask first is which operational dependency the interface must preserve, because visual simplification only works with that context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can ERP upgrades break existing custom scripts?
&lt;/h3&gt;

&lt;p&gt;Yes, when scripts depend on fields, events, APIs or libraries that changed. List those dependencies and test the target version in staging. If you're replacing a standard form with a custom frontend, also check which validations ran only in the original browser form and which the server enforces.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should warehouse access be isolated between departments?
&lt;/h3&gt;

&lt;p&gt;Define which records and actions each department membership allows, then enforce that policy on the server. Test detail views, writes and reports along with lists, and include users who belong to several departments. Interface filters should reflect the policy, but every endpoint needs its own access check.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should a standalone React frontend authenticate with an existing ERP?
&lt;/h3&gt;

&lt;p&gt;Use the ERP's supported browser authentication, and document session expiry, CSRF protection and allowed origins. Check the user's permissions on every API request. Never put privileged integration credentials in browser code. A working CORS setup is only one part of the picture, next to authentication and authorization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do ERP data problems often show up late in UAT?
&lt;/h3&gt;

&lt;p&gt;Early checks often confirm that fields load and miss whether the records work together to complete a transaction. Test full transactions and downstream reports with business users earlier. At UITOP, we treat the operational result, like the resulting stock document, as part of acceptance, next to the screen the user sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which ERP workflow would you modernize first, and what business result would tell you the replacement works?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>backend</category>
      <category>architecture</category>
      <category>api</category>
    </item>
    <item>
      <title>A React and TypeScript walkthrough. Redesign an existing SaaS screen, keep record identity and filters intact, and test the behavior</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:20:46 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/a-react-and-typescript-walkthrough-redesign-an-existing-saas-screen-keep-record-identity-and-3d8b</link>
      <guid>https://dev.to/oleksandr_sainchuk/a-react-and-typescript-walkthrough-redesign-an-existing-saas-screen-keep-record-identity-and-3d8b</guid>
      <description>&lt;p&gt;A lot of React apps start life in an AI builder now. Auth works, routes work, customer records load. Then someone looks at the screens and says the UI needs design work.&lt;/p&gt;

&lt;p&gt;When you change a screen, you can quietly change how people get their work done on it. A row that used to open the right record now opens the wrong one. A filter that used to stick now resets. Nothing crashes, so nobody notices until a user complains.&lt;/p&gt;

&lt;p&gt;So before touching the layout, write down what the screen must keep doing. Which record an action opens. Which filters survive navigation. What happens while data loads or fails. Then check every design decision against that list.&lt;/p&gt;

&lt;p&gt;To show how this works, I built a small React and TypeScript feedback inbox with made-up customer requests. The flow is narrow on purpose: filter requests, open one, read it, and go back to the filtered list.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Write the behavior down before you move a pixel
&lt;/h2&gt;

&lt;p&gt;Start with a short scenario that a designer and a developer can both read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose &lt;strong&gt;Reporting&lt;/strong&gt; in the product-area filter.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;FR-108: Export feedback to CSV&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Confirm that the detail shows &lt;strong&gt;Northline&lt;/strong&gt;, the customer attached to FR-108.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Back to requests&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Confirm that &lt;strong&gt;Reporting&lt;/strong&gt; is still selected and keyboard focus returns to the request button.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now every design idea has a test to pass. Moving the status badge is presentation, so go ahead. Opening a different record, clearing the filter, or changing what "Planned" means is behavior, and it needs a conversation first.&lt;/p&gt;

&lt;p&gt;In a real app, write down the existing route, permission checks, API contracts and action callbacks next to the scenario. A UI change can usually leave those alone. If a new design needs a new endpoint or a different permission rule, the task just grew, so scope it that way.&lt;/p&gt;

&lt;p&gt;Our example runs on local records. A real product also needs checks against its real data and auth rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Let the row show what people scan for
&lt;/h2&gt;

&lt;p&gt;First question: what does someone need to see before they open a request?&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%2F0tzur9052o1uo1idiv3o.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%2F0tzur9052o1uo1idiv3o.png" alt="How to make feedback requests easy to scan: clear titles, customer details, distinct status badges, and visible selection" width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Selection and status mean different things, so they need different looks. An open request can still be &lt;strong&gt;New&lt;/strong&gt;, &lt;strong&gt;Planned&lt;/strong&gt; or &lt;strong&gt;In review&lt;/strong&gt;. If selection looked like a status, a click would read like a status change.&lt;/p&gt;

&lt;p&gt;This is the whole row component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RequestRow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FeedbackRequest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLButtonElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"request-row"&lt;/span&gt;
        &lt;span class="na"&gt;aria-expanded&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-controls&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;request-detail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"request-copy"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"request-title"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"request-meta"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; · &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;data-status&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FeedbackRequest&lt;/code&gt; holds the record's ID, title, customer, product area, status and message. The row gets a record and an open callback. It never changes the status.&lt;/p&gt;

&lt;p&gt;Why a native button? You get keyboard support for free. Because the button reveals related content, &lt;code&gt;aria-expanded&lt;/code&gt; tells assistive tech whether that content is open. When the detail opens, its heading gets focus, and &lt;strong&gt;Back to requests&lt;/strong&gt; sends focus back to the row you came from. The &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/" rel="noopener noreferrer"&gt;WAI-ARIA disclosure pattern&lt;/a&gt; explains the expanded-state part.&lt;/p&gt;

&lt;p&gt;This works because our detail panel is read-only. If your rows hold checkboxes, menus or inline actions, you need a different structure, because each control needs its own valid target.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Store the ID, not the record
&lt;/h2&gt;

&lt;p&gt;The inbox keeps three pieces of UI state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setArea&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selectedId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSelectedId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;request&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matchesArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;area&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nf"&gt;toLowerCase&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;matchesArea&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;text&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="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loadState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;selectedId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why derive &lt;code&gt;selected&lt;/code&gt; instead of storing it? A second copy of the record goes stale. If the parent sends an updated status for the same ID, the list and the detail both show it, because both read from the same records. React's &lt;a href="https://react.dev/learn/choosing-the-state-structure" rel="noopener noreferrer"&gt;state-structure guidance&lt;/a&gt; recommends this selected-ID approach.&lt;/p&gt;

&lt;p&gt;Search and the area filter work together. Changing either one closes the detail panel here. That's a deliberate product rule: the open record always belongs to the visible results. If your product keeps the detail open while filtering, pick a different rule and write a test for it.&lt;/p&gt;

&lt;p&gt;Render rows with &lt;code&gt;key={request.id}&lt;/code&gt;. And during a redesign, watch the component type, position and keys around stateful parts of the screen. React ties state to a component's place in the render tree, so wrapping a stateful component in a new container can quietly reset it. More on that in &lt;a href="https://react.dev/learn/preserving-and-resetting-state" rel="noopener noreferrer"&gt;preserving and resetting state&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In other words, keeping the same API calls isn't enough. The component tree matters too.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One state, two layouts
&lt;/h2&gt;

&lt;p&gt;On a wide screen, the list and detail sit side by side. On a narrow screen, the detail takes over, and &lt;strong&gt;Back to requests&lt;/strong&gt; returns to the list.&lt;/p&gt;

&lt;p&gt;The inbox component owns the filters and selected ID in both layouts. CSS only changes what's visible. There are no separate desktop and mobile copies of state to drift apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.inbox-layout.has-detail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;760px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.inbox-layout.has-detail&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.has-detail&lt;/span&gt; &lt;span class="nc"&gt;.inbox&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;760px is simply where this layout stops fitting. Check where yours does. Long titles, translated labels, enlarged text and an open detail panel will find problems that a short desktop fixture never shows.&lt;/p&gt;

&lt;p&gt;We also gave flex and grid children &lt;code&gt;min-width: 0&lt;/code&gt;, let long text wrap, and kept the Back button in the normal document flow, so narrow layouts behave predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Decide once, reuse everywhere
&lt;/h2&gt;

&lt;p&gt;Before you spread the design to more pages, agree on a few shared decisions: text hierarchy, spacing, focus style, selection style and status labels. Map them to the tokens and components the product already has.&lt;/p&gt;

&lt;p&gt;The example names its selection colors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--selection-bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eef3ff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--selection-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#294fc2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.request-row&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;aria-expanded&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"true"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--selection-bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;border-inline-start-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--selection-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.status&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"Planned"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#594013&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff2d7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your design system already has fitting tokens, use them. The new screen stays consistent with its neighbors, and later changes are easier to review.&lt;/p&gt;

&lt;p&gt;Design the loading, empty and error states alongside the main screen, not after it. In this inbox, loading has its own message. An empty filter result offers &lt;strong&gt;Clear filters&lt;/strong&gt;. A failed request offers &lt;strong&gt;Retry&lt;/strong&gt; and keeps your search and filter.&lt;/p&gt;

&lt;p&gt;Each state answers a different question. Is it working? Does anything match? Did the data load? So each one needs its own next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Test behavior, not only pixels
&lt;/h2&gt;

&lt;p&gt;A screenshot catches clipped text, uneven spacing or an unreadable badge. It can't tell you that a row opens the right customer or that Retry kept your filters.&lt;/p&gt;

&lt;p&gt;So the browser check runs the scenario from step 1 directly. These lines come from its Playwright script:&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Product area&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;selectOption&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reporting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.request-row&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#request-detail h2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Export feedback to CSV&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#request-detail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;textContent&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Northline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Back to requests&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Product area&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;inputValue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reporting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full script also covers keyboard activation and focus return, combined filters, a record update reaching the open detail, narrow layouts, loading, empty results and retry. All 14 checks passed in a local Chromium run, and the output logs the browser version and each check.&lt;/p&gt;

&lt;p&gt;For screenshot comparisons, use fixed records, a fixed viewport and the same rendering environment every time. Look at the first image yourself before you accept it as a baseline. &lt;a href="https://playwright.dev/docs/test-snapshots" rel="noopener noreferrer"&gt;Playwright's visual-comparison docs&lt;/a&gt; cover baselines and environment differences.&lt;/p&gt;

&lt;p&gt;These checks cover local UI behavior only. Real API integration, authorization, real devices and a screen-reader session are separate work.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Hand off decisions, not only files
&lt;/h2&gt;

&lt;p&gt;A good handoff lists the changed components, why you made each design decision, the acceptance scenarios and the checks you ran. It also flags anything that needs another owner, like a new response field or a permission rule.&lt;/p&gt;

&lt;p&gt;This matters most when design and frontend share responsibility for a working product. At UITOP we ran into this on &lt;a href="https://uitop.design/case-studies/logicore/" rel="noopener noreferrer"&gt;LogiCore&lt;/a&gt;, a logistics platform where we handled both the design and the React/TypeScript development, including shipment tracking and warehouse inventory. Interface decisions there had to match how the operational data behaved, or the screens looked right and worked wrong.&lt;/p&gt;

&lt;p&gt;So for a React UI refinement, start with one important workflow. Make its component and state decisions easy to review, run the agreed checks, and only then roll the patterns out across the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I keep a React redesign limited to the UI layer?
&lt;/h3&gt;

&lt;p&gt;Yes, if you scope it to presentation components, styles and their existing callbacks. Write down the routes, record IDs, API contracts, permissions and state that must keep working. Then review the changed files and run the affected workflow. Telling an AI agent "UI only" doesn't verify the code it writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I define design tokens and shared components before updating the pages?
&lt;/h3&gt;

&lt;p&gt;Look at what the app already uses first. Settle shared decisions on one representative workflow, including its error and narrow-screen states, then reuse them. Building a big new token system before checking the existing components can turn a small refinement into a migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should a team structure large AI-generated designs for implementation?
&lt;/h3&gt;

&lt;p&gt;Separate shared styles and components from page-specific content. Give each task one bounded screen or workflow, with its dependencies and acceptance scenarios. Check the exports against your real React architecture, because a large generated HTML file doesn't define component boundaries or data ownership.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I use screenshot comparison to review a React UI redesign?
&lt;/h3&gt;

&lt;p&gt;Capture stable fixtures at agreed viewports and compare them against baselines a person has reviewed, in a consistent environment. Add interaction checks for record identity, filters, focus and actions. A screenshot can match perfectly while the workflow behind it is broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can new features follow the existing brand guidelines?
&lt;/h3&gt;

&lt;p&gt;Turn the guidelines into the tokens, component variants and content rules your codebase uses. Review one representative feature against those decisions before extending it. A reference image gives useful context, but the code needs explicit rules for typography, color, spacing and states.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I hire a UI/UX designer, a frontend developer, or someone who handles both?
&lt;/h3&gt;

&lt;p&gt;Let the unresolved work decide. Flow and hierarchy decisions need design ownership. Component integration and state behavior need frontend ownership. When both are in scope, give the team shared acceptance criteria and one clear implementation reviewer. UITOP combines product design and frontend development, and we scope that work around the workflow and codebase that need attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which workflow in your React app needs a design change, and which behavior has to stay the same?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>react</category>
      <category>tutorial</category>
      <category>typescript</category>
    </item>
    <item>
      <title>We Took a Claude-Built SaaS UI into Figma and Back to React</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Mon, 19 Oct 2026 20:33:34 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/i-took-a-claude-built-saas-ui-into-figma-and-back-to-react-38mh</link>
      <guid>https://dev.to/oleksandr_sainchuk/i-took-a-claude-built-saas-ui-into-figma-and-back-to-react-38mh</guid>
      <description>&lt;p&gt;Claude gave us a tidy expense inbox. It had a table, statuses, buttons, and all the other signs of a functioning SaaS product. Then my teammate &lt;a href="https://www.linkedin.com/in/katerina-bulkina/" rel="noopener noreferrer"&gt;Katerina&lt;/a&gt; looked at one expense that was missing a cost center. The reason it needed attention was easy to miss.&lt;/p&gt;

&lt;p&gt;That seemed like a good place to start. We wanted to see what would happen if we built a screen with Claude, refined its design system in Figma, and asked Claude to continue the React prototype using the new design. Could it keep the same product decisions across an inbox, an expense detail view, and a dashboard?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=xE_n3Bjoi3Y" rel="noopener noreferrer"&gt;Katerina recorded the full experiment on YouTube&lt;/a&gt;. She used a spend management demo for the experiment, with a finance manager and a connected expense review flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first screen looked plausible. The queue still needed work.
&lt;/h2&gt;

&lt;p&gt;Kate started by telling Claude who would use the product: a finance manager reviewing employee expenses. She described the inbox, the information needed to make a decision, and the actions a manager could take. Claude built the first UI in React with reusable pieces.&lt;/p&gt;

&lt;p&gt;It was a useful starting point. But the merchant, the review reason, the urgency, and the available action competed for attention. If you're scanning a queue, you want to know why this expense is here before you spend time opening it. “Missing cost center” should be hard to miss. The button can wait its turn.&lt;/p&gt;

&lt;p&gt;We kept one expense in mind throughout the rest of the experiment: Alex Morgan submitted a $2,480 Atlas Cloud charge, and the cost center is missing. The manager opens it, checks the receipt and recent spending, then asks Alex for that information. The expense moves from Needs review to Waiting for employee, and the request appears in its activity.&lt;/p&gt;

&lt;p&gt;The short task note I would hand an AI coding tool includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;finance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;manager&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Task:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;review&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Alex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Morgan's&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Atlas&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Cloud&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;expense&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Problem:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;center&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;missing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Show:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;receipt,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;recent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;spending,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;reason&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;review&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Action:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;cost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;center&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Alex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;After&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;action:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Waiting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;employee&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Also&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;show:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;request&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;activity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;updated&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;queue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there is something to check besides whether the screen looks nice.&lt;/p&gt;

&lt;h2&gt;
  
  
  We used Figma to define what Claude should repeat
&lt;/h2&gt;

&lt;p&gt;Kate moved the initial UI into Figma and worked through its foundations, components, and larger screen patterns. Then changed the default indigo/purple direction to blue, adjusted the header and branding, and organized the components so we could use them across the product.&lt;/p&gt;

&lt;p&gt;The color change is the most obvious part of the video. The more useful part is deciding what a row, a status, and a review action mean in this product. If &lt;strong&gt;Waiting for employee&lt;/strong&gt; appears in both the queue and the expense detail, we want the label and treatment to agree. If a dashboard card points to &lt;strong&gt;Needs attention&lt;/strong&gt;, it should lead somewhere useful. Claude needs those patterns when it builds the next page.&lt;/p&gt;

&lt;p&gt;In Figma, we used variables for repeated values and components for repeated UI. &lt;a href="https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma" rel="noopener noreferrer"&gt;Figma's variables guide&lt;/a&gt; explains how values can be applied across a design; &lt;a href="https://help.figma.com/hc/en-us/articles/14506821864087-Overview-of-variables-collections-and-modes" rel="noopener noreferrer"&gt;aliases&lt;/a&gt; let one variable reference another. A token named for its role, such as &lt;code&gt;status/waiting&lt;/code&gt;, tells a future implementer more than a blue swatch does. &lt;a href="https://help.figma.com/hc/en-us/articles/39636737843735-Components-collection-Variants-and-component-set-fundamentals" rel="noopener noreferrer"&gt;Variants&lt;/a&gt; help when a component has predictable states.&lt;/p&gt;

&lt;p&gt;We also gave Claude patterns for choosing between tables and cards. A review queue needs dense rows you can scan. An expense detail needs room for the receipt, context, and action. Those are different jobs, even when both screens share the same buttons and statuses.&lt;/p&gt;

&lt;p&gt;We didn't resolve every UX question in Figma. We wanted to see what Claude would do once it had a stronger design reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then we sent Claude back to the React prototype
&lt;/h2&gt;

&lt;p&gt;After the Figma pass, we connected the design through MCP and told Claude to use the refined system for the next implementation pass. Otherwise, it could happily keep following its first UI. AI tools can be loyal to an old decision long after you've changed your mind.&lt;/p&gt;

&lt;p&gt;Figma's &lt;a href="https://developers.figma.com/docs/figma-mcp-server/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; can provide an agent with design context, including components and variables. &lt;a href="https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/" rel="noopener noreferrer"&gt;Code Connect&lt;/a&gt; can map Figma components to their code counterparts. Those references help Claude understand what to build. We still inspected the React screen and clicked through the flow.&lt;/p&gt;

&lt;p&gt;For this expense, a blue &lt;strong&gt;Request information&lt;/strong&gt; button would be a very small victory if clicking it left the expense in &lt;strong&gt;Needs review&lt;/strong&gt;. The prototype needed to show the request, change the status, and update the activity. The queue then needed to reflect the new state.&lt;/p&gt;

&lt;p&gt;There's a straightforward React point here too. Give the expense one status value such as &lt;code&gt;needs_review&lt;/code&gt; or &lt;code&gt;waiting_for_employee&lt;/code&gt;; don't maintain several independent booleans that can disagree. The &lt;a href="https://react.dev/learn/choosing-the-state-structure" rel="noopener noreferrer"&gt;React docs on state structure&lt;/a&gt; explain why mutually exclusive states help avoid impossible combinations. A real finance product would need persistence and permission rules around this action. The demo tests the screen flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard gave me another useful surprise
&lt;/h2&gt;

&lt;p&gt;Once the expense flow was in place, we asked Claude for a dashboard for the same finance manager. It brought in a &lt;strong&gt;Needs attention&lt;/strong&gt; area, spending against the monthly budget, categories and merchants, notifications, and recent activity. Those pieces made sense for the product we had described.&lt;/p&gt;

&lt;p&gt;One choice still bothered me: recent activity had its own white card and looked too prominent. It was getting prime dashboard real estate for telling me what had already happened. We removed that card treatment and put the activity on a quieter gray background. The items needing the manager's decision could take the lead.&lt;/p&gt;

&lt;p&gt;That is the part of an AI-generated dashboard we keep checking. The model can assemble all the requested sections. Someone still has to decide what the person should notice first. In this case, a pending expense deserves more attention than a routine activity log.&lt;/p&gt;

&lt;h2&gt;
  
  
  UITOP's quick check for the next generated page
&lt;/h2&gt;

&lt;p&gt;We would run through the same expense again before asking for five more screens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Can I spot the missing cost center from the queue?&lt;/li&gt;
&lt;li&gt;Does the detail view give me enough context to decide what to do?&lt;/li&gt;
&lt;li&gt;When I request information, do I see the new status and an activity entry?&lt;/li&gt;
&lt;li&gt;Does the queue show the updated expense when I return?&lt;/li&gt;
&lt;li&gt;Does &lt;strong&gt;Needs attention&lt;/strong&gt; on the dashboard take me to the right work?&lt;/li&gt;
&lt;li&gt;Did the implementation use the current Figma components and values?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If one of those fails, you have a specific problem to give Claude. “Make the UX better” is a much harder prompt to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions I've seen developers ask about Figma and AI coding tools
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does my Figma file need auto layout and components before Claude can use it?
&lt;/h3&gt;

&lt;p&gt;Claude can work from a visual reference. A well-structured file gives it more to work with: layout, named components, variants, and variables bound to the elements you care about. Start with one relevant frame and check what appears in code. You still have to describe behavior such as what happens after Request information. A static frame doesn't contain that whole story.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get Claude to use our existing components?
&lt;/h3&gt;

&lt;p&gt;Show it the relevant Figma design and point it to the components in your codebase. Say which existing variants and tokens the screen should use. If you have &lt;a href="https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/" rel="noopener noreferrer"&gt;Code Connect mappings&lt;/a&gt;, they can provide a more explicit link. Then inspect what Claude wrote: did it import the existing component, or make a slightly different button that will become everyone's problem next month?&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need one huge &lt;code&gt;design.md&lt;/code&gt; file?
&lt;/h3&gt;

&lt;p&gt;I prefer a short product context, a place to find the design-system rules, and a task brief for the current flow. Claude should be able to find the role, the current components, and what changes after a click. There's no magic file count. In the video, the finance scenario and the refined Figma system did more work than a long list of generic design adjectives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will MCP keep Figma and React in sync for me?
&lt;/h3&gt;

&lt;p&gt;It gives the agent access to Figma context. You still need to ask for the code change and review the rendered screen. I explicitly told Claude that the refined Figma work was the current design reference before it continued the React prototype. MCP doesn't know whether Alex's expense changed status just because the component looks right. &lt;/p&gt;

&lt;h3&gt;
  
  
  How do you test a coded prototype before putting it in front of users?
&lt;/h3&gt;

&lt;p&gt;Click through a real task. In this demo, I can request Alex's missing cost center and check the status, queue, and activity. That catches broken connections between screens. It doesn't tell me whether finance managers understand the flow; for that, I'd ask people who do the job to try it and watch what happens. The video shows the first kind of check.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you keep Claude consistent as it generates more screens?
&lt;/h3&gt;

&lt;p&gt;Give it the current components and a bounded task. Review the new page against existing screens, including states and behavior. A waiting expense should use the same status language in the inbox and detail view. Check the implementation for one-off colors or duplicate components too. &lt;a href="https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/" rel="noopener noreferrer"&gt;Figma's MCP tools&lt;/a&gt; can supply design context; the screen still needs your review.&lt;/p&gt;

&lt;p&gt;You can watch the full Claude, Figma, and React walkthrough here, including the initial inbox, the design-system pass, the expense request, and the dashboard change. At UITOP, this is the kind of B2B SaaS work we spend time on: what a screen needs to show, what changes when someone takes an action, and whether the next screen still makes sense.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>figma</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Structure a B2B SaaS Frontend Around Changing Operational Data</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Thu, 15 Oct 2026 05:15:19 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/how-to-structure-a-b2b-saas-frontend-around-changing-operational-data-5ghk</link>
      <guid>https://dev.to/oleksandr_sainchuk/how-to-structure-a-b2b-saas-frontend-around-changing-operational-data-5ghk</guid>
      <description>&lt;p&gt;Imagine you're working in a logistics dashboard. You filter shipments to see everything that's delayed. You open one record, check its ETA, and start investigating the problem.&lt;/p&gt;

&lt;p&gt;Meanwhile, new shipment information arrives from the server. One shipment changes status. Another gets a new ETA. A background request finishes just after you've changed your filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should the interface do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If every update replaces the entire screen state, users can lose their place. If the application keeps showing old records, the interface becomes unreliable.&lt;/p&gt;

&lt;p&gt;We worked with this type of operational data while designing and developing LogiCore, a logistics and warehouse SaaS application at UITOP.&lt;/p&gt;

&lt;p&gt;The product connects shipment monitoring, warehouse locations, inventory records, and operational metrics. Its frontend uses React, TypeScript, and Zustand, with data served through REST APIs backed by Node.js, Express, PostgreSQL, and Prisma.&lt;/p&gt;

&lt;p&gt;For shipment monitoring, our team used Zustand-managed polling to retrieve updated status, ETA, and timestamp information. That project gives us a useful starting point for a broader engineering question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How should a React application manage user interactions and changing server data at the same time?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Start by separating three kinds of state
&lt;/h2&gt;

&lt;p&gt;A common source of frontend bugs is treating every piece of information on a screen as one large object.&lt;/p&gt;

&lt;p&gt;Consider a shipment monitoring page. It may contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current shipment records and their statuses&lt;/li&gt;
&lt;li&gt;Active filters and pagination&lt;/li&gt;
&lt;li&gt;The shipment the user has selected&lt;/li&gt;
&lt;li&gt;Loading and refresh indicators&lt;/li&gt;
&lt;li&gt;Errors from API requests&lt;/li&gt;
&lt;li&gt;A timestamp showing when the data was updated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These values have different responsibilities. I find it useful to separate them into three groups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server data
&lt;/h2&gt;

&lt;p&gt;This represents information the application receives from its backend.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shipment ID&lt;/li&gt;
&lt;li&gt;Current shipment status&lt;/li&gt;
&lt;li&gt;ETA&lt;/li&gt;
&lt;li&gt;Last carrier update&lt;/li&gt;
&lt;li&gt;Total number of records matching a query&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend determines the current business state. The frontend presents a snapshot of that information.&lt;/p&gt;

&lt;h2&gt;
  
  
  User interface state
&lt;/h2&gt;

&lt;p&gt;This represents the user's current interaction with the page.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active status filter&lt;/li&gt;
&lt;li&gt;Current page&lt;/li&gt;
&lt;li&gt;Selected shipment ID&lt;/li&gt;
&lt;li&gt;Expanded details&lt;/li&gt;
&lt;li&gt;Open dialogs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A background data refresh generally should not reset these choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request state
&lt;/h2&gt;

&lt;p&gt;This describes what's happening while the application communicates with the server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initial loading&lt;/li&gt;
&lt;li&gt;Background refresh in progress&lt;/li&gt;
&lt;li&gt;Last successful response&lt;/li&gt;
&lt;li&gt;Request failure&lt;/li&gt;
&lt;li&gt;Data that may be stale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This third group matters because a screen can have valid data and an active request at the same time. A user should still be able to inspect the current results while the application retrieves newer information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the user's current view explicit
&lt;/h2&gt;

&lt;p&gt;Suppose a shipment table supports two filters: All and Delayed. The user can also navigate between pages and select a shipment. Those interactions can be represented independently from the returned shipment records.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zustand&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StatusFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delayed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ShipmentViewState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StatusFilter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selectedShipmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StatusFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;selectShipment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useShipmentView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ShipmentViewState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kd"&gt;set&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;selectedShipmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;selectedShipmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;setPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;

    &lt;span class="na"&gt;selectShipment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;selectedShipmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the status filter resets pagination to page 1. The previous page might not exist in the filtered result.&lt;/p&gt;

&lt;p&gt;Selection is stored by shipment ID. If shipment records are refreshed, the application can still identify the selected item. The selected object doesn't need to be stored as a separate copy of the entire record.&lt;/p&gt;

&lt;p&gt;Whether selection should be cleared when filters change depends on the workflow. In this example, we clear it explicitly. In other products, keeping the detail view open may be preferable.&lt;/p&gt;

&lt;p&gt;The important part is making that behavior intentional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep polling separate from table interactions
&lt;/h2&gt;

&lt;p&gt;In LogiCore, shipment status and ETA information are refreshed through background requests to the REST API.&lt;/p&gt;

&lt;p&gt;Polling is a reasonable approach when the application needs periodic updates and the source data doesn't require a continuous streaming connection. The challenge is managing those requests as the user changes the current view.&lt;/p&gt;

&lt;p&gt;A naive implementation might 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="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/shipments&lt;/span&gt;&lt;span class="dl"&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;setShipments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't account for current filters or pagination. It has no error handling. Requests can overlap if one response takes longer than the interval.&lt;/p&gt;

&lt;p&gt;It also doesn't address what happens when the component unmounts or the user switches to a different query. A more reliable implementation gives the polling process a clear lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  React polling example
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Shipment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;eta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ShipmentPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FeedState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShipmentPage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useShipmentFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&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="nx"&gt;page&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFeed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FeedState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;setFeed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nf"&gt;setFeed&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;try&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;params&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;page&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;page&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`/api/shipments?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ShipmentPage&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;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;setFeed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&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;active&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="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;setFeed&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
              &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not refresh shipments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
              &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;
          &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return &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="nx"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&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;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;syncing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The endpoint, response shape, and 15-second interval are illustrative.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Several common problems here:&lt;/p&gt;

&lt;h2&gt;
  
  
  Requests follow the active query
&lt;/h2&gt;

&lt;p&gt;The effect depends on the current filter and page. When either changes, React cleans up the previous effect and starts a new request process for the new query. &lt;/p&gt;

&lt;h2&gt;
  
  
  Old requests cannot update the new view
&lt;/h2&gt;

&lt;p&gt;The cleanup aborts the previous fetch. The active check also prevents a response from updating component state after its effect has been cleaned up. That matters because network requests do not necessarily finish in the order they started. Imagine the user selects Delayed and immediately switches back to All.&lt;/p&gt;

&lt;p&gt;The Delayed request may finish last. Its response should not replace the current All result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Polling requests don't overlap
&lt;/h2&gt;

&lt;p&gt;The next timer is scheduled after the current request settles. That prevents this particular polling loop from starting another request while its previous request is still pending. In a production application, I would also consider an upper limit on request duration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Existing results remain available during a refresh
&lt;/h2&gt;

&lt;p&gt;After the initial load succeeds, setting syncing to true doesn't clear the current response. The interface can continue showing its most recent results while the next request is in progress. This makes a useful distinction between initial loading and refreshing existing data.&lt;/p&gt;

&lt;p&gt;For a real application, I would also consider request deduplication, visibility-aware polling, authentication expiration, retry backoff, and how multiple components share the same data. A dedicated server-state library may be appropriate when those responsibilities grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every refresh a predictable UI behavior
&lt;/h2&gt;

&lt;p&gt;Fetching current data is only half the implementation. The frontend also needs rules for presenting that data. Consider a shipment with this information:&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%2Fi6cz2d9txey9szdky4hk.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%2Fi6cz2d9txey9szdky4hk.png" alt="A shipment record before and after an update: status, ETA, and the latest carrier timestamp." width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the user is looking at that shipment, the interface should present the newer information in the relevant record. But the application must also decide what happens if the new status changes which rows match the active filter.&lt;/p&gt;

&lt;p&gt;Suppose the current view shows only In transit shipments. After a refresh, a shipment becomes Delayed.&lt;/p&gt;

&lt;p&gt;Should it disappear immediately?&lt;/p&gt;

&lt;p&gt;There isn't one correct answer for every operational product. Immediate removal maintains strict consistency with the filter. It can also make a row disappear while someone is examining it.&lt;/p&gt;

&lt;p&gt;An alternative interaction is to retain the currently displayed set temporarily and indicate that newer results are available.&lt;/p&gt;

&lt;p&gt;The appropriate behavior depends on the task. For a monitoring dashboard, timely changes may take priority. For a table where operators select records and perform multi-step actions, preserving working context may be more important. This is a product decision that frontend and UX teams should agree on before implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat timestamps as part of data reliability
&lt;/h2&gt;

&lt;p&gt;A label such as "Last updated" sounds simple, but developers need to agree on what it represents.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the external carrier produced the information&lt;/li&gt;
&lt;li&gt;When the backend received the update&lt;/li&gt;
&lt;li&gt;When the API response was generated&lt;/li&gt;
&lt;li&gt;When the frontend successfully fetched the response&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These times can differ. A successful API request at 12:30 doesn't prove that the carrier supplied new data at 12:30.&lt;/p&gt;

&lt;p&gt;In LogiCore, shipment monitoring includes status information, ETA drift indicators, and last-update timestamps associated with refreshed carrier data.&lt;/p&gt;

&lt;p&gt;For similar systems, I would make timestamp ownership explicit in the API contract. The response might distinguish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shipmentId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SHP-2048"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"delayed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-23T16:10:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"carrierUpdatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-23T12:24:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fetchedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-23T12:30:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can show when the shipment information last changed and separately track when the application last synchronized.&lt;/p&gt;

&lt;p&gt;This distinction becomes useful during outages, integration delays, and incidents where a user needs to know how current the information actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  A failed refresh should be visible
&lt;/h2&gt;

&lt;p&gt;Imagine a dispatcher reviewing 40 shipments. The page initially loads correctly. Five minutes later, the network becomes unstable and the background refresh fails.&lt;/p&gt;

&lt;p&gt;Should the table disappear?&lt;/p&gt;

&lt;p&gt;Usually, the application still has a previously successful response that the user can inspect. The interface can preserve those records and show that the latest refresh failed. A useful set of UI states will include:&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%2Fam6v76ts8yx1p7zefbwt.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%2Fam6v76ts8yx1p7zefbwt.png" alt="How the shipment table handles loading, refreshes, errors, empty results, and expired sessions." width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An expired session needs special handling. The product should not keep issuing the same unauthorized request every few seconds. If the request fails repeatedly, retry logic may need an increasing delay.&lt;/p&gt;

&lt;p&gt;These are recommended patterns for operational software. Their exact implementation should follow the application's security requirements and data sensitivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't let background updates interrupt active work
&lt;/h2&gt;

&lt;p&gt;There is another problem that appears when a product moves beyond read-only dashboards.&lt;/p&gt;

&lt;p&gt;Suppose a user is editing the assigned carrier or reviewing a shipment exception while new information arrives from the backend. A refresh that blindly replaces an entire local object can overwrite in-progress changes.&lt;/p&gt;

&lt;p&gt;For editable records, I would define which fields belong to the current server snapshot and which fields belong to an active local operation. The application also needs a policy for conflicts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has the record changed since the user opened it?&lt;/li&gt;
&lt;li&gt;Is the user editing the same field that changed on the server?&lt;/li&gt;
&lt;li&gt;Can the operation be submitted against the newer record version?&lt;/li&gt;
&lt;li&gt;Does the user need to review the difference before continuing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Depending on the product, that may require version fields, conditional updates, or a conflict-resolution interaction.&lt;/p&gt;

&lt;p&gt;These decisions are especially important in ERP, CRM, warehouse management, and other B2B applications where multiple users work with related records. This is a general implementation consideration, not a claim about a specific LogiCore editing workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would test before release
&lt;/h2&gt;

&lt;p&gt;For a React interface with changing operational data, I would test scenarios beyond the happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rapid filter changes&lt;/strong&gt;. Switch between filters while requests are pending. Confirm that the UI displays the result for the current query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow responses&lt;/strong&gt;. Simulate a request taking longer than the normal refresh interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unmounting&lt;/strong&gt;. Navigate away while a request is pending. Confirm that the polling process stops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeated failures&lt;/strong&gt;. Simulate network errors and authentication expiration. Check what remains visible and how the interface recovers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changing records&lt;/strong&gt;. Update a shipment while a user is reviewing its details. Confirm that selection and navigation behave as intended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inactive tabs&lt;/strong&gt;. Check whether background polling continues unnecessarily when the page isn't being used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Different refresh speeds&lt;/strong&gt;. Evaluate whether status changes appear frequently enough for the operational requirements and whether polling creates unnecessary backend traffic.&lt;/p&gt;

&lt;p&gt;I would also review accessibility: refresh indicators should communicate meaningful changes, and frequent updates should not continuously interrupt screen-reader users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LogiCore taught us about operational frontend architecture
&lt;/h2&gt;

&lt;p&gt;UITOP designed and developed LogiCore as one application connecting shipment monitoring, warehouse structures, inventory, and operational data.&lt;/p&gt;

&lt;p&gt;The shipment interface uses React and TypeScript, with TanStack Table for table behavior and Zustand-managed polling for refreshed shipment information.&lt;/p&gt;

&lt;p&gt;The backend uses Node.js, Express, PostgreSQL, Prisma, and REST APIs.&lt;/p&gt;

&lt;p&gt;Those implementation decisions illustrate an important relationship between the frontend and its data sources. Users control filters, pagination, and navigation. The backend supplies current operational records. The request lifecycle determines when newer data reaches the interface.&lt;/p&gt;

&lt;p&gt;A frontend architecture needs to manage those responsibilities consistently as the product grows. If I were reviewing an existing B2B SaaS product today, I would start with one practical scenario:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when the user changes a filter at the same moment a background request returns newer data?&lt;/strong&gt;&lt;br&gt;
The answer reveals a lot about the quality of the application's state management, request handling, and user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, read also about the project and the team
&lt;/h2&gt;

&lt;p&gt;LogiCore is a logistics and warehouse SaaS designed and developed by UITOP, a product design and software development team specializing in complex B2B applications.&lt;/p&gt;

&lt;p&gt;The project covered product design, frontend and backend development, database architecture, API development, and QA.&lt;/p&gt;

&lt;p&gt;Read the &lt;a href="https://uitop.design/case-studies/logicore/" rel="noopener noreferrer"&gt;LogiCore case study&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>saas</category>
    </item>
    <item>
      <title>Building a shipment table for thousands of live records. What I learned on LogiCore?</title>
      <dc:creator>Oleksandr Sainchuk</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:30:58 +0000</pubDate>
      <link>https://dev.to/oleksandr_sainchuk/building-a-shipment-table-for-thousands-of-live-records-what-i-learned-on-logicore-51np</link>
      <guid>https://dev.to/oleksandr_sainchuk/building-a-shipment-table-for-thousands-of-live-records-what-i-learned-on-logicore-51np</guid>
      <description>&lt;p&gt;A shipment table seems easy, a few columns, some filters, a status badge. Then real data shows up...&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;THOUSANDS&lt;/strong&gt; of shipments, statuses that change all day, and operators who need to find the one late truck fast.&lt;/p&gt;

&lt;p&gt;A table that feels fine with 50 test rows can crawl with 5,000.&lt;/p&gt;

&lt;p&gt;I ran into this on LogiCore, a logistics and warehouse management platform UITOP designed and built from scratch, for a UK logistics company. The client ran a multi-million dollar business across three or four separate tools. Data didn’t match between them, updates came late, and managers made big calls without the full picture. They wanted one system for the warehouse floor and the office.&lt;/p&gt;

&lt;p&gt;Our design, project management, development and QA teams worked on it together. Here’s how we built the shipment table, and why we made each choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table had to do
&lt;/h2&gt;

&lt;p&gt;Operators used the table to find shipments and spot the ones that need attention. And they did it while statuses and ETAs kept changing.&lt;/p&gt;

&lt;p&gt;The frontend runs on &lt;strong&gt;React&lt;/strong&gt;, &lt;strong&gt;TypeScript&lt;/strong&gt; and &lt;strong&gt;Vite&lt;/strong&gt;, with TanStack Table (React Table v8), &lt;strong&gt;Zustand&lt;/strong&gt; and &lt;strong&gt;Tailwind CSS&lt;/strong&gt;. The backend uses &lt;strong&gt;Node.js&lt;/strong&gt;, &lt;strong&gt;Express&lt;/strong&gt;, &lt;strong&gt;PostgreSQL&lt;/strong&gt; and &lt;strong&gt;Prisma ORM&lt;/strong&gt; behind a REST API.&lt;/p&gt;

&lt;p&gt;We had three separate problems to solve:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;too much data to load, &lt;/li&gt;
&lt;li&gt;too many rows to draw,&lt;/li&gt;
&lt;li&gt;data that goes stale while the page is open.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 1: the browser doesn’t need every shipment
&lt;/h2&gt;

&lt;p&gt;Why load thousands of records when the operator looks at one screen at a time? Loading everything makes the first view slow and wastes memory.&lt;/p&gt;

&lt;p&gt;So we used server-side pagination. The frontend asks the API for one page. PostgreSQL runs the query with the active filters and returns only that page.&lt;/p&gt;

&lt;p&gt;Details matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reset the page when a filter changes. If someone sits on page 12 and the new filter returns three pages, they should land on page 1, not on an empty screen.&lt;/li&gt;
&lt;li&gt;Keep sorting stable. Records change all day, so the API needs a fixed order. Otherwise rows jump between pages and people lose their place.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Problem 2: one page can still be heavy
&lt;/h2&gt;

&lt;p&gt;Pagination cuts how much data comes back. It doesn’t cut how much the browser has to draw. Each shipment row carries a status badge, an ETA drift indicator and a “Last Updated” timestamp. Draw hundreds of those at once, and React has a lot of work to do.&lt;/p&gt;

&lt;p&gt;So we added row virtualization on top. The table only mounts the rows on screen, plus a small buffer for scrolling. TanStack Table handles the table logic, and the virtualization layer decides which rows exist in the DOM right now.&lt;/p&gt;

&lt;p&gt;The two techniques solve different problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-side pagination controls how much data travels from the database to the browser.&lt;/li&gt;
&lt;li&gt;Virtualization controls how much the browser renders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you test both: API response times and rendering.&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%2F9w76xrdkz5t1tdcywy0o.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%2F9w76xrdkz5t1tdcywy0o.png" alt="Left node: a white rounded platform holding a floating frosted-glass UI panel showing a data table with rows, a pagination control and a filter chip, rendered in blue glass. Small clean label floating above: " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: the data changes while someone is looking at it
&lt;/h2&gt;

&lt;p&gt;A shipment moves to a new status. An ETA slips. A carrier sends an update. If the screen shows old data, the operator decides based on a stale picture, and that’s the problem LogiCore was built to fix.&lt;/p&gt;

&lt;p&gt;We handled this with background polling. A Zustand store fetches fresh data from the REST API on a schedule, and the badges, ETA drift indicators and timestamps update from it. “Last Updated” shows the latest carrier update, not a cached value.&lt;/p&gt;

&lt;p&gt;The tricky part here is UX. If a badge changes while someone reads the list, the table shouldn’t reset their filters or scroll position. Polling also raises its own questions: how often to refresh, what to do with failed requests, and whether to keep polling when nobody is at the screen.&lt;/p&gt;

&lt;p&gt;Polling fit LogiCore’s update rhythm. A product that needs second-by-second updates might be better off with WebSockets or server-sent events.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend decides how fast the table feels
&lt;/h2&gt;

&lt;p&gt;A slow query makes even a well-tuned React table feel broken. So a lot of the speed work happened below the UI.&lt;/p&gt;

&lt;p&gt;On the inventory screen, a multi-location SKU expands with a scoped REST call that fetches only that item’s child records. The first load stays at 100 rows, no matter how deep the data goes. The warehouse location tree loads the same way, one level at a time, from facility down to shelf.&lt;/p&gt;

&lt;p&gt;Filters by client, warehouse or SKU run through Prisma’s dynamic where clauses. That way we avoid raw SQL and still support any filter combination. For the KPI cards, PostgreSQL window functions calculate week-over-week changes inside the database, and the frontend just shows the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design and performance are one conversation
&lt;/h2&gt;

&lt;p&gt;More columns don’t make a table more useful. They make it harder to scan. We gave status and timing a fixed place in each row, because that’s what operators check first.&lt;/p&gt;

&lt;p&gt;Every design choice here has a frontend cost. Columns decide how heavy each cell is. Filters decide what the API has to query. And the design system needs states for loading and errors, not only the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out of it
&lt;/h2&gt;

&lt;p&gt;Before launch, our QA team ran stress tests under heavy load, and the platform held at 100% stability. The office and the warehouse now work from the same live data. Managers see transit delays and stock shortages as they happen, so they fix problems before those cost money. Daily shipping decisions got 2x faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start with your own table
&lt;/h2&gt;

&lt;p&gt;If you’re building a B2B SaaS product with big tables, start with three questions. How much data does the screen request? How much does it render? What happens when the records change? The answers usually point to the next engineering decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do you make a React table fast with thousands of records?&lt;/strong&gt;&lt;br&gt;
Combine server-side pagination with row virtualization. Pagination limits what the API sends, and virtualization limits what the browser draws. On LogiCore, we used both with TanStack Table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should a logistics dashboard use polling or WebSockets?&lt;/strong&gt;&lt;br&gt;
It depends on how fast the data changes. Polling worked for LogiCore’s shipment statuses and ETAs. If operators need second-by-second updates, WebSockets or server-sent events fit better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should I look for in a team building custom warehouse management software?&lt;/strong&gt;&lt;br&gt;
Look for a team that covers design, backend and QA together, because table speed depends on the database as much as on the UI. UITOP built LogiCore that way, and the platform held 100% stability under peak-load stress tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the project
&lt;/h2&gt;

&lt;p&gt;LogiCore is a logistics and warehouse management platform designed and built by UITOP, a product design and development studio for complex B2B software.&lt;/p&gt;

&lt;p&gt;Case study: &lt;a href="https://uitop.design/case-studies/logicore/" rel="noopener noreferrer"&gt;https://uitop.design/case-studies/logicore/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
