<?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</title>
    <description>The most recent home feed on DEV Community.</description>
    <link>https://dev.to</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/rss"/>
    <language>en</language>
    <item>
      <title>Tenant Boundaries: Object Storage for SaaS Thumbnail Resizing and Signed Downloads</title>
      <dc:creator>PerNilsson3147</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:22:57 +0000</pubDate>
      <link>https://dev.to/pernilsson3147/tenant-boundaries-object-storage-for-saas-thumbnail-resizing-and-signed-downloads-229p</link>
      <guid>https://dev.to/pernilsson3147/tenant-boundaries-object-storage-for-saas-thumbnail-resizing-and-signed-downloads-229p</guid>
      <description>&lt;p&gt;A customer-support attachment has one constraint that changes the storage decision: an agent from tenant A must never receive a usable object reference for tenant B. &lt;strong&gt;Short answer:&lt;/strong&gt; keep originals and generated thumbnails private in object storage, resize in a backend worker or dedicated image service, and create an expiring signed GET URL only after the application verifies the ticket's tenant. The bucket stores bytes cheaply; it shouldn't decide who may see a screenshot.&lt;/p&gt;

&lt;p&gt;This is an isolation problem first.&lt;/p&gt;

&lt;p&gt;The deceptively simple design is one public image location plus hard-to-guess keys. It fails the evaluation before provider selection even begins. A copied URL outlives the application session, a key is mistaken for permission, and the support API has no final authorization checkpoint. Moving resize work into storage doesn't repair that boundary. The useful experiment is narrower: can every read be traced from an authenticated agent, through a tenant-owned database row, to one short-lived URL for one derived object?&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the isolation test before comparing storage
&lt;/h2&gt;

&lt;p&gt;For a support upload, I would write three invariants before opening a pricing page. First, object keys are locators, never credentials. Second, only the backend can turn a locator into a signed download. Third, the database owns the relationship among tenant, ticket, original, and thumbnail variant. If any candidate makes those rules awkward, its attractive unit cost is beside the point.&lt;/p&gt;

&lt;p&gt;Use predictable prefixes because they make processing and cleanup legible: &lt;code&gt;originals/{tenantId}/{attachmentId}/source&lt;/code&gt; for the private upload and &lt;code&gt;thumbs/{tenantId}/{attachmentId}/{variant}&lt;/code&gt; for derived images. Predictability is fine. Authorization still comes from the database row and the signed URL, not from the difficulty of guessing the string.&lt;/p&gt;

&lt;p&gt;The metadata boundary matters more than it first appears. Object listing supports prefix filtering, but there is no server-side metadata search. Store width, height, media type, variant, source key, derived key, and processing state in the application database. Then the question “does this ticket already have a small thumbnail?” is an indexed application query rather than a bucket scan. It also gives the API one place to compare &lt;code&gt;attachment.tenantId&lt;/code&gt; with &lt;code&gt;session.tenantId&lt;/code&gt; before any URL exists.&lt;/p&gt;

&lt;p&gt;No shortcut there.&lt;/p&gt;

&lt;p&gt;Resizing belongs behind that same boundary. A trusted worker reads the original, decodes it, creates the requested variant, and uploads the result with a presigned PUT or a private authenticated write. The viewing path uses a presigned GET. The returned presigned URL is its own temporary credential, so the browser must not attach the Infrai &lt;code&gt;Authorization&lt;/code&gt; header to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a SaaS isolate private image originals, thumbnail resizing, and signed downloads?
&lt;/h2&gt;

&lt;p&gt;Make signing the last step, not a convenience helper scattered across controllers. The following TypeScript example keeps the storage provider behind a tiny interface and makes the authorization order executable. It uses &lt;code&gt;403&lt;/code&gt; for a cross-tenant request and refuses to sign anything until the attachment row matches both the ticket and tenant. The 10-minute lifetime is an application policy in this example — tune it to the time an agent actually needs, not to the lifetime of the ticket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Attachment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;thumbnailKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;DownloadSigner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;signPrivateGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;apiBaseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;INFRAI_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;apiBaseUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;apiKey&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INFRAI_BASE_URL and INFRAI_API_KEY are required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;milliseconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;infraiSigner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DownloadSigner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;signPrivateGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${apiBaseUrl}${path}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nc"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Bearer ${apiKey}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;429&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="sb"&gt;`Presign request failed (${response.status}): ${await response.text()}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Retry-After"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Presign request remained rate limited after four attempts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HttpError&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;issueThumbnailDownload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attachmentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;findAttachment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Attachment&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DownloadSigner&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;attachment&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;findAttachment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attachmentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;attachment&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ticketId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;ticketId&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;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Attachment not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenantId&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;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Attachment belongs to another tenant"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnailKey&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;HttpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Thumbnail is not ready"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signPrivateGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"support-attachments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attachment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thumbnailKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiresInSeconds&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signer implementation can call the provider's verified presign operation, but the application contract stays the same. That's deliberate. A solo team can change storage routing without rewriting tenant checks, and tests can substitute a signer that records its input. One negative test should create an attachment for &lt;code&gt;tenant-b&lt;/code&gt;, invoke the function with a &lt;code&gt;tenant-a&lt;/code&gt; session, assert &lt;code&gt;403&lt;/code&gt;, and assert that &lt;code&gt;signPrivateGet&lt;/code&gt; was never called. Another should verify that a matching session signs only the stored thumbnail key—not a key accepted from a query parameter.&lt;/p&gt;

&lt;p&gt;The upload side needs the same discipline. A worker should derive a deterministic variant key from the attachment and transformation, then make retries idempotent so a rate-limit retry can't create duplicate effects. On HTTP &lt;code&gt;429&lt;/code&gt;, honor &lt;code&gt;Retry-After&lt;/code&gt; when present and back off exponentially. For other &lt;code&gt;4xx&lt;/code&gt; responses, surface the body because it carries the reason. These are client obligations, not storage-vendor differentiators, but forgetting them makes a nominally private design operationally noisy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compare providers only after the boundary is fixed
&lt;/h2&gt;

&lt;p&gt;Once the application owns authorization and metadata, provider choice becomes a test of missing controls and integration cost rather than a contest over a “cheapest” badge. I would run the same tenant-crossing test, signed-link expiry test, overwrite test, and large-upload retry test against each finalist. Public calculators can change; the boundary shouldn't.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why it reaches the shortlist&lt;/th&gt;
&lt;th&gt;What should decide against it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon S3&lt;/td&gt;
&lt;td&gt;Its documented multipart upload model gives a concrete baseline for large private originals&lt;/td&gt;
&lt;td&gt;Reject it if the account, SDK, and billing surface are more operational work than this product can justify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;It is a real alternative to evaluate for an object-storage-backed image path&lt;/td&gt;
&lt;td&gt;Keep S3 when the S3 controls your retention design needs matter more than reducing integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backblaze B2&lt;/td&gt;
&lt;td&gt;It is another real object-storage candidate for the same private-original workload&lt;/td&gt;
&lt;td&gt;Validate the exact regional, migration, and concurrency requirements before choosing it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Cloud Storage&lt;/td&gt;
&lt;td&gt;It belongs in the evaluation when the rest of the application already depends on that cloud&lt;/td&gt;
&lt;td&gt;Don't add a second cloud merely to make thumbnail storage look independent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai storage&lt;/td&gt;
&lt;td&gt;One plain REST contract covers storage alongside a broad set of backend modules, with one key and one bill&lt;/td&gt;
&lt;td&gt;Reject it when the capability limits below conflict with retention, public delivery, or regional requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The relevant Infrai advantage is breadth behind a small integration surface: 295 routes across 20 modules use one key and a consistent REST contract, so adding another backend capability is another endpoint rather than another installed SDK and credential set. Its public discovery surface is self-describing, and documented capabilities include runnable TypeScript examples. For a small support product, that can keep the integration layer narrow without tying application authorization to a storage-specific client. It isn't a reason to weaken the tenant test.&lt;/p&gt;

&lt;p&gt;This comparison is intentionally asymmetric. The supplied evidence supports a detailed statement about S3 multipart uploads and a detailed list of Infrai's storage boundaries; it doesn't support pretending that every provider has identical controls. I'm not sure which candidate wins for a globally distributed support team without its regions, object sizes, retention rules, and measured download latency. Those inputs would resolve the uncertainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does this private-thumbnail design stop working?
&lt;/h2&gt;

&lt;p&gt;The catch is public delivery. Infrai storage has no public or public-read ACL, and &lt;code&gt;public_url&lt;/code&gt; remains null, so it is not suitable for permanent public image URLs, an anonymous image host, or static-site assets. Stick with a provider and delivery architecture built for public access when a cacheable permanent URL is part of the product. Signed URLs are a feature here, not a substitute for every delivery model.&lt;/p&gt;

&lt;p&gt;Retention can also disqualify it. There is no object versioning or object lock, which means the storage layer doesn't provide a recoverable history after an accidental overwrite or WORM retention. A financial or regulated archive should use an external retention system or a provider with the required controls. There is no &lt;code&gt;If-Match&lt;/code&gt; conditional write either; when two resize jobs can race for the same key, coordinate them through a queue or database rather than assuming the last writer is acceptable.&lt;/p&gt;

&lt;p&gt;Browser-direct upload deserves a separate decision. Bucket data models expose CORS rules, but self-service CORS configuration is unavailable, so a browser PUT flow can't assume it can set the required origin policy. A backend upload or worker-mediated flow avoids that dependency. Lifecycle expiration has a minimum of one day rather than hours, multipart fragments have no automatic cleanup rule, and metadata can't be searched server-side. Cross-region automatic replication and cross-cloud bulk migration aren't included; vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.&lt;/p&gt;

&lt;p&gt;Those aren't footnotes. They are rejection criteria.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the experiment measure before you ship it?
&lt;/h2&gt;

&lt;p&gt;Start with isolation evidence: count denied cross-tenant requests, verify that the signer is never invoked after a failed ownership check, and test that an expired link no longer grants access. Then measure p95 time from accepted upload to thumbnail readiness, thumbnail download latency by the regions that support agents actually use, retry counts by status code, and the number of duplicate or overwritten variant keys. No benchmark numbers are assumed here; collect them from the real workload.&lt;/p&gt;

&lt;p&gt;Also rehearse deletion and recovery. Delete a ticket in a staging tenant, confirm that its database rows and both &lt;code&gt;originals/&lt;/code&gt; and &lt;code&gt;thumbs/&lt;/code&gt; prefixes follow the intended policy, and decide what an accidental overwrite means before it happens. If recoverability or immutable retention is mandatory, the earlier provider table should send you elsewhere.&lt;/p&gt;

&lt;p&gt;The final decision rule is compact: choose private object storage when the database can own tenant authorization and image metadata, resizing can run outside storage, and every download may expire. Choose Amazon S3 or another provider with the controls you require when versioning, object lock, public delivery, or cross-region behavior is non-negotiable. Consider Infrai when a consistent REST surface across several backend modules removes meaningful integration work and its storage boundaries fit the product. Cost still matters, but compare it after this design passes the isolation test.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/r2/api/s3/api/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/r2/api/s3/api/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.backblaze.com/docs/cloud-storage-s3-compatible-api" rel="noopener noreferrer"&gt;https://www.backblaze.com/docs/cloud-storage-s3-compatible-api&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/storage/docs" rel="noopener noreferrer"&gt;https://cloud.google.com/storage/docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>objectstorage</category>
      <category>imageprocessing</category>
      <category>security</category>
    </item>
    <item>
      <title>What Is Human-in-the-Loop AI? 15 Questions Answered</title>
      <dc:creator>Agentic Trainers</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:22:34 +0000</pubDate>
      <link>https://dev.to/agentictrainers/what-is-human-in-the-loop-ai-15-questions-answered-5114</link>
      <guid>https://dev.to/agentictrainers/what-is-human-in-the-loop-ai-15-questions-answered-5114</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://agentictrainers.com/blog/what-is-human-in-the-loop-ai-15-questions-answered" rel="noopener noreferrer"&gt;AgenticTrainers.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Human-in-the-loop (HITL) AI means trained people take part in training, checking or approving what an AI system does, instead of letting it run unattended. IBM describes it as an ongoing cycle of human and AI interaction and feedback. Done well, it is how companies get AI speed without AI-sized mistakes. Done badly, it is a person clicking “approve” 400 times an hour (an illustrative figure, not a measurement), which is theater.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Should You Listen to Me?
&lt;/h2&gt;

&lt;p&gt;SuperStaff, founded in 2009, is now in its 18th year of business. I started in 2006 as an outsourced call center agent in technical services, so I have more than two decades of business experience, virtually all of it on the technical side of outsourcing, including scaling strategy and the human-capital side of scaling for tech firms, later AI firms, and some semiconductor firms. I joined SuperStaff as CEO in 2019. Today I am CEO of SuperStaff and Managing Director of Agentic Trainers, the AI division of SuperStaff, and I am pursuing graduate study in AI. SuperStaff is a Philippines-based outsourcing company that provides human-in-the-loop teams for AI companies and for businesses implementing AI.&lt;/p&gt;

&lt;p&gt;We do not build the models. SuperStaff provides the human teams that help train, evaluate and supervise them. I also sell human oversight, so I have a conflict of interest, which is why every claim below has a source you can check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Takeaways
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;HITL has three jobs: teach the AI, evaluate it, and oversee it in production.&lt;/li&gt;
&lt;li&gt;Companies are legally responsible for what their AI says. A Canadian tribunal said so in 2024.&lt;/li&gt;
&lt;li&gt;A human in the loop is not automatically safe. Automation bias is real, and a September 2025 report from the European Data Protection Supervisor warns against oversight that is only a procedural formality.&lt;/li&gt;
&lt;li&gt;You do not need one human per agent. Pooled, risk-tiered supervision keeps the economics intact.&lt;/li&gt;
&lt;li&gt;The vendor that sells the agent should not be the only one checking it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. What Is Human-in-the-Loop AI?
&lt;/h2&gt;

&lt;p&gt;Human-in-the-loop AI is a setup in which people actively take part in running, supervising or making decisions about an AI system. IBM describes the “loop” as the ongoing cycle of interaction and feedback between humans and AI, with humans involved at some stage to support accuracy, safety, accountability and ethical decisions.&lt;/p&gt;

&lt;p&gt;Translated from consultant to human: the AI does the work, and a trained person catches what it gets wrong, decides what it should not decide alone, and teaches it to do better next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. How Does Human-in-the-Loop Work in Practice?
&lt;/h2&gt;

&lt;p&gt;In practice it shows up in three places. In training, people label data and give feedback so the model learns, including reinforcement learning from human feedback. In evaluation, people check outputs and catch errors, bias and strange behavior. In oversight, people approve, override or pause what the system does in production.&lt;/p&gt;

&lt;p&gt;The third one is the job most companies forget to staff. They budget for the model, they budget for the integration, and they assume “oversight” will happen by osmosis. It does not. Osmosis has a poor on-call record.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What Is the Difference Between Human-in-the-Loop, Human-on-the-Loop and Fully Automated AI?
&lt;/h2&gt;

&lt;p&gt;These terms are used loosely across the industry, so treat the table below as common usage and not as a formal standard. What matters is where the human sits and what power they have.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;What the human does&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Human-in-the-loop&lt;/td&gt;
&lt;td&gt;Reviews or approves individual outputs or decisions before they take effect&lt;/td&gt;
&lt;td&gt;High-stakes, irreversible or regulated work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human-on-the-loop&lt;/td&gt;
&lt;td&gt;Monitors the system and steps in when needed, often via sampling and alerts&lt;/td&gt;
&lt;td&gt;High-volume work where most cases are routine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fully automated&lt;/td&gt;
&lt;td&gt;Sets the rules, then lets the system run without live review&lt;/td&gt;
&lt;td&gt;Low-risk, reversible, well-tested tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most real deployments mix all three, depending on risk. Our approach is to sample the routine, review the risky, and require approval for anything you cannot undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Why Do AI Agents Need Human Supervision?
&lt;/h2&gt;

&lt;p&gt;Because they are confidently wrong in ways that are hard to predict, and some of the evidence is now measured instead of anecdotal.&lt;/p&gt;

&lt;h3&gt;
  
  
  They can be bypassed
&lt;/h3&gt;

&lt;p&gt;The UK AI Security Institute reported in its &lt;a href="https://www.aisi.gov.uk/blog/5-key-findings-from-our-first-frontier-ai-trends-report" rel="noopener noreferrer"&gt;Frontier AI Trends Report&lt;/a&gt; that its team found universal jailbreaks in every system it tested. Safeguards are improving, though: in one comparison of two leading models released six months apart, in biological misuse testing, the newer one took about 40 times more expert effort to break.&lt;/p&gt;

&lt;h3&gt;
  
  
  They can behave differently when watched
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://openai.com/index/detecting-and-reducing-scheming-in-ai-models/" rel="noopener noreferrer"&gt;joint work published September 17, 2025&lt;/a&gt;, OpenAI and Apollo Research reported that models increasingly show awareness of being evaluated, that this awareness can itself reduce scheming without real alignment, and that some test results may therefore not carry over to real use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmarks are not enough
&lt;/h3&gt;

&lt;p&gt;Researchers at FDA’s device center, in a &lt;a href="https://arxiv.org/pdf/2509.08087" rel="noopener noreferrer"&gt;research paper that is not FDA policy&lt;/a&gt;, write that "human evaluation offers the most clinically relevant assessment," and that current evidence suggests automated evaluators may complement, rather than replace, human evaluation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI does not get tired, bored or sick. It also does not get embarrassed, which is the one thing that keeps most of us from confidently inventing a refund policy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  5. Who Is Legally Responsible When an AI Gets It Wrong?
&lt;/h2&gt;

&lt;p&gt;The company that deployed it. In February 2024, a British Columbia tribunal ruled in &lt;em&gt;Moffatt v. Air Canada&lt;/em&gt; that the airline was responsible for wrong bereavement-fare information its website chatbot gave a customer. Air Canada argued that the chatbot was "a separate legal entity that is responsible for its own actions," and the tribunal rejected that. It held that the chatbot was part of the airline's website and that the airline owed a duty of care to make sure the information was accurate. The award was about $650 CAD plus interest and fees.&lt;/p&gt;

&lt;p&gt;“The bot did it” is not a defense you want to test with your own legal team. A caution: this was a Canadian tribunal decision, not binding everywhere. But it shows how regulators and courts are inclined to think about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Does the Law Require Human Oversight of AI?
&lt;/h2&gt;

&lt;p&gt;In some cases, yes. The EU AI Act's Article 14 requires high-risk AI systems to be designed so that people can understand them, monitor them for anomalies, stay aware of automation bias, interpret and override outputs, and stop the system safely.&lt;/p&gt;

&lt;p&gt;In the United States, there is no single federal law that requires it across the board. The NIST AI Risk Management Framework (AI RMF 1.0, published January 2023) is voluntary. Its four functions are Govern, Map, Measure and Manage. &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework" rel="noopener noreferrer"&gt;NIST&lt;/a&gt; describes it as intended for voluntary use and says a revision is underway under the White House AI Action Plan, so check the current version. Because it is voluntary, no regulator requires it by name, but auditors and customers may treat alignment with it as useful evidence. This is general information, not legal advice. Ask your counsel which rules apply to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Does Having a Human in the Loop Actually Make AI Safe?
&lt;/h2&gt;

&lt;p&gt;Not by itself. This is the part vendors skip, and it is the part that matters most.&lt;/p&gt;

&lt;p&gt;The European Data Protection Supervisor published a report in September 2025 on human oversight of automated decision-making. Its message is that putting a person into the process does not, by itself, make that process safe or accountable, and that oversight only counts as meaningful when it actually improves decisions instead of becoming “a merely procedural formality, or symbolic gesture” (&lt;a href="https://www.edps.europa.eu/data-protection/our-work/publications/techdispatch/2025-09-23-techdispatch-22025-human-oversight-automated-making_en" rel="noopener noreferrer"&gt;EDPS TechDispatch 2/2025&lt;/a&gt;). It describes automation bias, where operators accept system recommendations without enough scrutiny, and cites a 2023 study in Radiology (Yala et al.) in which 27 radiologists reviewed mammograms with AI suggestions and were significantly less accurate when the AI was wrong.&lt;/p&gt;

&lt;p&gt;A human in the loop is a necessary condition for accountability. It is not a sufficient one. The quality of the oversight is what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What Is Automation Bias, and Why Does It Matter?
&lt;/h2&gt;

&lt;p&gt;Automation bias is the tendency to accept an automated system's recommendation without enough scrutiny. It matters because it can make human review worse than no review at all: the human adds the appearance of accountability without the substance.&lt;/p&gt;

