<?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: Muhammad Abdullah</title>
    <description>The latest articles on DEV Community by Muhammad Abdullah (@mabdullah_built).</description>
    <link>https://dev.to/mabdullah_built</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%2F4154125%2F36a58d80-aa99-4a76-9df9-918fb408832f.jpg</url>
      <title>DEV Community: Muhammad Abdullah</title>
      <link>https://dev.to/mabdullah_built</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mabdullah_built"/>
    <language>en</language>
    <item>
      <title>Google Ads handover checklist: verify conversion tracking before changing spend</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:23:27 +0000</pubDate>
      <link>https://dev.to/stackendbox/google-ads-handover-checklist-verify-conversion-tracking-before-changing-spend-k4k</link>
      <guid>https://dev.to/stackendbox/google-ads-handover-checklist-verify-conversion-tracking-before-changing-spend-k4k</guid>
      <description>&lt;p&gt;Before changing spend after a Google Ads agency handover, prove that one representative enquiry reaches both the business and the expected conversion action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map access and ownership
&lt;/h2&gt;

&lt;p&gt;Record who can administer Google Ads, Tag Manager and Analytics, and who receives enquiries. For each primary conversion action, document its source and any manager-account dependency. Review billing separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace one test enquiry
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Submit a clearly labeled test enquiry from the landing page.&lt;/li&gt;
&lt;li&gt;Check that the receiving inbox or CRM has it and that the browser or server sends the expected event.&lt;/li&gt;
&lt;li&gt;Check the event against the intended conversion action. A debug event and an attributed Google Ads conversion are different observations. Record any reporting delay.&lt;/li&gt;
&lt;li&gt;Repeat the confirmation step to check for duplicate events. Save the result and assign an owner to any gaps.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Before unlinking the old manager account, agree which dependencies must move, who can restore access, and which checks must pass afterward.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.stackendbox.com/blog/google-ads-agency-handover-checklist?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ads_handover_nov2026" rel="noopener noreferrer"&gt;StackEndBox Google Ads agency handover checklist&lt;/a&gt; covers access, billing, conversion ownership and handover evidence.&lt;/p&gt;

</description>
      <category>analytics</category>
    </item>
    <item>
      <title>A CRM migration can pass the row count and fail the account history</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Mon, 09 Nov 2026 18:23:44 +0000</pubDate>
      <link>https://dev.to/stackendbox/a-crm-migration-can-pass-the-row-count-and-fail-the-account-history-38j1</link>
      <guid>https://dev.to/stackendbox/a-crm-migration-can-pass-the-row-count-and-fail-the-account-history-38j1</guid>
      <description>&lt;p&gt;A migration can move every activity record and still show a customer’s history on the wrong account. Before cutover, check a few important records by their relationships and by the staff role that needs to open them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small test with a real failure condition
&lt;/h2&gt;

&lt;p&gt;This synthetic fixture uses account and activity IDs already mapped to the same identifiers in both exports. The source links A101 to C001 and A102 to C002. The test destination still contains two rows, but A102 is attached to C001. A count check passes. The relationship check fails.&lt;/p&gt;

&lt;p&gt;Run this JavaScript in a local console with the synthetic fixture. For a real rehearsal, use approved exports in a controlled workspace and validate that activity IDs are unique in each input before using this example.&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;function&lt;/span&gt; &lt;span class="nf"&gt;compareActivityLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;destination&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;byId&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activityId&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&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;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;byId&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="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activityId&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;actual&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;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;missing&lt;/span&gt;&lt;span class="dl"&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;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&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="na"&gt;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activityId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wrong account&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;return&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="nx"&gt;source&lt;/span&gt; &lt;span class="o"&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;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A101&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C001&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;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A102&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C002&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;destination&lt;/span&gt; &lt;span class="o"&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;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A101&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C001&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;activityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A102&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C001&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;compareActivityLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// [{ activityId: "A102", issue: "wrong account" }]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Check what the new owner can actually see
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Evidence to keep&lt;/th&gt;
&lt;th&gt;Who signs off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Activity relationship&lt;/td&gt;
&lt;td&gt;Source activity ID, mapped account ID, observed destination account ID, and any approved exception&lt;/td&gt;
&lt;td&gt;Migration lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staff access&lt;/td&gt;
&lt;td&gt;The new account owner can find the last meaningful interaction and open its permitted attachments in the normal CRM view&lt;/td&gt;
&lt;td&gt;Sales or service owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted access&lt;/td&gt;
&lt;td&gt;A staff member outside the approved role cannot view a restricted activity or file&lt;/td&gt;
&lt;td&gt;Data owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final changes&lt;/td&gt;
&lt;td&gt;A record created or edited after the rehearsal arrives once, with the expected owner and history, after the final load&lt;/td&gt;
&lt;td&gt;Cutover owner&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Record the exception, owner, and disposition for each failure. Ask the cutover owner to resolve or explicitly accept exceptions before switching systems. Keep evidence in the project workspace and use record IDs in the sign-off sheet.&lt;/p&gt;

&lt;p&gt;StackEndBox’s migration checklist covers the wider scope, including people, records, permissions, and cutover. Use it when comparing a proposal or preparing a rehearsal. &lt;a href="https://www.stackendbox.com/blog/crm-migration-acceptance-checklist" rel="noopener noreferrer"&gt;CRM migration acceptance checklist&lt;/a&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
    <item>
      <title>A CRM accepted the enquiry. Who owns it now?</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Mon, 09 Nov 2026 17:57:16 +0000</pubDate>
      <link>https://dev.to/stackendbox/a-crm-accepted-the-enquiry-who-owns-it-now-chj</link>
      <guid>https://dev.to/stackendbox/a-crm-accepted-the-enquiry-who-owns-it-now-chj</guid>
      <description>&lt;p&gt;A contact record can exist in your CRM while nobody is responsible for replying. The form succeeds, the contact appears, and the sales inbox stays quiet because the assignment rule found no eligible owner.&lt;/p&gt;

&lt;p&gt;A useful acceptance test follows the enquiry from submission to the person or queue that can act on it. Start with one form and one fallback queue. Agree who monitors that queue and how long an item can wait before it gets escalated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every enquiry a visible outcome
&lt;/h2&gt;

&lt;p&gt;Store a submission ID and keep it separate from the CRM contact ID. Two requests from the same contact can be legitimate enquiries. A retry of the same submission should preserve the original assignment and avoid a second notification.&lt;/p&gt;

&lt;p&gt;Record the assigned owner or fallback queue, the routing reason, and the time the enquiry entered that state. Record when a person accepts the enquiry. For a missing match, an inactive owner, or incomplete routing fields, send the enquiry to the monitored queue with the reason visible. If no one accepts it within the agreed window, escalate it once to the duty owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the cases your inbox can't explain
&lt;/h2&gt;