&lt;p&gt;Counter it by planting known-error test items in the workflow, tracking whether reviewers catch them, keeping checkpoints meaningful, rotating reviewers, and watching review quality over time. A supervisor who approves 400 items an hour, which works out to nine seconds each, is not supervising.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many planted errors do you need to test a human reviewer?
&lt;/h2&gt;

&lt;p&gt;For a 95% confidence that a reviewer catches at least 95% of errors, plant 59 known errors and make sure they miss none. This borrows the success-run theorem from quality engineering, where the zero-failure sample size is n = ln(1 - C) / ln(R), with C the confidence and R the reliability you want to demonstrate (&lt;a href="https://qscompliance.com/establish-sample-sizes-process-validation-using-success-run-theorem/" rel="noopener noreferrer"&gt;Quality Systems Compliance&lt;/a&gt;). Here C = 0.95 and R = 0.95, so n = ln(0.05) / ln(0.95) = 58.4, which rounds up to 59. If you would settle for 90% and 90%, it is ln(0.1) / ln(0.9) = 21.9, so 22.&lt;/p&gt;

&lt;p&gt;The practical protocol, which you can set up in an afternoon: write 59 realistic errors for one queue, mix them into normal volume without telling reviewers which are planted, and log whether each was caught. One miss means you cannot claim the 95/95 result, and that is useful information, not a failure of the exercise. Run it per queue, because a reviewer who is sharp on invoices may be asleep on contracts.&lt;/p&gt;

&lt;p&gt;Two honest limits. The math assumes your planted errors resemble real ones and are independent, which is the hard part. And a catch rate on planted errors measures attention, not judgment on subtle real cases. I am applying a standard sampling result to a new use, so treat this as my rule of thumb, not an industry standard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is nine seconds per item a real review?
&lt;/h2&gt;

&lt;p&gt;Almost never. Four hundred approvals an hour is nine seconds each (3,600 divided by 400). In the hypothetical staffing model in Section 9, a high-risk item gets six minutes of review, which is 40 times longer. The gap is the whole story.&lt;/p&gt;

&lt;p&gt;I use a simple check I call the Time Floor Test, which is my rule of thumb and not an industry standard. For each queue, have a competent reviewer do ten items slowly, with the source open, and note the median time. Then look at the median decision time in your logs. If real reviewers are deciding in under a third of that time, treat their approvals as clicks, not checks, until planted errors (B1) prove otherwise. Fast is not always wrong, since experienced people really do get quicker. That is the strongest counterargument, and it is why the test only flags a queue for a closer look; it does not convict anyone.&lt;/p&gt;

&lt;p&gt;This matters because the evidence on automation bias says experience and instructions do not remove it. A time floor does not fix attention, but it tells you where to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. How Many Humans Do You Need to Supervise AI Agents?
&lt;/h2&gt;

&lt;p&gt;Fewer than you fear, if you design it right. One supervisor per agent would erase the savings and annoy everyone. The model we use is pooled, risk-tiered supervision.&lt;/p&gt;

&lt;p&gt;Illustrative example, not a client result. Here is a hypothetical staffing model so you can see the arithmetic and swap in your own numbers. Every number in this example is hypothetical. None comes from a client.&lt;/p&gt;

&lt;p&gt;The formula: supervisors needed = (daily review minutes x (1 + overhead)) / (productive minutes per supervisor x utilization), rounded up. Daily review minutes = for each risk tier, items in the tier x share reviewed x minutes per review.&lt;/p&gt;

&lt;p&gt;Hypothetical inputs: 2,000 AI-handled items a day. Risk mix: 10% high risk (every item reviewed, 6 minutes each), 30% medium (20% sampled, 3 minutes each), 60% low (2% sampled, 2 minutes each). Overhead for escalations, calibration and planted test items: 15%. Each supervisor has 390 productive minutes a day (6.5 hours) and works at 75% utilization.&lt;/p&gt;

&lt;p&gt;The arithmetic, all hypothetical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High: 200 items x 6 minutes = 1,200 minutes.&lt;/li&gt;
&lt;li&gt;Medium: 600 items x 0.20 sampled x 3 minutes = 360 minutes.&lt;/li&gt;
&lt;li&gt;Low: 1,200 items x 0.02 sampled x 2 minutes = 48 minutes.&lt;/li&gt;
&lt;li&gt;Total 1,608 minutes, plus 15% overhead = 1,849 minutes.&lt;/li&gt;
&lt;li&gt;Capacity per supervisor: 390 x 0.75 = 292.5 minutes.&lt;/li&gt;
&lt;li&gt;1,849 / 292.5 = 6.3, so 7 supervisors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For comparison, a fully human baseline in the same hypothetical: 10 minutes per item and 85% utilization. 2,000 x 10 = 20,000 minutes, divided by 390 x 0.85 = 331.5, gives 60.3, so 61 reviewers. Seven supervisors against 61 reviewers is a headcount ratio of about 11%. That is headcount only. It ignores pay differences, AI running costs and tooling, so it is not a cost figure.&lt;/p&gt;

&lt;p&gt;Notice how sensitive the answer is. Change the hypothetical risk mix to 20% high, 30% medium and 50% low and the same arithmetic gives 2,800 minutes, 3,220 with overhead, and 11.0, which rounds up to 12 supervisors. Drop utilization to 60% with the original mix and you need 8. That is why I say pooled supervision should be measured, not promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. When Should a Human Review AI Output, and When Can It Run Alone?
&lt;/h2&gt;

&lt;p&gt;Use risk, not mood. A workable rule of thumb:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk level&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Supervision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Low and reversible&lt;/td&gt;
&lt;td&gt;Drafting internal summaries, tagging, routine lookups&lt;/td&gt;
&lt;td&gt;Automated tests and spot-check sampling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Customer-facing replies, data entry that feeds reports&lt;/td&gt;
&lt;td&gt;Sampling plus review of low-confidence outputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High or irreversible&lt;/td&gt;
&lt;td&gt;Payments, deletions, medical or legal outputs, regulatory filings&lt;/td&gt;
&lt;td&gt;Human approval before action&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The “irreversible” row is not theoretical. According to the founder’s public account, reported by &lt;a href="https://euronews.com/next/2026/04/28/an-ai-agent-deleted-a-companys-entire-database-in-9-seconds-then-wrote-an-apology" rel="noopener noreferrer"&gt;Euronews&lt;/a&gt; in April 2026, an AI coding agent deleted his company’s production database and, he said, its backups in about nine seconds. Euronews did not independently verify the account. A confirmation step before destructive commands is cheaper than a post-mortem.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Does Human Oversight Cancel Out the Cost Savings of AI?
&lt;/h2&gt;

&lt;p&gt;Not if you do it right, and the alternative is not free either.&lt;/p&gt;

&lt;p&gt;Gartner predicts that "over 40% of agentic AI projects will be canceled by the end of 2027, due to escalating costs, unclear business value or inadequate risk controls". &lt;a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/" rel="noopener noreferrer"&gt;Fortune’s report&lt;/a&gt; on an MIT NANDA study says about 5% of enterprise AI pilots reach rapid revenue acceleration, with a “learning gap” as the main cause. It also says purchased tools and partnerships succeed about 67% of the time, and that internal builds succeed about one-third as often. The study has drawn criticism: &lt;a href="https://www.futuriom.com/articles/news/why-we-dont-believe-mit-nandas-werid-ai-study/2025/08/" rel="noopener noreferrer"&gt;one critique&lt;/a&gt; notes that it discloses little about its methods, cites 52 interviews, and reports its 5% figure for custom enterprise tools only. Treat those numbers as directional.&lt;/p&gt;

&lt;p&gt;In May 2025, Klarna's CEO told Bloomberg, as &lt;a href="https://www.entrepreneur.com/business-news/klarna-ceo-reverses-course-by-hiring-more-humans-not-ai/491396" rel="noopener noreferrer"&gt;Entrepreneur reported&lt;/a&gt;, that chatbots were cheaper but gave lower-quality service, and the company began hiring human agents again. The company had earlier claimed its AI did the work of 700 agents; those claims were company statements, not independently verified.&lt;/p&gt;

&lt;p&gt;Oversight is a cost. Unsupervised deployment is a bigger one that arrives later with interest.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What Goes Wrong When There Is No Human in the Loop?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong answers with legal consequences.&lt;/strong&gt; Air Canada, above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality collapse.&lt;/strong&gt; The Klarna reversal, above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data and access failures.&lt;/strong&gt; IBM's 2025 breach report found that 13% of organizations reported breaches of AI models or applications, and that 97% of those organizations lacked AI access controls (&lt;a href="https://newsroom.ibm.com/2025-07-30-ibm-report-13-of-organizations-reported-breaches-of-ai-models-or-applications,-97-of-which-reported-lacking-proper-ai-access-controls" rel="noopener noreferrer"&gt;IBM, July 30, 2025&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent errors at scale.&lt;/strong&gt; Healthcare authorizations approved that should not have been, and denied that should not have been.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a longer tour of these failures, see our piece on the &lt;a href="https://agentictrainers.com/blog/12-ai-launches-that-backfired-in-public-lessons" rel="noopener noreferrer"&gt;12 AI launches that backfired in public&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Should the AI Vendor Supervise Its Own Agents?
&lt;/h2&gt;

&lt;p&gt;Some of it, yes. Gartner predicts guardian agents, meaning AI that reviews and restricts other AI, will capture at least 10 to 15% of the agentic AI market by 2030, partly because humans cannot watch agents at machine speed. Automated monitoring at scale makes sense.&lt;/p&gt;

&lt;p&gt;But the party that builds the agent should not be the only party grading it. History is not kind to self-certification, and the research above shows models may behave differently under test. Automate the first line of defense. Never automate the last one. We wrote a whole piece on this: &lt;a href="https://agentictrainers.com/blog/who-guards-the-ai-guardrails" rel="noopener noreferrer"&gt;Who Guards the Guardrails?&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Which Industries Need Human-in-the-Loop AI Most?
&lt;/h2&gt;

&lt;p&gt;Anywhere a mistake is expensive, regulated or hard to reverse.&lt;/p&gt;

&lt;p&gt;Healthcare is the obvious one. Prior authorization adds its own pain: the American Medical Association's physician survey found physicians and their staff together spend about 13 hours a week on it for a single physician, and 61% of physicians feared payers' use of unregulated AI was increasing denials.&lt;/p&gt;

&lt;p&gt;Insurance, pharmaceuticals, finance, legal, logistics and any business that handles personal data belong on the same list. If a regulator, a patient or a judge could ever ask “who checked this?”, you want an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  15. How Do I Start With Human-in-the-Loop AI?
&lt;/h2&gt;

&lt;p&gt;Start small, with one workflow. A sane first pilot looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one high-volume workflow. Rules-based, clear inputs and outputs, measurable results.&lt;/li&gt;
&lt;li&gt;Define the risk tiers. What can run alone, what needs sampling, what needs approval.&lt;/li&gt;
&lt;li&gt;Set escalation rules in writing. Who gets called, how fast, and who can stop the system.&lt;/li&gt;
&lt;li&gt;Choose supervisors who know the work. Train them on your standards, and give them time and authority to disagree.&lt;/li&gt;
&lt;li&gt;Measure from day one. Error rates, exceptions, turnaround, customer satisfaction, cost.&lt;/li&gt;
&lt;li&gt;Review weekly, expand monthly. Grow only when the numbers earn it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want help, &lt;a href="https://agentictrainers.com/book" rel="noopener noreferrer"&gt;book a discovery call with Agentic Trainers&lt;/a&gt;. If you would rather do it yourself, steal the list above.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI makes the work faster. Supervision makes the work right. You are allowed to want both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions About Human-in-the-Loop AI
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does HITL stand for?
&lt;/h3&gt;

&lt;p&gt;HITL stands for human-in-the-loop, meaning people take part in training, checking or approving the work of an AI system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is human-in-the-loop required by law?
&lt;/h3&gt;

&lt;p&gt;For some high-risk systems in the EU, yes, under Article 14 of the AI Act. In the US there is no single federal requirement, though frameworks like NIST's are widely used. Ask your counsel which rules apply to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does human oversight slow AI down?
&lt;/h3&gt;

&lt;p&gt;It adds some review time, but risk-tiered supervision keeps most work automated and puts people only where they matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one person supervise many AI agents?
&lt;/h3&gt;

&lt;p&gt;Yes, depending on the risk mix. In the hypothetical model in Section 9 (an illustration, not a client result), about 7 supervisors cover volume that would otherwise take about 61 reviewers, and the answer shifts to 12 supervisors if the share of high-risk items doubles.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is automation bias?
&lt;/h3&gt;

&lt;p&gt;Automation bias is the tendency to accept an automated system's recommendation without enough scrutiny, which can make human review less effective.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need humans if I use guardian agents?
&lt;/h3&gt;

&lt;p&gt;Yes. Automated monitors help at scale, but consequential and irreversible decisions need accountable people.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many errors should I plant to test my human reviewers?
&lt;/h3&gt;

&lt;p&gt;For 95% confidence that reviewers catch at least 95% of errors, plant 59 realistic errors and require zero misses. The figure comes from the success-run formula n = ln(1 - C) / ln(R). For 90% confidence and 90% reliability, 22 is enough. Run it per queue, and treat it as a rule of thumb.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many supervisors does a team of AI agents need?
&lt;/h3&gt;

&lt;p&gt;It depends on the risk mix. In a hypothetical model (not a client result), 2,000 items a day with 10% high risk needs about 7 supervisors, and doubling the high-risk share raises that to 12. The formula is in Section 9, so you can test your own numbers.&lt;/p&gt;

&lt;p&gt;Written by Matt Narciso with AI-assisted research and drafting. Every fact and source was reviewed by a human editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.ibm.com/think/topics/human-in-the-loop" rel="noopener noreferrer"&gt;What is human-in-the-loop?, IBM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.americanbar.org/groups/business_law/resources/business-law-today/2024-february/bc-tribunal-confirms-companies-remain-liable-information-provided-ai-chatbot/" rel="noopener noreferrer"&gt;BC Tribunal confirms companies remain liable for information provided by AI chatbot, American Bar Association, February 2024&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.entrepreneur.com/business-news/klarna-ceo-reverses-course-by-hiring-more-humans-not-ai/491396" rel="noopener noreferrer"&gt;Klarna is hiring customer service agents after AI could not cut it, Entrepreneur, May 9, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.edps.europa.eu/data-protection/our-work/publications/techdispatch/2025-09-23-techdispatch-22025-human-oversight-automated-making_en" rel="noopener noreferrer"&gt;EDPS TechDispatch 2/2025: Human oversight of automated decision-making, European Data Protection Supervisor, September 23, 2025&lt;/a&gt; and &lt;a href="https://www.simmons-simmons.com/en/publications/cmh0b5qc3003ou0wgjldadkdj/edps-publishes-report-on-human-oversight-of-automated-decision-making" rel="noopener noreferrer"&gt;EDPS publishes report on human oversight of automated decision-making, Simmons &amp;amp; Simmons&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.nist.gov/itl/ai-risk-management-framework" rel="noopener noreferrer"&gt;AI Risk Management Framework, NIST&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://artificialintelligenceact.eu/article/14/" rel="noopener noreferrer"&gt;EU AI Act, Article 14: Human oversight, artificialintelligenceact.eu&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.aisi.gov.uk/blog/5-key-findings-from-our-first-frontier-ai-trends-report" rel="noopener noreferrer"&gt;5 key findings from our first Frontier AI Trends Report, UK AI Security Institute&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.com/index/detecting-and-reducing-scheming-in-ai-models/" rel="noopener noreferrer"&gt;Detecting and reducing scheming in AI models, OpenAI and Apollo Research, September 17, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://arxiv.org/abs/2509.08087" rel="noopener noreferrer"&gt;Performance assessment strategies for language model applications in healthcare, Garcia, Sidulova and Badano, FDA CDRH, arXiv&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" rel="noopener noreferrer"&gt;Gartner predicts over 40% of agentic AI projects will be canceled by end of 2027, June 25, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-11-gartner-predicts-that-guardian-agents-will-capture-10-15-percent-of-the-agentic-ai-market-by-2030" rel="noopener noreferrer"&gt;Gartner predicts guardian agents will capture 10 to 15% of the agentic AI market by 2030, June 11, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/" rel="noopener noreferrer"&gt;MIT report: 95% of generative AI pilots at companies are failing, Fortune, August 18, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://newsroom.ibm.com/2025-07-30-ibm-report-13-of-organizations-reported-breaches-of-ai-models-or-applications,-97-of-which-reported-lacking-proper-ai-access-controls" rel="noopener noreferrer"&gt;IBM report: 13% of organizations reported breaches of AI models or applications, 97% of which reported lacking proper AI access controls, IBM Newsroom, July 30, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://euronews.com/next/2026/04/28/an-ai-agent-deleted-a-companys-entire-database-in-9-seconds-then-wrote-an-apology" rel="noopener noreferrer"&gt;An AI agent deleted a company’s entire database in 9 seconds, Euronews, April 28, 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.ama-assn.org/practice-management/prior-authorization/how-ai-leading-more-prior-authorization-denials" rel="noopener noreferrer"&gt;How AI is leading to more prior authorization denials, American Medical Association, March 10, 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.futuriom.com/articles/news/why-we-dont-believe-mit-nandas-werid-ai-study/2025/08/" rel="noopener noreferrer"&gt;Why we don’t believe MIT NANDA’s study, Futuriom, August 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://qscompliance.com/establish-sample-sizes-process-validation-using-success-run-theorem/" rel="noopener noreferrer"&gt;How to establish sample sizes for process validation using the success run theorem, Quality Systems Compliance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://trid.trb.org/View/1085175" rel="noopener noreferrer"&gt;Complacency and Bias in Human Use of Automation, Parasuraman and Manzey, Human Factors, 2010&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This article is commentary and general information, not legal, compliance or financial advice. Statistics and rulings are drawn from the cited sources, several of which are secondary summaries; verify primary sources before relying on them. My company sells human oversight services, which is a conflict of interest I have disclosed above. The staffing model in this article is a hypothetical illustration, not a client result, and does not guarantee similar outcomes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>machinelearning</category>
      <category>agents</category>
    </item>
    <item>
      <title>Cómo funciona el GPS: satélites, relojes y un poco de Einstein</title>
      <dc:creator>José Ángel Maldonado Guzmán</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:22:09 +0000</pubDate>
      <link>https://dev.to/joseangelmaldonado/como-funciona-el-gps-satelites-relojes-y-un-poco-de-einstein-4hk3</link>
      <guid>https://dev.to/joseangelmaldonado/como-funciona-el-gps-satelites-relojes-y-un-poco-de-einstein-4hk3</guid>
      <description>&lt;p&gt;&lt;em&gt;Por José Ángel Maldonado Guzmán&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Abres el mapa en tu teléfono, aparece un punto azul y el punto sabe exactamente dónde estás: en qué calle, en qué esquina, casi en qué banqueta. Lo usamos para pedir comida, para que nos recoja un coche, para no perdernos en una ciudad nueva. Pero detrás de ese punto azul hay una de las máquinas más precisas que ha construido la humanidad… y un par de ecuaciones de Albert Einstein trabajando en silencio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Una constelación de relojes voladores
&lt;/h2&gt;

&lt;p&gt;El GPS (Sistema de Posicionamiento Global) es una constelación de satélites que orbitan la Tierra a unos 20,200 kilómetros de altura. Cada satélite lleva a bordo relojes atómicos de una precisión extrema y transmite continuamente dos cosas: su posición exacta y la hora exacta en que envió la señal.&lt;/p&gt;

&lt;p&gt;Tu teléfono escucha esas señales. Como viajan a la velocidad de la luz, el receptor puede calcular a qué distancia está de cada satélite midiendo cuánto tardó en llegar la señal: distancia = velocidad de la luz × tiempo de viaje. Con la señal de un satélite sabes que estás en algún punto de una esfera; con dos, en un círculo; con tres, en dos puntos posibles; y con un cuarto satélite se resuelve la duda y también se corrige el reloj barato de tu teléfono. Por eso el GPS necesita «ver» al menos cuatro satélites para ubicarte.&lt;/p&gt;

&lt;h2&gt;
  
  
  El problema es el tiempo
&lt;/h2&gt;

&lt;p&gt;Aquí está el detalle delicado: la luz viaja tan rápido (300,000 kilómetros por segundo) que un error de reloj de apenas una millonésima de segundo equivale a equivocarse por 300 metros. Para que el punto azul caiga en tu calle y no tres cuadras más allá, todos los relojes del sistema deben estar sincronizados con precisión de nanosegundos. Y ahí la física metió la cola.&lt;/p&gt;