&lt;p&gt;These are illustrative acceptance tests. Replace the example rules and response window with ones your team actually uses.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Submit or simulate&lt;/th&gt;
&lt;th&gt;Verify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A valid enquiry with a matching, active owner&lt;/td&gt;
&lt;td&gt;The owner sees the enquiry and can accept it. The CRM shows when it was assigned.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An enquiry with no matching owner&lt;/td&gt;
&lt;td&gt;The monitored queue receives it with the reason. An unclaimed enquiry triggers one escalation after the agreed window.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A repeat delivery of the same submission&lt;/td&gt;
&lt;td&gt;The original enquiry and assignment remain. The recipient gets one notification for that assignment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new submission from an existing contact&lt;/td&gt;
&lt;td&gt;The existing contact is reused and the new enquiry is visible as separate work.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The CRM write succeeds but the notification fails&lt;/td&gt;
&lt;td&gt;The assignment stays visible. Retrying the notification uses the same assignment and doesn't create another enquiry.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run these against a test form and CRM workspace before switching on live routing. Ask the person who monitors the fallback queue to run the second case themselves. They should be able to find, accept, and reassign the enquiry using their normal permissions.&lt;/p&gt;

&lt;p&gt;If you're scoping this work, bring one recent example of an enquiry that reached the CRM but was slow to reach a person. Trace the submission, assignment and acknowledgement timestamps with the team. That shows which handoff needs attention and gives you a concrete test for the fix.&lt;/p&gt;

&lt;p&gt;StackEndBox can help scope that first handoff through its &lt;a href="https://www.stackendbox.com/services/custom-crm-development" rel="noopener noreferrer"&gt;CRM development and workflow integration service&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>management</category>
      <category>productivity</category>
      <category>software</category>
    </item>
    <item>
      <title>CRM quote reminders: check the quote again before sending</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Fri, 06 Nov 2026 14:32:48 +0000</pubDate>
      <link>https://dev.to/stackendbox/crm-quote-reminders-check-the-quote-again-before-sending-2485</link>
      <guid>https://dev.to/stackendbox/crm-quote-reminders-check-the-quote-again-before-sending-2485</guid>
      <description>&lt;p&gt;A customer accepts a quote at 9:58. At 10:00, an automated email asks them whether they’ve had a chance to look at it. The reminder was scheduled yesterday, when the quote was still open. The worker sent it using yesterday’s information.&lt;/p&gt;

&lt;p&gt;Imagine this happening at a maintenance business that uses a CRM for quotes and a separate inbox for replies. The useful design decision is where the reminder gets its final permission to send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schedule a check, then decide
&lt;/h2&gt;

&lt;p&gt;Store the quote ID, quote revision, reminder step and due time. When the job runs, reload the current quote and any reply or opt-out status your workflow relies on. Send only if that exact revision is still open, the channel is allowed and the step is still due. If you can’t confirm those facts, defer or put the job in a review queue.&lt;/p&gt;

&lt;p&gt;Pick one source of truth for each signal. The CRM might own quote status, while the inbox owns the latest customer reply. If reply ingestion is delayed and you can detect that lag, give the integration time to catch up before attempting the send. A last-minute check only sees events the system has received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle retries as separate decisions
&lt;/h2&gt;

&lt;p&gt;Give each planned send a unique key such as quote-184:rev-3:reminder-1:email. Reserve that key once in durable storage so two workers don’t both attempt delivery. If the provider supports idempotency, reuse the same key for retries according to its contract.&lt;/p&gt;

&lt;p&gt;A provider timeout can leave the delivery outcome unknown. Check the provider’s delivery record or put the attempt in a review queue before retrying. Record the quote revision, decision and provider reference where available. An audit trail should explain why the reminder was sent, skipped or held without storing the whole email thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acceptance checks for a test account
&lt;/h2&gt;

&lt;p&gt;These are illustrative expectations for synthetic records.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test input&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Quote accepted before the job runs&lt;/td&gt;
&lt;td&gt;Skip and record the accepted state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revision 4 replaces revision 3 while revision 3 has a job queued&lt;/td&gt;
&lt;td&gt;Skip the old job and evaluate revision 4 on its own schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer reply is present but a staff member hasn’t resolved it&lt;/td&gt;
&lt;td&gt;Pause reminders for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two workers pick up the same reminder&lt;/td&gt;
&lt;td&gt;Only one worker reserves the send key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider times out after the request&lt;/td&gt;
&lt;td&gt;Record an unknown outcome and reconcile before retrying&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with one reminder step and a test account. Have staff review the held cases before you add another channel or increase the send frequency.&lt;/p&gt;

&lt;p&gt;Related StackEndBox guide: &lt;a href="https://dev.to/stackendbox/a-quote-is-not-a-reservation-design-the-booking-handoff-before-automating-it-fif"&gt;Design the handoff from quote to booking&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For help implementing these checks around your existing tools, see &lt;a href="https://www.stackendbox.com/services/ai-automation-services?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=crm_workflow&amp;amp;utm_content=quote_reminder_checks" rel="noopener noreferrer"&gt;StackEndBox’s CRM and workflow automation services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>productivity</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Two systems can edit a CRM field. Who wins?</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Wed, 04 Nov 2026 12:32:51 +0000</pubDate>
      <link>https://dev.to/stackendbox/two-systems-can-edit-a-crm-field-who-wins-j75</link>
      <guid>https://dev.to/stackendbox/two-systems-can-edit-a-crm-field-who-wins-j75</guid>
      <description>&lt;p&gt;A sales representative corrects a telephone number in the CRM. An integration receives an older form submission and writes the old number back. Both systems completed their requests, but the useful correction disappeared.&lt;/p&gt;

&lt;p&gt;This is a synthetic example. The design below is a proposal for reviewing field ownership, not a tested connector or a client result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each field an authority rule
&lt;/h2&gt;

&lt;p&gt;Before implementing two-way synchronisation, write down which system may change each field, under which conditions, and what should happen when the systems disagree.&lt;/p&gt;

&lt;p&gt;A small agreement might look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Proposed authority&lt;/th&gt;
&lt;th&gt;Conflict action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original enquiry source&lt;/td&gt;
&lt;td&gt;Source event recorded at first receipt&lt;/td&gt;
&lt;td&gt;Preserve the original and record later touchpoints separately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telephone number&lt;/td&gt;
&lt;td&gt;CRM after a person verifies a correction&lt;/td&gt;
&lt;td&gt;Send a conflicting incoming value for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assigned salesperson&lt;/td&gt;
&lt;td&gt;CRM routing process and authorised operators&lt;/td&gt;
&lt;td&gt;An enrichment job must not overwrite it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing permission&lt;/td&gt;
&lt;td&gt;Approved consent process&lt;/td&gt;
&lt;td&gt;An ordinary contact update must not change it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are example rules, not universal defaults. The business must approve the meaning of each field. Marketing permissions need a separately reviewed process appropriate to the jurisdiction and actual systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  “Newest” needs a definition
&lt;/h2&gt;

&lt;p&gt;The latest arrival is not necessarily the latest business change. A delayed event can arrive after a more recent correction. A timestamp also needs a defined source and meaning.&lt;/p&gt;

&lt;p&gt;Record separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the business change occurred, if the source reliably supplies that information.&lt;/li&gt;
&lt;li&gt;When the integration observed the event.&lt;/li&gt;
&lt;li&gt;Which version of the destination record was read before a proposed update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the source supplies no reliable version or change time, preserve that uncertainty. Don’t silently invent an ordering rule from the arrival time.&lt;/p&gt;

&lt;p&gt;Before a write, check whether the destination changed since it was read. Use the platform’s supported conditional-update or version mechanism where available. If it offers no suitable mechanism, explain the limitation and choose an appropriate review or single-writer approach. A read followed by an unconditional write does not itself prevent a concurrent edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define empty values separately
&lt;/h2&gt;

&lt;p&gt;A missing field, an empty string and an explicit instruction to clear a value can mean different things. An update contract should distinguish them.&lt;/p&gt;

&lt;p&gt;For example, a form that doesn’t ask for a telephone number should not automatically erase a verified number already held in the CRM. An authorised deletion request needs its own explicit handling and audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make conflicts visible to an operator
&lt;/h2&gt;

&lt;p&gt;A useful conflict record identifies the field, the proposed value, the current authoritative value, the source event, and the reason the update was held. Restrict access and avoid copying unnecessary personal data into chat alerts.&lt;/p&gt;

&lt;p&gt;Give the operator a clear decision, with an inspectable history. Reprocessing the same event should preserve that decision rather than repeatedly creating a new unresolved task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agree acceptance tests before enabling writes
&lt;/h2&gt;

&lt;p&gt;Use synthetic records in an authorised test environment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A delayed form event arrives after a verified CRM correction. The agreed authoritative value remains intact, and the conflict is inspectable.&lt;/li&gt;
&lt;li&gt;A contact update omits a field. The existing value follows the approved missing-value rule.&lt;/li&gt;
&lt;li&gt;The destination changes between reading and writing. The update is rejected, reconciled or held according to the supported concurrency design.&lt;/li&gt;
&lt;li&gt;The same event is delivered again. It produces no extra intended business change or duplicate review task.&lt;/li&gt;
&lt;li&gt;A source changes a marketing field outside the consent process. The ordinary synchronisation path does not treat that as permission to change consent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are expected behaviours to verify, not reported test results.&lt;/p&gt;

&lt;p&gt;Start with one workflow and a few fields. An ownership agreement small enough for an operator to understand is more useful than connecting every editable field and discovering the conflict policy later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written for StackEndBox’s engineering publication. StackEndBox offers &lt;a href="https://www.stackendbox.com/services/ai-automation-services" rel="noopener noreferrer"&gt;CRM and workflow integration scoping&lt;/a&gt;. Supported systems, evidence, delivery capacity and commercial terms are confirmed for each engagement. This article was prepared with AI assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Your webhook never arrived. A failed-run alert cannot tell you that.</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:14:47 +0000</pubDate>
      <link>https://dev.to/stackendbox/your-webhook-never-arrived-a-failed-run-alert-cannot-tell-you-that-2i2j</link>
      <guid>https://dev.to/stackendbox/your-webhook-never-arrived-a-failed-run-alert-cannot-tell-you-that-2i2j</guid>
      <description>&lt;p&gt;A CRM workflow can show no failed executions while enquiries are missing. If an event never reaches the workflow, there may be no execution to inspect.&lt;/p&gt;

&lt;p&gt;This article describes a proposed diagnostic design with synthetic examples. It is not a client case study, a tested connector, or a claim about a particular business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate three pieces of evidence
&lt;/h2&gt;

&lt;p&gt;Treat these as separate questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Was an event created at the source?&lt;/strong&gt; Use the source's supported event history, delivery records or an authorised API, where available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did the receiver accept it?&lt;/strong&gt; Inspect the receiving webhook's records and queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did the intended business action finish?&lt;/strong&gt; Verify the destination record and its assigned owner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An HTTP response from the receiver does not, by itself, prove that the final CRM action finished. Make's documentation distinguishes acceptance into a webhook queue from later processing. Its webhook logs expose the request and response for investigation. &lt;a href="https://help.make.com/webhooks" rel="noopener noreferrer"&gt;Make webhook documentation&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep execution alerts too. n8n supports an error workflow for execution failures, with different error information for trigger-related failures. That helps investigate reported failures. It cannot establish that every expected external event arrived. &lt;a href="https://docs.n8n.io/flow-logic/error-handling/" rel="noopener noreferrer"&gt;n8n error-handling documentation&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the business acceptance rule first
&lt;/h2&gt;

&lt;p&gt;For a simple enquiry workflow, an acceptance rule might be:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Every eligible source enquiry has a destination record, a responsible person and an inspectable processing result within the agreed time window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Define eligible, responsible and the time window with the business. A spam submission, an opted-out contact and a genuine sales enquiry should not automatically receive the same treatment.&lt;/p&gt;

&lt;p&gt;Use a stable event identifier when the source provides one. Record its relationship to the destination record. Avoid copying message bodies, access tokens or unnecessary personal data into routine alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small reconciliation record
&lt;/h2&gt;

&lt;p&gt;A useful record can contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source event ID and observed time.&lt;/li&gt;
&lt;li&gt;Whether receipt is confirmed, missing or unknown.&lt;/li&gt;
&lt;li&gt;Destination record ID, if verified.&lt;/li&gt;
&lt;li&gt;Current state and next review time.&lt;/li&gt;
&lt;li&gt;Responsible operator and recovery decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A timeout should stay unknown until evidence resolves it. If the source offers no supported event history or read API, say so. You may need a documented manual check rather than an automated completeness claim.&lt;/p&gt;

&lt;p&gt;The reconciliation check also needs its own freshness signal. Running it only inside the same webhook-triggered workflow would miss the same events when that trigger stops arriving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four synthetic acceptance tests
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Expected evidence&lt;/th&gt;
&lt;th&gt;Operator action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source event exists, receiver has no record&lt;/td&gt;
&lt;td&gt;Receipt missing after the agreed allowance&lt;/td&gt;
&lt;td&gt;Check delivery configuration and source delivery evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receiver accepted the event, CRM has no record&lt;/td&gt;
&lt;td&gt;Queue or processing still pending, or a recorded downstream error&lt;/td&gt;
&lt;td&gt;Investigate the specific stage before replaying&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same source event arrives twice&lt;/td&gt;
&lt;td&gt;One intended business outcome, with both deliveries accounted for&lt;/td&gt;
&lt;td&gt;Use the agreed deduplication rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The CRM action finished but confirmation was lost&lt;/td&gt;
&lt;td&gt;Existing destination record reconciled to the source event&lt;/td&gt;
&lt;td&gt;Confirm the result before another write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are test expectations, not results from an executed demonstration. Only use synthetic data and authorised systems when testing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one handoff
&lt;/h2&gt;

&lt;p&gt;Before buying a CRM replacement, map one enquiry from its source to an accountable owner. Check the existing platform's supported logs, queue and connectors first. Then agree what evidence is sufficient to close an exception and who handles it when the automation cannot.&lt;/p&gt;