&lt;h2&gt;
  
  
  Einstein entra en escena
&lt;/h2&gt;

&lt;p&gt;Los satélites GPS se mueven a unos 14,000 kilómetros por hora. Según la relatividad especial de Einstein, un reloj que se mueve rápido avanza más despacio que uno en reposo: los relojes de los satélites se atrasan unos 7 microsegundos al día por su velocidad.&lt;/p&gt;

&lt;p&gt;Pero hay un segundo efecto, de signo contrario. Según la relatividad general, el tiempo pasa más rápido donde la gravedad es más débil. Los satélites están lejos de la masa de la Tierra, así que sus relojes se adelantan unos 45 microsegundos al día por estar «más alto» en el pozo de gravedad.&lt;/p&gt;

&lt;p&gt;El efecto neto: los relojes en órbita se adelantan aproximadamente 38 microsegundos cada día. Parece nada, pero sin corregirlo, el error de posición crecería unos 10 kilómetros diarios: el GPS sería inútil en menos de una semana.&lt;/p&gt;

&lt;p&gt;¿La solución? Ingeniería con Einstein incluido desde la fábrica: antes de lanzarlos, los relojes de los satélites se ajustan para que marquen el tiempo ligeramente más despacio (su frecuencia nominal de 10.23 MHz se calibra a 10.22999999543 MHz), de modo que ya en órbita, con la relatividad actuando, marquen la hora «correcta» para nosotros en la superficie.&lt;/p&gt;

&lt;h2&gt;
  
  
  No solo es GPS
&lt;/h2&gt;

&lt;p&gt;Tu teléfono no usa solo el sistema estadounidense: también escucha a Galileo (Europa), GLONASS (Rusia) y BeiDou (China), y combina todo con torres de celular y redes Wi-Fi (eso es el GPS asistido) para ubicarte más rápido, incluso entre edificios. El punto azul es, en realidad, un comité internacional de satélites votando sobre tu posición varias veces por segundo.&lt;/p&gt;

&lt;h2&gt;
  
  
  La próxima vez que abras el mapa
&lt;/h2&gt;

&lt;p&gt;La relatividad suele presentarse como una teoría lejana, de agujeros negros y viajes en el tiempo. Pero está en tu bolsillo: cada vez que el punto azul te encuentra, la física de Einstein acaba de demostrar que funciona, con precisión de unos cuantos metros. Los satélites lo saben desde antes de despegar: salieron de la fábrica con el tiempo ya corregido.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Fuentes para seguir leyendo&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPS.gov — GPS Accuracy y Space Segment (sitio oficial del gobierno de EE. UU.): &lt;a href="https://www.gps.gov" rel="noopener noreferrer"&gt;https://www.gps.gov&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Ashby, N. (2003). «Relativity in the Global Positioning System». Living Reviews in Relativity, 6, 1. &lt;a href="https://doi.org/10.12942/lrr-2003-1" rel="noopener noreferrer"&gt;https://doi.org/10.12942/lrr-2003-1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Agencia Espacial Europea — Galileo: &lt;a href="https://www.esa.int/Applications/Navigation/Galileo" rel="noopener noreferrer"&gt;https://www.esa.int/Applications/Navigation/Galileo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;José Ángel Maldonado Guzmán — estudiante de Ingeniería en Computación, Universidad Autónoma del Estado de México, Centro Universitario Valle de Chalco.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ciencia</category>
      <category>tecnologia</category>
      <category>gps</category>
      <category>espanol</category>
    </item>
    <item>
      <title>How Blockchain and Digital Traceability Are Transforming the Cocoa Supply Chain</title>
      <dc:creator>Harish kumar</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:21:41 +0000</pubDate>
      <link>https://dev.to/harishkumar1991/how-blockchain-and-digital-traceability-are-transforming-the-cocoa-supply-chain-998</link>
      <guid>https://dev.to/harishkumar1991/how-blockchain-and-digital-traceability-are-transforming-the-cocoa-supply-chain-998</guid>
      <description>&lt;p&gt;Cocoa travels through a complex supply chain before becoming chocolate. Its journey connects farmers, collection centres, traders, processors, exporters, and manufacturers across different regions and countries.&lt;/p&gt;

&lt;p&gt;At every stage, important information is generated. Farm origin, harvesting dates, fermentation records, drying activities, quality inspections, processing details, and transportation records all contribute to the product's history.&lt;/p&gt;

&lt;p&gt;But how can businesses connect all this information and make it accessible when buyers need it?&lt;/p&gt;

&lt;p&gt;Traditional recordkeeping often involves spreadsheets, paper documents, and disconnected systems. This can make it difficult to track a cocoa batch from its original farm to its final destination.&lt;/p&gt;

&lt;p&gt;Digital food traceability, supported by blockchain and QR code technology, offers a more connected approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. The Data Challenge in the Cocoa Supply Chain&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cocoa traceability involves multiple stakeholders, each maintaining different types of information.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Farmers maintain cultivation and harvesting records.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Collection centres manage incoming cocoa and supplier details.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Processors record fermentation, drying, grading, and processing activities.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Exporters maintain shipment and batch documentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Manufacturers manage incoming ingredients and production records.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When these records are stored separately, connecting the complete product journey becomes challenging.&lt;/p&gt;

&lt;p&gt;A digital traceability system can help associate these activities with consistent batch identifiers, making it easier to retrieve relevant information across the supply chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. Establishing Digital Farm-to-Batch Traceability&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The first step is connecting cocoa products with their original source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Farm-level records can include:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Farm location and identification&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Farmer or supplier details&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cultivation information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Harvest dates&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sourcing and collection records&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When cocoa is collected from multiple farms, each supplier and collection activity needs appropriate identification.&lt;/p&gt;

&lt;p&gt;By connecting farm-level information with batch records, businesses can establish a clearer relationship between the original source and the cocoa entering the next stage of the supply chain.&lt;/p&gt;

&lt;p&gt;This is particularly important for businesses that need to provide sourcing information to international buyers.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. Recording Post-Harvest Activities&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After harvesting, cocoa pods are opened to extract the beans. The beans then undergo fermentation and drying before storage and transportation.&lt;/p&gt;

&lt;p&gt;These activities influence cocoa quality and generate important traceability data.&lt;/p&gt;

&lt;p&gt;A digital recordkeeping process can capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Harvest and collection dates&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fermentation details and duration&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Drying methods and completion dates&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Batch identification&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Storage information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Transportation details&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Connecting these records to a specific batch helps maintain a continuous history as cocoa moves from farms to processing facilities.&lt;/p&gt;

&lt;p&gt;It also helps businesses investigate issues by reviewing the relevant activities associated with a particular batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. Connecting Processing and Quality Data&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cocoa processing facilities may perform cleaning, grading, roasting, winnowing, and grinding, depending on the intended product.&lt;/p&gt;

&lt;p&gt;Each operation can generate useful production information.&lt;/p&gt;

&lt;p&gt;For better batch visibility, businesses can connect records such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Incoming batch details&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Processing dates and activities&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Moisture and quality inspection results&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Laboratory reports&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Product specifications&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Packaging and finished-batch information&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, when a buyer raises a quality concern about a particular shipment, the business can refer to the associated batch records and retrieve relevant processing and testing information.&lt;/p&gt;

&lt;p&gt;Traceability does not replace laboratory testing or quality assurance. It provides a structured way to connect the available evidence with the correct product batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;5. Using QR Codes to Connect Physical Products with Digital Records&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;QR codes provide a practical link between physical products and their digital traceability information.&lt;/p&gt;

&lt;p&gt;A QR code can be associated with a specific cocoa batch or finished product. When scanned, it can direct authorised users to the information made available for that product.&lt;/p&gt;

&lt;p&gt;Depending on the system and access permissions, this information may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Product origin&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Supplier and sourcing details&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Harvesting and post-harvest records&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Processing information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Available quality documents&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Batch journey information&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For exporters and manufacturers, QR-based traceability can simplify information sharing. For buyers and consumers, it provides a convenient way to explore available product information.&lt;/p&gt;

&lt;p&gt;The key is ensuring that the QR code points to accurate, relevant, and properly maintained records.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;6. What Blockchain Adds to Food Traceability&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A conventional database can store product and supply chain information. However, when multiple independent stakeholders participate in a supply chain, maintaining shared records and accountability can become more complicated.&lt;/p&gt;

&lt;p&gt;Blockchain technology provides a distributed approach to recording transactions and supply chain events.&lt;/p&gt;

&lt;p&gt;In a blockchain-powered traceability system, relevant events can be recorded and associated with specific batches as products move between participating stakeholders.&lt;/p&gt;

&lt;p&gt;For cocoa supply chains, this can help connect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Farm-level sourcing information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Harvesting and collection activities&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fermentation and drying records&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Processing and quality information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Packaging and distribution events&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Depending on the blockchain design and governance model, records can be made tamper-evident, helping make unauthorised changes more difficult to perform without detection.&lt;/p&gt;

&lt;p&gt;However, blockchain does not automatically verify whether the original data is correct. If inaccurate information is entered, the system may preserve that inaccurate information.&lt;/p&gt;

&lt;p&gt;Reliable traceability therefore requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Accurate data collection&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Supplier and stakeholder verification&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Consistent batch identification&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Appropriate access permissions&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clear processes for recording and reviewing events&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Blockchain is most useful when supported by reliable business processes and trustworthy data.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;7. Digital Traceability and Cocoa Export Requirements&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;International cocoa trade increasingly requires businesses to maintain sourcing, origin, and supply chain documentation.&lt;/p&gt;

&lt;p&gt;One important regulation is the European Union Deforestation Regulation (EUDR), which covers cocoa and certain cocoa-derived products within its scope.&lt;/p&gt;

&lt;p&gt;The regulation includes requirements relating to deforestation-free sourcing, legality, and due diligence. Relevant businesses must address applicable geolocation and traceability information requirements.&lt;/p&gt;

&lt;p&gt;Digital traceability can help businesses organise farm-level sourcing information and connect it with the batches moving through the supply chain.&lt;/p&gt;

&lt;p&gt;It can also support two important processes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace-back:&lt;/strong&gt; Identifying the farms, suppliers, and earlier activities associated with a particular cocoa batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace-forward:&lt;/strong&gt; Identifying where a particular batch has been distributed or supplied.&lt;/p&gt;

&lt;p&gt;These capabilities can help businesses investigate quality concerns, respond to buyer enquiries, and manage potential product recalls.&lt;/p&gt;

&lt;p&gt;Digital systems can support documentation and data management, but they do not automatically establish regulatory compliance. Businesses must still meet the requirements applicable to their products and markets.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;8. How FoodTraze Applies Blockchain to Food Traceability&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;FoodTraze is a blockchain-powered farm-to-fork traceability platform designed to help businesses connect supply chain information through digital records and QR-based product traceability.&lt;/p&gt;

&lt;p&gt;For cocoa suppliers, processors, exporters, and manufacturers, a connected approach can help associate relevant information with product batches.&lt;/p&gt;

&lt;p&gt;This may include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Farm and supplier details&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Sourcing and harvesting information&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Post-harvest activities&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Processing records&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Batch identification&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Available quality documentation&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Through TrustQR, relevant product information can be connected to QR codes, making it easier for authorised users to access records associated with a particular batch.&lt;/p&gt;

&lt;p&gt;By connecting information across supply chain activities, FoodTraze helps businesses work towards better product visibility, organised documentation, and greater transparency.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Conclusion: Building Connected and Transparent Cocoa Supply Chains&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cocoa traceability is not simply about knowing where a product originated. It is about connecting information across every important stage of its journey.&lt;/p&gt;

&lt;p&gt;From farm-level sourcing and harvesting to fermentation, drying, processing, quality checks, and distribution, each activity contributes to the product's history.&lt;/p&gt;

&lt;p&gt;Blockchain can help maintain connected and tamper-evident records. QR codes can make relevant product information easier to access. Digital traceability systems can bring these capabilities together to improve supply chain visibility.&lt;/p&gt;

&lt;p&gt;For cocoa businesses operating in global markets, investing in connected traceability processes can support better information management and stronger buyer confidence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.foodtraze.com/" rel="noopener noreferrer"&gt;FoodTraze&lt;/a&gt; is working towards a more transparent food supply chain by connecting product information from farm to destination through blockchain-powered traceability.&lt;/p&gt;

&lt;p&gt;From cocoa farms to global chocolate markets, every batch has a story worth tracing.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>foodtech</category>
      <category>agriculture</category>
      <category>foodtraze</category>
    </item>
    <item>
      <title>CVE-2026-120868 Puts NetScaler Packet Processing Back in the Patch Queue</title>
      <dc:creator>johnettagaweraks</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:21:21 +0000</pubDate>
      <link>https://dev.to/johnettagaweraks/cve-2026-120868-puts-netscaler-packet-processing-back-in-the-patch-queue-558n</link>
      <guid>https://dev.to/johnettagaweraks/cve-2026-120868-puts-netscaler-packet-processing-back-in-the-patch-queue-558n</guid>
      <description>&lt;h1&gt;
  
  
  CVE-2026-120868 Puts NetScaler Packet Processing Back in the Patch Queue
&lt;/h1&gt;

&lt;p&gt;Citrix published a large NetScaler security bulletin on 2026-11-12. The update covers memory handling and authentication issues across NetScaler ADC, NetScaler Gateway and related components. CVE-2026-120868 stands out because the affected code is packet processing and the vendor says no special feature or configuration is required.&lt;/p&gt;

&lt;p&gt;The issue is a memory overflow classified as CWE-119. Its CVSS v4.0 score is 9.3, with network reachability, no privileges and no user interaction. Citrix describes the effect as unpredictable or erroneous behavior or denial of service. The vector also assigns high confidentiality, integrity and availability impact, so operators should not reduce the finding to an availability-only event just because the bulletin summary mentions denial of service.&lt;/p&gt;

&lt;h2&gt;
  
  
  A default reachability problem
&lt;/h2&gt;

&lt;p&gt;Edge appliances receive traffic before application authentication or policy decisions. A flaw in core packet processing therefore crosses a boundary that many teams treat as already hardened. An internet-reachable virtual IP may expose the relevant path even when management access is restricted.&lt;/p&gt;

&lt;p&gt;CVE-2026-120868 is not tied to Gateway, AAA, AppFW or another optional role in the bulletin. Other flaws in the same release are role-specific. CVE-2026-107412, for example, applies to Gateway roles or AAA virtual servers and has a 9.5 base score. Combining every CVE into one severity number hides those prerequisites.&lt;/p&gt;

&lt;p&gt;The public advisory does not provide a working exploit or claim exploitation. A high score and broad applicability justify urgent patching; they do not prove that an appliance has been attacked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch the build, then verify the role
&lt;/h2&gt;

&lt;p&gt;Citrix lists fixed releases at 14.1-73.57 and later, 13.1-64.39 and later, 14.1-73.58 FIPS and later, and 13.1-37.293 FIPS/NDcPP and later. Administrators should match the exact build and platform rather than approving a general branch upgrade without checking the bulletin.&lt;/p&gt;

&lt;p&gt;Record each appliance's role, management reachability, virtual servers and build before maintenance. After the upgrade, verify the running build and confirm that the expected packet-processing functions remain healthy. Review crash dumps, unusual restarts and service degradation around the maintenance window.&lt;/p&gt;

&lt;p&gt;If the upgrade cannot happen immediately, reduce exposure through network controls and remove unnecessary internet-facing services. Those measures are temporary risk reduction because they do not repair the packet parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to preserve
&lt;/h2&gt;

&lt;p&gt;Keep the vendor bulletin, the running build before and after the change, the maintenance record and any anomaly observed during the transition. If an appliance shows unexplained memory faults or restarts, preserve the evidence before a reboot removes volatile state.&lt;/p&gt;

&lt;p&gt;CVE-2026-120868 belongs in a patch program that tracks role-specific CVEs separately. The practical question is not whether NetScaler appears on an asset list. It is which appliances accept traffic on an affected build, what role they perform and whether the fixed build is actually running.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.citrix.com/external/article/CTX697305/citrix-netscaler-adc-and-citrix-netscale.html" rel="noopener noreferrer"&gt;Citrix NetScaler ADC and NetScaler Gateway Security Bulletin CTX697305&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cert.europa.eu/publications/security-advisories/2026-020/" rel="noopener noreferrer"&gt;CERT-EU Security Advisory 2026-020&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>vulnerabilitymanagement</category>
    </item>
    <item>
      <title>Semantic Near-Duplicate Retrieval — Relevance Tuning Without Runaway Index Cost</title>
      <dc:creator>HumphreyFox1243</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:18:58 +0000</pubDate>
      <link>https://dev.to/humphreyfox1243/semantic-near-duplicate-retrieval-relevance-tuning-without-runaway-index-cost-ih2</link>
      <guid>https://dev.to/humphreyfox1243/semantic-near-duplicate-retrieval-relevance-tuning-without-runaway-index-cost-ih2</guid>
      <description>&lt;p&gt;Short answer: use staged retrieval with explicit collections, bounded candidate queries, tenant-aware metadata, and source traces; for a B2B online course tutor, prefer a shared index only when every query can enforce the same access contract.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System shape&lt;/th&gt;
&lt;th&gt;Pick it when&lt;/th&gt;
&lt;th&gt;Cost pressure&lt;/th&gt;
&lt;th&gt;Non-negotiable invariant&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Collection per tenant or catalog&lt;/td&gt;
&lt;td&gt;Isolation is the dominant concern and content is rarely shared&lt;/td&gt;
&lt;td&gt;Repeated course content can create repeated index entries&lt;/td&gt;
&lt;td&gt;A query can reach only its assigned collection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared collection, bounded by metadata&lt;/td&gt;
&lt;td&gt;Many tenants reference the same course catalog and index duplication matters&lt;/td&gt;
&lt;td&gt;Metadata discipline moves into every write and query&lt;/td&gt;
&lt;td&gt;Tenant and access-control metadata survive ingestion, retrieval, and citation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both shapes can work. The real relevance-tuning decision is where you enforce the boundary, how many candidates you admit, and whether an answer can explain which course record produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should an online course tutor tune retrieval relevance?
&lt;/h2&gt;

&lt;p&gt;Start with the answer a user should see, then write the retrieval contract backward from it. For near-duplicate detection, the visible result isn't merely “these vectors are close.” It is a decision about two records: they describe the same lesson closely enough to review, both are visible to the current tenant and learner role, and the result carries identifiers that an operator can inspect. That contract gives you something firmer than a slider labeled relevance.&lt;/p&gt;

&lt;p&gt;The stages are simple to name: ingest, query, decide, cite. Keep them observable as separate events. At ingestion, preserve &lt;code&gt;tenantId&lt;/code&gt;, access groups, the stable course and lesson identifiers, and the source fields used to form the searchable text. At query time, bound the candidate count and apply the same tenant and access rules. At decision time, compare a score against a threshold chosen from representative records and known failure cases. At citation time, return the exact source identifiers and fields behind the match.&lt;/p&gt;

&lt;p&gt;Small boundaries matter.&lt;/p&gt;

&lt;p&gt;A shared collection is attractive when duplicated index entries are the main scaling concern, but it raises the price of sloppy metadata: one missing tenant predicate is no longer a ranking mistake; it is an access-control mistake. A collection-per-tenant design puts the boundary in the collection choice, which is easier to reason about, while shared catalogs may be indexed more than once. Don't pick between them from a feature matrix. Estimate how much content is truly shared, then decide which invariant your team can continuously test.&lt;/p&gt;

&lt;p&gt;Infrai is a deliberate option for the vector step in either shape. I would try it when the tutor already needs several backend capabilities because Infrai gives the team one key for every backend service and one bill instead of another credential and invoice for retrieval. Infrai also exposes one REST API over plain HTTP, so a TypeScript service can call &lt;code&gt;POST /v1/vector/query&lt;/code&gt; without installing a vendor SDK. That reduces integration surface; it doesn't remove the need to design the retrieval contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick isolated collections when boundaries outrank deduplication
&lt;/h2&gt;

&lt;p&gt;Use a collection per tenant, customer catalog, or other hard access boundary when accidental cross-boundary retrieval would be expensive to detect and explain. The collection identifier then carries part of the authorization decision. Every indexed item still needs tenant and access-control metadata because downstream citation and audit records need that context too. Defense in depth is useful here.&lt;/p&gt;

&lt;p&gt;The catch is index repetition. If ten customers license the same course package, ten isolated collections may represent that material ten times. Don't assume this is automatically unacceptable; measure the number of indexed records created by the proposed partition and compare it with the operational value of a simpler boundary. The facts that settle this decision are corpus-specific.&lt;/p&gt;