&lt;p&gt;A useful handover lets an operator answer: which enquiry is unresolved, what is known, and what action is safe next?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written for StackEndBox's engineering publication. StackEndBox offers &lt;a href="https://www.stackendbox.com/services/ai-automation-services" rel="noopener noreferrer"&gt;CRM and workflow integration scoping&lt;/a&gt;. Supported systems, delivery capacity, scope and commercial terms must be confirmed for each project.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A quote is not a reservation: design the booking handoff before automating it</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:21:23 +0000</pubDate>
      <link>https://dev.to/stackendbox/a-quote-is-not-a-reservation-design-the-booking-handoff-before-automating-it-fif</link>
      <guid>https://dev.to/stackendbox/a-quote-is-not-a-reservation-design-the-booking-handoff-before-automating-it-fif</guid>
      <description>&lt;p&gt;A customer accepts a quote for an event rental. The automation sends “Your booking is confirmed.” Someone then discovers that another customer has already reserved the same items.&lt;/p&gt;

&lt;p&gt;The messages worked. The business decision was wrong.&lt;/p&gt;

&lt;p&gt;This is an illustrative design problem, not a StackEndBox client case study. The same boundary appears in equipment hire, appointments, transport and other services with limited capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what acceptance actually means
&lt;/h2&gt;

&lt;p&gt;A quote can describe a proposed service without reserving the resources needed to deliver it. Before connecting forms, a CRM and a calendar, ask the business owner:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does sending a quote create a hold, or can several customers receive quotes for the same capacity?&lt;/li&gt;
&lt;li&gt;How long does a hold last, and who can extend it?&lt;/li&gt;
&lt;li&gt;What confirms a booking: approval, a deposit, a signed agreement, or a combination?&lt;/li&gt;
&lt;li&gt;Who resolves a conflict when a customer accepts an expired quote?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those answers determine the workflow. An AI-generated reply must not silently decide them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the business states explicitly
&lt;/h2&gt;

&lt;p&gt;One possible model is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Request received → Quote issued → Hold active → Booking confirmed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;A hold can also expire or be released. A quote can be revised or declined. This is a proposed model, not a requirement to add all these states to every business.&lt;/p&gt;

&lt;p&gt;Each transition should have an owner and evidence. A confirmation record might retain the request ID, accepted quote revision, resource and time interval, expiry rule, approving person, and the booking system's reference.&lt;/p&gt;

&lt;p&gt;If the quote changes, the previous acceptance should not silently approve a different price, date or item list. Preserve the revision the customer actually accepted and decide whether fresh acceptance is required.&lt;/p&gt;

&lt;h2&gt;
  
  
  An availability check does not create a hold
&lt;/h2&gt;

&lt;p&gt;Google Calendar's &lt;a href="https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query" rel="noopener noreferrer"&gt;Freebusy query&lt;/a&gt; returns availability information. &lt;a href="https://developers.google.com/workspace/calendar/api/v3/reference/events/insert" rel="noopener noreferrer"&gt;Creating an event&lt;/a&gt; is a separate operation. A successful availability response alone is therefore insufficient evidence that a reservation exists.&lt;/p&gt;

&lt;p&gt;Two requests can both observe availability before either commits a booking. Choose an authoritative reservation system and enforce the capacity rule when the reservation is recorded. A preliminary screen check cannot replace that decision.&lt;/p&gt;

&lt;p&gt;For a custom database, PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-EXCLUSION" rel="noopener noreferrer"&gt;constraint documentation&lt;/a&gt; explains exclusion constraints, which can help prevent conflicting ranges. The appropriate rule still depends on resource identity, quantity, time zones, buffers and which states consume capacity. A unique booking ID alone does not prevent two different bookings from overlapping.&lt;/p&gt;

&lt;p&gt;If staff can reserve the same resource through another channel that bypasses this authority, the protection is incomplete. Keep manual coordination or integrate that channel before promising automatic confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the booking decision separate from its notifications
&lt;/h2&gt;

&lt;p&gt;Record the accepted reservation before sending a confirmation. If sending fails, the operator should see “booking recorded, notification pending,” with a safe recovery action.&lt;/p&gt;

&lt;p&gt;If the reservation request times out, do not automatically tell the customer it failed or submit another booking. Look up the original request in the authoritative system and reconcile the outcome. Keep an unresolved state visible when it cannot yet be established.&lt;/p&gt;

&lt;p&gt;A calendar entry may be a downstream copy rather than the capacity authority. Document that distinction. A delayed calendar sync should not cause staff to recreate a reservation that already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the exceptions before handing it over
&lt;/h2&gt;

&lt;p&gt;These are proposed acceptance checks, not results from a completed integration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Evidence to require&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Two customers accept the same remaining capacity&lt;/td&gt;
&lt;td&gt;The capacity rule is enforced at reservation time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A customer accepts after the hold expires&lt;/td&gt;
&lt;td&gt;A visible review or re-availability step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staff revise a quote after it was sent&lt;/td&gt;
&lt;td&gt;Acceptance remains tied to the correct revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The booking succeeds but the notification fails&lt;/td&gt;
&lt;td&gt;One booking, with notification recovery tracked separately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A request times out&lt;/td&gt;
&lt;td&gt;Reconciliation against the original request before another write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A staff member books through a second channel&lt;/td&gt;
&lt;td&gt;A shared authority or an explicit manual conflict check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with one resource type and one agreed booking path. Preserve working tools when they already enforce the rule. The handover should show staff where to find a booking, release a hold and resolve an uncertain result.&lt;/p&gt;

&lt;p&gt;StackEndBox's &lt;a href="https://www.stackendbox.com/services/ai-automation-services?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=quote_booking_boundary"&gt;lead-handling and CRM automation scope&lt;/a&gt; describes how to define a bounded business handoff before agreeing implementation. No live customer systems were connected for this article.&lt;/p&gt;

&lt;p&gt;Muhammad Abdullah is co-founder of StackEndBox. This draft was generated with AI assistance and requires editorial review before publication.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>automation</category>
    </item>
    <item>
      <title>CRM CSV Imports: A Preview Is Not a Rollback Plan</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Tue, 03 Nov 2026 07:34:19 +0000</pubDate>
      <link>https://dev.to/stackendbox/crm-csv-imports-a-preview-is-not-a-rollback-plan-1417</link>
      <guid>https://dev.to/stackendbox/crm-csv-imports-a-preview-is-not-a-rollback-plan-1417</guid>
      <description>&lt;p&gt;A CSV import can report “120 rows updated” and still overwrite the wrong customer records.&lt;/p&gt;

&lt;p&gt;The difficult part is deciding what a row means when a customer already exists, a field is blank, or someone changes the CRM after the preview. Parsing the file is only the first boundary.&lt;/p&gt;

&lt;p&gt;Consider this fictional import into a small service company's CRM:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;External customer ID&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Phone&lt;/th&gt;
&lt;th&gt;Assigned owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C-104&lt;/td&gt;
&lt;td&gt;Northfield Repairs&lt;/td&gt;
&lt;td&gt;empty cell&lt;/td&gt;
&lt;td&gt;Sam&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C-104&lt;/td&gt;
&lt;td&gt;Northfield Repairs Ltd&lt;/td&gt;
&lt;td&gt;555-0100&lt;/td&gt;
&lt;td&gt;blank&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C-205&lt;/td&gt;
&lt;td&gt;Harbor Studio&lt;/td&gt;
&lt;td&gt;555-0101&lt;/td&gt;
&lt;td&gt;Ines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The identifiers and businesses are illustrative. The two C-104 rows conflict before the importer reaches the database. A generic “upsert every row” instruction hides that decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what a blank cell means
&lt;/h2&gt;

&lt;p&gt;A blank value might mean “leave the existing value unchanged,” “clear this field,” or “the exporter didn't include it.” Those are different operations.&lt;/p&gt;

&lt;p&gt;Represent the choice explicitly in the import mapping. If clearing is supported, make it distinguishable from an omitted field. Show the consequence in the preview: retained, replaced or cleared. Do the same for defaults such as the assigned owner.&lt;/p&gt;

&lt;p&gt;Avoid using visual emptiness as a destructive instruction. A business user shouldn't have to know whether an empty string becomes SQL NULL to understand the outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose an identity rule before choosing an upsert
&lt;/h2&gt;

&lt;p&gt;Use a stable identifier whose scope is understood, such as the source-system customer ID within a particular account. A name is usually an unsafe merge key. An email address can also change or be shared.&lt;/p&gt;

&lt;p&gt;PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/sql-insert.html" rel="noopener noreferrer"&gt;INSERT documentation&lt;/a&gt; describes how ON CONFLICT uses a conflict target and can either skip or update a conflicting row. That machinery doesn't decide whether two records represent the same customer. The application must establish the identity rule and permitted updates.&lt;/p&gt;

&lt;p&gt;Detect duplicate keys inside the incoming file as a separate validation step. An operator should see both source row numbers and a proposed resolution. Silently choosing whichever row happened to run last makes the result depend on processing order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind approval to a specific import plan
&lt;/h2&gt;

&lt;p&gt;Store an import ID, the source file's fingerprint, mapping version, normalization rules and validation results. The preview should describe the exact batch awaiting approval.&lt;/p&gt;

&lt;p&gt;If the file or mapping changes, require a new preview. If existing CRM records can change while the operator reviews the file, define a freshness rule. For example, compare the reviewed record version when applying each update and hold a row whose current version differs.&lt;/p&gt;

&lt;p&gt;A file fingerprint alone cannot detect changes in the destination database. The approval and commit checks need to cover both sides of the proposed change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the transaction boundary visible
&lt;/h2&gt;

&lt;p&gt;A single transaction can provide an all-or-nothing database change for the work inside it. PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/tutorial-transactions.html" rel="noopener noreferrer"&gt;transaction tutorial&lt;/a&gt; explains this atomicity. A rollback issued before commit cancels that transaction's changes.&lt;/p&gt;

&lt;p&gt;After a successful commit, “undo import” requires a separate design. Restoring a previous field value blindly could overwrite a legitimate edit made after the import. Keep enough change provenance to identify what the import actually wrote, and require conflict handling before reversing it.&lt;/p&gt;

&lt;p&gt;For larger jobs, a deliberately chunked import may be appropriate. Its interface should show committed, rejected and unprocessed rows separately. An interrupted batch is not equivalent to zero writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep external effects out of the guesswork
&lt;/h2&gt;

&lt;p&gt;Updating the CRM may trigger notifications, accounting synchronization or onboarding. A database rollback won't automatically retract an email that has already been sent.&lt;/p&gt;

&lt;p&gt;Record intended downstream work durably, dispatch it under a defined retry policy and keep its state separate from the row's import state. Retrying the import should not silently send a second welcome message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use an acceptance matrix before importing live data
&lt;/h2&gt;

&lt;p&gt;These are proposed test cases, not a claim that a particular importer has passed them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test input or event&lt;/th&gt;
&lt;th&gt;Expected evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Two incoming rows share an identity key&lt;/td&gt;
&lt;td&gt;Explicit conflict with both row numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A mapped phone field is blank&lt;/td&gt;
&lt;td&gt;The chosen retain-or-clear policy is shown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A record changes after preview&lt;/td&gt;
&lt;td&gt;Revalidation or a visible conflict hold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The operator repeats the same import request&lt;/td&gt;
&lt;td&gt;Existing batch status, without duplicate effects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Processing stops after one committed chunk&lt;/td&gt;
&lt;td&gt;Accurate partial-progress receipt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo meets a newer staff edit&lt;/td&gt;
&lt;td&gt;Manual resolution rather than a blind overwrite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A useful handover includes the approved mapping, identity policy, import receipt, rejected-row report and recovery procedure. That gives a nontechnical owner something concrete to inspect before trusting the next bulk change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect the import to the enquiry workflow
&lt;/h2&gt;

&lt;p&gt;An import replay and a form retry share a question: what has already been accepted? Our related guide explains &lt;a href="https://dev.to/stackendbox/your-form-needs-an-uncertain-state-not-just-success-and-failure-4ic2"&gt;how an enquiry form handles an uncertain response&lt;/a&gt;, including the limits of browser-only duplicate protection.&lt;/p&gt;

&lt;p&gt;For a business implementation, StackEndBox’s &lt;a href="https://www.stackendbox.com/services/ai-automation-services?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm_import_acceptance"&gt;lead-handling and CRM automation scope&lt;/a&gt; describes how to define one handoff, its owner, exceptions and recovery before agreeing a build.&lt;/p&gt;

&lt;p&gt;Muhammad Abdullah is co-founder of &lt;a href="https://www.stackendbox.com/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=crm_import_acceptance"&gt;StackEndBox&lt;/a&gt;, a software engineering company building web applications, custom software and integrations.&lt;/p&gt;

</description>
      <category>database</category>
      <category>testing</category>
    </item>
    <item>
      <title>The browser title was correct. The Open Graph title wasn’t.</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:21:44 +0000</pubDate>
      <link>https://dev.to/stackendbox/the-browser-title-was-correct-the-open-graph-title-wasnt-9i1</link>
      <guid>https://dev.to/stackendbox/the-browser-title-was-correct-the-open-graph-title-wasnt-9i1</guid>
      <description>&lt;p&gt;On 1 November 2026, an audit of the &lt;a href="https://www.stackendbox.com/" rel="noopener noreferrer"&gt;StackEndBox homepage&lt;/a&gt; found a small inconsistency that a browser-tab check had missed.&lt;/p&gt;

&lt;p&gt;The HTML title already contained the company name. The Open Graph and Twitter titles still said “We design and build the software your business needs.” A separate page extractor returned that generic sentence as its page label. Its output matched the Open Graph field, while the browser tab remained correctly branded.&lt;/p&gt;

&lt;p&gt;Both observations were accurate. They described different title sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three fields, two answers
&lt;/h2&gt;

&lt;p&gt;The initial page contained this combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;StackEndBox | Product Engineering, AI &lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt; Custom Software&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"We design and build the software your business needs."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"We design and build the software your business needs."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The release aligned the two share titles with the existing HTML title. It preserved the heading, design, canonical URL, robots directive and company schema.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTML title&lt;/td&gt;
&lt;td&gt;Branded&lt;/td&gt;
&lt;td&gt;Branded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Graph title&lt;/td&gt;
&lt;td&gt;Generic sentence&lt;/td&gt;
&lt;td&gt;Same branded title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Twitter title&lt;/td&gt;
&lt;td&gt;Generic sentence&lt;/td&gt;
&lt;td&gt;Same branded title&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a useful homepage policy when every surface should identify the same company. Other pages can legitimately use different social copy. Agree on a per-page expectation before turning consistency into a release gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why checking only document.title misses the problem
&lt;/h2&gt;