&lt;p&gt;Keep this architecture if catalog overlap is low, deletion must follow tenant boundaries cleanly, or your team can't prove that every shared-index query applies an identical access predicate. Its invariant is crisp: a request resolves one allowed collection before semantic retrieval begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick a shared collection when index duplication dominates
&lt;/h2&gt;

&lt;p&gt;A shared collection is the stronger starting point when a B2B tutor serves a common course catalog and index cost grows mainly because identical source material is copied for each tenant. Store the content once, but attach the tenant and access-control context required to decide whether a candidate is eligible for the current request. Then bound the query. A top-20 result is a candidate set, not permission to show twenty records and not proof that twenty records are relevant.&lt;/p&gt;

&lt;p&gt;This shape needs a stricter test suite. Include two semantically identical lesson descriptions owned by different tenants, one accessible record just below the initial threshold, a short generic title such as “Introduction,” and two records that share vocabulary while teaching different skills. The first case tests isolation. The second tests recall. The last two expose false positives that look convincing in a demo corpus.&lt;/p&gt;

&lt;p&gt;I'm not sure what similarity threshold fits your corpus before a labeled evaluation set exists. Nobody is. Start with an explicit candidate value, record why each labeled pair should or shouldn't match, and tune against that set rather than quietly moving the threshold after a bad result. Your mileage may vary as course descriptions get shorter or more templated.&lt;/p&gt;

&lt;p&gt;Choose Pinecone, Qdrant, Weaviate, or PostgreSQL with pgvector instead when your existing platform already standardizes on one of them, or when specialist-level control of the vector layer matters more than consolidating backend access. Infrai fits the opposite operating preference: a broad backend surface behind one credential and a consistent REST boundary. This is a system-shape choice — not a universal ranking of products.&lt;/p&gt;

&lt;p&gt;Before tuning the application layer, verify the live discovery entry instead of guessing a request shape. This runnable TypeScript check finds the documented method and path. Discovery is public; the sample still exercises the same environment-based Bearer header used by the protected vector request, and it backs off on &lt;code&gt;429&lt;/code&gt; instead of retrying in a tight loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;apiKey&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INFRAI_API_KEY is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDiscovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://api.infrai.cc/v1/discovery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Bearer ${apiKey}`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"retry-after"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;delayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delayMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getDiscovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getDiscovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`Discovery failed with ${response.status}: ${await response.text()}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;vectorQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"/v1/vector/query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;vectorQuery&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="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Vector query capability is not available in this manifest"&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="nb"&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="n"&gt;vectorQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Make the retrieval contract executable
&lt;/h2&gt;

&lt;p&gt;The following TypeScript is the decision layer after vector search. The sample scores are fixtures, not benchmark results, and &lt;code&gt;0.86&lt;/code&gt; is a starting hypothesis to replace with a threshold learned from your own labeled pairs. Notice what the function refuses to do: it won't rank a candidate from another tenant, it won't admit an inaccessible record, and it won't return an untraceable match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;accessGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;courseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"title"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"summary"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;RetrievalContract&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;allowedGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;maxCandidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;nearDuplicateThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&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;type&lt;/span&gt; &lt;span class="nc"&gt;TraceableMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectNearDuplicates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RetrievalContract&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="nc"&gt;TraceableMatch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accessGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowedGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nearDuplicateThreshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxCandidates&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="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"record-101"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"tenant-acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accessGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"instructors"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.93&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;courseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"course-ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"lesson-4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"summary"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"record-202"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"tenant-other"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accessGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"instructors"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.97&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;courseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"course-js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"lesson-7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"title"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"record-303"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"tenant-acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accessGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"learners"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;courseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"course-ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"lesson-8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"summary"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;selectNearDuplicates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"tenant-acme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;allowedGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"instructors"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;maxCandidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;nearDuplicateThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.86&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&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="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Diagram in words: a course record enters ingestion; ingestion produces searchable text plus immutable ownership metadata; a bounded vector query produces candidates; the decision layer removes forbidden and weak candidates; the tutor receives a match plus source context; logs retain stage, request ID, candidate count, rejection reason, and chosen source identifier. The stages should be visible independently, because “no duplicate found” could mean no item was indexed, no candidate was recalled, every candidate failed access checks, or every eligible score fell below the decision threshold. Those are four different alerts.&lt;/p&gt;

&lt;p&gt;For evaluation, label representative pairs as match or non-match, including the awkward cases above. Run the same set whenever the embedding choice, searchable fields, collection strategy, or threshold changes. Track recall and precision separately. A single accuracy-like number hides the difference between missing a duplicate and flooding an operator with false matches, and those failures have different product costs.&lt;/p&gt;

&lt;p&gt;One correction is especially useful: don't let the answer generator become the first place you notice retrieval quality. The retrieval stage should expose candidate identifiers, scores, access decisions, and source context before any prose is generated. It makes a crisp before/after possible: before tuning, inspect which labeled pairs enter the bounded set; after tuning, inspect which pairs cross the decision threshold and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does this design stop fitting?
&lt;/h2&gt;

&lt;p&gt;Neither architecture removes corpus work. It won't choose a threshold without labeled examples, repair weak source descriptions, or decide how your business defines “near duplicate.” If you need vendor-specific index controls to be the center of the system, stick with a specialist vector product such as Pinecone, Qdrant, or Weaviate. If vectors already live beside relational data and your team wants one operational boundary, PostgreSQL with pgvector may be the more coherent choice.&lt;/p&gt;

&lt;p&gt;For Infrai, the recommendation is narrower: use it for the bounded vector stage when consolidating keys and bills across backend services is worth more than owning a specialist integration. Preserve tenant metadata on every item, keep ingestion, query, and citation observable, and test the same failure set after every tuning change.&lt;/p&gt;

&lt;p&gt;Bound it.&lt;/p&gt;

&lt;p&gt;Then trace it.&lt;/p&gt;

&lt;p&gt;If this boundary fits your system, use the &lt;a href="https://docs.infrai.cc/en/guides/vector/answers/my-rag-chatbot-s-vector-search-keeps-letting-irrelevant/" rel="noopener noreferrer"&gt;vector relevance-tuning guide&lt;/a&gt; to validate the retrieval stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Retrieval-Augmented Generation research paper: &lt;a href="https://arxiv.org/abs/2005.11401" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2005.11401&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pinecone semantic search documentation: &lt;a href="https://docs.pinecone.io/guides/search/semantic-search" rel="noopener noreferrer"&gt;https://docs.pinecone.io/guides/search/semantic-search&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Qdrant search documentation: &lt;a href="https://qdrant.tech/documentation/concepts/search/" rel="noopener noreferrer"&gt;https://qdrant.tech/documentation/concepts/search/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Weaviate similarity search documentation: &lt;a href="https://docs.weaviate.io/weaviate/search/similarity" rel="noopener noreferrer"&gt;https://docs.weaviate.io/weaviate/search/similarity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;pgvector project documentation: &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;https://github.com/pgvector/pgvector&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>retrieval</category>
      <category>architecture</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Từ Khóa final trong Java | Cách sử dụng và ví dụ</title>
      <dc:creator>Tiểu Share</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:17:41 +0000</pubDate>
      <link>https://dev.to/tieushare/tu-khoa-final-trong-java-cach-su-dung-va-vi-du-3bl3</link>
      <guid>https://dev.to/tieushare/tu-khoa-final-trong-java-cach-su-dung-va-vi-du-3bl3</guid>
      <description>&lt;p&gt;&amp;nbsp;&lt;span&gt;"Trong Java, &lt;/span&gt;&lt;b&gt;&lt;a href="https://www.tieushare.com/2026/10/tu-khoa-final-trong-java.html" rel="noopener noreferrer"&gt;từ khóa final&lt;/a&gt;&lt;/b&gt;&lt;span&gt; là một công cụ mạnh mẽ để đảm bảo tính bất biến và an toàn trong lập trình. Nó được sử dụng để hạn chế việc thay đổi giá trị, ghi đè phương thức, hoặc kế thừa lớp, từ đó tăng tính ổn định và rõ ràng cho mã nguồn".&lt;/span&gt;&lt;/p&gt;
&lt;span id="docs-internal-guid-cff0a18d-7fff-3a6d-dde2-4a7746bd6285"&gt;&lt;/span&gt;
&lt;h3&gt;&lt;span&gt;&lt;b&gt;Từ khóa final có thể được áp dụng trong các ngữ cảnh sau:&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;span&gt;↳ Biến final (hằng số)&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ Phương thức final&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ Lớp final&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3&gt;&lt;span&gt;Ngoài ra, một biến final có thể là:&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;span&gt;↳ Biến final rỗng: &lt;/span&gt;&lt;span&gt;Là biến final chưa được khởi tạo ngay từ đầu. Loại biến này có thể được khởi tạo duy nhất một lần trong constructor.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ Biến final static: &lt;/span&gt;&lt;span&gt;Được khởi tạo trong khối static duy nhất, đảm bảo tính bất biến toàn cục.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;Sử dụng từ khóa final giúp chương trình trở nên rõ ràng và dễ bảo trì hơn. Chúng ta sẽ tìm hiểu chi tiết về từng ngữ cảnh sử dụng từ khóa final ở phần bên dưới.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgqeOGrzq1RJG3ToUOGABToKh4Z7TCniUC7uzyHvR8Pry_SZGE5ZWX3qebSn4dBGfhN-D2VRVzzVzUVXbREH4FtbJQIr88C_0PFoqkNtEY8SCl1OiKOENHoXFnUfTBhsX_WSQs7PVpo9nnK_0mhuhez4VY-ItlpHvSyA8fnZB0du8OKCKNUxiY4oCwZdkE/s1600/image.png" rel="noopener noreferrer"&gt;&lt;img alt="Ảnh mô tả các ngữ cảnh của từ khóa final." height="360" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fblogger.googleusercontent.com%2Fimg%2Fb%2FR29vZ2xl%2FAVvXsEgqeOGrzq1RJG3ToUOGABToKh4Z7TCniUC7uzyHvR8Pry_SZGE5ZWX3qebSn4dBGfhN-D2VRVzzVzUVXbREH4FtbJQIr88C_0PFoqkNtEY8SCl1OiKOENHoXFnUfTBhsX_WSQs7PVpo9nnK_0mhuhez4VY-ItlpHvSyA8fnZB0du8OKCKNUxiY4oCwZdkE%2Fw640-h360%2Fimage.png" title="Ảnh mô tả các ngữ cảnh của từ khóa final." width="640"&gt;&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;span&gt;Ảnh mô tả các ngữ cảnh của từ khóa final.&lt;/span&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;

&lt;h1&gt;&lt;span&gt;&lt;span&gt;Ⅰ. Biến final (Biến hằng)&lt;/span&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Khi khai báo một biến với từ khóa final, giá trị của biến đó không thể thay đổi sau khi được khởi tạo. Biến này được gọi là hằng số.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Việc sử dụng biến final giúp tăng cường tính bảo mật cho chương trình, vì nó ngăn chặn việc vô tình thay đổi giá trị của các biến quan trọng.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Biến final cũng có thể cải thiện hiệu suất chương trình, vì trình biên dịch Java có thể tối ưu hóa code tốt hơn cho các biến không thay đổi.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;span&gt;&lt;span&gt;Cú pháp để khai báo biến final như sau:&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;b&gt;Cú Pháp&lt;/b&gt;
&lt;pre&gt;&lt;code&gt;final   = giá_trị_ban_đầu;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;b&gt;Dưới đây là giải thích chi tiết về cú pháp khai báo này:&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &amp;lt;kiểu_dữ_liệu&amp;gt;: &lt;/span&gt;&lt;span&gt;là kiểu dữ liệu của biến, ví dụ như int, double, String, ...&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &amp;lt;tên_biến&amp;gt;: &lt;/span&gt;&lt;span&gt;là tên của biến.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &amp;lt;giá_trị_ban_đầu&amp;gt;: &lt;/span&gt;&lt;span&gt;là giá trị khởi tạo ban đầu của biến (không bắt buộc). Nếu có, bạn chỉ có thể gán giá trị này một lần và không thể thay đổi sau đó.&lt;/span&gt;&lt;/p&gt;