&lt;p&gt;A test that asserts &lt;code&gt;document.title&lt;/code&gt; will pass for the initial page above. A test that uses &lt;code&gt;querySelector&lt;/code&gt; for each metadata field can also overlook duplicate tags because it returns only the first match.&lt;/p&gt;

&lt;p&gt;Collect every matching tag and keep the arrays intact:&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="nx"&gt;snapshot&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:title&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta[property="og:title"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;twitter:title&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta[name="twitter:title"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This collection is read-only. It assumes an existing Playwright-compatible page handle and should run after the application has reached its rendered-ready state. Choose that state for your application instead of adding an arbitrary delay.&lt;/p&gt;

&lt;p&gt;The arrays make four separate findings visible: a missing field, duplicate fields, an empty value and a value that differs from the reviewed expectation. DOM values have already decoded HTML entities, so compare the rendered ampersand with an ampersand rather than the source spelling &lt;code&gt;&amp;amp;amp;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A runnable contract check
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://gitlab.com/-/snippets/6074081" rel="noopener noreferrer"&gt;public audit module and instructions&lt;/a&gt; contain a dependency-free function and eight synthetic example cases. The function accepts the snapshot above and an expected title:&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="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;auditTitleSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;StackEndBox | Product Engineering, AI &amp;amp; Custom Software&lt;/span&gt;&lt;span class="dl"&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;result&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="nx"&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&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;The examples cover aligned values, generic Open Graph copy, missing Twitter metadata, duplicate Open Graph tags, an empty title, three generic fields, surrounding whitespace and a malformed snapshot. All eight passed when executed in a JavaScript isolate. The public file includes the examples so readers can rerun them.&lt;/p&gt;

&lt;p&gt;Those examples test the comparison contract. Collecting the correct page and waiting for its rendered state remain responsibilities of the browser test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the artifact that actually ships
&lt;/h2&gt;

&lt;p&gt;This homepage uses a preserved static release. Editing future application source alone would leave the served artifact unchanged. The correction therefore covered the served response and future source, with validation around the known metadata structure.&lt;/p&gt;

&lt;p&gt;The production change went through source checks, canonical release checks, provider preflight and hosted staging before publication. After publication, a browser reload showed exactly one of each title field with the branded value. A fresh independent fetch then returned the branded label too.&lt;/p&gt;

&lt;p&gt;For another application, the implementation may simply be a shared metadata object. The acceptance question stays the same: does the response and rendered page that readers receive contain the reviewed values?&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep search outcomes separate
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developers.google.com/search/docs/appearance/title-link" rel="noopener noreferrer"&gt;Google documents Open Graph titles among the sources it can use for search-result title links&lt;/a&gt;. That makes the field worth checking alongside the HTML title and visible heading. Google chooses the resulting title automatically and has to recrawl and reprocess updates.&lt;/p&gt;

&lt;p&gt;The metadata correction established a consistent page. It did not establish a ranking improvement. The ordinary company-name query still received a spelling substitution immediately after deployment. A title audit cannot measure indexability, Google’s interpretation of a brand query or search position.&lt;/p&gt;

&lt;p&gt;A practical release checklist is therefore two-part: verify the page contract you control, then observe search behaviour separately. Passing one check should never silently mark the other complete.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>seo</category>
    </item>
    <item>
      <title>AI Agent Approval: Bind the Decision to the Action That Will Execute</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Fri, 30 Oct 2026 16:01:45 +0000</pubDate>
      <link>https://dev.to/stackendbox/ai-agent-approval-bind-the-decision-to-the-action-that-will-execute-49m2</link>
      <guid>https://dev.to/stackendbox/ai-agent-approval-bind-the-decision-to-the-action-that-will-execute-49m2</guid>
      <description>&lt;p&gt;An operator reviews a proposed refund for order-17. Before execution, an agent changes the target to order-18. A boolean approved flag still says yes. The action that reaches the provider is now different from the one the operator reviewed.&lt;/p&gt;

&lt;p&gt;The useful control is a server-side approval record bound to the material action fields. The following Python fixture makes that decision independently testable. It uses synthetic orders and makes no network calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the object that approval covers
&lt;/h2&gt;

&lt;p&gt;Choose a small explicit schema. This example binds a tenant, operation, target order, integer amount in minor units, currency and request identity. Unknown fields are rejected, so a new execution parameter cannot silently escape the comparison.&lt;/p&gt;

&lt;p&gt;Only USD refunds are supported by this example. That is a deliberately narrow fixture scope, not a universal currency or financial policy. A real adapter needs its own schema, provider validation, account permissions and business rules.&lt;/p&gt;

&lt;p&gt;The digest below is a content fingerprint. It is not an authentication token, digital signature or proof that an operator approved anything. The approval record must be created by a trusted server after the real review flow and stored where an agent or browser cannot replace it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the decision outside model output
&lt;/h2&gt;

&lt;p&gt;The predicate checks the current tenant, expiry, the reviewer's current permission and the action fingerprint. It reads identity and approval state from trusted server-side inputs. Never expose this function as an endpoint that accepts a caller-supplied Approval object or a caller-selected permission result.&lt;/p&gt;

&lt;p&gt;OWASP's &lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;excessive agency guidance&lt;/a&gt; separates excessive functionality, permissions and autonomy. A prompt asking the model to be careful cannot replace an application check on the action that will actually execute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sha256&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unittest&lt;/span&gt;


&lt;span class="n"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&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;fingerprint&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;set&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="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unexpected action shape&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsupported action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Use a positive integer amount in minor units&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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;name&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&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;name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing action identity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&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;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                         &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_nan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Approval&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="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;action_digest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;permits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval&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="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reviewer_allowed&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Approval and identity inputs must come from trusted server-side systems.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Approval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;current_tenant&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;approval&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;reviewer_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_tenant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_tenant&lt;/span&gt;
                &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;fingerprint&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_digest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The expiry rule is explicit: approval is invalid at the expiry instant. Amounts use integers, and Python booleans are rejected even though bool is an int subclass. Reordering dictionary keys preserves the fingerprint because serialization sorts them. Changing a material value changes the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the mutation cases
&lt;/h2&gt;

&lt;p&gt;Save both code blocks in one file, with the following tests beneath the predicate, then run &lt;code&gt;python approval_fixture.py -v&lt;/code&gt;. The standard-library fixture passed all nine checks in the local run prepared for this article.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApprovalTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TestCase&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;setUp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&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="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order-17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;amount_minor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;req-9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reviewer-2&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;self&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="mi"&gt;200&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;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;reviewer_allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;reviewer&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="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;args&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="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;permits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;approval&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;args&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;test_identical_action_with_reordered_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;items&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;test_changed_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2600&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;test_changed_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order-18&lt;/span&gt;&lt;span class="sh"&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;test_changed_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-b&lt;/span&gt;&lt;span class="sh"&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;test_expiry_boundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&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;test_reviewer_permission_revoked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;reviewer_allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&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="bp"&gt;False&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;test_unknown_field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elsewhere&lt;/span&gt;&lt;span class="sh"&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;test_boolean_amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&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;test_missing_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertFalse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;permits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&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;current_tenant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                 &lt;span class="n"&gt;reviewer_allowed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&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="bp"&gt;True&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;unittest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The successful case proves that the chosen predicate accepts the unchanged action. The denied cases cover a changed amount, order or tenant, the expiry boundary, a revoked reviewer, an unknown field, a boolean amount and a missing approval.&lt;/p&gt;

&lt;p&gt;These tests establish the fixture's decisions. They do not establish that a deployed identity provider, database, agent framework or payment adapter supplies trustworthy inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval validity and replay protection need separate checks
&lt;/h2&gt;

&lt;p&gt;Calling permits twice can return True twice. This predicate does not consume an approval, reserve a request or prevent two workers from executing the same action.&lt;/p&gt;

&lt;p&gt;The executor needs durable request state and a concurrency-safe transition into execution. Keep the approved snapshot unchanged and pass the same validated fields to the adapter. Rebuilding a provider request from fresh model output after checking the digest breaks that connection.&lt;/p&gt;

&lt;p&gt;If an approval includes a business-state version, check that version at the appropriate transactional boundary too. A snapshot can remain byte-for-byte identical while the underlying order or account changes. Authorization and state checks need to remain valid at execution, not merely earlier in the conversation.&lt;/p&gt;

&lt;p&gt;Use a provider's supported idempotency mechanism where appropriate. &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe's idempotency documentation&lt;/a&gt; describes its request-key behaviour and parameter comparisons. Provider idempotency helps reconcile repeated requests, but it does not prove that a human authorised their contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat a timeout as an unresolved result
&lt;/h2&gt;

&lt;p&gt;If execution times out after a request may have reached the provider, inspect the durable attempt and provider-supported status before issuing another consequential request. Do not turn an unknown result into a new approved action simply to clear the queue.&lt;/p&gt;

&lt;p&gt;Keep audit evidence for the reviewed snapshot, approval identity, validation decision, execution attempt and confirmed outcome. Minimise private payloads in logs and restrict access to operational records.&lt;/p&gt;

&lt;p&gt;The next useful integration test is a controlled adapter that pauses between approval and execution. Mutate the proposed action, revoke the reviewer's permission or race two workers. Then inspect both the application decision and the external side effect. A pure predicate and its tests are the starting point for that proof.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This article and its educational fixture were prepared with AI assistance. The nine local tests use synthetic data.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>RAG Access Control: Test the Documents a User Must Never Retrieve</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Fri, 30 Oct 2026 11:39:45 +0000</pubDate>
      <link>https://dev.to/stackendbox/rag-access-control-test-the-documents-a-user-must-never-retrieve-pc3</link>
      <guid>https://dev.to/stackendbox/rag-access-control-test-the-documents-a-user-must-never-retrieve-pc3</guid>
      <description>&lt;p&gt;A document assistant can produce a correct answer and still fail its most important acceptance check. The answer may use a document that the person asking wasn't allowed to read.&lt;/p&gt;

&lt;p&gt;For a multi-tenant RAG application, test the retrieval boundary before judging how fluent the answer sounds. Start with two tenants, an intentionally tempting document and a user who must never receive it. The first useful result is evidence that the restricted text never enters that user's answer context.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG access control starts before the prompt
&lt;/h2&gt;

&lt;p&gt;Consider a synthetic support application serving two companies. Both have a group called &lt;code&gt;sales&lt;/code&gt;, and both store a document about contract renewals. A filter that checks only the group name can mix their records. A filter that checks only the tenant can expose finance documents to colleagues who lack the corresponding permission.&lt;/p&gt;

&lt;p&gt;The application needs an authenticated identity and an explicit policy connecting that identity to the documents. A tenant or group identifier typed into a browser request isn't proof of membership. Resolve identity and permissions through the trusted server-side mechanism before constructing the search query.&lt;/p&gt;

&lt;p&gt;Microsoft documents a security-filter pattern for Azure AI Search that includes or excludes results using principal identifiers. Its documentation also warns that the filter strings don't authenticate the caller. Hiding a field from returned results doesn't itself enforce document access. These distinctions matter even when your retrieval stack uses a different provider.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://learn.microsoft.com/en-us/azure/search/search-security-trimming-for-azure-search" rel="noopener noreferrer"&gt;Azure AI Search security filters&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the permission rule independently testable
&lt;/h2&gt;

&lt;p&gt;The following small Python example expresses a chosen policy: a document must belong to the same tenant and share at least one permitted group. Missing identity, an empty group set and unclassified documents are denied. Public documents would need their own explicit policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Principal&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="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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;visible_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;documents&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;principal&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;doc&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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 is an educational result-filter predicate, not production authentication code. In a real RAG path, apply the authorization constraint in the retrieval layer wherever the backend supports it. Don't retrieve an unrestricted set into the model and ask the model to conceal the forbidden parts. Validate returned document permissions before assembling context as an additional application check.&lt;/p&gt;

&lt;p&gt;The synthetic fixture contains &lt;code&gt;a-sales&lt;/code&gt;, &lt;code&gt;b-sales&lt;/code&gt;, &lt;code&gt;a-finance&lt;/code&gt; and an unclassified document. A tenant-A sales principal receives only &lt;code&gt;a-sales&lt;/code&gt;. The accompanying local test run passed five checks covering that result, absent identity, revoked membership, an unrelated group and a missing tenant. Those tests prove the example's deterministic decisions. They don't establish that a deployed search service, identity provider or cache obeys the same policy.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://gitlab.com/-/snippets/6073529" rel="noopener noreferrer"&gt;runnable fixture and all five tests are public on GitLab&lt;/a&gt;. They use the Python standard library and synthetic documents. Save the file as &lt;code&gt;rag_access_fixture.py&lt;/code&gt; and run &lt;code&gt;python rag_access_fixture.py -v&lt;/code&gt;. The public download was checked against the reviewed source and passed the same tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a denied-document evaluation set
&lt;/h2&gt;

&lt;p&gt;Put a unique synthetic marker inside a restricted document. Ask questions that would make that document highly relevant. Run the questions with an authorized identity, a same-tenant unauthorized identity and an identity from another tenant.&lt;/p&gt;

&lt;p&gt;Inspect the retrieved document IDs and the context actually supplied to generation. An answer that omits the marker isn't enough if the forbidden document still reached the model. Conversely, an empty retrieval result shouldn't be rewritten into a confident answer about the restricted material.&lt;/p&gt;

&lt;p&gt;Keep these checks separate from answer-quality evaluation. Relevance, groundedness and permission enforcement answer different questions. A high relevance score cannot compensate for a cross-tenant result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test permission changes and cached answers
&lt;/h2&gt;

&lt;p&gt;Repeat a question, remove the user's membership through the real permission mechanism, then ask again. Check whether the new request still retrieves the document or receives an old cached answer containing it.&lt;/p&gt;

&lt;p&gt;Choose a cache design that reflects the authorization scope and the application's permission-change requirements. A shared cache keyed only by question text is unsafe when different users can see different sources. Including a tenant identifier alone won't distinguish two roles inside that tenant.&lt;/p&gt;

&lt;p&gt;Document how permission changes reach the search index, what delay is possible and what the system does during that interval. If the application cannot enforce the required access semantics, narrow the pilot to approved material instead of presenting it as ready for confidential documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review citations, logs and failure responses
&lt;/h2&gt;

&lt;p&gt;A citation title can disclose information even when its document body is hidden. Apply the same access decisions to snippets, downloadable sources and citation routes. Check whether an operator's ordinary logs contain restricted text or complete prompts, and restrict or minimise that content according to the application's data requirements.&lt;/p&gt;

&lt;p&gt;When identity resolution or permission lookup fails, define a deliberate unavailable response. Falling back to an unrestricted query can turn an infrastructure failure into an access failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commission a permission-aware first milestone
&lt;/h2&gt;

&lt;p&gt;A useful RAG pilot can start with one approved collection, a small role matrix and agreed denial cases. Ask the provider to demonstrate permitted retrieval, forbidden retrieval, membership removal, citation access and failure behaviour before expanding the source set.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.stackendbox.com/services/ai-systems-agents#retrieval-development" rel="noopener noreferrer"&gt;StackEndBox's AI systems and retrieval scope&lt;/a&gt; provides context for that implementation discussion. The acceptance evidence should identify the tested identities, source versions, expected decisions and remaining limitations. The buyer should be able to inspect why the assistant could use each document in its answer.&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
    </item>
    <item>
      <title>Your form needs an uncertain state, not just success and failure</title>
      <dc:creator>Muhammad Abdullah</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:56:59 +0000</pubDate>
      <link>https://dev.to/stackendbox/your-form-needs-an-uncertain-state-not-just-success-and-failure-4ic2</link>
      <guid>https://dev.to/stackendbox/your-form-needs-an-uncertain-state-not-just-success-and-failure-4ic2</guid>
      <description>&lt;p&gt;Prepared with substantial AI assistance for StackEndBox. This article uses the company's public enquiry-controller code and synthetic local tests. It contains no customer payloads or claimed customer outcomes.&lt;/p&gt;

&lt;p&gt;A browser sends an enquiry. The server saves it. The response never reaches the browser. What should the interface say?&lt;/p&gt;

&lt;p&gt;“Failed, try again” sounds helpful, but it asserts something the browser doesn't know. If the endpoint doesn't provide an idempotency contract, another send can create a second enquiry. A disabled button prevents overlapping clicks in one page instance. It doesn't resolve a lost response or deduplicate work on the server.&lt;/p&gt;

&lt;p&gt;That distinction led to a small public form controller with four states: ready, sending, received and uncertain. The useful part is the boundary between the last two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the receipt before styling the success message
&lt;/h2&gt;

&lt;p&gt;For this particular endpoint contract, the controller accepts a response only when the HTTP response is successful, the JSON body says &lt;code&gt;ok: true&lt;/code&gt;, and a UUID-shaped reference is present. A UUID shape is a format check, not proof of authenticity. HTTPS, endpoint trust and server behavior still matter.&lt;/p&gt;

&lt;p&gt;MDN documents &lt;code&gt;Response.ok&lt;/code&gt; as an HTTP 200-299 check. It doesn't inspect your application's business result. Your client must interpret the agreed response body separately.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Response/ok" rel="noopener noreferrer"&gt;MDN Response.ok&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here is the public controller's key decision, with transport construction omitted:&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="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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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;body&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="k"&gt;if &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;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{8}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{12}&lt;/span&gt;&lt;span class="sr"&gt;$/i&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;received&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference&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;The receipt means this application acknowledged the request. It doesn't mean an email notification was delivered, a salesperson read the brief, or the enquiry became a qualified opportunity. Those are later observations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat ambiguity as a real state
&lt;/h2&gt;

&lt;p&gt;The controller enters &lt;code&gt;sending&lt;/code&gt; before awaiting the transport. Further submissions are ignored while the state differs from &lt;code&gt;ready&lt;/code&gt;. A valid receipt moves it to &lt;code&gt;received&lt;/code&gt;, which also prevents another send in the same controller instance.&lt;/p&gt;

&lt;p&gt;Rejected JSON parsing, a lost connection, an unsuccessful response with an unknown body, and a success-shaped body without its required reference all produce &lt;code&gt;uncertain&lt;/code&gt;. The page doesn't automatically resend. It can tell the user that receipt couldn't be confirmed and offer an alternative contact route without asserting that nothing was stored.&lt;/p&gt;

&lt;p&gt;This is deliberately conservative. It trades some convenient retries for avoiding unsupported claims about a non-idempotent collection endpoint. It is not an exactly-once guarantee and does not survive a reload as a durable deduplication mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only reopen submission for documented pre-acceptance errors
&lt;/h2&gt;

&lt;p&gt;The implementation recognizes four exact status/body pairs for validation, verification, size and rate-limit rejections. Those return it to &lt;code&gt;ready&lt;/code&gt; for a deliberate corrected attempt.&lt;/p&gt;

&lt;p&gt;That mapping belongs to this endpoint's contract. Don't copy it to a service whose &lt;code&gt;429&lt;/code&gt; can arrive after partial processing or whose error body has different semantics. If the client cannot establish that rejection occurred before acceptance, it should preserve the ambiguity.&lt;/p&gt;

&lt;p&gt;For endpoints supporting idempotency, use their actual key and retention rules instead. Stripe's API documentation, for example, defines specific behavior for repeated idempotency keys. That contract isn't automatically inherited by an unrelated contact form.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;Stripe idempotent requests&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the UI's claims, not just its happy path
&lt;/h2&gt;

&lt;p&gt;The existing synthetic test suite exercises concurrent clicks, a saved receipt, lost and malformed responses, success without a reference and deliberate correction after documented pre-acceptance errors. It also checks source categorization and the service-page enquiry paths. Seven tests passed in the local run for this article.&lt;/p&gt;

&lt;p&gt;The ambiguous-response test verifies that the state becomes &lt;code&gt;uncertain&lt;/code&gt; and a second attempt is ignored. It doesn't prove what a real server stored. The transport is a stub, so the test proves the client's decisions under chosen responses.&lt;/p&gt;

&lt;p&gt;Public implementation: &lt;a href="https://www.stackendbox.com/seo-assets/service-enquiry.js" rel="noopener noreferrer"&gt;https://www.stackendbox.com/seo-assets/service-enquiry.js&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep engineering acceptance separate from sales reporting
&lt;/h2&gt;

&lt;p&gt;A receipt counter can measure acknowledged submissions. Calling it “clients won” hides everything that follows: fit review, reply, scope, agreement and payment. Even a correctly implemented source category describes an observed referrer, not a proven first touch or AI recommendation.&lt;/p&gt;

&lt;p&gt;For a form review, start by writing down the evidence behind each message the user sees. Then ask what the interface does when that evidence is missing. The uncertain state often tells you more about the integration than another green success animation.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