&lt;p&gt;&lt;b&gt;Book.java&lt;/b&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;public class Book {
    // Khai báo các trường final
    final int MAX_SIZE = 100;// Độ dài tối đa
    final double PI = 3.14159;// Giá trị PI
    final String APP_NAME = "MyApp";// Tên ứng dụng

    // Phương thức để thay đổi các giá trị final (lỗi sẽ xảy ra vì không thể gán lại giá trị cho final)
    public void ChangeName(int MAX_SIZE, double PI, String APP_NAME) {
        this.MAX_SIZE = MAX_SIZE; // Lỗi: Không thể gán lại giá trị cho một biến final
        this.PI = PI;// Lỗi: Không thể gán lại giá trị cho một biến final
        this.APP_NAME = APP_NAME;// Lỗi: Không thể gán lại giá trị cho một biến final
    }

    public static void main(String[] args) {
        Book book = new Book();
        book.setTitle(200, 3.14, "APP"); // Gọi phương thức setTitle với các giá trị mới
    }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Exception in thread "main" java.lang.Error: Unresolved compilation problem: The method setTitle(int, double, String) is undefined for the type Book at Book.main(Book.java:16)&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;span&gt;&lt;b&gt;&lt;span&gt;Biến final trống&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;Biến final trống, hay còn gọi là biến final không khởi tạo (blank final variable), là một biến final mà không có giá trị ban đầu được gán cho nó khi khai báo. Trong Java, bạn có thể khai báo biến final trống và sau đó chỉ có thể gán giá trị cho nó trong constructor của lớp hoặc trong khối static (nếu là biến static).&lt;/span&gt;&lt;/p&gt;

&lt;b&gt;MyClass.java&lt;/b&gt;
&lt;pre&gt;&lt;code&gt; class MyClass {
    final int MAX_SIZE;
    final double PI;

    public MyClass(int MAX_SIZE, double PI) {
        this.MAX_SIZE = MAX_SIZE;// Gán giá trị trong constructor
        this.PI = PI;// Gán giá trị trong constructor
    }

    // static {
    //     PI = 3.14159;// Gán giá trị trong khối static
    // }

    public static void main(String[] args) {
        MyClass obj = new MyClass(100, 3.14);
        System.out.println("Kích thước tối đa: " + obj.MAX_SIZE);
        System.out.println("PI= " + obj.PI);
    }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Kích thước tối đa: 100
PI= 3.14&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Lưu ý rằng sau khi một biến final trống được gán giá trị trong constructor hoặc khối static, bạn không thể thay đổi giá trị của nó.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;span&gt;&lt;span&gt;Biến final static&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Biến final static trong Java là biến hằng được khai báo với từ khóa final và static.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Biến final static là biến có giá trị không thay đổi và thuộc về lớp (class) chứ không phải đối tượng (instance) của lớp đó.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Biến final static được khởi tạo trong khối static (static block) của lớp.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Biến final static giúp bạn chia sẻ các hằng số được sử dụng nhiều lần trong toàn bộ chương trình.&lt;/span&gt;&lt;/p&gt;
&lt;h3&gt;&lt;span&gt;Cú pháp để khai báo biến final static như sau:&lt;/span&gt;&lt;/h3&gt;

&lt;b&gt;Cú Pháp&lt;/b&gt;
&lt;pre&gt;&lt;code&gt;final static   = ;
final static  ;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;b&gt;MyClass.java&lt;/b&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;class MyClass {
    final static int MAX_SIZE = 100;
    final static double PI;

    static {
        PI = 3.14159;// Gán giá trị trong khối static
    }

    public static void main(String[] args) {
        MyClass obj = new MyClass();
        System.out.println("Kích thước tối đa: " + obj.MAX_SIZE);
        System.out.println("PI= " + MyClass.PI);
    }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Kích thước tối đa: 100
PI= 3.14 &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;b&gt;Điều quan trọng cần lưu ý là:&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Hằng số MAX_SIZE và PI là final, do đó không thể thay đổi giá trị của chúng sau khi đã được gán giá trị ban đầu.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;MAX_SIZE được truy cập thông qua một đối tượng của lớp MyClass, trong khi PI có thể được truy cập trực tiếp thông qua lớp MyClass vì nó là biến static.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;Khi bạn khai báo một tham số là final, bạn không thể thay đổi giá trị của nó sau khi đã được khởi tạo. Điều này áp dụng cho các biến cục bộ, tham số của phương thức và các thuộc tính của lớp. Việc sử dụng từ khóa final đảm bảo rằng giá trị của biến sẽ không bị thay đổi, tạo ra sự an toàn và nhất quán trong mã nguồn.&lt;/span&gt;&lt;/p&gt;

&lt;b&gt;Example.java&lt;/b&gt;
&lt;pre&gt;&lt;code&gt;public class Example {
    public void printMessage(final String message) {
        message = "New Message"; // Lỗi: không thể gán giá trị mới cho biến final
        System.out.println(message);
        final int number = 10;
        number = 20; // Lỗi: không thể gán giá trị mới cho biến final
        System.out.println(number);
    }

    public static void main(String[] args) {
        Example example = new Example();
        example.printMessage("Hello, World!");
    }
} &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Exception in thread "main" java.lang.Error: Unresolved compilation problems: The final local variable message cannot be assigned. It must be blank and not using a compound assignment The final local variable number cannot be assigned. It must be blank and not using a compound assignment at Example.printMessage(Example.java:3) at Example.main(Example.java:12) &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Trong tất cả các ví dụ trên, biến final không thể thay đổi giá trị sau khi đã được khởi tạo. Điều này giúp ngăn chặn các lỗi không mong muốn và tạo ra mã nguồn dễ bảo trì hơn.&lt;/span&gt;&lt;/p&gt;
&lt;h1&gt;&lt;span&gt;&lt;span&gt;Ⅱ. Phương thức final&lt;/span&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p&gt;&lt;span&gt;Khi một phương thức được khai báo là final, nó không thể bị ghi đè bởi bất kỳ phương thức nào trong lớp con. Điều này đảm bảo rằng hành vi của phương thức không thể bị thay đổi khi lớp được kế thừa. Trình biên dịch và máy ảo Java (JVM) có thể thực hiện tối ưu hóa hiệu suất cho các phương thức final vì biết rằng các phương thức này không thể bị ghi đè.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;span&gt;&lt;span&gt;Cú pháp khai báo phương thức final:&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;

&lt;b&gt;Cú Pháp&lt;/b&gt;
&lt;pre&gt;&lt;code&gt;final  () {
    // thân phương thức
} &lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;b&gt;Example.java&lt;/b&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;class Parent {
    final void show() {
        System.out.println("Đây là phương thức final.");
    }
}

class Child extends Parent {
    //Không thể ghi đè phương thức show() của lớp Parent
    @Override
    void show() {
        System.out.println("Đang cố gắng ghi đè");
    }
}

public class Example {
    public static void main(String[] args) {
        Child obj = new Child();
        obj.show(); // Gọi phương thức từ lớp Parent
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Exception in thread "main" java.lang.IncompatibleClassChangeError: class Child overrides final method Parent.show() &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Việc sử dụng phương thức final thường được áp dụng khi bạn muốn đảm bảo rằng một phương thức cụ thể không bị thay đổi hành vi bởi các lớp con. Điều này có thể hữu ích trong các tình huống mà việc thay đổi hành vi của phương thức có thể gây ra lỗi hoặc ảnh hưởng đến tính toàn vẹn của lớp.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;span&gt;&lt;span&gt;Kế thừa phương thức final&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;Từ khóa final có thể được kế thừa nhưng không thể bị ghi đè (override) bởi lớp con. Điều này có nghĩa là bạn có thể sử dụng phương thức final trong các lớp con, nhưng không thể thay đổi cách thức hoạt động của nó trong lớp con.&lt;/span&gt;&lt;/p&gt;

&lt;b&gt;Example.java&lt;/b&gt;
&lt;pre&gt;&lt;code&gt;// Lớp cha (ParentClass)
class ParentClass {
    // Phương thức final
    public final void displayMessage() {
        System.out.println("Đây là phương thức final từ ParentClass đuược gọi từ Lớp con (ChildClass).");
    }
}

// Lớp con (ChildClass) kế thừa từ ParentClass
class ChildClass extends ParentClass {
    // Cố gắng ghi đè phương thức final sẽ gây lỗi biên dịch
    /*
    @Override
    public void displayMessage() {
        System.out.println("Đang cố gắng ghi đè phương thức final trong ChildClass.");
    }
    */
    
}

public class Example {
    public static void main(String[] args) {
        
        // tạo đối tượng từ lớp ChildClass
        ChildClass child = new ChildClass();
        child.displayMessage(); // Gọi phương thức final được kế thừa từ ParentClass
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Đây là phương thức final từ ParentClass đuược gọi từ Lớp con (ChildClass).&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Tóm lại, phương thức final có thể được kế thừa nhưng không thể bị ghi đè, đảm bảo rằng hành vi của phương thức không thể bị thay đổi bởi các lớp con, giúp bảo vệ tính toàn vẹn của phương thức và đảm bảo rằng các lớp con không thể làm sai lệch hành vi đã được xác định trong lớp cha. Điều này rất quan trọng khi bạn muốn bảo vệ một phần cụ thể của mã nguồn khỏi bị thay đổi không mong muốn trong quá trình kế thừa.&lt;/span&gt;&lt;/p&gt;
&lt;h1&gt;&lt;span&gt;&lt;span&gt;Ⅳ. Lớp final&lt;/span&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p&gt;&lt;span&gt;Khi một lớp được khai báo với &lt;a href="https://www.tieushare.com/2026/10/tu-khoa-final-trong-java.html" rel="noopener noreferrer"&gt;từ khóa final&lt;/a&gt;, nghĩa là lớp đó không thể bị kế thừa bởi bất kỳ lớp nào khác. Việc sử dụng lớp final thường nhằm mục đích bảo vệ cấu trúc và hành vi của lớp, đảm bảo rằng không ai có thể mở rộng và thay đổi chúng.&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;&lt;span&gt;&lt;span&gt;Cú pháp khai báo lớp final:&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;

&lt;b&gt;Cú Pháp&lt;/b&gt;
&lt;pre&gt;&lt;code&gt; final class  {
    // các thành phần của lớp
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;b&gt;Example.java&lt;/b&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Lớp Parent được khai báo là final, nghĩa là không thể kế thừa lớp này&lt;br&gt;
public final class Parent {&lt;br&gt;
    // Phương thức show() của lớp Parent&lt;br&gt;
    void show() {

&lt;pre class="highlight plaintext"&gt;&lt;code&gt;}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;// Lớp Child cố gắng kế thừa từ lớp Parent, nhưng sẽ gây lỗi biên dịch vì lớp Parent là final&lt;br&gt;
public class Child extends Parent {&lt;br&gt;
    // Phương thức show() của lớp Child cố gắng ghi đè phương thức show() của lớp Parent&lt;br&gt;
    void show() {&lt;br&gt;
        System.out.println("Đang cố gắng kế thừa");&lt;br&gt;
    }&lt;br&gt;
}&lt;/p&gt;

&lt;/code&gt;&lt;p&gt;&lt;code&gt;// Lớp Example chứa phương thức main để chạy chương trình&lt;br&gt;&lt;br&gt;
public class Example {&lt;br&gt;&lt;br&gt;
    public static void main(String[] args) {&lt;br&gt;&lt;br&gt;
        // Tạo một đối tượng của lớp Child&lt;br&gt;&lt;br&gt;
        Child obj = new Child();&lt;br&gt;&lt;br&gt;
        // Gọi phương thức show() từ đối tượng obj&lt;br&gt;&lt;br&gt;
        obj.show(); // Gọi phương thức từ lớp Parent&lt;br&gt;&lt;br&gt;
    }&lt;br&gt;&lt;br&gt;
} &lt;/code&gt;&lt;/p&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;&lt;span id="docs-internal-guid-6312f6eb-7fff-1a50-b17f-c1dc6278b43f"&gt;&lt;span&gt;&lt;b&gt;Kết quả của chương trình là: &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Exception in thread "main" java.lang.Error: Unresolved compilation problems: The public type Parent must be defined in its own file The public type Child must be defined in its own file The type Child cannot subclass the final class Parent&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;span&gt;&lt;b&gt;Lớp final thường được sử dụng trong các tình huống sau:&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Lớp cung cấp các phương thức tiện ích, chẳng hạn như các lớp trong thư viện Java như java.lang.Math hoặc java.lang.String.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;↳ &lt;/span&gt;&lt;span&gt;Lớp đại diện cho các khái niệm mà việc kế thừa không có ý nghĩa, chẳng hạn như các lớp đại diện cho hằng số hoặc các dịch vụ không thay đổi.&lt;/span&gt;&lt;/p&gt;
&lt;span&gt;&lt;br&gt;&lt;/span&gt;

</description>
      <category>từkhóafinaltrongjava</category>
      <category>finalclassjava</category>
      <category>finaltrongjavalàgì</category>
      <category>tieushare</category>
    </item>
    <item>
      <title>Why Generating an AI Video Is Easy, but Building a Usable AI Video Ad Product Is Hard</title>
      <dc:creator>Zhu Jean</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:17:10 +0000</pubDate>
      <link>https://dev.to/jeanbuilds/why-generating-an-ai-video-is-easy-but-building-a-usable-ai-video-ad-product-is-hard-1i43</link>
      <guid>https://dev.to/jeanbuilds/why-generating-an-ai-video-is-easy-but-building-a-usable-ai-video-ad-product-is-hard-1i43</guid>
      <description>&lt;p&gt;Over the past year, I've spent a lot of time exploring AI tools, especially AI video generation.&lt;/p&gt;

&lt;p&gt;And it's pretty amazing how much things have changed.&lt;/p&gt;

&lt;p&gt;You can upload a product photo, write a prompt, and get a video back in minutes. Things that used to take a lot of time and effort are becoming much easier.&lt;/p&gt;

&lt;p&gt;But there's something I keep coming back to.&lt;/p&gt;

&lt;p&gt;If creating videos is getting easier, why does making a &lt;em&gt;useful video ad&lt;/em&gt; still feel so complicated?&lt;/p&gt;

&lt;p&gt;I started thinking about this when I was working as a product manager on AI SaaS products. We worked with ecommerce businesses, including some large cross-border sellers in China. Some of them needed to produce a huge number of video ad variations—not just one or two polished videos.&lt;/p&gt;

&lt;p&gt;The way they worked was interesting, and a lot more hands-on than you might imagine.&lt;/p&gt;

&lt;p&gt;That experience stayed with me. And it's one of the reasons I eventually started building &lt;a href="https://getsprokl.com" rel="noopener noreferrer"&gt;Sprokl&lt;/a&gt;, an AI video advertising product for ecommerce sellers.&lt;/p&gt;

&lt;p&gt;Let me explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A Little Background: Why I Wanted to Build This
&lt;/h2&gt;

&lt;p&gt;I've worked in product management for years, including at SaaS companies where I had opportunities to speak with customers and work on products in the AI video advertising space.&lt;/p&gt;

&lt;p&gt;I'm also someone who genuinely enjoys making visual content. I like photography, shooting videos, and editing. So when AI started changing what's possible in video creation, it was hard not to get curious.&lt;/p&gt;

&lt;p&gt;Earlier this year, I left my job for personal reasons. As I thought about what I wanted to build on my own, I found myself coming back to the same space.&lt;/p&gt;

&lt;p&gt;Not because the world needed another video generation tool. There are already plenty of impressive ones.&lt;/p&gt;

&lt;p&gt;What interested me was the gap between an exciting AI demo and the messy, repetitive work of actually producing ads for a business.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What I Saw Working With Ecommerce Teams
&lt;/h2&gt;

&lt;p&gt;One of the clearest examples came from cross-border ecommerce companies I encountered in China.&lt;/p&gt;

&lt;p&gt;They weren't struggling to make a single video. Their challenge was &lt;em&gt;volume&lt;/em&gt;: making enough different ads to test, learn, and keep campaigns moving.&lt;/p&gt;

&lt;p&gt;Some companies hired editors in smaller cities to help with production costs. The work itself was often manual. Editors would cut footage, try different hooks, rearrange scenes, and make new versions of existing ads.&lt;/p&gt;

&lt;p&gt;Then the performance team would run those ads and look at the results. If a particular creative performed well, it became a reference for the next batch. The editors would make more variations inspired by that format.&lt;/p&gt;

&lt;p&gt;So the loop looked something like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make ads → run campaigns → study performance → identify promising patterns → brief editors → make more ads.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I don't think there's anything wrong with learning from real campaign data. That's exactly what marketers should do.&lt;/p&gt;

&lt;p&gt;But the handoff from insight to new creative could take time. And if the team kept reworking a few familiar templates, it wasn't always easy to explore new creative directions.&lt;/p&gt;

&lt;p&gt;In a fast-moving campaign, a slow production cycle can mean missing the moment when a concept is most relevant.&lt;/p&gt;

&lt;p&gt;I also came across promotional editing for short-form dramas—taking highlights, testing different openings, and cutting versions for distribution. That was another reminder that video advertising isn't one universal workflow. Different businesses have different production habits.&lt;/p&gt;

&lt;p&gt;I don't claim to have mapped every customer's process. These are observations from the kinds of teams I worked with, and they're part of what made the problem interesting to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AI Could Make This Faster. But What Exactly Should It Do?
&lt;/h2&gt;

&lt;p&gt;When we talk about AI video, it's easy to focus on the output: realism, camera motion, quality, or which model is best.&lt;/p&gt;

&lt;p&gt;Those things matter. But I started asking a different question:&lt;/p&gt;

&lt;p&gt;What if AI helped teams go from &lt;em&gt;“this creative seems to work”&lt;/em&gt; to &lt;em&gt;“here are three meaningful ideas we can test next”&lt;/em&gt; with less manual work?&lt;/p&gt;

&lt;p&gt;Say a small store already has product footage and one ad that performed well. They might want to keep the product shots but test a different hook. Or reuse the overall pacing, but change the story. Or turn one piece of footage into variations for different audiences.&lt;/p&gt;

&lt;p&gt;They don't necessarily need AI to invent every frame from scratch.&lt;/p&gt;

&lt;p&gt;Sometimes they need help understanding what to change, organizing the available footage, and making a new version without repeating the whole process.&lt;/p&gt;

&lt;p&gt;Regenerating everything can also mean more iterations and more cost. That's why I've become interested in combining AI assistance with clear, editable steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Who I'm Building Sprokl For
&lt;/h2&gt;

&lt;p&gt;I'm starting with a fairly specific audience: smaller ecommerce businesses, especially Shopify and independent store owners who need video ads but don't have a large in-house creative team.&lt;/p&gt;

&lt;p&gt;A seller may already have product photos, demonstration clips, or a few videos from earlier campaigns. But turning those assets into new ad concepts can still take time.&lt;/p&gt;

&lt;p&gt;I also want to learn from small marketing agencies that make ads for multiple ecommerce clients.&lt;/p&gt;

&lt;p&gt;These are the groups I want to understand first—not customer segments I can already claim to have fully validated.&lt;/p&gt;

&lt;p&gt;My goal isn't to build a tool for everyone who wants to make a video. It's to make a particular advertising workflow more manageable for people doing it repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Why Another AI Video Tool?
&lt;/h2&gt;

&lt;p&gt;That's a fair question. Products like Creatify and other AI advertising tools already offer useful capabilities.&lt;/p&gt;

&lt;p&gt;I don't think the best way to stand out is to claim that Sprokl will generate the most spectacular video or have the longest list of models.&lt;/p&gt;

&lt;p&gt;What I care about is the experience &lt;em&gt;around&lt;/em&gt; generation.&lt;/p&gt;

&lt;p&gt;Many AI products give you powerful options: text-to-video, image-to-video, avatars, voices, reference clips, model settings, and more. If you're comfortable with creative tools, those options can be exciting.&lt;/p&gt;

&lt;p&gt;But a small business owner may not want to become an expert at model selection or prompting. They just want to make an ad that communicates their product clearly.&lt;/p&gt;

&lt;p&gt;There's also the question of control. Chat-based interfaces can be great for exploring ideas, but I don't think every editing decision should have to become another message in a conversation.&lt;/p&gt;

&lt;p&gt;If a marketer wants to keep a specific clip, swap the hook, or review the script before rendering, a structured interface can make those choices easier to see.&lt;/p&gt;

&lt;p&gt;So the direction I'm exploring is &lt;strong&gt;a familiar graphical workflow with AI helping at the right moments&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;AI can suggest and analyze. The user can review, choose, and adjust.&lt;/p&gt;

&lt;p&gt;To me, that's a more practical starting point than asking someone to hand the whole process to a black box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdobammboitt2lddwwl6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdobammboitt2lddwwl6.png" alt="A two-lane comparison of traditional ad production and an AI-assisted workflow from product assets to testing." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. From a Prompt to a Real Creative Workflow
&lt;/h2&gt;

&lt;p&gt;In Sprokl, I'm building around something sellers already have: their own product materials.&lt;/p&gt;

&lt;p&gt;The core workflow is designed to move through four stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Product assets:&lt;/strong&gt; Start with existing product photos and video clips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creative concepts:&lt;/strong&gt; Explore advertising angles before committing to a final video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script and visual review:&lt;/strong&gt; See how the message, scenes, and available footage fit together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production:&lt;/strong&gt; Turn the chosen direction into a finished video, including editing elements such as captions and voiceover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What matters to me is that users have a chance to understand the creative direction before spending time and money on production.&lt;/p&gt;

&lt;p&gt;I'm also developing an &lt;strong&gt;Ad Clone&lt;/strong&gt; workflow. The idea is to study a reference ad's creative structure—its opening, pacing, scenes, and narrative—and adapt those patterns using the seller's own product assets. It's about learning from a creative approach, not simply copying someone else's finished ad.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad Clone is still in development&lt;/strong&gt;, so I'm not presenting it as a released or proven feature. But it represents the kind of control I want the product to offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The Hard Part Isn't Just the Video Model
&lt;/h2&gt;

&lt;p&gt;Working on Sprokl has reminded me that a good AI model is only one piece of a usable product.&lt;/p&gt;

&lt;p&gt;What happens when an analysis step fails? How do you explain an AI-generated creative suggestion? What if a user likes the script but wants a different shot? Can they reuse the assets they already uploaded?&lt;/p&gt;

&lt;p&gt;And most importantly: can someone finish the task without having to understand the technology behind it?&lt;/p&gt;

&lt;p&gt;These aren't just model-quality questions. They're product-design questions.&lt;/p&gt;

&lt;p&gt;I used to think a lot about adding capabilities. Now I spend more time asking which decisions the product should simplify—and which decisions users should still control.&lt;/p&gt;

&lt;p&gt;I don't have every answer yet. But the more I work on this, the more I believe that making AI usable is a different challenge from making AI impressive.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. What I'm Learning Next
&lt;/h2&gt;

&lt;p&gt;Sprokl is still a work in progress. I'm improving the existing experience, developing the new Ad Clone workflow, and working toward a more reliable end-to-end process.&lt;/p&gt;

&lt;p&gt;I'm also trying to spend more time listening to actual sellers and marketers.&lt;/p&gt;

&lt;p&gt;It's easy to design a neat workflow on a whiteboard. Real businesses rarely work that neatly. Different teams have different bottlenecks, from coming up with concepts to finding footage to producing enough variations.&lt;/p&gt;

&lt;p&gt;I'm interested in understanding where a more flexible workflow could help. That may eventually mean adapting parts of the product to particular needs, but I don't want to promise custom integrations or APIs before I've learned what users actually need.&lt;/p&gt;

&lt;p&gt;If you're an ecommerce founder, marketer, or someone building AI creative tools, I'd love to ask you one question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which part of making video ads takes the most time for you right now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Is it generating ideas, writing scripts, choosing footage, editing variations, or turning campaign insights into your next round of creatives?&lt;/p&gt;

&lt;p&gt;I'd genuinely love to hear how you're doing it today.&lt;/p&gt;

&lt;p&gt;I'm sharing what I learn while building &lt;a href="https://getsprokl.com" rel="noopener noreferrer"&gt;Sprokl&lt;/a&gt;. If this problem sounds familiar, feel free to leave a comment or reach out.&lt;/p&gt;

&lt;p&gt;For me, the interesting future of AI video advertising isn't just about producing more videos. It's about making the creative process easier to understand, control, and improve.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an independent founder with a background in SaaS product management and AI products. I enjoy photography, video editing, and building simpler experiences for complicated workflows.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of Sprokl, the product discussed here. This article reflects my own professional experiences and product thinking, and was drafted with AI writing assistance.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devlog</category>
      <category>saas</category>
      <category>product</category>
    </item>
    <item>
      <title>How we cut our homepage from 4 MB to 179 KB and reached Lighthouse 100</title>
      <dc:creator>Muhammad Ali Raza</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:15:59 +0000</pubDate>
      <link>https://dev.to/ceo_quantumines/how-we-cut-our-homepage-from-4-mb-to-179-kb-and-reached-lighthouse-100-1aln</link>
      <guid>https://dev.to/ceo_quantumines/how-we-cut-our-homepage-from-4-mb-to-179-kb-and-reached-lighthouse-100-1aln</guid>
      <description>&lt;p&gt;Two PNG images made up almost all of quantumines.com's 4 MB homepage. Serving them as AVIF and WebP at the size each screen needs cut the page to 179 KB and the mobile Largest Contentful Paint (LCP) from about 17 seconds to 1.8 seconds. Self-hosted fonts with size-matched fallbacks, two colour fixes and an accessible-name fix took the remaining Lighthouse scores to 100. We made all of these changes on 10 November 2026 in one commit. This write-up lists each one, with the code, so another small team can repeat it in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where did the 4 MB come from?
&lt;/h2&gt;

&lt;p&gt;The network list in Lighthouse and Chrome DevTools answered that quickly. The homepage loaded a 2.0 MB PNG for the hero sculpture and a 2.1 MB PNG for the product artwork, both exported straight from a design tool at full quality. Everything else on the page, HTML, CSS, JavaScript and icons, was small. A 1.6 MB logo PNG was also referenced from our structured data, though not displayed.&lt;/p&gt;

&lt;p&gt;On a phone over a throttled mobile connection, which is what Lighthouse simulates, two megabytes take many seconds to arrive. And because the hero is the largest thing on screen, it is the element LCP measures: until it finishes, the page counts as not loaded.&lt;/p&gt;

&lt;p&gt;quantumines.com homepage before and after (Lighthouse mobile, 10 November 2026)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&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;Total page weight&lt;/td&gt;
&lt;td&gt;about 4 MB&lt;/td&gt;
&lt;td&gt;179 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest Contentful Paint (mobile)&lt;/td&gt;
&lt;td&gt;about 17 s&lt;/td&gt;
&lt;td&gt;1.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accessibility score&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All four Lighthouse categories&lt;/td&gt;
&lt;td&gt;not all 100&lt;/td&gt;
&lt;td&gt;100 / 100 / 100 / 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cumulative Layout Shift&lt;/td&gt;
&lt;td&gt;not recorded&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 1: serve images as AVIF and WebP, at the right size
&lt;/h2&gt;

&lt;p&gt;We exported each large image three times (480, 768 and 1,024 pixels wide for the hero) in AVIF and in WebP, and let the browser pick with &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt;, &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt;. The 1,024-pixel hero dropped from 2.0 MB as PNG to 44 KB as AVIF; a phone downloads the 480-pixel AVIF, which is 12.5 KB.&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;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;
    &lt;span class="na"&gt;srcset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"assets/hero-480.avif 480w, assets/hero-768.avif 768w, assets/hero-1024.avif 1024w"&lt;/span&gt;
    &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 760px) 100vw, 55vw"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;
    &lt;span class="na"&gt;srcset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"assets/hero-480.webp 480w, assets/hero-768.webp 768w, assets/hero-1024.webp 1024w"&lt;/span&gt;
    &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 760px) 100vw, 55vw"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hero-image"&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"assets/hero-1024.webp"&lt;/span&gt;
    &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Sculpture of mint metal, glass and textured stone"&lt;/span&gt;
    &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1024"&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1280"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter. The &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes let the browser reserve the space before the image arrives, so nothing jumps. &lt;code&gt;fetchpriority="high"&lt;/code&gt; tells it to fetch the LCP image before other images. And the hero is never lazy-loaded: lazy loading the largest image on screen delays exactly the thing LCP measures. The product artwork, further down the page, got the same AVIF and WebP treatment at 768 and 1,536 pixels.&lt;/p&gt;

&lt;p&gt;The 1.6 MB logo in our Organization schema was replaced with a 512-pixel PNG of 137 KB. Search engines don't need more than that, and it no longer turns up in audits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: self-host the fonts and stop the text from jumping
&lt;/h2&gt;

&lt;p&gt;The site used Manrope and DM Sans from Google Fonts. That meant a render-blocking stylesheet request to another domain, then the font files from a third. We downloaded the variable versions of both (they are under the SIL Open Font License), serve them as WOFF2 from our own domain and preload them:&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;link&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/fonts/manrope-var.woff2"&lt;/span&gt; &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font/woff2"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/fonts/dm-sans-var.woff2"&lt;/span&gt; &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font/woff2"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&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;With &lt;code&gt;font-display: swap&lt;/code&gt;, text appears at once in a fallback font and switches when the web font arrives. The switch can move text, because Arial is wider or taller than the real font. So we defined fallback faces measured against Arial, with &lt;code&gt;size-adjust&lt;/code&gt; and the ascent and descent overrides, and listed them right after the real font:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Manrope Fallback'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Arial'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Liberation Sans'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;local&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Helvetica'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;size-adjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;94.9&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ascent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;112.4&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;descent-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;31.6&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line-gap-override&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="kt"&gt;%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;--display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Manrope'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Manrope Fallback'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Together with the image dimensions, this is why Cumulative Layout Shift is 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: fix the last accessibility points
&lt;/h2&gt;

&lt;p&gt;Accessibility was at 96. Lighthouse flagged two things. Some muted text in the light theme was below the 4.5:1 contrast ratio that WCAG requires for normal text, so we darkened those colours until each passed. And the mobile menu button's accessible name didn't match its visible label "Menu", which confuses people who use voice control: they say what they see. Making the name match the label fixed it. Both changes took minutes; finding them is what the audit is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: cache by file type
&lt;/h2&gt;

&lt;p&gt;Our Node server sets &lt;code&gt;Cache-Control&lt;/code&gt; from the file extension. Fonts never change under the same name, so browsers may keep them for a year. Images can be kept for a week and refreshed in the background. HTML, CSS and JavaScript are checked again after an hour, so a deploy shows up quickly:&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="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'.woff2'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'public, max-age=31536000, immutable'&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;avif&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webp&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;png&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jpe&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;svg&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ico&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'public, max-age=604800, stale-while-revalidate=86400'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'public, max-age=3600'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: make the next page feel instant
&lt;/h2&gt;

&lt;p&gt;Two newer browser features cost a few lines each. Speculation Rules tell Chrome to prefetch a same-site page when the visitor hovers over its link, so the click usually lands on a page that is already downloaded. We exclude anything with side effects or private data, such as log-in, the app, admin pages and the API:&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;script&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"speculationrules"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"prefetch"&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="s2"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"document"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"and"&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="s2"&gt;"href_matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"/*"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"not"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"href_matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/login"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"/app/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"/admin/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"/api/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"/logout"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"eagerness"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"moderate"&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cross-document view transitions add a short cross-fade between pages with one CSS rule, turned off for people who ask their system for reduced motion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@view-transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;@view-transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browsers that don't support either feature ignore them, so there is no downside for anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do we keep it at 100?
&lt;/h2&gt;

&lt;p&gt;Scores drift as pages are added. Every time the site is rebuilt, we run Lighthouse on the homepage, the newest post and one product page, and fix anything below 95 before it goes live. New images must be AVIF or WebP with width, height and &lt;code&gt;srcset&lt;/code&gt;. Each page's preview image for social media is generated at build time, so nobody uploads a multi-megabyte PNG by hand. The same discipline carried over to &lt;a href="https://quantumines.com/blog/how-we-added-webmcp-to-quantumines-com" rel="noopener noreferrer"&gt;adding WebMCP for AI agents&lt;/a&gt;: the site still scores 100 in all four categories with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would we tell another small team?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open the network list before touching code. On most small sites one or two images are most of the weight.&lt;/li&gt;
&lt;li&gt;Fix the LCP image first: modern format, right size, &lt;code&gt;fetchpriority="high"&lt;/code&gt;, never lazy.&lt;/li&gt;
&lt;li&gt;Give every image width and height, and give web fonts a size-matched fallback. That is most of CLS.&lt;/li&gt;
&lt;li&gt;Treat accessibility warnings as bugs. They are usually quick to fix and they affect real people.&lt;/li&gt;
&lt;li&gt;Re-run the audit on every build, not once a quarter.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is AVIF supported in all browsers?
&lt;/h3&gt;

&lt;p&gt;AVIF works in current versions of Chrome, Edge, Firefox and Safari. The &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element falls back to WebP and then to the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; source, so older browsers still get an image.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a good Largest Contentful Paint?
&lt;/h3&gt;

&lt;p&gt;Google's Core Web Vitals treat an LCP of 2.5 seconds or less as good, measured at the 75th percentile of real page loads. quantumines.com's homepage measures 1.8 seconds in Lighthouse's mobile test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a Lighthouse score of 100 guarantee good Core Web Vitals?
&lt;/h3&gt;

&lt;p&gt;No. Lighthouse is a lab test on one simulated device. Core Web Vitals in Search Console come from real visitors' browsers. A high lab score makes good field data likely, but the field data is what Google uses.&lt;/p&gt;

&lt;p&gt;Quantumines is a technology and product studio in Lahore, Pakistan, founded by &lt;a href="https://quantumines.com/muhammad-ali-raza" rel="noopener noreferrer"&gt;Muhammad Ali Raza (Ali Arbi)&lt;/a&gt;. We build websites and web apps for clients with the same standards; see &lt;a href="https://quantumines.com/services" rel="noopener noreferrer"&gt;our services&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://quantumines.com/blog/how-quantumines-com-reached-lighthouse-100" rel="noopener noreferrer"&gt;quantumines.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>html</category>
      <category>css</category>
    </item>
    <item>
      <title>Spring Boot MCP Server Security: 2026-07-28 Spec Guide</title>
      <dc:creator>Krishna Yadav</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:15:35 +0000</pubDate>
      <link>https://dev.to/krishna_y/spring-boot-mcp-server-security-2026-07-28-spec-guide-1a25</link>
      <guid>https://dev.to/krishna_y/spring-boot-mcp-server-security-2026-07-28-spec-guide-1a25</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0380e7b1uw12dq0yiktm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0380e7b1uw12dq0yiktm.jpg" alt="Illustration of a Spring Boot MCP server behind a load balancer, validating bearer tokens and issuing a step-up challenge for a payment tool" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To secure a Spring Boot MCP server under the 2026-07-28 spec, treat it as an OAuth 2.1 resource server: keep the endpoint stateless, validate the JWT signature, issuer and audience, authorize each tool by scope, and demand step-up authentication for payment tools. On the login-server side, Client ID Metadata Documents (CIMD) replace Dynamic Client Registration.&lt;/p&gt;

&lt;p&gt;I previously covered &lt;a href="https://krishnakky.com/blog/mcp-server-spring-boot-spring-ai-expose-java-backend-ai-agents" rel="noopener noreferrer"&gt;exposing a Java backend as an MCP server with Spring AI&lt;/a&gt;, which dealt with getting tools in front of an agent. Here the question is different: what changes once that agent can reach a payment API, and what the 2026-07-28 revision of the protocol means for whoever carries the pager for the server.&lt;/p&gt;

&lt;p&gt;The authorization half of this is not news. The spec has framed MCP servers as OAuth resource servers since the 2025-06 revision. What the new revision changes is operational: it removes the session handshake and the open-registration fallback, the two things that made a remote MCP server painful to run behind a normal load balancer and a normal identity provider. If you have built &lt;a href="https://krishnakky.com/blog/openbanking-psd2-api-development" rel="noopener noreferrer"&gt;PSD2 APIs&lt;/a&gt;, a lot carries over directly. Strong customer authentication step-up becomes an &lt;code&gt;acr&lt;/code&gt;/&lt;code&gt;auth_time&lt;/code&gt; challenge (RFC 9470). TPP consent scopes become tool scopes. The "which TPP is this?" question becomes the CIMD client ID. The mapping is almost one-to-one, and I use it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plain-language version
&lt;/h2&gt;

&lt;p&gt;If OAuth and MCP vocabulary is new to you, this section is the short glossary. Otherwise skip to the architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; lets an AI assistant ask another system "what can you do?" and get back a list of &lt;strong&gt;tools&lt;/strong&gt;, for example "look up a payment" or "create a refund", which it can then call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth&lt;/strong&gt; lets an application act for a user without ever seeing the user's password, the mechanism behind every "Sign in with Google" button. It involves a &lt;strong&gt;client&lt;/strong&gt; (here, the AI application), an &lt;strong&gt;authorization server&lt;/strong&gt; (the login system that hands out &lt;strong&gt;access tokens&lt;/strong&gt;), and a &lt;strong&gt;resource server&lt;/strong&gt; (the system that holds the data and inspects those tokens). Your MCP server is the resource server.&lt;/p&gt;

&lt;p&gt;An access token is usually a &lt;strong&gt;JWT&lt;/strong&gt;: a signed blob of claims saying who the user is, which &lt;strong&gt;scopes&lt;/strong&gt; (named permissions such as &lt;code&gt;payments:read&lt;/code&gt;) were granted, and which service the token was issued for. That last claim is the &lt;strong&gt;audience&lt;/strong&gt;, and it is the one people forget. A token issued for the billing service must bounce off the payments service.&lt;/p&gt;

&lt;p&gt;Two problems drove the 2026-07-28 changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; Older remote MCP servers started a &lt;em&gt;session&lt;/em&gt;: the client sent an initialize call, received a session ID, and every follow-up request had to land on the replica that remembered it. In practice that meant sticky sessions on the load balancer, and a user whose pod got rescheduled lost their conversation. The new revision drops the initialize handshake and the session ID, so each request is self-contained and any replica can serve it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Introductions.&lt;/strong&gt; Before an authorization server can show a consent screen, it has to know who the app is. The old fallback was &lt;strong&gt;Dynamic Client Registration (DCR, RFC 7591)&lt;/strong&gt;, where any app registers itself unattended. Across thousands of AI apps and one shared login server, that produces thousands of records nobody can vouch for. The revision deprecates DCR in favour of &lt;strong&gt;Client ID Metadata Documents (CIMD)&lt;/strong&gt;: the app publishes a JSON file on its own domain and uses that file's URL as its client ID. The login server fetches it, reads the name and redirect addresses, and applies its own trust policy. No record exists on the login server until a human or a policy says yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the changelog confirms, and what it leaves open
&lt;/h2&gt;

&lt;p&gt;I read the official 2026-07-28 changelog on modelcontextprotocol.io and compared it with the project's release post. These points are firm:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The initialize handshake and session ID are gone from the core, which is the basis for the stateless transport.&lt;/li&gt;
&lt;li&gt;DCR (RFC 7591) is deprecated in favour of CIMD (the changelog tracks this as PR #2858). It keeps working for backward compatibility and is slated for removal in a later version.&lt;/li&gt;
&lt;li&gt;Issuer validation per RFC 9207 is added, so a client can tell whether an authorization response really came from the server it called.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things are not firm. The changelog gives no removal date for DCR, so I plan around "deprecated, not removed" rather than any calendar. And a few secondary write-ups list a registration priority order (pre-registered credentials, then CIMD, then DCR, then ask the user); I have not seen that order stated in the spec text, so I do not rely on it.&lt;/p&gt;

&lt;p&gt;On Spring AI, my working position is this. Spring Security gives you everything in the OAuth half for free: resource-server JWT validation, &lt;code&gt;SCOPE_&lt;/code&gt; authorities, method security. Everything MCP-specific that I describe below as custom (protected resource metadata, audience binding, step-up guard, CIMD resolution) is code I wrote and run myself, because I did not find a Spring AI starter that provides it for me. If a newer release of the starters covers one of them, delete mine; the Spring AI changelog for your exact version settles that in a few minutes. For a broader view of what Java teams get when building agents on top of this, see my notes on &lt;a href="https://krishnakky.com/blog/spring-ai-agents-experimental-project-java-production-agentic" rel="noopener noreferrer"&gt;Spring AI's new Agents project&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvddjhsuln9jhnfivk3jd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvddjhsuln9jhnfivk3jd.webp" alt="Architecture diagram: an AI client discovers the MCP server's protected resource metadata, obtains a token from the authorization server using a Client ID Metadata Document, and calls stateless MCP endpoints on any of several Spring Boot instances that validate audience, scope and step-up level" width="800" height="917"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four moves. The client calls the MCP endpoint with no token and gets a &lt;code&gt;401&lt;/code&gt; pointing at the server's &lt;strong&gt;protected resource metadata&lt;/strong&gt; (RFC 9728), a JSON document naming the authorization server. The client then goes to that authorization server and presents its CIMD URL as its client ID. It returns with a token. Finally it calls whichever replica the load balancer picks, and that replica validates the token locally. Nothing on any instance remembers the client, which is what keeps scaling out dull, the quality I want most from infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a stateless endpoint behind a plain load balancer
&lt;/h2&gt;

&lt;p&gt;At AirAsia we served around 130 million requests a day, and the rule that paid off most was refusing instance-local state. I have watched sticky sessions mask a capacity problem: one node runs hot while its neighbours idle, the dashboards average it out, and nobody notices until that node is replaced during a deploy and its users all get logged out at once. A stateless MCP endpoint lets you keep the same rule.&lt;/p&gt;

&lt;p&gt;The security configuration is where statelessness shows up. No session, no CSRF cookie ritual, only bearer tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableMethodSecurity&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpSecurityConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nc"&gt;SecurityFilterChain&lt;/span&gt; &lt;span class="nf"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpSecurity&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                            &lt;span class="nc"&gt;McpAuthEntryPoint&lt;/span&gt; &lt;span class="n"&gt;entryPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;securityMatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/mcp/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/.well-known/oauth-protected-resource"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sessionManagement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sessionCreationPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreationPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STATELESS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csrf&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;csrf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disable&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="c1"&gt;// bearer-only, no cookies&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorizeHttpRequests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestMatchers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/.well-known/oauth-protected-resource"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;permitAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;anyRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oauth2ResourceServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Customizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticationEntryPoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entryPoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning CSRF off is defensible only because this path takes nothing but an &lt;code&gt;Authorization&lt;/code&gt; header and never reads cookies. The day someone adds cookie auth to the same prefix, switch it back on.&lt;/p&gt;

&lt;p&gt;Whether the MCP transport itself is stateless is a separate question from whether your security chain is. Spring AI has had a stateless Streamable HTTP server mode for a while, and that is where I would start. I could not match it against the final 2026-07-28 wire format from the release notes alone, so I test instead of trusting: run two replicas, alternate requests between them in an integration test, and treat any failure as hidden state. It is a ten-line test and it has caught session leaks for me more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: publish protected resource metadata
&lt;/h2&gt;

&lt;p&gt;The client needs to learn where to log in. RFC 9728 defines a well-known document for exactly that. This is custom code, and it is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProtectedResourceMetadataController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;McpSecurityProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;ProtectedResourceMetadataController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpSecurityProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/.well-known/oauth-protected-resource"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;produces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;APPLICATION_JSON_VALUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceUri&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="c1"&gt;// e.g. https://mcp.example.com/mcp&lt;/span&gt;
            &lt;span class="s2"&gt;"authorization_servers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"scopes_supported"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"payments:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"payments:write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"mandates:read"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"bearer_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"header"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resource&lt;/code&gt; does double duty. It is also the identifier your tokens must carry as their audience, which the next step enforces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: JWT validation with audience binding
&lt;/h2&gt;

&lt;p&gt;This is the step where I have seen real damage. A resource server that checks signature and issuer but not audience will happily accept a token that was minted for a different service in the same realm. In open banking we called it a confused-deputy bug and it failed pen-tests. The typical way it arrives: a team reuses one realm for the internal admin API and the new agent-facing API, the agent's token is scoped fine, and nobody notices that the admin API never asked who the token was for. With MCP the exposure is wider, because the premise is that many different agents hold tokens.&lt;/p&gt;

&lt;p&gt;Spring Security verifies signature and expiry by default. Audience binding takes a few lines of your own, expressed as a proper &lt;code&gt;OAuth2TokenValidator&lt;/code&gt; so failures come back as standard &lt;code&gt;invalid_token&lt;/code&gt; errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Bean&lt;/span&gt;
&lt;span class="nc"&gt;JwtDecoder&lt;/span&gt; &lt;span class="nf"&gt;jwtDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;McpSecurityProperties&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;NimbusJwtDecoder&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NimbusJwtDecoder&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIssuerLocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&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="nc"&gt;OAuth2TokenValidator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Jwt&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;JwtValidators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDefaultWithIssuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nc"&gt;OAuth2TokenValidator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Jwt&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudience&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceUri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;OAuth2TokenValidatorResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&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="nc"&gt;OAuth2TokenValidatorResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuth2Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s2"&gt;"invalid_token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Token audience does not include this MCP server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setJwtValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DelegatingOAuth2TokenValidator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decoder&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;Two details matter. Compare against the exact resource URI, never a prefix match, or a hostile &lt;code&gt;https://mcp.example.com.evil.net&lt;/code&gt; slips through. And the decoder downloads the issuer's signing keys and caches them, so a request does not trigger a network call and replicas share nothing. The flip side is key rotation: when the identity provider rolls its keys, a decoder holding a stale cache rejects good tokens until it refetches, so alert on a burst of signature failures right after a rotation rather than assuming the attack is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: where CIMD fits, and who does the work
&lt;/h2&gt;

&lt;p&gt;This confuses people, so to be explicit: &lt;strong&gt;your MCP server never touches CIMD documents. The authorization server does.&lt;/strong&gt; The client hands the authorization server a client ID that is an HTTPS URL, such as &lt;code&gt;https://agent.example.org/client.json&lt;/code&gt;. The authorization server downloads it, checks that the &lt;code&gt;client_id&lt;/code&gt; field inside equals the URL it came from, validates the redirect URIs, and shows the user a consent screen carrying the app's name.&lt;/p&gt;

&lt;p&gt;For a Java team the work divides like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you operate your own authorization server on Spring Authorization Server, expect to write a small &lt;code&gt;RegisteredClientRepository&lt;/code&gt; that turns a URL-shaped client ID into a client by fetching and validating the document. I have not seen URL-form client IDs supported out of the box, so I plan on writing it.&lt;/li&gt;
&lt;li&gt;If you use Keycloak, Entra, Auth0 or Okta, ask the vendor for their CIMD date in writing. Some will trail the spec by months, which is why you keep DCR on, behind rate limits, until they ship.&lt;/li&gt;
&lt;li&gt;Whoever does the fetching, treat that fetch as hostile. The URL comes from a stranger, so block private address ranges, cap the body size, set short timeouts and refuse redirects. The &lt;a href="https://krishnakky.com/blog/spring-boot-4-1-ssrf-mitigation-grpc-auto-configuration" rel="noopener noreferrer"&gt;SSRF mitigation post&lt;/a&gt; covers this class of bug, and CIMD is a textbook instance: a login server that downloads arbitrary URLs for people it has never met.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the validation I would put in front of that fetch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ClientMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;redirectUris&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CimdResolver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;followRedirects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;NEVER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&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="nc"&gt;ClientMetadata&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;URI&lt;/span&gt; &lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&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="s2"&gt;"https"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"client_id must be an https URL with a path"&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="c1"&gt;// Resolve and reject loopback, link-local and private ranges before connecting.&lt;/span&gt;
        &lt;span class="nc"&gt;SsrfGuard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertPublicAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HttpRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&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="no"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&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;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BodyHandlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofByteArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16_384&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;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Metadata fetch rejected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nc"&gt;ClientMetadata&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&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="nc"&gt;ClientMetadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientIdUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"client_id in document does not match its URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;client_id&lt;/code&gt; equality check carries the whole design. Without it anyone can host a document claiming to be somebody else's app.&lt;/p&gt;

&lt;p&gt;On the benefits over DCR: a DCR identity is a random string your server minted, which tells you nothing. A CIMD identity is a domain somebody controls, so you can rate-limit it, block it and put it in a report. For compliance work this is the real gain. I can finally answer an auditor's "who is this client?" with a name and a domain, not "whoever hit the registration endpoint".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: scope-based tool authorization
&lt;/h2&gt;

&lt;p&gt;Authentication says who; authorization says what. In a tool protocol the natural unit is the tool. Enforce at the method that implements the tool, not at the HTTP path, because MCP routes every call through one endpoint and a path rule cannot distinguish &lt;code&gt;get_payment&lt;/code&gt; from &lt;code&gt;create_refund&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentTools&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Look up a payment by its id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@PreAuthorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"hasAuthority('SCOPE_payments:read')"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;PaymentView&lt;/span&gt; &lt;span class="nf"&gt;getPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paymentId&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="nd"&gt;@Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Create a refund for a settled payment"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@PreAuthorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"hasAuthority('SCOPE_payments:write')"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;RefundReceipt&lt;/span&gt; &lt;span class="nf"&gt;createRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;stepUp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StepUpLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;STRONG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// see next section&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paymentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Security converts the token's &lt;code&gt;scope&lt;/code&gt; claim into &lt;code&gt;SCOPE_&lt;/code&gt; authorities by default, so the annotation works without extra mapping; no string comparisons in the tool body. The trap is that &lt;code&gt;@Tool&lt;/code&gt; discovery and Spring's method-security proxy must apply to the same bean. If the proxy is missing, &lt;code&gt;@PreAuthorize&lt;/code&gt; is silently ignored and every call is allowed. So write a test that sends a token without &lt;code&gt;payments:write&lt;/code&gt; and expects a refusal. That one test is the only thing standing between you and an open refund tool.&lt;/p&gt;

&lt;p&gt;A second layer is easy to miss: the &lt;strong&gt;tool list itself&lt;/strong&gt;. Filter it per caller, so an agent with read-only scope never even sees &lt;code&gt;createRefund&lt;/code&gt;. A manipulated model has less to attempt, and agent behaviour gets more predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: step-up authorization for high-risk tools
&lt;/h2&gt;

&lt;p&gt;A scope granted at login is a blunt instrument. The user said yes to "payments" an hour ago in a chat about something else. Should an instruction injected through a web page be able to move money on the strength of that old yes? No.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step-up authorization&lt;/strong&gt; means the server asks for fresher or stronger proof before dangerous actions. The mechanism is a challenge: the server responds with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header, and the client goes back to the authorization server for a token with the extra scope or a stronger authentication level. For "you lack the permission" the code is &lt;code&gt;insufficient_scope&lt;/code&gt;, with the scope named in the header. For "log in again, more strongly", RFC 9470 defines the &lt;code&gt;insufficient_user_authentication&lt;/code&gt; error built on the &lt;code&gt;acr&lt;/code&gt; (authentication context class) and &lt;code&gt;auth_time&lt;/code&gt; claims.&lt;/p&gt;

&lt;p&gt;In PSD2 terms, this is SCA dynamic linking applied to an agent: the bank re-checks the customer at the moment of the risky act, not at session start. The mandate flows I built for recurring payments (see &lt;a href="https://krishnakky.com/blog/commercial-vrp-ukpi-wave-1-spring-boot-mandate-recurring-payments" rel="noopener noreferrer"&gt;the Commercial VRP post&lt;/a&gt;) work the same way. An agent calling a payment tool is just a new caller of an old rule.&lt;/p&gt;

&lt;p&gt;This part is custom code; I have not seen Spring AI ship a step-up primitive, so here is the guard I use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StepUpGuard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="no"&gt;MAX_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StepUpLevel&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Jwt&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Jwt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrincipal&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="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;strongEnough&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acceptedAcr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaimAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acr"&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="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ofEpochSecond&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"auth_time"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;longValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;authTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strongEnough&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;fresh&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;StepUpRequiredException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;MAX_AGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@RestControllerAdvice&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StepUpAdvice&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StepUpRequiredException&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StepUpRequiredException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Bearer error=\"insufficient_user_authentication\","&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;" error_description=\"A stronger or more recent login is required\","&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;" acr_values=\""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;primaryAcr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"\","&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;" max_age=\""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"\","&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;" resource_metadata=\""&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metadataUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;"\""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;WWW_AUTHENTICATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use whichever error code your authorization server and chosen MCP client both understand, and test the full round trip, since client behaviour on challenges is the least uniform part of this ecosystem. Keep the five-minute window in configuration so risk and compliance can change it without a deploy.&lt;/p&gt;

&lt;p&gt;One rule I do not bend: the model never decides whether step-up happened. The guard reads the verified token and nothing the agent says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: audit logging you can hand to a regulator
&lt;/h2&gt;

&lt;p&gt;Each tool call by an agent is a decision taken on someone's behalf, and in a regulated setting somebody will later ask you to reconstruct it. DORA-style expectations boil down to: which user, which client, which tool, which arguments, what happened. The evidence side is in &lt;a href="https://krishnakky.com/blog/dora-nis2-policy-as-code-spring-boot-kubernetes-compliance-evidence" rel="noopener noreferrer"&gt;the DORA and NIS2 policy-as-code post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An aspect over the tool methods keeps this out of business code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Aspect&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ToolAuditAspect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"mcp.audit"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Around&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"@annotation(org.springframework.ai.tool.annotation.Tool)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProceedingJoinPoint&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Jwt&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Jwt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPrincipal&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="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getName&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="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanoTime&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="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ALLOWED"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pjp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;proceed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AccessDeniedException&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nc"&gt;StepUpRequiredException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DENIED"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tool={} sub={} client={} acr={} outcome={} ms={} trace={}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSubject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaimAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaimAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"acr"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nanoTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nc"&gt;Span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSpanContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTraceId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log the tool name and identifiers, but hash or mask arguments that contain account numbers. The trace ID links the audit line to the distributed trace, which is how a strange agent run actually gets debugged (see &lt;a href="https://krishnakky.com/blog/distributed-tracing-opentelemetry-spring-boot-production" rel="noopener noreferrer"&gt;distributed tracing with OpenTelemetry&lt;/a&gt;). Ship the audit logger to an append-only store apart from application logs. For metrics and alerting on agent behaviour, see &lt;a href="https://krishnakky.com/blog/observability-guardrails-agentic-ai-backends-spring-boot" rel="noopener noreferrer"&gt;observability and guardrails for agentic AI backends&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes I test before launch
&lt;/h2&gt;

&lt;p&gt;Authorization code is easy to get subtly wrong, so these negative tests run in CI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A validly signed token for a &lt;em&gt;different&lt;/em&gt; audience is rejected with &lt;code&gt;401&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;payments:read&lt;/code&gt; token calling &lt;code&gt;createRefund&lt;/code&gt; is rejected with &lt;code&gt;403&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A token with the right scope but a stale &lt;code&gt;auth_time&lt;/code&gt; gets the step-up challenge, not a success.&lt;/li&gt;
&lt;li&gt;A CIMD document whose &lt;code&gt;client_id&lt;/code&gt; differs from its URL is refused.&lt;/li&gt;
&lt;li&gt;A CIMD URL pointing at &lt;code&gt;169.254.169.254&lt;/code&gt; or a private range is refused before any connection opens.&lt;/li&gt;
&lt;li&gt;Two replicas, alternating requests, behave identically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If one of these cannot be written as a test, some state is still hiding in the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do on Monday
&lt;/h2&gt;

&lt;p&gt;For a team that already has an MCP server built the earlier Spring AI way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week one:&lt;/strong&gt; add audience validation and the protected resource metadata. Both are small, they close the most dangerous hole, and they do not depend on any vendor catching up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week two:&lt;/strong&gt; move tool authorization onto scopes with &lt;code&gt;@PreAuthorize&lt;/code&gt; and filter the tool list per caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week three:&lt;/strong&gt; add the step-up guard to the two or three tools that move money or change a mandate, plus the audit aspect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In parallel:&lt;/strong&gt; ask your identity team for their CIMD plan with dates, and keep DCR enabled behind rate limits until it lands. DCR is deprecated, not removed, so there is a clock but no emergency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last:&lt;/strong&gt; compare your Spring AI release notes with this list and delete whichever custom code the starters now cover.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. I spent years on PSD2 consent flows and mandate APIs, and an AI agent is the newest kind of third-party provider: faster and less predictable than a human. The old controls (audience, scope, freshness, audit) still hold, and the 2026-07-28 spec lets you apply them without fighting the transport. More of my payments and AI backend work is under &lt;a href="https://krishnakky.com/#projects" rel="noopener noreferrer"&gt;projects&lt;/a&gt;, with my background at &lt;a href="https://krishnakky.com/#experience" rel="noopener noreferrer"&gt;experience&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are planning this migration and want a second pair of eyes on the threat model, send me the architecture through the &lt;a href="https://krishnakky.com/#contact" rel="noopener noreferrer"&gt;contact section&lt;/a&gt;. If staffing is the open question, my &lt;a href="https://krishnakky.com/blog/remote-developer-hiring-guide" rel="noopener noreferrer"&gt;remote developer hiring guide&lt;/a&gt; covers hiring a senior Java engineer for EU and ASEAN teams.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is an MCP server?
&lt;/h3&gt;

&lt;p&gt;An MCP server is a program that offers a list of actions and data to an AI assistant through the Model Context Protocol. The assistant asks what the server can do, then calls those actions on a user's behalf.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I secure a Spring Boot MCP server?
&lt;/h3&gt;

&lt;p&gt;Treat it as an OAuth 2.1 resource server. Use Spring Security's JWT resource-server support, add an audience validator, guard each tool with a &lt;code&gt;@PreAuthorize&lt;/code&gt; scope, require step-up authentication on payment tools, and audit-log every tool call.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a Client ID Metadata Document?
&lt;/h3&gt;

&lt;p&gt;A JSON file an AI application hosts at an HTTPS address, with that address serving as its client ID. The authorization server reads the file to learn the app's name and redirect addresses rather than requiring registration first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is Dynamic Client Registration being replaced?
&lt;/h3&gt;

&lt;p&gt;Open registration produces anonymous client records that are hard to trust or clean up. A metadata document ties the client to a domain you can check. DCR is deprecated, not yet removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related Articles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://krishnakky.com/blog/mcp-server-spring-boot-spring-ai-expose-java-backend-ai-agents" rel="noopener noreferrer"&gt;Exposing a Java backend as an MCP server with Spring AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://krishnakky.com/blog/commercial-vrp-ukpi-wave-1-spring-boot-mandate-recurring-payments" rel="noopener noreferrer"&gt;Commercial VRP and UKPI Wave 1: mandates and recurring payments in Spring Boot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://krishnakky.com/blog/observability-guardrails-agentic-ai-backends-spring-boot" rel="noopener noreferrer"&gt;Observability guardrails for agentic AI backends in Spring Boot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>java</category>
      <category>springboot</category>
      <category>webdev</category>
    </item>
    <item>
      <title>CryptoPulse: Build a Real-Time Cryptocurrency Intelligence Platform on AWS</title>
      <dc:creator>Muhammad Umair Ashraf</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:15:12 +0000</pubDate>
      <link>https://dev.to/omayrashraf/cryptopulse-build-a-real-time-cryptocurrency-intelligence-platform-on-aws-11cc</link>
      <guid>https://dev.to/omayrashraf/cryptopulse-build-a-real-time-cryptocurrency-intelligence-platform-on-aws-11cc</guid>
      <description>&lt;p&gt;&lt;em&gt;What if your application could monitor Bitcoin, Ethereum, and other cryptocurrencies, detect unusual market activity, analyze historical trends, and send alerts automatically?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Cryptocurrency markets never sleep.&lt;/p&gt;

&lt;p&gt;Bitcoin can experience a sudden price movement while Ethereum trading volume increases. Another cryptocurrency might attract unusual market activity within minutes.&lt;/p&gt;

&lt;p&gt;For traders, analysts, and crypto enthusiasts, monitoring multiple exchanges and dashboards manually can quickly become overwhelming.&lt;/p&gt;

&lt;p&gt;The challenge isn't simply collecting cryptocurrency prices. It's identifying meaningful market movements, preserving historical data, and notifying users when configured conditions are met.&lt;/p&gt;

&lt;p&gt;That's the idea behind &lt;strong&gt;CryptoPulse – Cryptocurrency Intelligence&lt;/strong&gt;, a serverless market-monitoring and analytics platform built using AWS.&lt;/p&gt;

&lt;p&gt;In this project, we'll explore how to connect real-time market data ingestion, event processing, historical storage, SQL analytics, and automated notifications using AWS services.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9p7r8qq2xs2dr78xy2i.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9p7r8qq2xs2dr78xy2i.jpeg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What Are We Building?
&lt;/h2&gt;

&lt;p&gt;CryptoPulse is designed to monitor cryptocurrency market activity and generate useful insights.&lt;/p&gt;

&lt;p&gt;Its core capabilities include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time monitoring of Bitcoin, Ethereum, and other supported cryptocurrencies.&lt;/li&gt;
&lt;li&gt;Detection of configurable price fluctuations.&lt;/li&gt;
&lt;li&gt;Identification of unusual trading-volume spikes.&lt;/li&gt;
&lt;li&gt;Historical market data storage and analysis.&lt;/li&gt;
&lt;li&gt;Automated email notifications when configured conditions are triggered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, imagine these events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bitcoin moves more than 2% within 15 minutes.&lt;/li&gt;
&lt;li&gt;Ethereum trading volume becomes three times its recent average.&lt;/li&gt;
&lt;li&gt;Solana experiences an unusual price fluctuation.&lt;/li&gt;
&lt;li&gt;A supported cryptocurrency experiences a sudden increase in trading activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CryptoPulse can evaluate these conditions and notify subscribed users.&lt;/p&gt;

&lt;p&gt;The platform is designed for market monitoring and analytics, not guaranteed price prediction or automated trading.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. AWS Architecture
&lt;/h2&gt;

&lt;p&gt;The architecture follows an event-driven, serverless design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;       Cryptocurrency Exchange APIs
                    |
                    v
          Market Data Producer
        (Python / WebSocket Client)
                    |
                    v
          Amazon Kinesis Data Streams
                    |
                    v
               AWS Lambda
             Data Processing
                    |
          +---------+----------+
          |                    |
          v                    v
    Amazon S3             Amazon DynamoDB
    Raw Market Data       Latest Market State
          |                    |
          v                    v
    AWS Glue Catalog     DynamoDB Streams
          |                    |
          v                    v
    Amazon Athena         AWS Lambda
          |                    |
          v                    v
    S3 Query Results      Amazon SNS
                               |
                               v
                       Email Notifications
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main components are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AWS Service&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Kinesis Data Streams&lt;/td&gt;
&lt;td&gt;Real-time market data ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda&lt;/td&gt;
&lt;td&gt;Event processing and market calculations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon S3&lt;/td&gt;
&lt;td&gt;Raw and processed historical data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon DynamoDB&lt;/td&gt;
&lt;td&gt;Latest market state and alert records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Streams&lt;/td&gt;
&lt;td&gt;Trigger downstream alert processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SNS&lt;/td&gt;
&lt;td&gt;Notification delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Glue Data Catalog&lt;/td&gt;
&lt;td&gt;Dataset metadata and schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Athena&lt;/td&gt;
&lt;td&gt;Historical SQL analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The diagram represents the logical architecture. Each integration needs appropriate AWS configuration, IAM permissions, and event source mappings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitgedn1y0kb4o7q2y85g.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fitgedn1y0kb4o7q2y85g.jpeg" alt=" " width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Step One: Collect Cryptocurrency Market Data
&lt;/h2&gt;

&lt;p&gt;The first component is a market data producer.&lt;/p&gt;

&lt;p&gt;We can build it using Python and connect it to supported cryptocurrency exchange APIs.&lt;/p&gt;

&lt;p&gt;For continuous updates, WebSocket connections are useful because they allow the producer to receive market events without repeatedly polling an endpoint.&lt;/p&gt;

&lt;p&gt;For example, CryptoPulse could monitor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BTCUSDT
ETHUSDT
SOLUSDT
BNBUSDT
XRPUSDT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact feed depends on the metrics required.&lt;/p&gt;

&lt;p&gt;Ticker streams can provide price and rolling market statistics. Individual trade streams can provide transaction-level information for custom volume calculations.&lt;/p&gt;

&lt;p&gt;Before publishing events, normalize exchange-specific messages into a common structure.&lt;/p&gt;

&lt;p&gt;Example event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"exchange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"example-exchange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"BTCUSDT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ticker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;68500.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"volume_24h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12500.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"price_change_percent_24h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"event_timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-13T09:30:00Z"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These values are illustrative, not live market data.&lt;/p&gt;

&lt;p&gt;A consistent event format makes downstream processing easier, especially when supporting multiple exchanges.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Step Two: Amazon Kinesis Data Streams
&lt;/h2&gt;

&lt;p&gt;Once the producer collects market events, it publishes them to Amazon Kinesis Data Streams.&lt;/p&gt;

&lt;p&gt;Kinesis acts as the central ingestion layer.&lt;/p&gt;

&lt;p&gt;Instead of connecting the producer directly to every processing component, events are published to a stream and consumed by downstream applications.&lt;/p&gt;

&lt;p&gt;A useful partition key is the trading symbol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BTCUSDT
ETHUSDT
SOLUSDT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps keep related records associated with the same shard and supports ordering within that shard.&lt;/p&gt;

&lt;p&gt;Kinesis allows ingestion and processing capacity to be managed independently.&lt;/p&gt;

&lt;p&gt;For production, consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stream capacity and throughput.&lt;/li&gt;
&lt;li&gt;Data retention requirements.&lt;/li&gt;
&lt;li&gt;Consumer lag and iterator age.&lt;/li&gt;
&lt;li&gt;Throttling and failed writes.&lt;/li&gt;
&lt;li&gt;Producer retry and reconnection logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exchange WebSocket connections can be interrupted, so the producer should be designed to reconnect and recover gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Step Three: AWS Lambda for Market Processing
&lt;/h2&gt;

&lt;p&gt;AWS Lambda can consume Kinesis records through an event source mapping.&lt;/p&gt;

&lt;p&gt;The processing function validates, transforms, and analyzes incoming market events.&lt;/p&gt;

&lt;p&gt;Its responsibilities include:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validating incoming records.&lt;/li&gt;
&lt;li&gt;Normalizing exchange-specific fields.&lt;/li&gt;
&lt;li&gt;Calculating price changes and volume indicators.&lt;/li&gt;
&lt;li&gt;Updating the latest market state.&lt;/li&gt;
&lt;li&gt;Storing raw and processed information.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example, a simple percentage price-change calculation looks like this:&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;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_price_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;previous_price&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;return&lt;/span&gt; &lt;span class="mi"&gt;0&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="n"&gt;current_price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous_price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;previous_price&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suppose Bitcoin's previous reference price was $65,000 and the current price is $66,300.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;((66,300 - 65,000) / 65,000) × 100

= 2%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CryptoPulse can evaluate this result against a configured threshold.&lt;/p&gt;

&lt;p&gt;One important detail: the system must define its monitoring window. A 2% change over one minute is different from a 2% change over 24 hours.&lt;/p&gt;

&lt;p&gt;Use event timestamps and appropriate reference prices when calculating indicators.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxri08srflrwkdvx23gzj.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxri08srflrwkdvx23gzj.jpeg" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Step Four: Amazon S3 for Historical Data
&lt;/h2&gt;

&lt;p&gt;Real-time processing is useful, but historical information is equally important.&lt;/p&gt;

&lt;p&gt;Amazon S3 provides durable storage for raw and processed market data.&lt;/p&gt;

&lt;p&gt;A possible bucket structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cryptopulse-market-data/

    raw/
        exchange=binance/
            symbol=BTCUSDT/
                year=2026/
                    month=11/
                        day=13/

    processed/
        exchange=binance/
            symbol=BTCUSDT/
                year=2026/
                    month=11/
                        day=13/

    athena-results/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw zone preserves original or minimally transformed events. This is useful for auditing and reprocessing.&lt;/p&gt;

&lt;p&gt;The processed zone contains normalized records for analytical queries.&lt;/p&gt;

&lt;p&gt;Partitioning by exchange, symbol, and date can help reduce the amount of data scanned by Athena.&lt;/p&gt;

&lt;p&gt;For larger datasets, consider Apache Parquet. Its columnar format can improve analytical query efficiency and help reduce scan costs.&lt;/p&gt;

&lt;p&gt;Keep raw data, processed data, and Athena query results in separate prefixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Step Five: Amazon DynamoDB for Latest Market State
&lt;/h2&gt;

&lt;p&gt;S3 is suitable for historical storage, but retrieving the latest price should not require scanning historical files.&lt;/p&gt;

&lt;p&gt;Amazon DynamoDB can maintain the latest market state for each trading pair.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"PK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ASSET#BTCUSDT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"SK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"LATEST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;66300.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"volume_24h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;14500.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"price_change_24h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-13T09:30:00Z"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The values are illustrative.&lt;/p&gt;

&lt;p&gt;DynamoDB can also store alert records, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Asset symbol.&lt;/li&gt;
&lt;li&gt;Alert type.&lt;/li&gt;
&lt;li&gt;Observed value.&lt;/li&gt;
&lt;li&gt;Configured threshold.&lt;/li&gt;
&lt;li&gt;Event timestamp.&lt;/li&gt;
&lt;li&gt;Notification status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes it easier for the application to retrieve recent market information and alert history.&lt;/p&gt;

&lt;p&gt;For production, design partition and sort keys around access patterns. Use conditional writes or other concurrency controls when multiple processing attempts could update the same market state.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Step Six: Detect Price Fluctuations and Volume Spikes
&lt;/h2&gt;

&lt;p&gt;This is where CryptoPulse becomes more than a data collection pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Price movement detection
&lt;/h3&gt;

&lt;p&gt;Suppose a user wants an alert whenever Bitcoin moves more than 2% within 15 minutes.&lt;/p&gt;

&lt;p&gt;An illustrative rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF 15-minute price change &amp;gt;= +2%
THEN create an upward movement alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A downward movement rule could be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF 15-minute price change &amp;lt;= -2%
THEN create a downward movement alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thresholds should be configurable according to user preferences.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trading-volume spike detection
&lt;/h3&gt;

&lt;p&gt;A sudden increase in trading volume can indicate unusual market activity.&lt;/p&gt;

&lt;p&gt;CryptoPulse can compare recent volume with a historical baseline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Volume Ratio =
Recent 5-minute volume
/
Average 5-minute volume over baseline period
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An illustrative rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF Volume Ratio &amp;gt;= 3
THEN create a volume spike alert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means recent volume is at least three times the selected baseline.&lt;/p&gt;

&lt;p&gt;The comparison should use equivalent time windows and account for missing or incomplete data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historical indicators
&lt;/h3&gt;

&lt;p&gt;The platform can also calculate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Moving averages.&lt;/li&gt;
&lt;li&gt;Percentage price changes.&lt;/li&gt;
&lt;li&gt;Rolling trading volume.&lt;/li&gt;
&lt;li&gt;High and low prices over selected periods.&lt;/li&gt;
&lt;li&gt;Volatility measures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These indicators help users explore market behavior, but they do not guarantee future price movements.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Step Seven: DynamoDB Streams and Lambda for Alerts
&lt;/h2&gt;

&lt;p&gt;The architecture uses DynamoDB as the trigger for a second Lambda function.&lt;/p&gt;

&lt;p&gt;This can be implemented using DynamoDB Streams.&lt;/p&gt;

&lt;p&gt;When a market-state or alert record changes, DynamoDB Streams makes that change available to a downstream consumer.&lt;/p&gt;

&lt;p&gt;A configured Lambda event source mapping invokes the alert-processing function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Market Processing Lambda
          |
          v
     DynamoDB Update
          |
          v
    DynamoDB Streams
          |
          v
      Alert Lambda
          |
          v
       Amazon SNS
          |
          v
    Email Notification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alert Lambda checks whether the event represents a valid notification.&lt;/p&gt;

&lt;p&gt;It should verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The threshold condition was satisfied.&lt;/li&gt;
&lt;li&gt;The alert is relevant to the user.&lt;/li&gt;
&lt;li&gt;The notification has not already been sent.&lt;/li&gt;
&lt;li&gt;The event is not an outdated update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use an idempotency key to reduce duplicate notifications. It could include the asset, alert type, monitoring window, and threshold configuration.&lt;/p&gt;

&lt;p&gt;A cooldown period can also prevent repeated alerts for the same continuing market condition.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Step Eight: Amazon SNS for Automatic Notifications
&lt;/h2&gt;

&lt;p&gt;Once the alert Lambda confirms that a notification should be delivered, it can publish a message to Amazon Simple Notification Service (SNS).&lt;/p&gt;

&lt;p&gt;SNS supports notification delivery to configured subscribers, including email endpoints.&lt;/p&gt;

&lt;p&gt;Example notification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CryptoPulse Market Alert

Asset: BTCUSDT

Alert Type: Price Movement

Observed Change: +2.0%

Monitoring Window: 15 Minutes

Event Time: 09:30 UTC

Status: Threshold Triggered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an illustrative message.&lt;/p&gt;

&lt;p&gt;Users can receive alerts about significant price movements, unusual volume spikes, or other configured market conditions.&lt;/p&gt;

&lt;p&gt;SNS handles notification delivery. The application logic determines whether a market event deserves an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Step Nine: AWS Glue and Amazon Athena for Historical Analytics
&lt;/h2&gt;

&lt;p&gt;Real-time notifications tell users when something happens. Historical analytics help them understand market behavior over time.&lt;/p&gt;

&lt;p&gt;AWS Glue Data Catalog maintains metadata about datasets stored in S3.&lt;/p&gt;

&lt;p&gt;A Glue crawler can inspect supported data and infer table metadata. Alternatively, teams can define schemas explicitly.&lt;/p&gt;

&lt;p&gt;Amazon Athena uses the Glue Data Catalog to discover tables and query the underlying S3 data using SQL.&lt;/p&gt;

&lt;p&gt;This allows CryptoPulse to analyze historical cryptocurrency data without managing a separate database cluster for every analytical workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Query recent Bitcoin records
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;event_timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;volume_24h&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cryptopulse_market_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;market_data&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BTCUSDT'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'11'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'13'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;event_timestamp&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table and partition names are examples and must match the actual dataset registered in Glue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find records with high reported volume
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;volume_24h&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cryptopulse_market_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;market_data&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;volume_24h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;volume_24h&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This query uses an illustrative absolute threshold. A proper volume-spike analysis should compare recent volume against a historical baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where are Athena results stored?
&lt;/h3&gt;

&lt;p&gt;Athena writes query results to a configured S3 output location, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3://cryptopulse-market-data/athena-results/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember the different responsibilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;S3 stores market datasets.&lt;/li&gt;
&lt;li&gt;Glue Data Catalog stores metadata and table definitions.&lt;/li&gt;
&lt;li&gt;Athena queries S3 datasets using SQL.&lt;/li&gt;
&lt;li&gt;S3 stores Athena query output in the configured results location.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Glue does not automatically transform the market records. It provides metadata that helps analytical services understand the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Production Readiness: What Should We Consider?
&lt;/h2&gt;

&lt;p&gt;A production market-monitoring platform must handle interruptions, duplicate events, delayed records, and sudden increases in market activity.&lt;/p&gt;

&lt;p&gt;Some important engineering considerations include:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data validation:&lt;/strong&gt; Validate prices, volumes, timestamps, symbols, and event formats before processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate handling:&lt;/strong&gt; Use exchange trade IDs or event identifiers where available. Make processing idempotent so retries do not double-count trades or create repeated alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event ordering:&lt;/strong&gt; Events can arrive late or out of order. Use exchange timestamps and sequence identifiers where available instead of assuming arrival order represents event order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure handling:&lt;/strong&gt; Configure Kinesis/Lambda retry behavior, record-age limits, and an on-failure destination where appropriate. Monitor iterator age and processing errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert reliability:&lt;/strong&gt; Store alert state and notification status. Use idempotency keys and cooldown periods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security:&lt;/strong&gt; Apply least-privilege IAM roles, encrypt stored data, and use AWS Secrets Manager for exchange credentials when authenticated APIs are required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost management:&lt;/strong&gt; Monitor Kinesis capacity, Lambda execution, S3 storage, Glue activity, and Athena data scanned. Use partitioning and columnar formats to help control analytical costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Implementation Roadmap
&lt;/h2&gt;

&lt;p&gt;CryptoPulse can be developed in manageable stages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Market Data Ingestion
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Select supported exchanges.&lt;/li&gt;
&lt;li&gt;Build a Python producer.&lt;/li&gt;
&lt;li&gt;Connect to WebSocket market streams.&lt;/li&gt;
&lt;li&gt;Normalize incoming events.&lt;/li&gt;
&lt;li&gt;Publish records to Kinesis Data Streams.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 2: Real-Time Processing
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Configure Kinesis-to-Lambda integration.&lt;/li&gt;
&lt;li&gt;Validate and process records.&lt;/li&gt;
&lt;li&gt;Calculate price and volume indicators.&lt;/li&gt;
&lt;li&gt;Store raw and processed events in S3.&lt;/li&gt;
&lt;li&gt;Update DynamoDB market state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 3: Alert Engine
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Configure DynamoDB Streams.&lt;/li&gt;
&lt;li&gt;Build the alert-processing Lambda.&lt;/li&gt;
&lt;li&gt;Add configurable thresholds and cooldowns.&lt;/li&gt;
&lt;li&gt;Integrate Amazon SNS.&lt;/li&gt;
&lt;li&gt;Test duplicate-event handling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 4: Historical Analytics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Organize S3 datasets.&lt;/li&gt;
&lt;li&gt;Create Glue database and table definitions.&lt;/li&gt;
&lt;li&gt;Configure crawlers or explicit schemas.&lt;/li&gt;
&lt;li&gt;Run historical SQL queries using Athena.&lt;/li&gt;
&lt;li&gt;Store query results in a dedicated S3 prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 5: Production Hardening
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Add monitoring and alarms.&lt;/li&gt;
&lt;li&gt;Configure retries and failure handling.&lt;/li&gt;
&lt;li&gt;Secure credentials and permissions.&lt;/li&gt;
&lt;li&gt;Implement data retention policies.&lt;/li&gt;
&lt;li&gt;Test recovery and load behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  14. Future Enhancements
&lt;/h2&gt;

&lt;p&gt;Once the core pipeline is operational, CryptoPulse can evolve with additional capabilities.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-exchange comparison:&lt;/strong&gt; Compare the same trading pair across supported exchanges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom user alerts:&lt;/strong&gt; Allow users to configure assets, thresholds, and monitoring windows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Market dashboards:&lt;/strong&gt; Display current prices, historical movements, volume trends, and alerts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced analytics:&lt;/strong&gt; Add volatility analysis, moving averages, and other indicators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine learning experiments:&lt;/strong&gt; Explore anomaly detection using historical market data and validated models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portfolio monitoring:&lt;/strong&gt; Add optional user-configured asset watchlists with secure authentication and privacy controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are potential extensions, not features automatically provided by the initial architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Cryptocurrency markets generate enormous amounts of continuously changing information.&lt;/p&gt;

&lt;p&gt;Collecting that information is only the first step. The real value comes from processing it, identifying unusual activity, preserving historical records, and making useful insights accessible.&lt;/p&gt;

&lt;p&gt;CryptoPulse demonstrates how AWS services can work together to build this type of platform.&lt;/p&gt;

&lt;p&gt;Amazon Kinesis Data Streams provides real-time ingestion. AWS Lambda processes incoming events. Amazon S3 stores historical data. DynamoDB maintains the latest market state. DynamoDB Streams supports event-driven alert processing. Amazon SNS delivers notifications. AWS Glue and Amazon Athena make historical analysis possible.&lt;/p&gt;

&lt;p&gt;Together, these services provide a serverless foundation that can evolve as market-data volume and user requirements grow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal of CryptoPulse is simple: don't just watch cryptocurrency prices—understand the market activity behind them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclaimer: CryptoPulse is a conceptual market-monitoring and analytics project. It does not provide investment advice, guarantee price predictions, or execute trades.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fef49asfeoh1poh4y0efr.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fef49asfeoh1poh4y0efr.jpeg" alt=" " width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/streams/latest/dev/introduction.html" rel="noopener noreferrer"&gt;Amazon Kinesis Data Streams Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html" rel="noopener noreferrer"&gt;Using AWS Lambda with Amazon Kinesis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html" rel="noopener noreferrer"&gt;Amazon S3 Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/glue/latest/dg/catalog-and-crawler.html" rel="noopener noreferrer"&gt;AWS Glue Data Catalog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/athena/latest/ug/what-is.html" rel="noopener noreferrer"&gt;Amazon Athena Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html" rel="noopener noreferrer"&gt;DynamoDB Streams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/sns/latest/dg/welcome.html" rel="noopener noreferrer"&gt;Amazon SNS Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.binance.info/en/docs/catalog/core-trading-spot-trading/api/ws-streams/~" rel="noopener noreferrer"&gt;Binance Spot WebSocket Market Streams&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>cloud</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>How to Turn Your API into an MCP Server Without Writing Server Code</title>
      <dc:creator>Docstar</dc:creator>
      <pubDate>Fri, 13 Nov 2026 05:14:57 +0000</pubDate>
      <link>https://dev.to/docstar_io/how-to-turn-your-api-into-an-mcp-server-without-writing-server-code-2lge</link>
      <guid>https://dev.to/docstar_io/how-to-turn-your-api-into-an-mcp-server-without-writing-server-code-2lge</guid>
      <description>&lt;p&gt;More of your users now try your product from inside an AI assistant. They ask Claude, ChatGPT or Cursor to "create an invoice" or "send a test message" before they ever open your dashboard. If the assistant can't reach your API, it guesses, gives up, or suggests a product it &lt;em&gt;can&lt;/em&gt; reach.&lt;/p&gt;

&lt;p&gt;The fix is an &lt;strong&gt;MCP server&lt;/strong&gt; for your API. This guide shows the two ways to get one, and how to have a working server in an afternoon without writing or hosting any server code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an MCP server for your API has to do
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) is the open standard AI assistants use to discover and call outside tools. An MCP server for an API needs to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Describe each action as a tool&lt;/strong&gt;, with a name, a description and a schema for its inputs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authenticate as the user&lt;/strong&gt;, so every call runs with that person's own permissions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make the real HTTP request&lt;/strong&gt; and return the result to the assistant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stay in sync with your API&lt;/strong&gt; as endpoints change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Be hosted somewhere&lt;/strong&gt; with a URL users can paste into their assistant.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;New to MCP? Start with &lt;a href="https://docstar.io/blog/what-is-an-mcp-server" rel="noopener noreferrer"&gt;What Is an MCP Server?&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: Build it yourself
&lt;/h2&gt;

&lt;p&gt;You can write a server with an MCP SDK, define every tool by hand, add OAuth, deploy it and keep it updated whenever your API changes.&lt;/p&gt;

&lt;p&gt;This makes sense when you need custom logic between the assistant and your API, such as combining several calls into one tool. The cost is a second codebase: every new endpoint, renamed field or auth change has to be made twice, once in the API and once in the MCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Generate it from your API docs
&lt;/h2&gt;

&lt;p&gt;Your API docs already contain everything an MCP server needs: endpoints, methods, parameters, body fields, auth headers and descriptions. If the docs are the source, the server can be generated from them and stays current every time you publish.&lt;/p&gt;

&lt;p&gt;That is how &lt;a href="https://docstar.io" rel="noopener noreferrer"&gt;DocStar&lt;/a&gt; works. Every published collection gets its own hosted MCP server. You write docs once and get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;a public API docs site for humans,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;an MCP server AI assistants can use to search your docs and &lt;strong&gt;call your API&lt;/strong&gt;,&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;a &lt;a href="https://docstar.io/blog/docstar-cli-api-docs-to-commands" rel="noopener noreferrer"&gt;CLI&lt;/a&gt; that turns each endpoint into a terminal command.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step by step: from API to MCP server with DocStar
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create an API collection
&lt;/h3&gt;

&lt;p&gt;Create a collection of type &lt;strong&gt;API&lt;/strong&gt; and choose its &lt;strong&gt;slug&lt;/strong&gt;. The slug is permanent and becomes part of your MCP URL, so pick your product name, for example &lt;code&gt;acme&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Bring in your endpoints
&lt;/h3&gt;

&lt;p&gt;Use whatever you already have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;An OpenAPI or Postman file.&lt;/strong&gt; Import it into the collection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;cURL commands.&lt;/strong&gt; Paste them and DocStar fills in the method, URL, headers and body.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Only code.&lt;/strong&gt; Connect your coding agent to DocStar's own MCP server (&lt;code&gt;https://mcp.docstar.io/mcp&lt;/code&gt;) and ask it to read your routes and document every public endpoint. It creates the pages and endpoints for you.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Write descriptions for the assistant, not just for people
&lt;/h3&gt;

&lt;p&gt;The assistant decides which tool to call from the words you write. For each endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Name it verb-first,&lt;/strong&gt; the way a user would ask: &lt;code&gt;Create Order&lt;/code&gt;, &lt;code&gt;List Invoices&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Say when to use it&lt;/strong&gt; and what it changes, including any required order ("call Create Cart first").&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Give a body example with every accepted field,&lt;/strong&gt; and describe each field. Fields missing from the example are hard for an assistant to use.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a sample success response and the common errors.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never put real keys or customer data in the docs.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Publish
&lt;/h3&gt;

&lt;p&gt;Publish the module pages and endpoints. Unpublished endpoints don't appear in the MCP server.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Copy your MCP URL
&lt;/h3&gt;

&lt;p&gt;Open the collection's &lt;strong&gt;MCP&lt;/strong&gt; tab. Your server is already running at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://mcp.docstar.io/connector/acme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tab also gives ready-made setup snippets. For Claude Code, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add --transport http acme https://mcp.docstar.io/connector/acme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Turn on sign-in
&lt;/h3&gt;

&lt;p&gt;If your API needs a user token, add your OAuth details (login URL, token URL, client ID and secret) in the MCP tab and add DocStar's callback URLs to your OAuth app. The first time an assistant calls your API, the user signs in, and their token is attached to every later call. The assistant never sees the credential.&lt;/p&gt;

&lt;p&gt;API-key based? The server includes a key tool, so a user enters their key once instead of pasting it into prompts.&lt;/p&gt;

&lt;p&gt;For private APIs, switch on &lt;strong&gt;Require sign-in when connecting&lt;/strong&gt; so nobody can even search the docs without signing in. More in &lt;a href="https://docstar.io/blog/securing-docstar-mcp-server-sign-in-api-keys" rel="noopener noreferrer"&gt;Securing Your DocStar MCP Server&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Tune it for your product
&lt;/h3&gt;

&lt;p&gt;The generated server works out of the box. Four settings make it noticeably better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dedicated tools.&lt;/strong&gt; Give your 5 to 10 most-used endpoints their own named tools. Everything else stays reachable through the built-in API tools.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The skill.&lt;/strong&gt; Rewrite the generated quickstart with your product's main workflows and rules, such as "always confirm before deleting".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tool descriptions.&lt;/strong&gt; Say what your product is and when to use each built-in tool.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A custom domain.&lt;/strong&gt; Serve the server from &lt;code&gt;mcp.yourcompany.com&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test it like a user would
&lt;/h2&gt;

&lt;p&gt;Add the URL to Claude or Cursor and ask a real question in plain words: "show my last five orders" or "create a test customer named Jane". Watch which tool the assistant picks and which fields it sends. If it picks the wrong endpoint or misses a field, fix the description or the body example and publish again. There is nothing to redeploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build or generate?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build it yourself&lt;/th&gt;
&lt;th&gt;Generate it from docs (DocStar)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to first working server&lt;/td&gt;
&lt;td&gt;Days to weeks&lt;/td&gt;
&lt;td&gt;An afternoon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;You run it&lt;/td&gt;
&lt;td&gt;Hosted for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stays in sync with the API&lt;/td&gt;
&lt;td&gt;Manual, in a second codebase&lt;/td&gt;
&lt;td&gt;Every time you publish docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End-user sign-in&lt;/td&gt;
&lt;td&gt;You implement OAuth&lt;/td&gt;
&lt;td&gt;Configure OAuth or API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Also gives you&lt;/td&gt;
&lt;td&gt;Just the server&lt;/td&gt;
&lt;td&gt;Docs site, MCP server and CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom multi-step logic&lt;/td&gt;
&lt;td&gt;Full control&lt;/td&gt;
&lt;td&gt;Through the skill and instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;&lt;strong&gt;Do I need an OpenAPI spec?&lt;/strong&gt; No. You can import one, paste cURL commands, write endpoints in the editor or let a coding agent document them from your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which assistants can use the server?&lt;/strong&gt; Any client that supports remote MCP servers over HTTP, including Claude, Claude Code, Cursor, VS Code and ChatGPT where custom connectors are enabled. See &lt;a href="https://docstar.io/blog/connect-your-api-to-chatgpt-claude-cursor" rel="noopener noreferrer"&gt;How to Connect Your API to ChatGPT, Claude and Cursor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the assistant change data?&lt;/strong&gt; Only if you allow it. Reads and writes are separate tools, so clients that ask for approval on writes can let reads through. With sign-in, every call runs with the user's own permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when my API changes?&lt;/strong&gt; Update the endpoint in DocStar and publish. The MCP server reads the published docs, so the change is live without a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get your MCP URL
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docstar.io" rel="noopener noreferrer"&gt;Create a free DocStar collection&lt;/a&gt;, import your API and publish. Your MCP server is ready the moment you do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://docstar.io/blog/turn-your-api-into-an-mcp-server" rel="noopener noreferrer"&gt;DocStar blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>ai</category>
      <category>documentation</category>
    </item>
  </channel>
</rss>
