<?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: Shola Jegede</title>
    <description>The latest articles on DEV Community by Shola Jegede (@sholajegede).</description>
    <link>https://dev.to/sholajegede</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1705040%2F3266d189-ef54-4f0e-ae81-ecabae5aac1c.jpg</url>
      <title>DEV Community: Shola Jegede</title>
      <link>https://dev.to/sholajegede</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sholajegede"/>
    <language>en</language>
    <item>
      <title>How to Check a Customer's Plan on Every Request Without Slowing It Down</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:51:13 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-check-a-customers-plan-on-every-request-without-slowing-it-down-3cnj</link>
      <guid>https://dev.to/sholajegede/how-to-check-a-customers-plan-on-every-request-without-slowing-it-down-3cnj</guid>
      <description>&lt;p&gt;A customer downgrades their subscription at 10:00. Their access token was issued at 9:00, and by default, it remains valid for another 24 hours.&lt;/p&gt;

&lt;p&gt;The token still proves who the customer is. What it doesn't tell you is whether their new plan allows them to use a particular feature.&lt;/p&gt;

&lt;p&gt;This creates a problem for your API. Every request needs to answer a question: does this customer's current subscription include the feature they're trying to access?&lt;/p&gt;

&lt;p&gt;The answer lives in your billing provider, not in the access token.&lt;/p&gt;

&lt;p&gt;You could ask the provider on every request, but then every API call has to wait for a network request. Alternatively, you could trust the token, but that means a downgrade might not take effect until the token expires.&lt;/p&gt;

&lt;p&gt;Neither approach is particularly attractive.&lt;/p&gt;

&lt;p&gt;I wanted to explore a third option: a gateway that checks entitlements on every request, keeps the check fast, and still responds to plan changes or provider outages.&lt;/p&gt;

&lt;p&gt;To test this, I built a small gateway called Stub. It runs on Bun with Hono and retrieves subscription entitlements from Kinde, which handles authentication and billing.&lt;/p&gt;

&lt;p&gt;An entitlement is a feature that a subscription plan grants to a customer.&lt;/p&gt;

&lt;p&gt;I tested Stub against a &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=30&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;real Kinde account&lt;/a&gt; with three test customers. Unless I explicitly mention a stand-in provider, the measurements in this article come from live API calls.&lt;/p&gt;

&lt;p&gt;I've written two earlier articles covering related problems. One looks at receiving billing events safely, while the other explores how to enforce usage limits for long-running jobs. This article focuses on the entitlement check itself: its latency, how long its answers can become outdated, and what happens when the billing provider stops responding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The access token tells you who the customer is, not what their plan allows
&lt;/h2&gt;

&lt;p&gt;Before building the gateway, I needed to understand exactly what information Kinde makes available in an access token.&lt;/p&gt;

&lt;p&gt;The relevant claims include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sub&lt;/code&gt;: The user.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;org_code&lt;/code&gt;: The organization.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;permissions&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature_flags&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The billing information available in the documented token claims is more limited. Kinde documents two trial-related claims: &lt;code&gt;has_trial_period&lt;/code&gt; and &lt;code&gt;trial_expires_on&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I couldn't find a claim containing the customer's current plan or entitlement limits.&lt;/p&gt;

&lt;p&gt;The token's lifetime makes relying on it even more problematic.&lt;/p&gt;

&lt;p&gt;By default, an access token remains valid for 24 hours. Kinde's documentation also explains that revoking a token, ending a session or deleting a user doesn't shorten the lifetime of a token that has already been issued.&lt;/p&gt;

&lt;p&gt;So a customer can continue presenting a valid token long after the subscription information associated with it has changed.&lt;/p&gt;

&lt;p&gt;Kinde provides two ways to retrieve entitlements outside the token.&lt;/p&gt;

&lt;p&gt;The first is the Account API:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /account_api/v1/entitlements&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This uses the signed-in user's access token.&lt;/p&gt;

&lt;p&gt;The second is the Management API:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/billing/entitlements&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This requires a machine-to-machine token with the &lt;code&gt;read:billing_entitlements&lt;/code&gt; scope. The response includes each entitlement's &lt;code&gt;feature_code&lt;/code&gt; and &lt;code&gt;entitlement_limit_max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I chose the Management API because Stub operates as a backend gateway rather than a user-facing application.&lt;/p&gt;

&lt;p&gt;Kinde also discusses enforcing entitlements on the backend, alongside the checks performed in the product interface, in its &lt;a href="https://www.kinde.com/blog/engineering/access-denied/" rel="noopener noreferrer"&gt;engineering blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This gives the gateway two separate responsibilities. It verifies the access token to establish the customer's identity, then retrieves their entitlements to determine what their subscription currently allows.&lt;/p&gt;

&lt;p&gt;The remaining question is how to perform that second check without making every request unnecessarily expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the access token locally
&lt;/h2&gt;

&lt;p&gt;I wanted the identity check to happen without an additional network request.&lt;/p&gt;

&lt;p&gt;Kinde signs access tokens using RS256, which combines RSA signatures with SHA-256. It publishes the corresponding public keys at &lt;code&gt;/.well-known/jwks&lt;/code&gt; on the Kinde domain.&lt;/p&gt;

&lt;p&gt;Stub uses those public keys to verify the token signature, issuer and expiration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=30&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Each Kinde environment&lt;/a&gt; has its own issuer value, so a token issued by another environment fails verification.&lt;/p&gt;

&lt;p&gt;Hono provides built-in JWK middleware for this. I chose to use the &lt;code&gt;jose&lt;/code&gt; library directly instead, keeping the verification logic inside a plain function.&lt;/p&gt;

&lt;p&gt;That made testing easier. I could provide a local key set and verify tokens without making any network requests.&lt;/p&gt;

&lt;p&gt;An invalid token receives a 401 response before the gateway attempts an entitlement lookup.&lt;/p&gt;

&lt;p&gt;Once the token passes verification, Stub identifies the customer using the organization code when one is available, falling back to the user otherwise.&lt;/p&gt;

&lt;p&gt;The cache key includes that identity, which prevents one organization's entitlement response from being reused for another.&lt;/p&gt;

&lt;p&gt;With identity verification handled locally, I moved on to the actual billing lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The entitlements endpoint needs a billing customer ID
&lt;/h2&gt;

&lt;p&gt;I ran into an unexpected detail when connecting Stub to the Kinde Management API.&lt;/p&gt;

&lt;p&gt;The access token contains an organization code, something like &lt;code&gt;org_8c27…&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, the entitlements endpoint expects a billing customer ID, which looks like &lt;code&gt;customer_01a1…&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The API reference I was working from didn't make it clear which identifier the endpoint expected, so I tested both against my three organizations.&lt;/p&gt;

&lt;p&gt;Using the organization code, all three requests returned HTTP 500. The response body contained only an error ID.&lt;/p&gt;

&lt;p&gt;Using the billing customer ID, all three requests returned HTTP 200 with the expected entitlements.&lt;/p&gt;

&lt;p&gt;The Free plan returned &lt;code&gt;reports&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pro returned &lt;code&gt;reports&lt;/code&gt; and &lt;code&gt;exports&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Business returned all three features.&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%2Fvswnwlzg51ybhrb04ick.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%2Fvswnwlzg51ybhrb04ick.png" alt="The same endpoint asked with an organization code and with a billing customer ID" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I then tried retrieving the billing customer ID through Kinde's organization endpoint. All three organizations returned &lt;code&gt;billing: null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For this implementation, I created a small file mapping organization codes to their corresponding billing customer IDs.&lt;/p&gt;

&lt;p&gt;There's a more automated approach available. A live plan-change event from Kinde includes both &lt;code&gt;org_code&lt;/code&gt; and &lt;code&gt;customer_id&lt;/code&gt;, so a webhook handler could maintain this mapping as events arrive. I didn't implement that part in Stub.&lt;/p&gt;

&lt;p&gt;An organization missing from the mapping also needs special treatment.&lt;/p&gt;

&lt;p&gt;Stub classifies it as unknown rather than treating it as a billing provider failure. Otherwise, a missing mapping for one customer could count towards the circuit breaker threshold and potentially prevent other customers from retrieving their entitlements.&lt;/p&gt;

&lt;p&gt;One other detail about my test plans: all their features are unmetered. Kinde reports their entitlement limit as &lt;code&gt;2,147,483,647&lt;/code&gt;, the maximum signed 32-bit integer.&lt;/p&gt;

&lt;p&gt;Consequently, Stub checks whether a feature exists in the customer's entitlements. It doesn't enforce usage counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you call Kinde on every request?
&lt;/h2&gt;

&lt;p&gt;The simplest implementation would be to call Kinde's Management API whenever a request needs an entitlement check.&lt;/p&gt;

&lt;p&gt;I started by measuring what that actually costs.&lt;/p&gt;

&lt;p&gt;I made 30 consecutive entitlement lookups from my laptop in Lagos.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median latency&lt;/td&gt;
&lt;td&gt;477 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastest request&lt;/td&gt;
&lt;td&gt;434 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slowest request&lt;/td&gt;
&lt;td&gt;857 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There was another cost worth accounting for: obtaining the machine-to-machine access token needed for the lookup.&lt;/p&gt;

&lt;p&gt;The first token request took 4,598 ms.&lt;/p&gt;

&lt;p&gt;Stub handles this separately. It retrieves the token at startup and refreshes it 30 seconds before expiration, so individual customer requests don't have to wait for token acquisition.&lt;/p&gt;

&lt;p&gt;I then sent 40 requests through Stub, with four running concurrently and a fresh entitlement lookup for each request.&lt;/p&gt;

&lt;p&gt;The gateway handled 6.0 requests per second, with a median request latency of 499 ms.&lt;/p&gt;

&lt;p&gt;For comparison, I ran another test with the entitlement cache already populated. This time, I sent 2,000 requests with 50 running concurrently.&lt;/p&gt;

&lt;p&gt;The gateway handled approximately 16,000 requests per second, with a median latency of 1.8 ms.&lt;/p&gt;

&lt;p&gt;The two tests used different load patterns, so throughput isn't directly comparable. The median latency gives us a more useful comparison: moving from a live lookup to a warm cache reduced the median from 499 ms to 1.8 ms, roughly 280 times.&lt;/p&gt;

&lt;p&gt;I also wanted to understand what happened when multiple requests needed Kinde simultaneously.&lt;/p&gt;

&lt;p&gt;I sent 16 entitlement lookups at different concurrency levels: 1, 4, 8 and 16 concurrent requests.&lt;/p&gt;

&lt;p&gt;As concurrency increased, the median lookup latency rose from 472 ms to 1,328 ms. The slowest request at eight concurrent lookups took 1,640 ms.&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%2Fuv04onz3ajo4efisc0hs.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%2Fuv04onz3ajo4efisc0hs.png" alt="Live lookup time at four levels of overlap, against Stub's 1,500 ms timeout" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=30&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's API rate-limit documentation&lt;/a&gt; describes both a rate limiter and a concurrency limiter. Exceeding either can produce a 429 response, although the documentation doesn't publish the actual limits.&lt;/p&gt;

&lt;p&gt;None of my requests received a 429, so I couldn't establish where those limits sit.&lt;/p&gt;

&lt;p&gt;What I could establish was that a gateway making one billing request per application request inherits the billing provider's latency and concurrency constraints.&lt;/p&gt;

&lt;p&gt;That makes the approach difficult to scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two cache windows: fresh and stale
&lt;/h2&gt;

&lt;p&gt;A cache isn't simply a performance optimisation. It's also a decision about how long the application is willing to use information that might have changed.&lt;/p&gt;

&lt;p&gt;I wanted Stub to handle two different situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The cached entitlement is recent enough to trust.&lt;/li&gt;
&lt;li&gt;The cached entitlement might be outdated, but retrieving a new one would slow down the current request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Instead of using one expiration time, Stub has two windows.&lt;/p&gt;

&lt;p&gt;An entitlement response younger than 30 seconds is considered fresh. The gateway returns it immediately.&lt;/p&gt;

&lt;p&gt;Between 30 seconds and 5 minutes, the response is considered stale. Stub still returns the existing value immediately, but starts a background refresh at the same time.&lt;/p&gt;

&lt;p&gt;The next request can then use the refreshed value.&lt;/p&gt;

&lt;p&gt;Once the cached response is older than five minutes, or no response exists at all, the request has to wait for Kinde.&lt;/p&gt;

&lt;p&gt;Here's the relevant part of the implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ageOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;entry&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="nf"&gt;now&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="nx"&gt;freshMs&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="nx"&gt;staleOkMs&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// refresh behind the answer&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&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="na"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetched&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&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%2Fcopk0donycec7bwuqhaj.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%2Fcopk0donycec7bwuqhaj.png" alt="The path of one request: token check, cache lookup, and the access decision" width="800" height="911"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important behaviour is that a request with an existing answer doesn't have to wait for the background refresh.&lt;/p&gt;

&lt;p&gt;That keeps the gateway responsive, including when the cached information is slightly outdated.&lt;/p&gt;

&lt;p&gt;Of course, there's a trade-off. A customer who has just downgraded might still use their previous entitlements during that stale window.&lt;/p&gt;

&lt;p&gt;I wanted to understand what happens when several requests encounter an empty cache at the same time, because that's another situation where a seemingly simple caching implementation can become expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevent 200 requests from making 200 billing calls
&lt;/h2&gt;

&lt;p&gt;Consider a customer who has never used the gateway before.&lt;/p&gt;

&lt;p&gt;Their entitlements aren't in the cache. Now imagine 200 requests from that customer arrive simultaneously.&lt;/p&gt;

&lt;p&gt;A basic cache implementation could send 200 separate requests to Kinde. Each incoming request checks the cache, finds nothing and starts its own lookup.&lt;/p&gt;

&lt;p&gt;This is commonly known as a cache stampede.&lt;/p&gt;

&lt;p&gt;Stub handles it by keeping track of entitlement lookups that are already in progress.&lt;/p&gt;

&lt;p&gt;When a request needs an entitlement response, it first checks whether another request is already retrieving that customer's information. If so, it joins the existing lookup rather than starting another one.&lt;/p&gt;

&lt;p&gt;I tested this with 200 simultaneous requests for a customer whose entitlements weren't cached.&lt;/p&gt;

&lt;p&gt;Against live Kinde, all 200 requests completed between 0.83 and 0.87 seconds across two runs.&lt;/p&gt;

&lt;p&gt;That is consistent with all 200 requests sharing the time required for one lookup.&lt;/p&gt;

&lt;p&gt;Every response was marked as &lt;code&gt;fetched&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The live test doesn't expose the number of API calls made, so I used a stand-in provider and a unit test to verify the actual call count.&lt;/p&gt;

&lt;p&gt;With 200 simultaneous requests, the stand-in received:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;th&gt;Provider lookups&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Without request merging&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With request merging&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I didn't run the unmerged implementation against live Kinde.&lt;/p&gt;

&lt;p&gt;This behaviour is particularly useful after deployments. If a restart empties the cache and a large number of customers begin making requests, the gateway doesn't immediately turn that traffic spike into an equally large spike of billing API calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Kinde webhooks to invalidate the cache when a plan changes
&lt;/h2&gt;

&lt;p&gt;The two cache windows solve the latency problem, but there's still a question of accuracy.&lt;/p&gt;

&lt;p&gt;Without any notification mechanism, Stub can continue serving an outdated entitlement response for up to 30 seconds after a plan change.&lt;/p&gt;

&lt;p&gt;I wanted to reduce that window.&lt;/p&gt;

&lt;p&gt;Kinde provides billing events that tell an application when a customer's subscription changes.&lt;/p&gt;

&lt;p&gt;The two relevant events here are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer.plan_changed&lt;/code&gt;, sent when a customer upgrades or downgrades.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer.plan_assigned&lt;/code&gt;, sent when a customer is associated with a plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stub exposes a webhook endpoint that receives these events, verifies their signatures and removes the affected customer from the cache.&lt;/p&gt;

&lt;p&gt;The next request for that customer retrieves their entitlements from Kinde.&lt;/p&gt;

&lt;p&gt;There's also a fallback in the event handler. If an event doesn't contain a customer identifier in a format Stub recognises, the handler clears the entire cache.&lt;/p&gt;

&lt;p&gt;I'd rather trigger a few additional lookups than leave an affected customer's old entitlements in place because the application couldn't identify which cache entry to invalidate.&lt;/p&gt;

&lt;p&gt;I've covered the webhook verification and delivery handling in more detail in my earlier article on billing events, so I won't repeat that implementation here.&lt;/p&gt;

&lt;p&gt;What I did want to investigate was the actual event payload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=30&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's documentation&lt;/a&gt; lists the available event types but doesn't specify all their fields. I inspected a live &lt;code&gt;customer.plan_changed&lt;/code&gt; event and found the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;customer_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;customer_type&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;org_code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;plan.key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;event_timestamp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;timestamp&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There was also an issue with signature verification.&lt;/p&gt;

&lt;p&gt;My first two webhook events were rejected because Stub expected an issuer claim in the event token. Kinde's webhook event tokens don't include one.&lt;/p&gt;

&lt;p&gt;I adjusted the verifier to check the signature against Kinde's public keys while skipping the issuer check for webhook events only.&lt;/p&gt;

&lt;p&gt;I also added a test to make sure an event signed with a different key was still rejected.&lt;/p&gt;

&lt;p&gt;With that fixed, I measured how long a plan change took to reach the gateway.&lt;/p&gt;

&lt;p&gt;Kinde stamped the event at &lt;code&gt;22:18:04.8 UTC&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A script polling the Management API every 250 milliseconds detected the new plan at &lt;code&gt;22:18:05.8&lt;/code&gt;, approximately one second later.&lt;/p&gt;

&lt;p&gt;Stub received and logged the event at &lt;code&gt;22:18:07.5&lt;/code&gt;, which was 2.7 seconds after Kinde's event timestamp.&lt;/p&gt;

&lt;p&gt;The webhook then invalidated the customer's cached entitlements.&lt;/p&gt;

&lt;p&gt;The next request had to perform a fresh lookup, which had a median latency of 477 ms in my earlier measurements.&lt;/p&gt;

&lt;p&gt;Combining those two measurements gives us approximately 3.2 seconds from the event timestamp to the next request receiving an updated entitlement response.&lt;/p&gt;

&lt;p&gt;These figures come from two separate runs.&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%2Ffg7gtxebwlfdc67krgw3.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%2Ffg7gtxebwlfdc67krgw3.png" alt="A plan change from Kinde's event stamp to the gateway's next answer, against the 30-second expiry" width="799" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this particular test, the event arrived after the new plan was already available through the Management API. That meant the lookup following cache invalidation returned the updated entitlements.&lt;/p&gt;

&lt;p&gt;I didn't test what would happen if the webhook arrived before the new plan became readable through the API.&lt;/p&gt;

&lt;p&gt;I also wanted to compare this with what happens when the gateway relies entirely on cache expiration.&lt;/p&gt;

&lt;p&gt;I didn't run that experiment against live Kinde, but I tested it with the stand-in provider.&lt;/p&gt;

&lt;p&gt;Across three trials, a plan change became visible after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;29.9 seconds&lt;/li&gt;
&lt;li&gt;28.6 seconds&lt;/li&gt;
&lt;li&gt;5.8 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The variation is expected. The plan change can happen at any point during the 30-second fresh window, so the application waits for whatever time remains before the cached value expires.&lt;/p&gt;

&lt;p&gt;The webhook significantly reduces that delay, but it doesn't make cache expiration unnecessary.&lt;/p&gt;

&lt;p&gt;Events can arrive late, be delivered more than once or fail to arrive altogether.&lt;/p&gt;

&lt;p&gt;Kinde documents a retry schedule that starts immediately and then continues after 5 seconds, 30 seconds, 2 minutes and subsequent intervals.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;webhook-id&lt;/code&gt; header remains consistent across retries, allowing the application to identify duplicate deliveries.&lt;/p&gt;

&lt;p&gt;Keeping the expiration policy provides another way to recover when webhook delivery doesn't go as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should happen when the entitlement lookup fails?
&lt;/h2&gt;

&lt;p&gt;So far, the gateway has been able to retrieve the customer's entitlements.&lt;/p&gt;

&lt;p&gt;But billing providers can become unavailable, requests can time out, and a customer might make their first request while the provider is experiencing an outage.&lt;/p&gt;

&lt;p&gt;At that point, Stub can be in one of two situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It has an older entitlement response for the customer.&lt;/li&gt;
&lt;li&gt;It has never successfully retrieved the customer's entitlements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application needs to decide what to do in each case.&lt;/p&gt;

&lt;p&gt;I didn't want to make this a single global decision because the consequences of granting access differ between features.&lt;/p&gt;

&lt;p&gt;For example, allowing someone to read a report when the billing provider is unavailable might be acceptable.&lt;/p&gt;

&lt;p&gt;Allowing someone to perform an expensive export or run an AI agent that incurs usage costs could be a different matter.&lt;/p&gt;

&lt;p&gt;Stub divides routes into two classes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheap routes:&lt;/strong&gt; These are operations where temporarily allowing access has a relatively low cost, such as reading a report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive routes:&lt;/strong&gt; These are operations where granting access incorrectly could expose data or create financial costs, such as exports and agent runs.&lt;/p&gt;

&lt;p&gt;When Stub has no usable entitlement information, it allows cheap routes and returns HTTP 503 for sensitive routes.&lt;/p&gt;

&lt;p&gt;Here's the decision function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Entitlements&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="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;routeClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouteClass&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routeClass&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cheap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown-allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The plan could not be checked right now, so this route is closed until it can.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The plan does not include "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;feature&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means each route declares its own classification in the route table.&lt;/p&gt;

&lt;p&gt;I prefer this to a single global fail-open or fail-closed setting. The consequences of a wrong access decision depend on what the endpoint actually does.&lt;/p&gt;

&lt;p&gt;I tested the decision logic against all three live Kinde plans, checking three routes for each customer.&lt;/p&gt;

&lt;p&gt;All nine checks matched the expected entitlements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Reports&lt;/th&gt;
&lt;th&gt;Exports&lt;/th&gt;
&lt;th&gt;Agent runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There were two additional mechanisms I wanted in place to protect the gateway when Kinde became slow or unavailable.&lt;/p&gt;

&lt;p&gt;The first is a timeout.&lt;/p&gt;

&lt;p&gt;Stub stops waiting for an entitlement lookup after 1.5 seconds. This prevents a slow provider from holding application requests indefinitely.&lt;/p&gt;

&lt;p&gt;The second is a circuit breaker.&lt;/p&gt;

&lt;p&gt;After three consecutive lookup failures, Stub stops making further requests to Kinde for two seconds. Once that period expires, one request is allowed through to test whether the provider has recovered.&lt;/p&gt;

&lt;p&gt;Without the circuit breaker, every new customer making a request during an outage could trigger another 1.5-second wait.&lt;/p&gt;

&lt;p&gt;I saw both mechanisms in action during the live tests, although I hadn't deliberately caused the provider to fail.&lt;/p&gt;

&lt;p&gt;In one run, 37 out of 40 entitlement lookups returned an unknown result.&lt;/p&gt;

&lt;p&gt;Of the 40 application requests, 16 received HTTP 200 and 24 received HTTP 503. The 503 responses came from sensitive routes.&lt;/p&gt;

&lt;p&gt;The 95th percentile latency was 1,512 ms, almost exactly the configured timeout.&lt;/p&gt;

&lt;p&gt;I ran the same script again, and all 40 lookups succeeded.&lt;/p&gt;

&lt;p&gt;I couldn't establish why the first run failed. One possible explanation is the overlap behaviour from the earlier concurrency experiment: at eight simultaneous lookups, the slowest request took 1,640 ms, exceeding Stub's 1,500 ms timeout.&lt;/p&gt;

&lt;p&gt;That is a possible explanation, not a confirmed cause.&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%2Fo5cvw9x353etszuxho2u.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%2Fo5cvw9x353etszuxho2u.png" alt="What the gateway does after a failed lookup" width="800" height="877"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stub also allows an older entitlement response to remain usable for up to five minutes while Kinde is unavailable.&lt;/p&gt;

&lt;p&gt;This introduces another trade-off.&lt;/p&gt;

&lt;p&gt;A customer who downgraded just before an outage could continue using their previous plan for up to five minutes.&lt;/p&gt;

&lt;p&gt;On the other hand, a customer who is still paying for a feature shouldn't necessarily lose access because the billing provider is temporarily unavailable.&lt;/p&gt;

&lt;p&gt;For this demonstration, I considered the first cost smaller than the second.&lt;/p&gt;

&lt;p&gt;That decision will depend on the product. The five-minute window is configurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarking the gateway with a stand-in provider
&lt;/h2&gt;

&lt;p&gt;Some of the behaviours I wanted to measure are difficult to reproduce reliably against a live billing provider.&lt;/p&gt;

&lt;p&gt;I can't ask Kinde to fail on demand, deliberately delay every request or generate a controlled traffic spike.&lt;/p&gt;

&lt;p&gt;For those experiments, I built a stand-in provider.&lt;/p&gt;

&lt;p&gt;It has a plan table, a configurable lookup delay and a switch that forces entitlement requests to fail.&lt;/p&gt;

&lt;p&gt;I set the delay to 150 milliseconds. This was a value I chose for the experiment; a real Kinde lookup took approximately three times as long.&lt;/p&gt;

&lt;p&gt;Each benchmark starts a fresh server process, and both the server and load generator run on the same machine.&lt;/p&gt;

&lt;p&gt;The first benchmark compares four gateway modes across 3,000 requests.&lt;/p&gt;

&lt;p&gt;I used 20 concurrent requests across 200 customers, with 80% of requests distributed among 20% of those customers.&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%2Fziynm40o3qtvjwzqg5l8.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%2Fziynm40o3qtvjwzqg5l8.png" alt="Latency of four gate modes, measured with the 150 ms stand-in" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a warm cache, the median latency was 0.97 ms. Without an entitlement check, it was 1.06 ms.&lt;/p&gt;

&lt;p&gt;That difference falls within the variation between runs, so I wouldn't claim that entitlement checks have zero overhead. What I can say is that I couldn't reliably measure their additional cost at this precision.&lt;/p&gt;

&lt;p&gt;The more interesting result was the empty-cache test.&lt;/p&gt;

&lt;p&gt;Its 95th percentile latency was 151 ms because the first request for a customer had to wait for the provider lookup.&lt;/p&gt;

&lt;p&gt;For reference, the 95th percentile is the latency below which 95% of requests complete.&lt;/p&gt;

&lt;p&gt;The cache hit rates were:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cache state&lt;/th&gt;
&lt;th&gt;Hit rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Warm cache&lt;/td&gt;
&lt;td&gt;99.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty start&lt;/td&gt;
&lt;td&gt;91.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even with an empty cache, the gateway made only 192 entitlement lookups across 3,000 requests.&lt;/p&gt;

&lt;p&gt;The second benchmark focused on provider failures, traffic bursts and stale entitlement responses.&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%2F2bvq840k7vviosxvxd1g.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%2F2bvq840k7vviosxvxd1g.png" alt="Failure, bursts and stale answers, measured with the stand-in" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the stand-in provider was unavailable, all 1,000 requests from customers whose entitlements were already cached were allowed through.&lt;/p&gt;

&lt;p&gt;The median latency was 1.71 ms.&lt;/p&gt;

&lt;p&gt;I also tested customers who had no cached entitlement information:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All 40 requests to cheap routes were allowed.&lt;/li&gt;
&lt;li&gt;All 40 requests to sensitive routes received HTTP 503.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the next experiment, I configured the provider to hang for five seconds.&lt;/p&gt;

&lt;p&gt;The first three requests took 308 ms, 302 ms and 306 ms. These requests each waited for the 300 ms timeout configured for that particular benchmark.&lt;/p&gt;

&lt;p&gt;The remaining 117 requests completed with median latencies between 0.4 and 0.5 ms.&lt;/p&gt;

&lt;p&gt;Across the entire run, the gateway made only three provider lookups.&lt;/p&gt;

&lt;p&gt;The circuit breaker remained open for the rest of the run, which finished before its two-second recovery window expired.&lt;/p&gt;

&lt;p&gt;I also built an inspector page in the repository so these behaviours can be explored without running the benchmark scripts.&lt;/p&gt;

&lt;p&gt;It lets you send requests as three different customers, simulate a provider outage, assign a new feature to a customer and manually invalidate a cache entry.&lt;/p&gt;

&lt;p&gt;The page also shows where each entitlement response came from.&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%2Ftcn48mvm7etyydnwloxx.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%2Ftcn48mvm7etyydnwloxx.png" alt="The inspector page after a burst, a refused export and a service outage" width="800" height="668"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;p&gt;There are several limitations worth keeping in mind when interpreting these results.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The live measurements came from one laptop and three customers.&lt;/strong&gt; Every live test ran from Lagos, so the lookup timings include that network path. A server closer to Kinde will see different results. Three customers also tell us very little about cache behaviour at production scale.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I never received a 429 response.&lt;/strong&gt; Kinde documents both rate and concurrency limits, but doesn't publish their numerical thresholds. I don't know where those limits sit or what an actual limit response looks like.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One live run failed without a confirmed cause.&lt;/strong&gt; Thirty-seven of 40 lookups returned unknown in that run, while a repeat succeeded. The 1,500 ms timeout and the latency observed during concurrent requests are possible contributing factors, but I haven't established that connection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I timed one plan change end to end.&lt;/strong&gt; The 1.0-second and 2.7-second measurements came from one event. I didn't compare the clocks on Kinde's side and my laptop, and the earlier plan changes weren't timed against event timestamps.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Two events reached Stub approximately half a second apart.&lt;/strong&gt; Both were rejected. I couldn't determine whether these were separate events or a retry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I didn't test webhook delivery while Stub was unavailable.&lt;/strong&gt; The retry schedule comes from Kinde's documentation. I also didn't test a webhook arriving before the updated plan became readable through the API.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The expiry-only measurements used the stand-in provider.&lt;/strong&gt; I didn't measure that path against live Kinde.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The outage and timeout benchmarks used the stand-in.&lt;/strong&gt; Those results come from a provider I controlled with a 150 ms lookup delay.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The benchmarks used an already-loaded public key set.&lt;/strong&gt; Verifying a token against locally available keys is different from handling a request that needs to retrieve Kinde's public keys. I didn't measure the latter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The cache lives inside one process.&lt;/strong&gt; If Stub runs across multiple instances, each instance has its own cache. A webhook received by one instance won't automatically invalidate the others. A shared cache or broadcast mechanism would be needed, with its own latency and implementation costs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The customer mapping is a file.&lt;/strong&gt; This works for three test customers. A production implementation needs a way to populate and maintain that mapping as customers sign up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stub checks feature presence, not remaining usage.&lt;/strong&gt; All my test plans use unmetered entitlements, so I didn't test counted limits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stale responses can temporarily grant access to a feature a customer has lost.&lt;/strong&gt; This can happen for up to 30 seconds during normal operation and up to five minutes while Kinde is unavailable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The route classifications are my own design decision.&lt;/strong&gt; Two categories were enough for this demonstration. A production application might need more granular classifications or customer-specific rules.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who decides how long an entitlement can be stale?
&lt;/h2&gt;

&lt;p&gt;There's one decision in this implementation that deserves more attention than it usually gets: the cache duration.&lt;/p&gt;

&lt;p&gt;It's easy to treat a cache TTL as a performance setting. Someone adds &lt;code&gt;ttl: 60&lt;/code&gt; to a pull request, the application gets faster, and nobody revisits the number.&lt;/p&gt;

&lt;p&gt;But the TTL determines how long a customer might continue receiving access based on information that is no longer accurate.&lt;/p&gt;

&lt;p&gt;A 30-second fresh window means a downgrade can take up to 30 seconds to be enforced through expiration alone. It also means an upgrade can take up to 30 seconds to become visible if no invalidation event arrives.&lt;/p&gt;

&lt;p&gt;Those delays have consequences beyond engineering.&lt;/p&gt;

&lt;p&gt;Finance may care about how long a downgraded customer can continue using a paid feature. Support may care about how long an upgraded customer waits before gaining access. The customer has an expectation about when their subscription change takes effect.&lt;/p&gt;

&lt;p&gt;The engineer building the gateway shouldn't have to make that decision alone.&lt;/p&gt;

&lt;p&gt;The acceptable stale window should be documented, its trade-offs understood, and agreed upon by the people responsible for the product and the costs associated with incorrect access decisions.&lt;/p&gt;

&lt;p&gt;The fast path is relatively straightforward to implement. Deciding how long the application is allowed to be wrong is the more important product decision.&lt;/p&gt;

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

&lt;p&gt;The complete implementation is available in &lt;a href="https://github.com/sholajegede/stub" rel="noopener noreferrer"&gt;the Stub repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You'll need &lt;a href="https://bun.sh" rel="noopener noreferrer"&gt;Bun&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Clone the repository and install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun &lt;span class="nb"&gt;install
&lt;/span&gt;bun run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3100&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Stub uses the stand-in provider by default and generates its own test tokens, so you don't need a Kinde account to explore the implementation.&lt;/p&gt;

&lt;p&gt;There are 29 tests, which you can run with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To reproduce the stand-in benchmarks, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun scripts/bench.ts perf
bun scripts/bench.ts burst
bun scripts/bench.ts down
bun scripts/bench.ts slow
bun scripts/bench.ts stale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stale&lt;/code&gt; benchmark takes approximately three minutes.&lt;/p&gt;

&lt;p&gt;To reproduce the live Kinde experiments, create a machine-to-machine application with the &lt;code&gt;read:billing_entitlements&lt;/code&gt; scope.&lt;/p&gt;

&lt;p&gt;Then configure &lt;code&gt;.env.local&lt;/code&gt; and &lt;code&gt;customers.json&lt;/code&gt; using the supplied example files.&lt;/p&gt;

&lt;p&gt;The live scripts are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bun run live
bun run live-gate
bun run live-concurrency
bun run plan-watch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final script watches one customer for three minutes while you change their subscription plan.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not put the subscription plan in the access token?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kinde's documented access token claims don't include billing entitlements. Tokens also last 24 hours by default, and revoking one doesn't shorten the lifetime of a token that has already been issued.&lt;/p&gt;

&lt;p&gt;Even if you added the plan to the token, you'd still need a mechanism to update tokens that were issued before a subscription change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which identifier does the Kinde entitlements endpoint require?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my live tests, it required the billing customer ID.&lt;/p&gt;

&lt;p&gt;Using the organization code returned HTTP 500 for all three test organizations. Kinde's organization endpoint also returned &lt;code&gt;billing: null&lt;/code&gt;, so Stub maintains a mapping between organization codes and billing customer IDs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How quickly does a plan change reach the gateway?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my single timed experiment, Kinde's event reached Stub 2.7 seconds after its recorded event timestamp.&lt;/p&gt;

&lt;p&gt;The next entitlement lookup took approximately another half-second, based on the median from my earlier tests.&lt;/p&gt;

&lt;p&gt;Without webhook invalidation, the fresh cache window can delay the update by up to 30 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why use a stale window instead of simply reducing the cache duration?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A shorter cache duration means more requests will have to wait for a fresh entitlement lookup.&lt;/p&gt;

&lt;p&gt;The stale window allows the gateway to return an existing response immediately while refreshing it in the background. Only a request without a usable cached answer has to wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a 30-second cache window too long?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That depends on the consequences of using outdated entitlements.&lt;/p&gt;

&lt;p&gt;The webhook reduces the delay for plan changes that Kinde successfully reports. If your product cannot tolerate 30 seconds without a notification, you can shorten the fresh window, although that will increase the number of provider lookups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when 200 requests arrive for a customer whose entitlements aren't cached?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They share one in-progress lookup.&lt;/p&gt;

&lt;p&gt;In my stand-in test, 200 simultaneous requests resulted in one provider call instead of 200. In the live Kinde test, all 200 requests completed within approximately the time required for one lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should an entitlement gateway fail open or fail closed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I treat this as a route-level decision.&lt;/p&gt;

&lt;p&gt;Allowing access when the provider is unavailable makes sense when the cost of a wrong approval is low. For operations that can expose sensitive data or incur financial costs, returning an error is the more appropriate choice.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/sholajegede/stub" rel="noopener noreferrer"&gt;Stub source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;About access tokens, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/configure-tokens/" rel="noopener noreferrer"&gt;Configure token and session expiry, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/verifying-json-web-tokens" rel="noopener noreferrer"&gt;Verifying JSON Web Tokens, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/developer-tools/account-api/about-account-api/" rel="noopener noreferrer"&gt;About the Account API, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apis.io/apis/kinde/kinde-billing-entitlements-api/" rel="noopener noreferrer"&gt;Kinde Billing Entitlements API reference, apis.io mirror&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/developer-tools/kinde-api/api-rate-limits/" rel="noopener noreferrer"&gt;API rate limits, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/integrate/webhooks/about-webhooks/" rel="noopener noreferrer"&gt;About webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/manage-customer-activity-webhooks/" rel="noopener noreferrer"&gt;Respond to customer activity using webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kinde.com/blog/engineering/access-denied/" rel="noopener noreferrer"&gt;Access denied, Kinde blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hono.dev/docs/middleware/builtin/jwk" rel="noopener noreferrer"&gt;JWK middleware, Hono docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>architecture</category>
      <category>kinde</category>
    </item>
    <item>
      <title>How to Stop Background Jobs From Running on a Customer's Old Plan</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:45:29 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-background-jobs-from-running-on-a-customers-old-plan-4f7f</link>
      <guid>https://dev.to/sholajegede/how-to-stop-background-jobs-from-running-on-a-customers-old-plan-4f7f</guid>
      <description>&lt;p&gt;Imagine a customer using an uptime monitoring service on the Pro plan. They have 10 monitors running, and their subscription allows up to 25.&lt;/p&gt;

&lt;p&gt;They downgrade to Free, which only allows 3 monitors.&lt;/p&gt;

&lt;p&gt;What happens to the other 7?&lt;/p&gt;

&lt;p&gt;The billing platform knows about the downgrade. The application knows about it too. But there's another part of the system that needs to know: the background job responsible for running those monitors.&lt;/p&gt;

&lt;p&gt;Here's how the sequence plays out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Kinde, the billing platform, stores the new plan. Its API reports the updated limit within three seconds.&lt;/li&gt;
&lt;li&gt;Kinde sends the application a &lt;code&gt;customer.plan_changed&lt;/code&gt; event.&lt;/li&gt;
&lt;li&gt;Separately, a cron trigger starts the monitoring job once a minute. Before doing anything, the job needs to know how many monitors the customer's plan allows. It gets this information from a stored copy.&lt;/li&gt;
&lt;li&gt;If that copy is older than 30 seconds, the job fetches the latest limit from Kinde and pauses the 7 newest monitors. If the copy is younger, it trusts the stored value and all 10 monitors continue running.&lt;/li&gt;
&lt;/ol&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%2Fl56t7wb4t19c4kriir1j.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%2Fl56t7wb4t19c4kriir1j.png" alt="What happens after a customer moves from Pro to Free" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem is in that final step.&lt;/p&gt;

&lt;p&gt;The billing platform has already processed the downgrade, but the background job is still working with information from the previous plan. For a short period, the customer continues receiving a service their new subscription no longer covers.&lt;/p&gt;

&lt;p&gt;The reverse happens when someone upgrades. A customer who has just paid for Pro might still have their monitors restricted by the Free plan.&lt;/p&gt;

&lt;p&gt;This is an easy problem to overlook because both parts of the system are doing what they're supposed to do. The billing platform updates the subscription, and the background job follows the rules it was given. The issue is that they're working with different versions of the same information.&lt;/p&gt;

&lt;p&gt;I wanted to understand exactly how long this gap could last, what happens when a plan changes during an active session, and how to keep background jobs in sync with billing changes.&lt;/p&gt;

&lt;p&gt;I built a small uptime monitoring application called Siteward to investigate.&lt;/p&gt;

&lt;p&gt;Siteward runs on a Cloudflare Worker, uses a cron trigger for scheduled checks, and stores its data in D1, Cloudflare's SQLite database. Kinde handles authentication and billing.&lt;/p&gt;

&lt;p&gt;Every measurement in this article comes from experiments against a &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=29&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;real Kinde account&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up plan limits in Siteward
&lt;/h2&gt;

&lt;p&gt;Siteward has two features controlled by subscription limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The number of uptime monitors a customer can run.&lt;/li&gt;
&lt;li&gt;The number of alert channels they can connect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Kinde, these are called entitlements. An entitlement represents a feature granted by a plan, and a metered entitlement can have a maximum allowed value.&lt;/p&gt;

&lt;p&gt;I configured two plans with the following limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monitors&lt;/th&gt;
&lt;th&gt;Alert channels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Siteward retrieves these limits through the Kinde Management API:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/billing/entitlements&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The request uses a machine-to-machine token and returns the customer's entitlements, including their &lt;code&gt;feature_code&lt;/code&gt; and &lt;code&gt;entitlement_limit_max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's one detail worth understanding here: the API request needs the billing customer ID, which looks something like &lt;code&gt;customer_01a1…&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An access token from a signed-in user provides an organization code instead. To connect the two, Siteward maintains a small mapping between organization codes and billing customer IDs.&lt;/p&gt;

&lt;p&gt;For the experiments, I created three organizations, each assigned to a different Siteward plan. The organization used for most of the tests was on Pro and named Dabbit Pro.&lt;/p&gt;

&lt;p&gt;I also wrote a script that moves the organization between Pro and Free through the Management API. It does this by cancelling the existing agreement and creating a new one.&lt;/p&gt;

&lt;p&gt;With the billing setup in place, I could start looking at what the application actually knows when a customer's plan changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an existing session know about the customer's plan?
&lt;/h2&gt;

&lt;p&gt;When someone signs in to Siteward, Kinde issues an access token containing claims such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;sub&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;org_code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;permissions&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;feature_flags&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, Kinde's documented access token claims don't include the customer's current plan or entitlement limits. The billing-related claims are &lt;code&gt;has_trial_period&lt;/code&gt; and &lt;code&gt;trial_expires_on&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The token's lifetime makes this particularly important.&lt;/p&gt;

&lt;p&gt;By default, an access token lasts 24 hours. According to Kinde's documentation, revoking a token, ending a session or deleting a user does not shorten the lifetime of a token that has already been issued.&lt;/p&gt;

&lt;p&gt;That means a customer can have a perfectly valid access token created under a previous subscription.&lt;/p&gt;

&lt;p&gt;For Siteward, this means the access token can establish who is making a request, but it cannot tell the application what their subscription currently allows.&lt;/p&gt;

&lt;p&gt;I handle those as two separate checks. Siteward verifies the token signature on its server, then retrieves the customer's plan limits independently.&lt;/p&gt;

&lt;p&gt;I wanted to see what happened when the application relied on a stored copy of those limits instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring the problem
&lt;/h2&gt;

&lt;p&gt;The first version of the monitoring job used a straightforward caching strategy.&lt;/p&gt;

&lt;p&gt;Siteward stored each organization's plan limits in D1 alongside a timestamp. Whenever the job needed the limits, it checked the age of the stored value.&lt;/p&gt;

&lt;p&gt;If the value was less than 30 seconds old, it used it. Otherwise, it fetched the latest information from Kinde.&lt;/p&gt;

&lt;p&gt;To test this, I configured Dabbit Pro with 10 monitors, all due to run, and moved the organization between Pro and Free five times in each direction.&lt;/p&gt;

&lt;p&gt;I ran the entire sequence twice.&lt;/p&gt;

&lt;p&gt;In the first run, I cleared the stored plan before every cron execution. This forced the job to fetch the latest limits from Kinde.&lt;/p&gt;

&lt;p&gt;In the second run, I left the stored plan untouched, simulating what happens during normal operation between updates.&lt;/p&gt;

&lt;p&gt;Here are the results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Monitors allowed to run&lt;/th&gt;
&lt;th&gt;Stored plan cleared first&lt;/th&gt;
&lt;th&gt;Stored plan kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pro to Free, 5 times&lt;/td&gt;
&lt;td&gt;3 of 10&lt;/td&gt;
&lt;td&gt;3 running, 5 of 5&lt;/td&gt;
&lt;td&gt;10 running, 5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free to Pro, 5 times&lt;/td&gt;
&lt;td&gt;10 of 10 (limit 25)&lt;/td&gt;
&lt;td&gt;10 running, 5 of 5&lt;/td&gt;
&lt;td&gt;3 running, 5 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the stored plan was cleared, the job enforced the correct limit in all 10 changes.&lt;/p&gt;

&lt;p&gt;When I left the stored plan in place, it got every change wrong.&lt;/p&gt;

&lt;p&gt;After a downgrade, all 10 monitors continued running even though the new plan allowed only 3.&lt;/p&gt;

&lt;p&gt;After an upgrade, only 3 monitors continued running even though the customer had paid for a plan that allowed 25.&lt;/p&gt;

&lt;p&gt;The interesting part is that the stored values were only a few seconds old during these tests. They weren't obviously stale. They were simply older than the billing change.&lt;/p&gt;

&lt;p&gt;This also gives us two separate sources of delay.&lt;/p&gt;

&lt;p&gt;The 30-second cache allows the application to keep using an old value until it expires. After that, the cron schedule can introduce another delay of up to one minute before the next job execution fetches the new value.&lt;/p&gt;

&lt;p&gt;Those figures come from the application's cache configuration and cron schedule. I did not measure them in a deployed Worker.&lt;/p&gt;

&lt;p&gt;The results made one thing clear: having a cache expiry policy doesn't guarantee that a background job will act on the latest subscription information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the background job check the plan before running
&lt;/h2&gt;

&lt;p&gt;My first change was to make the background job retrieve the plan limits before processing any monitors.&lt;/p&gt;

&lt;p&gt;The job groups due monitors by organization. For each organization, it retrieves the entitlement limits and determines which monitors should remain active.&lt;/p&gt;

&lt;p&gt;It then pauses monitors that exceed the limit and runs the rest.&lt;/p&gt;

&lt;p&gt;The stored copy still helps avoid unnecessary API calls. If it's fresh, the job uses it. If it has expired, the job fetches the latest value from Kinde.&lt;/p&gt;

&lt;p&gt;I repeated the test with the stored plan cleared before execution.&lt;/p&gt;

&lt;p&gt;The job correctly handled both upgrades and downgrades in a single run.&lt;/p&gt;

&lt;p&gt;However, this only proved that the reconciliation logic worked. It didn't solve the original timing problem.&lt;/p&gt;

&lt;p&gt;During normal operation, the stored value could still be less than 30 seconds old, and the cron trigger might not run again for almost another minute.&lt;/p&gt;

&lt;p&gt;I considered reducing the cache duration, but that creates another problem.&lt;/p&gt;

&lt;p&gt;Every organization with due monitors could trigger a fresh Kinde API request on every cron execution. In my tests, a fresh read took approximately 1.5 seconds.&lt;/p&gt;

&lt;p&gt;That would increase API traffic and add latency to the background job without actually addressing why the information became stale in the first place.&lt;/p&gt;

&lt;p&gt;Kinde's billing FAQ also recommends keeping entitlement cache durations short and configuring webhooks to keep application access synchronized with subscription changes.&lt;/p&gt;

&lt;p&gt;That pointed towards a better approach: instead of making the background job check more frequently, let the application know when something changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding what happens to monitors that exceed the new limit
&lt;/h2&gt;

&lt;p&gt;Before implementing the webhook, I needed to decide what Siteward should do with monitors that were already running when a customer downgraded.&lt;/p&gt;

&lt;p&gt;Deleting them would be a poor experience. If the customer upgraded again a week later, those monitors would be gone.&lt;/p&gt;

&lt;p&gt;I decided to pause them instead.&lt;/p&gt;

&lt;p&gt;Each monitor has one of three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;active&lt;/code&gt;: The monitor is running.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;paused_plan&lt;/code&gt;: The monitor was stopped because the subscription limit was reached.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;paused_user&lt;/code&gt;: The customer explicitly paused it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reconciliation logic determines which monitors should be active based on the current entitlement limit.&lt;/p&gt;

&lt;p&gt;The oldest monitors retain their active status. When the limit decreases, the newest monitors are paused. When the limit increases, previously paused monitors are resumed, starting with the oldest.&lt;/p&gt;

&lt;p&gt;There's another important detail: a monitor paused by the customer must never be resumed automatically because of a plan change.&lt;/p&gt;

&lt;p&gt;I implemented this as a pure function, which means the logic doesn't depend on a database or network request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MonitorRow&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paused_user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paused_plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resume&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;Sorting by creation time gives the function a predictable result. The same set of monitors and the same plan limit will always produce the same outcome.&lt;/p&gt;

&lt;p&gt;It also means customers retain the monitors they created first.&lt;/p&gt;

&lt;p&gt;I wrote six unit tests for this function, including a case where a customer had manually paused a monitor before changing plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Kinde webhooks to keep the application in sync
&lt;/h2&gt;

&lt;p&gt;The second change was to introduce a webhook.&lt;/p&gt;

&lt;p&gt;Kinde signs its webhook events as JWTs and sends them to an HTTPS endpoint exposed by Siteward.&lt;/p&gt;

&lt;p&gt;Whenever a customer's plan changes, Kinde sends a &lt;code&gt;customer.plan_changed&lt;/code&gt; event.&lt;/p&gt;

&lt;p&gt;I configured Siteward's webhook handler to perform four operations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify the event signature against Kinde's public keys. Kinde's event tokens don't contain an issuer claim, so the issuer check is skipped for these events only.&lt;/li&gt;
&lt;li&gt;Record the &lt;code&gt;webhook-id&lt;/code&gt; header. Kinde keeps this ID consistent across retries, allowing Siteward to identify and ignore duplicate deliveries.&lt;/li&gt;
&lt;li&gt;Clear the stored plan for the affected organization.&lt;/li&gt;
&lt;li&gt;Fetch the latest entitlements from Kinde and reconcile the customer's monitors against the updated limits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice that the webhook doesn't need to contain the new entitlement values.&lt;/p&gt;

&lt;p&gt;It simply tells Siteward that something has changed. Siteward then retrieves the authoritative information directly from Kinde.&lt;/p&gt;

&lt;p&gt;I preferred this approach because it keeps the webhook handler relatively small and avoids having to treat the event payload as the source of truth for the customer's current subscription.&lt;/p&gt;

&lt;p&gt;It also means that a delayed event won't overwrite the stored plan with an older value. The handler always fetches the current entitlements when it processes the event.&lt;/p&gt;

&lt;p&gt;With that implemented, I ran the experiment again.&lt;/p&gt;

&lt;p&gt;I changed Dabbit Pro's plan 10 times, five times from Pro to Free and five times from Free to Pro.&lt;/p&gt;

&lt;p&gt;This time, I didn't run the cron job at all. I wanted to see whether the webhook could handle the changes independently.&lt;/p&gt;

&lt;p&gt;It did.&lt;/p&gt;

&lt;p&gt;All 10 plan changes triggered a &lt;code&gt;customer.plan_changed&lt;/code&gt; event, and Siteward applied the new entitlement limits without needing a background job execution.&lt;/p&gt;

&lt;p&gt;I also received no duplicate events during these runs. All 10 webhook requests returned HTTP 200.&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%2Fi7kdm558cfvl85kc3ys0.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%2Fi7kdm558cfvl85kc3ys0.png" alt="Time from a plan change to the new limit running, for ten changes" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Kinde API reported the new limit between 0.5 and 2.0 seconds after each plan change.&lt;/p&gt;

&lt;p&gt;Siteward finished applying the updated limit between 2.8 and 4.8 seconds after the change, with a median of 4.3 seconds.&lt;/p&gt;

&lt;p&gt;The webhook handler itself took approximately 1.5 seconds. Almost all of that time was spent fetching the updated entitlements from Kinde.&lt;/p&gt;

&lt;p&gt;The remaining delay came from Kinde delivering the event to my local development environment through a tunnel.&lt;/p&gt;

&lt;p&gt;I also tested a plan change through the Kinde dashboard rather than the Management API. I used the Change plan action on the organization's billing page, and Siteward received the same event.&lt;/p&gt;

&lt;p&gt;The difference was significant.&lt;/p&gt;

&lt;p&gt;With the original caching approach, the application could continue enforcing an outdated plan until the stored value expired and the next cron execution ran.&lt;/p&gt;

&lt;p&gt;With the webhook in place, all 10 changes were reflected in Siteward in under five seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when someone creates a monitor during a plan change?
&lt;/h2&gt;

&lt;p&gt;There was still another part of the application to consider.&lt;/p&gt;

&lt;p&gt;A customer doesn't have to wait for a background job to create a monitor. They can submit a request at any time.&lt;/p&gt;

&lt;p&gt;That means a monitor could be created while a subscription is changing, even if the webhook is already configured.&lt;/p&gt;

&lt;p&gt;The create endpoint needs to enforce the plan limit independently.&lt;/p&gt;

&lt;p&gt;There's a common database problem here: checking the current count and inserting a new record as separate operations.&lt;/p&gt;

&lt;p&gt;Imagine a Free customer with two monitors. Their limit is three.&lt;/p&gt;

&lt;p&gt;If 10 requests arrive simultaneously, each request could read the database, see two monitors and conclude that another one is allowed.&lt;/p&gt;

&lt;p&gt;By the time those requests finish, the customer could have 12 monitors.&lt;/p&gt;

&lt;p&gt;To prevent this, I put the count check and insert into a single SQL statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;monitors&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;org_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;interval_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;next_run_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;
  &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;monitors&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;org_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database evaluates the condition as part of the insert operation, rather than leaving a gap between checking the count and creating the record.&lt;/p&gt;

&lt;p&gt;I tested this on the Free plan, where the limit is three monitors.&lt;/p&gt;

&lt;p&gt;I sent 10 create requests at the same time.&lt;/p&gt;

&lt;p&gt;Three returned HTTP 201, the remaining seven returned HTTP 402, and the database contained exactly three monitors.&lt;/p&gt;

&lt;p&gt;When an insert doesn't create a row because the limit has been reached, the endpoint returns a 402 response with the applicable limit in the message.&lt;/p&gt;

&lt;p&gt;That handled simultaneous requests correctly. The next experiment was more interesting.&lt;/p&gt;

&lt;p&gt;I wanted to see what would happen if someone started creating monitors at almost exactly the same moment their subscription was downgraded.&lt;/p&gt;

&lt;p&gt;I started Dabbit Pro on the Pro plan with eight existing monitors.&lt;/p&gt;

&lt;p&gt;I made a request to populate the stored plan, which now indicated that the customer could have up to 25 monitors.&lt;/p&gt;

&lt;p&gt;Then I started sending one create request every 300 milliseconds.&lt;/p&gt;

&lt;p&gt;The first request went out at the moment I triggered the downgrade.&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%2Ft9o0nhdvm781i19q4wm2.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%2Ft9o0nhdvm781i19q4wm2.png" alt="Ten creates during a downgrade from Pro to Free" width="799" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first five requests succeeded.&lt;/p&gt;

&lt;p&gt;They were sent within 1.2 seconds of the plan change, while the stored entitlement still allowed 25 monitors.&lt;/p&gt;

&lt;p&gt;Between the fifth and sixth requests, the webhook arrived and cleared the stored plan.&lt;/p&gt;

&lt;p&gt;The remaining five requests fetched the updated limit and returned HTTP 402.&lt;/p&gt;

&lt;p&gt;After eight seconds, the database contained 13 monitors, but only three were active.&lt;/p&gt;

&lt;p&gt;The five monitors created during the stale-plan window had been paused, and the reconciliation logic retained the three oldest monitors as active.&lt;/p&gt;

&lt;p&gt;This experiment showed that the webhook and the create endpoint were handling different parts of the same problem.&lt;/p&gt;

&lt;p&gt;For approximately 1.5 seconds, Siteward accepted monitor creation requests under the previous plan. Once the webhook processed the change, subsequent requests were rejected, and the monitors created during that window were paused.&lt;/p&gt;

&lt;p&gt;Nothing was deleted.&lt;/p&gt;

&lt;p&gt;All 10 requests used the same access token. It had been issued before the downgrade and remained valid throughout the experiment.&lt;/p&gt;

&lt;p&gt;The first five requests succeeded, while the remaining five failed, even though the token never changed.&lt;/p&gt;

&lt;p&gt;That's an important detail: the session wasn't carrying the subscription limit. Siteward was retrieving that information separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  An unexpected detail about editing published plans
&lt;/h2&gt;

&lt;p&gt;While testing subscription changes, I came across another behaviour worth documenting.&lt;/p&gt;

&lt;p&gt;Kinde plans have versions.&lt;/p&gt;

&lt;p&gt;I edited Siteward Pro and reduced the monitor entitlement from 25 to 15. After saving the change, the plan became a draft.&lt;/p&gt;

&lt;p&gt;When I went to publish it, the confirmation dialog explained that existing subscribers would remain on their current plan version.&lt;/p&gt;

&lt;p&gt;I wanted to see what this meant for an existing customer.&lt;/p&gt;

&lt;p&gt;Dabbit Pro was already subscribed to Pro, so I checked its entitlements after publishing the updated plan.&lt;/p&gt;

&lt;p&gt;It still reported a limit of 25 monitors.&lt;/p&gt;

&lt;p&gt;I then moved the organization to Free and back to Pro.&lt;/p&gt;

&lt;p&gt;This time, the entitlement limit was 15.&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%2Fa5x385djs7a7g6kq43z0.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%2Fa5x385djs7a7g6kq43z0.png" alt="Two customers on the same plan key with different limits" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This meant two customers could have the same plan key, &lt;code&gt;siteward_pro&lt;/code&gt;, but different entitlement limits.&lt;/p&gt;

&lt;p&gt;Publishing the updated plan also didn't trigger a plan-change event because no customer's subscription had changed.&lt;/p&gt;

&lt;p&gt;Siteward handles this by retrieving entitlement limits for each billing customer rather than assuming that every customer with the same plan name has identical limits.&lt;/p&gt;

&lt;p&gt;An application that stores plan limits in its own database and looks them up using only the plan name would have treated both customers identically.&lt;/p&gt;

&lt;p&gt;In this experiment, that would have produced an incorrect result for one of them.&lt;/p&gt;

&lt;p&gt;After completing the test, I restored the Pro monitor limit to 25 and published the change again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these experiments don't tell us
&lt;/h2&gt;

&lt;p&gt;The results are useful, but there are a few things worth keeping in mind when interpreting them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The experiments ran locally.&lt;/strong&gt; I used &lt;code&gt;wrangler dev&lt;/code&gt; on a laptop in Lagos and a quick tunnel to receive webhook events. A deployed Cloudflare Worker, running closer to Kinde, will have different delivery and processing times. The measured 2.8 to 4.8 seconds includes the tunnel and local database.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I triggered the cron job manually.&lt;/strong&gt; The one-minute interval comes from my configured schedule, not a measurement of deployed cron execution. Cloudflare documents that cron triggers can run on underutilized machines and doesn't guarantee exact execution timing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The timing measurements came from one organization.&lt;/strong&gt; Ten plan changes show the range observed for Dabbit Pro. They don't establish how the system behaves when hundreds or thousands of customers change plans simultaneously.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I didn't simulate failed webhook deliveries.&lt;/strong&gt; Kinde documents retries after 5 seconds, 30 seconds, 2 minutes and subsequent intervals. Siteward uses &lt;code&gt;webhook-id&lt;/code&gt; to ignore repeated deliveries, but I didn't verify the retry behaviour through an intentionally failed delivery in these experiments.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The plan-version experiment involved one edit.&lt;/strong&gt; I didn't test what happens when an existing subscriber is moved to a plan whose limit is lower than the version they currently have.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These distinctions matter because the timings are measurements from a particular test environment, while some of the retry and execution behaviour comes from the respective platform documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The most interesting part of this exercise was seeing how easily a billing change can leave different parts of an application working with different information.&lt;/p&gt;

&lt;p&gt;A request can retrieve the current entitlement when the customer makes an action. A background job is different. It might execute minutes after the original request, using information that was accurate when it was first retrieved.&lt;/p&gt;

&lt;p&gt;Siteward now handles this through two mechanisms.&lt;/p&gt;

&lt;p&gt;First, the background job checks the entitlement limits before processing monitors. It uses the stored copy when it's fresh and retrieves the latest value from Kinde when it isn't.&lt;/p&gt;

&lt;p&gt;Second, Kinde's &lt;code&gt;customer.plan_changed&lt;/code&gt; webhook tells Siteward when a subscription changes. The application clears its stored value, fetches the current entitlements and reconciles the monitors immediately.&lt;/p&gt;

&lt;p&gt;Both mechanisms matter.&lt;/p&gt;

&lt;p&gt;The background job provides a way to retrieve the latest information independently of webhook delivery. The webhook means Siteward doesn't have to wait for the next scheduled execution to respond to a subscription change.&lt;/p&gt;

&lt;p&gt;The monitor reconciliation logic also ensures that customers don't lose their existing monitors when they downgrade, and that monitors paused by the customer remain paused when they upgrade.&lt;/p&gt;

&lt;p&gt;There is still a short window in which a request can arrive before the webhook has been processed. The experiments showed that this window can result in a few monitors being accepted under the previous plan. The reconciliation process subsequently pauses them.&lt;/p&gt;

&lt;p&gt;For this application, that was a useful result: the subscription change is enforced, and the customer's existing data is preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the experiments yourself
&lt;/h2&gt;

&lt;p&gt;The complete implementation and experiment scripts are available in &lt;a href="https://github.com/sholajegede/siteward" rel="noopener noreferrer"&gt;the Siteward repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You'll need a Cloudflare account for D1 and a Kinde account with billing enabled.&lt;/p&gt;

&lt;p&gt;Clone the repository and install the dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the D1 database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler d1 create siteward
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the resulting database ID into &lt;code&gt;wrangler.toml&lt;/code&gt;, then initialise the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run db:init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up your local environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .dev.vars.example .dev.vars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your Kinde machine-to-machine credentials and the customer mapping, then start the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll also need a machine-to-machine application in Kinde with permission to read billing entitlements and create billing agreements.&lt;/p&gt;

&lt;p&gt;Create three plans with &lt;code&gt;monitors&lt;/code&gt; and &lt;code&gt;alert_channels&lt;/code&gt; features to reproduce the experiments.&lt;/p&gt;

&lt;p&gt;The repository includes several scripts for running the tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Compare plan changes with and without a cleared cache&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/plan-flip.mjs 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes the plan five times in each direction and runs the cron job after each change.&lt;/p&gt;

&lt;p&gt;To repeat the experiment while keeping the stored plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEEP_CACHE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 node scripts/plan-flip.mjs 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Test webhook-based reconciliation&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;WEBHOOK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 node scripts/plan-flip.mjs 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs without the cron job and waits for the webhook to process each change. You'll need a tunnel and a Kinde webhook pointing to &lt;code&gt;/webhooks/kinde&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Test simultaneous monitor creation&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SITEWARD_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;… node scripts/parallel-create.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use an access token from the dashboard. The dashboard stores it in session storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Inspect entitlement limits&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/limit.mjs &lt;span class="o"&gt;[&lt;/span&gt;plan_code]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prints the limits Kinde reports and moves the organization to the plan you specify.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why not include the plan in the access token?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kinde's documented access token claims don't include plan or entitlement information. Access tokens also last 24 hours by default, and revoking a token doesn't shorten the lifetime of one that has already been issued.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a downgrade in Kinde take effect immediately?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my experiments, the API reported the new entitlement limit within approximately two seconds. This was true for changes made through both the Management API and the Kinde dashboard.&lt;/p&gt;

&lt;p&gt;Kinde's billing policies also cover refunds and credits for unused days. The documentation I reviewed didn't specify a setting for delaying a downgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why pause excess monitors instead of deleting them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Customers who upgrade again should be able to use their existing monitors. Pausing preserves their data, and the reconciliation logic determines which monitors should resume when their limits increase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the stored plan still necessary if webhooks handle subscription changes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Webhooks can arrive late, be delivered more than once or fail to arrive. Kinde also documents that a webhook that continues failing can be disabled after 72 hours.&lt;/p&gt;

&lt;p&gt;The stored plan, combined with its 30-second age limit, provides a fallback when webhook delivery fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What prevents simultaneous requests from exceeding the plan limit?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Siteward performs the count check and insert in one SQL statement. In my test, 10 simultaneous creation requests against a limit of three resulted in exactly three monitors being created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when a customer manually pauses a monitor and then upgrades?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It stays paused. The reconciliation function only resumes monitors that were paused because of a plan limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does editing a published plan change the limits for existing subscribers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In my experiment, it didn't. Existing subscribers retained their previous plan version, while customers who moved away from the plan and subscribed again received the updated entitlement limit.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/sholajegede/siteward" rel="noopener noreferrer"&gt;Siteward source code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;About access tokens, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/configure-tokens/" rel="noopener noreferrer"&gt;Configure token and session expiry, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/integrate/webhooks/about-webhooks/" rel="noopener noreferrer"&gt;About webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/manage-customer-activity-webhooks/" rel="noopener noreferrer"&gt;Respond to customer activity using webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/upgrade-downgrade-methods/" rel="noopener noreferrer"&gt;Subscription upgrade and downgrade methods, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-plans/upgrade-downgrade-plans/" rel="noopener noreferrer"&gt;Upgrade and downgrade policies, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/about-billing/kinde-billing-faqs/" rel="noopener noreferrer"&gt;Kinde billing FAQs, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/developer-tools/kinde-api/api-rate-limits/" rel="noopener noreferrer"&gt;API rate limits, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/configuration/cron-triggers/" rel="noopener noreferrer"&gt;Cron Triggers, Cloudflare docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/d1/worker-api/d1-database" rel="noopener noreferrer"&gt;D1 Database API, Cloudflare docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>programming</category>
      <category>kinde</category>
      <category>stripe</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Stop Concurrent Jobs From Spending the Same Usage Limit Twice</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Wed, 21 Oct 2026 10:25:00 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-concurrent-jobs-from-spending-the-same-usage-limit-twice-2952</link>
      <guid>https://dev.to/sholajegede/how-to-stop-concurrent-jobs-from-spending-the-same-usage-limit-twice-2952</guid>
      <description>&lt;p&gt;Let's say you have a customer on a plan of 100,000 tokens a month. Five of their jobs start at the same moment, and each job expects to use 40,000. Each job checks the account before it starts. Each check sees 100,000 left, so each job runs.&lt;/p&gt;

&lt;p&gt;The five jobs spend 200,000 tokens but the plan allowed only 100,000. Every check was correct when it ran, and the customer still ended up 100,000 over.&lt;/p&gt;

&lt;p&gt;This failure is easy to miss, because it needs no bug in the check. It needs only two jobs that start close together and take a long time to finish. AI agents and long-running jobs fit that description well, and they are the work that costs the most.&lt;/p&gt;

&lt;p&gt;So, how do you make a usage limit hold when many expensive jobs start at once, when some of them cost more than expected, and when some of them never finish?&lt;/p&gt;

&lt;p&gt;This post answers that question with a small system I built called Earmark. Earmark is a gate that sits in front of expensive work. It holds part of the customer's limit before a job starts, stops the job at what it holds, and charges the real cost when the job ends. The plan limits come from Kinde, &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=29&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;a platform for authentication and billing&lt;/a&gt;, but the design needs only two things from a provider: a way to read a limit, and a way to report usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a limit check is not a limit
&lt;/h2&gt;

&lt;p&gt;A limit is the most a customer may use in one billing cycle. A billing cycle is one month of usage. A check is a read: it looks at what the customer has used so far and compares that number with the limit.&lt;/p&gt;

&lt;p&gt;A check is a read, and a limit has to be a promise. The difference shows up when work takes time. Usage is counted when a job ends, so a job that is still running has not spent anything yet. A new job that checks the account cannot see it.&lt;/p&gt;

&lt;p&gt;Four things go wrong, and each one needs a different part of the design.&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%2Fviq8flju97mwdjb47h4c.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%2Fviq8flju97mwdjb47h4c.png" alt="Five jobs against one limit: check then run, compared with hold, run and settle" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every job passes the check.&lt;/strong&gt; This is the case from the start of this post. Five jobs look at the same remaining capacity at the same moment, and none of them can see the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A job costs more than expected.&lt;/strong&gt; The estimate was 10,000 tokens, and the model used 15,000. A check at the start cannot stop this, because the job was allowed in at the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A job fails halfway.&lt;/strong&gt; It used 60% of its estimate and then stopped. If you charge nothing, you give the work away. If you charge the estimate, you bill for work that did not happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A worker disappears.&lt;/strong&gt; The process is killed in the middle of a job. Whatever the system set aside for that job stays set aside, and the customer is locked out of their own limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the provider gives you
&lt;/h2&gt;

&lt;p&gt;A gate needs a number to enforce, and Kinde is where that number lives. In &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=29&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, a plan has features, and a feature can be metered. A metered feature is billed by consumption and has a usage limit that you set for each plan. Kinde's billing concepts page describes features and limits, and its metered usage page describes how to report consumption.&lt;/p&gt;

&lt;p&gt;The Kinde pages I read about plans and billing concepts do not say whether Kinde blocks work when a limit is reached. Kinde's guide on billing for AI and LLM-based APIs covers cost control for this kind of work, but it does not describe holding capacity before a request runs. So I treat Kinde as the source of the limit and let the gate enforce it.&lt;/p&gt;

&lt;p&gt;Earmark reads the limit from the customer's entitlements. An entitlement is a feature that a plan grants to a customer. The API reference for the billing entitlements endpoint lists &lt;code&gt;GET /api/v1/billing/entitlements&lt;/code&gt;, which takes a &lt;code&gt;customer_id&lt;/code&gt; and returns each entitlement with a &lt;code&gt;feature_code&lt;/code&gt; and an &lt;code&gt;entitlement_limit_max&lt;/code&gt;. The call needs the &lt;code&gt;read:billing_entitlements&lt;/code&gt; scope. A &lt;code&gt;null&lt;/code&gt; maximum means the plan sets no maximum, and Earmark treats that as no limit.&lt;/p&gt;

&lt;p&gt;Settled usage goes the other way, from the gate to the meter. The meter is the provider's running total for the cycle. Earmark sends the settled total as an absolute value, which sets the meter to that number, so a repeated report changes nothing. Proving that the meter matches what happened is a separate problem, and I covered it in my earlier post on reconciling metered usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve: check and hold in one step
&lt;/h2&gt;

&lt;p&gt;The first design decision is the most important one. The check and the hold must be one transaction. If a job checks first and holds second, two jobs can still check at the same moment.&lt;/p&gt;

&lt;p&gt;A hold is a part of the limit that is promised to a running job. Earmark keeps one budget row for each account and cycle. The row has two numbers: &lt;code&gt;used&lt;/code&gt;, which is final usage, and &lt;code&gt;held&lt;/code&gt;, which is the sum of the holds of running jobs. The gate counts both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;admit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Budget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Admission&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&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;max&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="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;admitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The job needs &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;amount&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="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is left.`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;admitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;amount&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 function is pure, which means the same input always gives the same answer, so a test can check each rule alone. The database call around it reads the budget row, runs &lt;code&gt;admit&lt;/code&gt;, and writes the new &lt;code&gt;held&lt;/code&gt; value in the same transaction.&lt;/p&gt;

&lt;p&gt;That transaction is what makes the five-job race safe. Convex, the database I used, runs mutations as serializable transactions. Its documentation describes optimistic concurrency control: a mutation that read data which another mutation changed is run again. When two mutations touch the same budget row, one of them commits first, and the other is run again and sees the new &lt;code&gt;held&lt;/code&gt; value. Any database with serializable transactions, or a conditional update on one row, gives you the same guarantee.&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%2Fj8iouth5mbt4af4ngq81.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%2Fj8iouth5mbt4af4ngq81.png" alt="The decision a job passes through before it is allowed to start" width="800" height="879"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each job also sends a key. If the same key arrives twice, the gate returns the same job and holds nothing new. This matters because a client that times out will try again, and a second hold for the same job would take capacity that no job uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run: the hold is also the cap
&lt;/h2&gt;

&lt;p&gt;Once a job holds 40,000, the hold works as a limit on the job. The worker charges each step against the hold before it starts the next step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;chargeStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&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;max&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="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt; &lt;span class="o"&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;max&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="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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;capped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a step would pass the hold, the gate charges only what is left and marks the job as capped. The job stops. This handles the job that costs more than expected: the customer cannot be charged for more than the hold, and no job can take capacity that another job holds.&lt;/p&gt;

&lt;p&gt;A capped job loses its unfinished work, and that is a real cost. I chose it on purpose. The alternative is to let a job pass its hold and hope that capacity is free, which brings back the first problem in a smaller form.&lt;/p&gt;

&lt;p&gt;Every charge also renews the hold. Earmark gives each hold a lease, which is a time limit that a sign of life renews. The lease lasts two minutes. A worker that is alive and charging keeps its hold. A worker that stops does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settle: pay for what was used
&lt;/h2&gt;

&lt;p&gt;When a job ends, the gate settles it. It charges what the job used and returns the rest of the hold to the budget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt; &lt;span class="o"&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="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;released&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;charged&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A job that held 40,000 and used 31,200 is charged 31,200, and 8,800 goes back. A job that failed halfway is charged for the half it did. Partial completion needs no special case, because every ending is the same step with a different &lt;code&gt;used&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;The idea is older than usage billing. A card authorization holds an amount on a customer's card before the final price is known. Stripe's documentation on placing a hold describes the same pattern: you capture up to the held amount, and the rest of the hold is released. Settling a job is the same move with tokens.&lt;/p&gt;

&lt;p&gt;A worker that disappears never calls &lt;code&gt;settle&lt;/code&gt;, so something else must. A sweeper runs on a schedule and looks for running jobs whose lease has run out. It settles each one as expired, at the usage it had recorded, and returns the rest of the hold. A worker that comes back late finds its job already settled, and the gate ignores it. Settling a job twice does nothing, so the late call and the sweeper cannot charge the same job twice.&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%2Fdn5jls553lnumgerse9f.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%2Fdn5jls553lnumgerse9f.png" alt="A job from hold to settlement, including the three ways it can end early" width="800" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail closed when the limit is unknown
&lt;/h2&gt;

&lt;p&gt;A gate is only as good as its number. The limit comes from the provider, and the provider can be slow or down.&lt;/p&gt;

&lt;p&gt;Earmark keeps the last limit it read. It uses that limit as it is for 60 seconds, so it asks the provider at most once a minute for each account. If the provider cannot be read, Earmark keeps using the last known limit for up to 10 minutes. After that, it refuses new work until the limit can be read again.&lt;/p&gt;

&lt;p&gt;I think refusing is the right default for expensive work. A wrong refusal costs the customer a short delay. A wrong admission costs real money, and it may spend past a limit that changed while the gate could not see it. For cheap work I would choose differently, because the price of a mistake is small. The cost of the work should set the default.&lt;/p&gt;

&lt;p&gt;A plan can also change while jobs run. Earmark does not change a running job's hold when the plan changes. The new limit applies to the next admission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the budget row small
&lt;/h2&gt;

&lt;p&gt;Every admission and every settlement writes the same budget row, so that row is a serial point. This is the price of an exact limit, and it is the right price. The design goal is to keep everything else off that row.&lt;/p&gt;

&lt;p&gt;Convex's error documentation names two causes of write conflicts: frequent writes to one document, and mutations that read a whole table or a wide range. Both apply here, so Earmark follows three rules.&lt;/p&gt;

&lt;p&gt;First, the rate limit is a counter and not a scan. Earmark allows 120 new jobs a minute for each account. Counting recent job rows would make every running job conflict with every new admission. Earmark keeps a small counter on the account's billing row instead.&lt;/p&gt;

&lt;p&gt;Second, the report state lives in its own table. Whether a new total is waiting for the provider is not part of admission, so it does not belong on the budget row.&lt;/p&gt;

&lt;p&gt;Third, a conflict is retried only where retrying is safe. Admission is safe to repeat because it is keyed, and settlement is safe because a job settles once. Each retry waits a random time that grows, up to one second, and gives up after eight tries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened when I ran it
&lt;/h2&gt;

&lt;p&gt;I ran the system in two ways. The first is the Concurrency Lab, a page in the app that starts five jobs of 40,000 at the same moment against a plan of 100,000. It runs the same jobs twice: once with a plain check, and once with a hold.&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%2Fxs4p1d41y0ye1jm7w2ds.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%2Fxs4p1d41y0ye1jm7w2ds.png" alt="The Concurrency Lab: the plain check spent 200,000 and the hold spent 80,000" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a plain check, all five jobs started and 200,000 was spent, which is 100,000 over the plan. With a hold, two jobs started, three were refused with the amount that was left, and 80,000 was spent. This is the case from the start of this post.&lt;/p&gt;

&lt;p&gt;The second run is a load script. It sends 120 jobs of 1,000 tokens, 50 requests at a time, against an account that has room for exactly 100 of them.&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%2Ff9wb55hwzeucdh0y4iyn.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%2Ff9wb55hwzeucdh0y4iyn.png" alt="Results of the load run: 100 started, 20 refused, 0 failed, 100,000 spent" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The gate admitted 100 jobs and refused 20. No request failed, and the run spent 100,000 tokens, which is all the plan had left and not one token more. Nothing was still held when the jobs ended. The run took 6.7 seconds, about 18 requests a second. The median time to start a job was 1,316 ms, the 95th percentile was 3,717 ms, and the 99th was 5,099 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;This system has edges, and I want to name them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The numbers come from a laptop.&lt;/strong&gt; I ran the load script from a laptop in Lagos against a Convex cloud development deployment. The latency includes that distance. The run shows that the limit holds under concurrency, not what a production host will do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One account is one serial point.&lt;/strong&gt; The load run used one account, and every admission wrote one budget row. Many accounts run in parallel because each has its own row, but I did not measure that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The worker is simulated.&lt;/strong&gt; The jobs are real database records that hold, charge and settle. The work itself is a loop that charges tokens. I did not run an actual model, and I did not kill a real process. The crash path is tested with a simulated crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not run it against a live Kinde account.&lt;/strong&gt; The limit read and the usage report are tested with fake responses. I took the entitlements endpoint from its API reference, so the scope and field names still need a live check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard cap costs work.&lt;/strong&gt; A job that reaches its hold stops, and the customer loses what it had not finished. A softer policy is possible, but it brings the overspend back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate is only as good as the estimate.&lt;/strong&gt; An estimate that is too low means more capped jobs. An estimate that is too high locks capacity, and the gate refuses a job even though room exists. Earmark does not learn estimates. You have to supply a good one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan changes and caching are only touched on.&lt;/strong&gt; Earmark keeps a limit for a minute and uses the new one at the next admission. Keeping every service in agreement during a plan change is a larger problem than this post covers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three questions to ask of your own gate
&lt;/h2&gt;

&lt;p&gt;Before you copy this design, ask these questions of the gate you have now.&lt;/p&gt;

&lt;p&gt;Does your check also take a hold? If it only reads usage, it is a check and not a limit.&lt;/p&gt;

&lt;p&gt;What happens to the hold when a worker stops? If the answer is "nothing", one crash can lock a customer out until someone notices.&lt;/p&gt;

&lt;p&gt;How far apart are your estimates and your real costs? Pull the last month of finished jobs and compare the two. The size of that gap tells you how much capacity your holds will lock up and how often your jobs will be capped.&lt;/p&gt;

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

&lt;p&gt;The code is in &lt;a href="https://github.com/sholajegede/earmark" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;. It runs with no billing account, using a built-in stand-in for the plan limit. In the project folder, run these commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install
&lt;/span&gt;npx convex dev
npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;/app&lt;/code&gt; and open the Concurrency Lab. To repeat the load run, start with a fresh account and run &lt;code&gt;node scripts/load.mjs --jobs 120 --estimate 1000 --limit 100000 --concurrency 50 --url http://localhost:3000&lt;/code&gt;, using the port that &lt;code&gt;npm run dev&lt;/code&gt; prints. The 49 tests run with &lt;code&gt;npm test&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why hold the estimate instead of charging it?&lt;/strong&gt;&lt;br&gt;
A hold is a promise that can be returned. A charge is final. When the job ends early or costs less, the unused hold goes back to the customer's limit, and the customer pays only for what the job used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not lock the whole account while a job runs?&lt;/strong&gt;&lt;br&gt;
A lock makes every other job wait, even when the plan has plenty of room. A hold takes only the part that the job needs, so many jobs can run at once until the limit is full.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the estimate is wrong?&lt;/strong&gt;&lt;br&gt;
If it is too low, the job is capped at its hold and stops. If it is too high, the extra hold goes back when the job settles, but it blocks other jobs while the job runs. Set the estimate close to what the job costs at the high end of normal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a lease and not a timeout on the job?&lt;/strong&gt;&lt;br&gt;
A job can run for a long time and still be healthy. A lease asks a different question: has the worker shown a sign of life recently? Every charge renews it, so long jobs keep their hold and dead workers lose it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this need Kinde?&lt;/strong&gt;&lt;br&gt;
No. The gate needs a way to read a limit and a way to report settled usage. Kinde supplies both in the version I built. The built-in stand-in in the repository shows the same behavior with no account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why send settled usage and not holds to the meter?&lt;/strong&gt;&lt;br&gt;
A hold is a promise and not usage. If the meter showed holds, a job that was refused or that returned most of its hold would still change the customer's invoice.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/about-billing/kinde-billing-model/" rel="noopener noreferrer"&gt;The Kinde billing model, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-plans/about-plans/" rel="noopener noreferrer"&gt;About plans, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/about-billing/billing-concepts-terms/" rel="noopener noreferrer"&gt;Commonly used billing concepts and terms, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/add-metered-usage/" rel="noopener noreferrer"&gt;Add metered usage for a customer via API, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kinde.com/learn/billing/pricing/billing-for-ai-and-llm-based-apis-cost-control-strategies/" rel="noopener noreferrer"&gt;Billing for AI and LLM-based APIs: cost control strategies, Kinde&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apis.io/apis/kinde/kinde-billing-entitlements-api/" rel="noopener noreferrer"&gt;Kinde Billing Entitlements API reference, apis.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/database/advanced/occ" rel="noopener noreferrer"&gt;Optimistic concurrency control, Convex docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/error" rel="noopener noreferrer"&gt;Errors and warnings, Convex docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sholajegede/earmark" rel="noopener noreferrer"&gt;Earmark source code&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>kinde</category>
    </item>
    <item>
      <title>How to Reconcile Metered Usage Billing When Events Arrive Late, Twice, or Never</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:24:42 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-reconcile-metered-usage-billing-when-events-arrive-late-twice-or-never-3fm9</link>
      <guid>https://dev.to/sholajegede/how-to-reconcile-metered-usage-billing-when-events-arrive-late-twice-or-never-3fm9</guid>
      <description>&lt;p&gt;A short link app has a simple job: count the clicks and bill the owner for them. The app counts each click in its own database. The billing provider keeps a second count, called a meter, and the invoice comes from the meter. Both counts start equal, but nothing keeps them equal.&lt;/p&gt;

&lt;p&gt;A click can reach the database after the total was sent. A retry can send the same usage twice. A report can fail on a timeout and never be sent again. Each case leaves the meter wrong, and the customer sees the error only as an invoice that is too high or too low.&lt;/p&gt;

&lt;p&gt;The hard question is this: how do you prove that the meter matches what really happened, and fix it when it does not?&lt;/p&gt;

&lt;p&gt;This article answers that question with a small system I call Notch. Notch is a short link and QR code app that bills per click. It keeps a ledger of every click, sends totals to the meter, and runs a reconciler that compares the two, repairs a difference, and keeps the proof. The billing provider in my test is Kinde, a platform for authentication and billing, but the design needs only two things from a provider: a way to set a total, and a way to read it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the meter and the ledger drift apart
&lt;/h2&gt;

&lt;p&gt;A ledger is the list of events your app wrote down, one row for each click. A meter is the provider's running total for a billing cycle, which is one month of usage. The ledger is the record, and the meter is a copy that you keep up to date. Three things break that copy.&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/img%2Ffailures.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/img%2Ffailures.png" alt="Late, twice and never: what happens to the meter with and without a ledger" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage arrives late.&lt;/strong&gt; The app sends a total at 10:00. A click is recorded at 10:01. Until the next report, the meter is one click behind. If the cycle closes before that report, the meter stays wrong, and a closed cycle is the one you can no longer change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage arrives twice.&lt;/strong&gt; A retry runs after a timeout, or a second job writes to the same meter. If the write adds usage, the second write adds it again. The customer pays for clicks that happened once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage never arrives.&lt;/strong&gt; A report fails on a timeout or a rate limit, and nothing sends it again. The meter stays low. Nothing fails loudly, so nobody sees it.&lt;/p&gt;

&lt;p&gt;All three look the same from the provider's side. The meter holds a number, and the provider cannot tell you whether the number is right. Only the app has the record to check it against, which is why the first rule of the design is that the ledger is the record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the provider lets you do
&lt;/h2&gt;

&lt;p&gt;A design has to match what the provider actually accepts, so I read the Kinde documentation and then tested each behavior on my own account before I relied on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=28&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's&lt;/a&gt; usage endpoint takes a &lt;code&gt;meter_type_code&lt;/code&gt;. The value &lt;code&gt;absolute&lt;/code&gt; sets the running total to the number you send, and it is the default. The value &lt;code&gt;delta&lt;/code&gt; adds the number to the running total. That difference decides the whole design. An absolute write is safe to repeat, because the second write sets the same total. A delta write is not, because a retry adds the usage again, and I confirmed this by writing the same delta twice and reading the total move twice.&lt;/p&gt;

&lt;p&gt;Stripe takes the opposite route. Each Stripe meter event has an &lt;code&gt;identifier&lt;/code&gt; that you can set, and Stripe uses it to prevent a second count of the same event. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=28&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's documentation&lt;/a&gt; shows no such key, so with Kinde the safe write is the absolute one.&lt;/p&gt;

&lt;p&gt;The write is only half of the API. To compare the meter with the ledger, the app must read the total back. Kinde's read endpoint returns the current cycle by default, and &lt;code&gt;all_cycles=true&lt;/code&gt; returns every record. The read needs the &lt;code&gt;read:meter_usage&lt;/code&gt; scope, which is separate from the write scope.&lt;/p&gt;

&lt;p&gt;Three more facts shaped the design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;meter_usage_timestamp&lt;/code&gt; is stored on the record, but it does not select a billing cycle or change the total. A write that I backdated became its own record, and the live total did not move.&lt;/li&gt;
&lt;li&gt;Each write is a record. After about 1,200 writes, the total still read correctly.&lt;/li&gt;
&lt;li&gt;Kinde bills recurring metered charges in arrears, and it starts each cycle on the customer's sign-up date, not on the first of the month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The ledger is the record
&lt;/h2&gt;

&lt;p&gt;Every click becomes one row in the ledger, and the row has a key that the app makes before it writes anything. The redirect to the destination goes out first. The write happens after the response, and it carries the same key on every try. A second try with the same key adds nothing, so a retry cannot count a click twice.&lt;/p&gt;

&lt;p&gt;A click that a person or a bot should not have caused is not deleted. The ledger is append only, so a correction is a new row with &lt;code&gt;units: -1&lt;/code&gt;, written into the cycle of the click it corrects. The total is the sum of all rows, and the history stays readable.&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/img%2Farchitecture.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/img%2Farchitecture.png" alt="The four parts: ledger, reporter, reconciler and evidence" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more detail keeps the ledger fast. If every click updated one counter row, many clicks at once would fight over that row. Notch splits each counter over 64 rows and adds them on read. Each click picks one shard at random, so two clicks rarely touch the same row. The click and its counter update commit in one transaction, and the same transaction marks the cycle as changed.&lt;/p&gt;

&lt;p&gt;That mark is what connects the ledger to the meter. A cycle is marked once, and more clicks while it is marked add no more writes. The mark says one thing: the meter may be behind, so send the total again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reporter sends totals, not clicks
&lt;/h2&gt;

&lt;p&gt;Every minute the reporter takes up to 100 marked cycles, deletes the marks, and reads the ledger total for each cycle. It sends each total to the meter with an absolute write, 10 at a time.&lt;/p&gt;

&lt;p&gt;Sending totals fixes two of the three failures at once. A repeated report is harmless, because it sets the same number. A late click is also harmless, because it marks the cycle again and the next report carries the new total.&lt;/p&gt;

&lt;p&gt;The third failure, the report that never arrives, needs one more step. If a write fails or gets a 429, the reporter marks the cycle again after the loop ends. The next minute tries again. Kinde documents a limit on concurrent requests and a &lt;code&gt;RateLimit-Reset&lt;/code&gt; header on a 429, which gives the seconds until the limit resets. I never saw a 429 in my tests, so that path is covered by tests with a fake meter, not by a real rejection.&lt;/p&gt;

&lt;p&gt;If a cycle has already closed, the reporter does not write. A closed cycle goes to a review list, because a write to a closed cycle has an effect that I could not confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconciler proves the copy
&lt;/h2&gt;

&lt;p&gt;The reporter keeps the meter close. It cannot prove the meter is right, because it only knows what it sent. The reconciler does the proof, and it runs nightly and on demand.&lt;/p&gt;

&lt;p&gt;For each cycle, the reconciler reads every ledger row and adds them up. It checks that sum against the running counters, and it rebuilds the counters if they differ. It then compares three numbers: the ledger total, the last total the app sent, and the total the provider holds.&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/img%2Fdecision.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/img%2Fdecision.png" alt="How the reconciler decides what to do with three numbers" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The comparison is a short pure function, so a test can check each case alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Evidence&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unreadable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;repair&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;repair&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closed_cycle_drift&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;repair&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over_billed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;under_billed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;repair&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the ledger is the record&lt;/span&gt;
    &lt;span class="cm"&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 function never trusts the meter over the ledger. When the two differ in an open cycle, the repair is always the ledger total. The cause text depends on the evidence: if the last report failed, the reconciler says so, and if the meter moved without a report from the app, it says that a report was counted twice or something else wrote to the meter.&lt;/p&gt;

&lt;p&gt;A repair is not finished when the write returns 200. The reconciler reads the meter back and checks that it now equals the ledger. Only then does it record "repaired and confirmed". If the read back differs, the run ends with an error.&lt;/p&gt;

&lt;p&gt;Every run stores evidence, which is one row with the three numbers, the verdict, the cause, and the read back. A run on the same numbers adds no new row, so running the reconciler twice leaves the record unchanged. That makes the evidence cheap to keep and useful to show: the answer to "was this customer billed correctly in October" is a row, not a memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meter's calendar is not yours
&lt;/h2&gt;

&lt;p&gt;One mistake would corrupt every number above. A cycle named by calendar month would put a click on 20 October into 2026-10 while the provider counts it in a cycle that started on 20 September. On 1 November the app would then write a small October-only total over the meter's real cycle.&lt;/p&gt;

&lt;p&gt;Kinde starts cycles on the customer's sign-up date. Notch therefore takes an agreement start when you connect an account, and it names each cycle by the month it starts in. Take an agreement start of 20 October at 15:29 UTC. A click on 19 November belongs to the cycle that began on 20 October, and a click on 21 November belongs to the next one.&lt;/p&gt;

&lt;p&gt;Two rules follow. The start must fall on the 1st to the 28th of a month, because a start on the 31st has no clear meaning in a short month. The start is set before the first click and refused afterward, because changing it later would move old clicks into different cycles. Kinde's FAQ says that only monthly cycles are supported, so Notch handles monthly cycles only.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened with live clicks
&lt;/h2&gt;

&lt;p&gt;I ran the system against a real Kinde account in two ways. First, I sent 5,000 clicks to a short link at 100 at a time, from a laptop dev server. All 5,000 were recorded. The server handled about 97 redirects a second, with a median of 455 ms, a 95th percentile of 916 ms and a 99th percentile of 1,633 ms. The reconciler then found a match at 6,048 in the ledger, the sent log and the meter, with 0 corrections.&lt;/p&gt;

&lt;p&gt;That first run proves that the loop works when nothing is wrong. The second run shows what happens when something is. After a later run of 2,100 clicks, the ledger held 8,457 clicks, and the meter held 8,031. The meter was 426 clicks behind. The reconciler marked the cycle as under-billed, wrote 8,457 with an absolute write, read it back, and stored the evidence. The next run matched at 8,457 and added no new row.&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/img%2Fproof.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/img%2Fproof.png" alt="Three reconciliation runs: a match, an under-billed repair, and a match again" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those are the numbers from the runs I watched:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5,000 of 5,000 clicks were recorded at 100 at a time.&lt;/li&gt;
&lt;li&gt;The 2,100 click run reached about 65 redirects a second, with a median of 274 ms and a 99th percentile of 705 ms.&lt;/li&gt;
&lt;li&gt;The reconciler repaired a gap of 426 clicks and confirmed the repair by reading the meter back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cause text that the reconciler stored reads "The ledger changed after the last report. A late click or a correction arrived." That is the late case from the start of this post, found and closed with evidence, and nobody had to notice it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;This system has edges, and I want to name them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The reconciler proves the copy, not the capture.&lt;/strong&gt; A click that never reached the ledger is invisible to it. The load run counts what was sent against what was recorded, and that is a separate check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the current cycle can be read.&lt;/strong&gt; Notch reads the current cycle from Kinde. A closed cycle that drifts is flagged for a person, and the reconciler does not repair it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cycle rollover is untested.&lt;/strong&gt; My test agreement starts a new cycle on 21 November. Until that date passes, I have not seen what Kinde does at the exact moment of the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I have not seen a real 429.&lt;/strong&gt; The 1,200 writes that I sent at about 180 a second all returned 200. The retry path rests on Kinde's documentation and on tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I do not know what a past-month write does to an invoice.&lt;/strong&gt; Kinde's documentation does not say. Stripe's documentation says that a late event may be accepted without changing an invoice that is already final. Check your own provider before you rely on a late write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The load numbers come from a laptop.&lt;/strong&gt; They show that the design holds under load, not what a production host will do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One provider was tested.&lt;/strong&gt; The design needs a set operation and a read, but I only ran it against Kinde.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rule that holds it together
&lt;/h2&gt;

&lt;p&gt;The ledger is the fact, and the meter is a copy of it. Once you treat them that way, a late click becomes a new mark, a repeated report becomes the same number, and a failed report becomes a retry. Each case is the same case: the copy is behind, so send the total again and check it.&lt;/p&gt;

&lt;p&gt;To run it yourself, clone &lt;a href="https://github.com/sholajegede/notch" rel="noopener noreferrer"&gt;the repository&lt;/a&gt; and run &lt;code&gt;npm test&lt;/code&gt; for the 58 tests. To measure your own deployment, run &lt;code&gt;node scripts/load.mjs &amp;lt;short code&amp;gt; --clicks 5000 --concurrency 100&lt;/code&gt;. Then compare the ledger with the meter in the reconciliation panel, and look for a gap that you did not cause. Do let me know what you find in the comments.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why send a total instead of each click?&lt;/strong&gt;&lt;br&gt;
A total is safe to repeat and safe to send late. A per-click write needs a unique key at the provider for every click. Where a provider has no such key, a repeated write counts twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the provider only supports delta writes?&lt;/strong&gt;&lt;br&gt;
Keep the ledger and send each change with an idempotency key if the provider accepts one. Stripe does, through the meter event &lt;code&gt;identifier&lt;/code&gt;. If the provider has neither an absolute write nor a key, you can only detect a drift and not repair it safely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not trust the meter after a repair?&lt;/strong&gt;&lt;br&gt;
A write that returns 200 says that the provider accepted the request. It does not say what total the provider now holds. Reading the value back is the only proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the cycle anchor matter if I bill on the first of the month?&lt;/strong&gt;&lt;br&gt;
No. With no anchor, a cycle is a UTC calendar month, which matches a provider that starts every cycle on the first. The anchor matters when the provider starts cycles on the customer's sign-up date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should the reconciler run?&lt;/strong&gt;&lt;br&gt;
Run it nightly, plus on demand after an incident. It reads every ledger row, so run it in pages. Notch does 50 accounts a page, 5 at a time.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/add-metered-usage/" rel="noopener noreferrer"&gt;Add metered usage for a customer via API, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/about-billing/kinde-billing-model/" rel="noopener noreferrer"&gt;The Kinde billing model, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/about-billing/billing-faq/" rel="noopener noreferrer"&gt;Billing FAQ, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/developer-tools/kinde-api/api-rate-limits/" rel="noopener noreferrer"&gt;API rate limits, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage-api" rel="noopener noreferrer"&gt;Record usage for billing with the API, Stripe docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sholajegede/notch" rel="noopener noreferrer"&gt;Notch source code&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>kinde</category>
    </item>
    <item>
      <title>How to Keep Billing State Correct When Webhook Events Repeat, Arrive Late, or Fail</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Tue, 20 Oct 2026 16:40:12 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-keep-billing-state-correct-when-webhook-events-repeat-arrive-late-or-fail-2aaj</link>
      <guid>https://dev.to/sholajegede/how-to-keep-billing-state-correct-when-webhook-events-repeat-arrive-late-or-fail-2aaj</guid>
      <description>&lt;p&gt;When building apps in production, the billing provider you decide to use tells your app what happened to a customer, such as a payment that succeeded, a plan that changed, or a subscription that ended. Your app then turns each message into a change in its own database: a plan column, a monthly allowance, or a flag that lets someone in.&lt;/p&gt;

&lt;p&gt;Those messages travel over a network that duplicates, delays and drops, so the same payment message can arrive three times, a cancellation can arrive after the upgrade that replaced it, and a server can stop half way through a message. Each case goes on to corrupt a customer's state in a different way, and none of them shows up when you test with one clean message.&lt;/p&gt;

&lt;p&gt;Which brings me to a question a developer friend of mine asked me last week: how does an app keep a customer's plan and allowance correct when the events that change them can repeat, arrive out of order, or stop in the middle?&lt;/p&gt;

&lt;p&gt;This post builds the answer as a small pipeline: an endpoint that stores each event before it answers, a queue in Postgres that holds the stored events, and a worker that applies them with effects that are safe to repeat.&lt;/p&gt;

&lt;p&gt;To show this in practice, I built an example app called &lt;a href="https://bottle-reminders.vercel.app" rel="noopener noreferrer"&gt;"Bottle"&lt;/a&gt;, it's a reminder app with a free plan and a paid plan using Kinde under the hood to send billing events.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=27&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; is a platform for authentication and billing, and here it is the billing provider whose plan and payment changes the app must follow. After building the app, I tested the pipeline against the events Kinde sent for my own account and I'll be breaking it all down in this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong when a handler applies each message as it arrives
&lt;/h2&gt;

&lt;p&gt;First, we will start with the handler most apps write, which reads the message, changes the database, and answers 200 (OK). That works on a quiet day with a clean network, but three things can potentially break it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgiyti6g5ltqaz3ae73sb.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%2Fgiyti6g5ltqaz3ae73sb.png" alt="Three ways one billing event goes wrong, and what the pipeline does in each case" width="800" height="737"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same event arrives more than once.&lt;/strong&gt; If the app applies a payment and the 200 answer never reaches the provider, the provider sends the payment again. The handler adds the monthly allowance a second time. And will do the same a third time if a third copy arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An old event arrives after a newer one.&lt;/strong&gt; A customer upgrades, so the old plan is cancelled. The cancellation happened first, but a retry delays it, and it arrives last. A handler that only trusts the order of arrival will set the customer back to Free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server stops in the middle.&lt;/strong&gt; A plan change needs two writes: the plan, then the allowance that goes with it. If the process stops between them, the customer is on Pro with a Free balance. The provider retries, but by then the state is already split.&lt;/p&gt;

&lt;p&gt;I wrote a handler that applies each message on arrival and ran nine deliveries of three events through it. The nine are the plan change three times, the payment five times, and one old cancellation. The worker stops once during the first plan change, and this is what the account looked like along the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After delivery 2, the plan was Pro and the balance was still 10, because the process stopped before the allowance write.&lt;/li&gt;
&lt;li&gt;After delivery 7, the plan was Free, because the old cancellation arrived last and won.&lt;/li&gt;
&lt;li&gt;After delivery 9, the balance was 2,950. The correct balance is 500.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The account ended on Pro only because the plan change happened to arrive a third time. The handler had no rule that made it come out right, and the rest of this post builds those rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the provider promises
&lt;/h2&gt;

&lt;p&gt;A fix has to match what the provider actually does, so I read Kinde's webhook documentation before I designed anything. These are the parts that matter for correctness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The body of each request is a JWT (a signed token) with the content type &lt;code&gt;application/jwt&lt;/code&gt;. You check its signature against the keys Kinde publishes at &lt;code&gt;/.well-known/jwks&lt;/code&gt; on your Kinde domain.&lt;/li&gt;
&lt;li&gt;Every request carries a &lt;code&gt;webhook-id&lt;/code&gt; header. Kinde describes it as unique to a webhook and an event, and the same on every attempt. The docs tell you to use it as the idempotency key.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;200&lt;/code&gt; answer means delivered. Any other answer, or no answer, makes Kinde try again.&lt;/li&gt;
&lt;li&gt;The retry schedule is roughly: immediately, then after 5 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour and 4 hours, then every 12 hours until 36 hours have passed.&lt;/li&gt;
&lt;li&gt;An endpoint that fails for 72 hours is disabled, and the account owner gets an email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list explains the three failures. The promise is "we keep trying until we hear 200", so a copy arrives whenever your 200 gets lost, even if your app applied the event correctly. A retry that waits 10 minutes also arrives after every event sent in those 10 minutes, which is how order is lost. The docs do not promise an order for events on one endpoint, and the retry schedule shows why no one could. The pipeline assumes no order and expects copies.&lt;/p&gt;

&lt;p&gt;The event itself carries what the handler needs. Here is a &lt;code&gt;customer.plan_changed&lt;/code&gt; event from my account, with the fields I use and the rest left out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer.plan_changed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"event_01a1…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-21T02:29:24.481368869+11:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-21T02:29:23.403633+11:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_4940…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_0198…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customer_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_pro_plan"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan is an object, and its key is at &lt;code&gt;data.plan.key&lt;/code&gt;. The user and customer ids tell the handler which account to change. The two timestamps matter later, in the section on ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;The design follows from two facts. An event can arrive many times, so the app needs one stored copy and a counter. An event can also arrive at any time, so the app must apply it in a way that does not depend on when. Four rules do that work.&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%2Fory0dixp49gppktv6sqj.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%2Fory0dixp49gppktv6sqj.png" alt="The pipeline: the endpoint stores each event, a worker applies it, and every effect is safe to repeat" width="800" height="606"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1: Store the event, then answer
&lt;/h3&gt;

&lt;p&gt;The endpoint does three things and nothing else: it checks the signature, writes the event to a table, and answers 200. Applying the event happens after the answer.&lt;/p&gt;

&lt;p&gt;If the signature is not valid, the endpoint answers 401 and stores nothing. If the database write fails, the endpoint answers with an error, and Kinde tries again later. The 200 therefore means one thing: the event is stored. It does not mean the event is applied. That difference is what lets Bottle survive a crash, because a stored event can run again and a lost event cannot.&lt;/p&gt;

&lt;p&gt;The signature check is one call to the &lt;code&gt;jose&lt;/code&gt; library, which fetches Kinde's keys once and caches them. After the check, the event goes into &lt;code&gt;inbox_events&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;inbox_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dedupe_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&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;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dedupe_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;seen_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inbox_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;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;xmax&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rule 2: One key for each event
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;webhook-id&lt;/code&gt; header is the dedupe key, and the table has a unique constraint on it. The first copy inserts a row. Every later copy hits the conflict, adds one to &lt;code&gt;seen_count&lt;/code&gt;, and does nothing else.&lt;/p&gt;

&lt;p&gt;The last line of the query tells the caller which case happened. In Postgres, &lt;code&gt;xmax&lt;/code&gt; is 0 for a row the statement just created, so &lt;code&gt;(xmax = 0)&lt;/code&gt; is true only for a new event. Bottle also writes one row to a receipts table for every delivery. That gives the app a record of all nine copies even though only three events exist.&lt;/p&gt;

&lt;p&gt;I considered keeping seen ids in memory or in a cache. Both lose the ids when the process restarts, and a restart is exactly when copies arrive. The key has to live in the same database as the event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 3: Apply each event in one transaction, with a lease
&lt;/h3&gt;

&lt;p&gt;A worker claims stored events and applies them. Two workers can run at once, for example when the endpoint starts one after its answer and a timer starts another, and they must not take the same event, so the claim uses a clause Postgres provides for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;inbox_events&lt;/span&gt;
   &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&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="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 seconds'&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
   &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;inbox_events&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'received'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;next_attempt_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
       &lt;span class="k"&gt;OR&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="s1"&gt;'processing'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&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;occurred_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
    &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; makes a second worker pass over rows the first worker holds. The 30-second lease handles a worker that dies. When the lease ends, the row is claimed again.&lt;/p&gt;

&lt;p&gt;The worker then runs the handler and marks the event applied inside one transaction. Either both happen or neither does. If the process stops half way, the database rolls back, and the event runs again from the start. That removes the third failure from the list, because the plan write and the allowance write can no longer be separated.&lt;/p&gt;

&lt;p&gt;When a handler throws, the event goes back to &lt;code&gt;failed&lt;/code&gt; and waits. The waits are 5 seconds, 30 seconds, 2 minutes, 10 minutes and 1 hour. After six attempts the event is marked &lt;code&gt;dead&lt;/code&gt;, which keeps it visible without running it forever.&lt;/p&gt;

&lt;p&gt;I chose Postgres over a message queue for one reason. The claim, the effect and the mark must share a transaction, and a separate queue cannot join one. Billing events are also low in volume, so one more service would add cost without adding safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 4: Make every effect safe to repeat
&lt;/h3&gt;

&lt;p&gt;The first three rules reduce how often a handler runs twice, but only this rule makes a second run harmless, and it has to protect two effects.&lt;/p&gt;

&lt;p&gt;The allowance is a ledger row, and the row has a key made of the account and the month. Writing it again does nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ledger&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pro:'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the payment handler and the plan change handler try to write this same row. Whichever event arrives first grants the Pro allowance, and the other finds the row and skips it. That is why a payment that arrives before its plan change does no harm. The plan change grants the allowance when it comes.&lt;/p&gt;

&lt;p&gt;The plan has its own protection, and it depends on time rather than arrival.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arrival order is not the order things happened
&lt;/h2&gt;

&lt;p&gt;Each stored event has an &lt;code&gt;occurred_at&lt;/code&gt; time taken from the event, not from the moment the copy arrived. A plan change applies only if its time is newer than the plan change already stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan_changed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;plan_changed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the update changes no row, the handler records &lt;code&gt;ignored: a newer plan change is already stored&lt;/code&gt; and stops.&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%2Ft9qz1lzmxyy5eb0haxt1.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%2Ft9qz1lzmxyy5eb0haxt1.png" alt="Arrival order against event time: the old cancellation arrives seventh and is ignored" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram shows the effect. The cancellation happened three minutes before the upgrade, but it arrives seventh. The database already holds a newer plan change, so the cancellation does nothing and the customer stays on Pro.&lt;/p&gt;

&lt;p&gt;The event has two timestamps. In the two events I stored, &lt;code&gt;event_timestamp&lt;/code&gt; was about one second earlier than &lt;code&gt;timestamp&lt;/code&gt;. I read the first as the time of the change and the second as the time Kinde sent the message. Bottle uses &lt;code&gt;event_timestamp&lt;/code&gt; when it is present and falls back to &lt;code&gt;timestamp&lt;/code&gt;. The choice matters, because the guard compares times, and a time that means "when it was sent" would reorder events that were sent late.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay and ignored events
&lt;/h2&gt;

&lt;p&gt;Two features come from the same idea: a stored event is a fact you can look at again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An ignored event keeps its reason.&lt;/strong&gt; When a handler chooses to do nothing, it writes why. The log shows "ignored: a newer plan change is already stored", not a silent skip. An event the handler cannot use shows up the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A replay puts a stored event back in the queue.&lt;/strong&gt; It sets the status to &lt;code&gt;received&lt;/code&gt; and lets the worker run it again. Replay is safe because of Rule 4. A replayed payment finds its ledger row and writes nothing. A replayed plan change finds a newer change and is ignored. Replay is how you run stored events again after a handler changes or after an outage, and it needs no new code path.&lt;/p&gt;

&lt;p&gt;In Bottle, each billing event on the account page has a Replay link. The route behind it only accepts events that belong to the signed-in account, because replay should not let one customer requeue another customer's events.&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%2Fcrxgjjurh7vm60qzcgfg.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%2Fcrxgjjurh7vm60qzcgfg.jpg" alt="The account page lists each billing event with its result and a Replay link" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened with live events
&lt;/h2&gt;

&lt;p&gt;The tests cover the rules, but I also wanted events sent by Kinde itself. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=27&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;In Kinde&lt;/a&gt;, I moved my own user from Pro to the Starter plan and back to Pro. Kinde sent the events to Bottle's live endpoint. To test a late delivery, I then replayed the older Starter change after the Pro change had applied.&lt;/p&gt;

&lt;p&gt;The log on the account page shows the result in the screenshot above. The Pro change reads "plan set to pro, Pro allowance granted". The older Starter change reads "ignored: a newer plan change is already stored". The account shows the Pro plan and 500 bottles for the month.&lt;/p&gt;

&lt;p&gt;The same nine deliveries from the first section run through the pipeline on Bottle's Engine Room page. The page uses the same intake code and handlers as the live endpoint, on a fresh test account each time.&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%2Fz82rrk9pb90cigqb95oc.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%2Fz82rrk9pb90cigqb95oc.jpg" alt="The Engine Room runs nine deliveries of three events against the pipeline" width="800" height="421"&gt;&lt;/a&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%2Fg4kqi8zwl2ez6dbels0b.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%2Fg4kqi8zwl2ez6dbels0b.jpg" alt="Steps 2 to 8: a crash, a retry, three dropped payments and a late cancellation" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The numbers are these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9 copies arrived and 3 events were stored. The other 6 copies were counted and dropped.&lt;/li&gt;
&lt;li&gt;1 plan change stopped in the middle. It stayed stored, and the next copy ran it to the end.&lt;/li&gt;
&lt;li&gt;The late cancellation was ignored, because a newer plan change was stored.&lt;/li&gt;
&lt;li&gt;The account ended on Pro with a balance of 500, and the allowance was granted once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The handler that applies on arrival ended with 2,950. That is 2,450 more bottles than the plan allows, and nothing in the database records how they got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;This design has edges.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order depends on the provider's clock.&lt;/strong&gt; The guard compares event times from Kinde. Two plan changes with the same timestamp are treated as a tie, and the second one is ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live log shows two event types.&lt;/strong&gt; The plan change and the cancellation came from real Kinde events. The payment handlers and the overdue-invoice handler follow the documented event names and are covered by tests, but I did not watch Kinde send them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token's timestamp has no age limit.&lt;/strong&gt; Kinde's docs mention checking the &lt;code&gt;timestamp&lt;/code&gt; for replay protection. Bottle does not reject old tokens. The dedupe table already refuses a second copy of any &lt;code&gt;webhook-id&lt;/code&gt;, but it does not expire a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dead event needs a person.&lt;/strong&gt; After six attempts, an event is marked dead and stays visible. Bottle does not page anyone. A production system needs an alert on that status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres is the queue.&lt;/strong&gt; It suits the low volume of billing events. A stream of millions of events a day is a job for a message queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rule that holds it together
&lt;/h2&gt;

&lt;p&gt;The stored event is the fact, and a delivery is one attempt to tell you about it. Once the app treats it that way, a copy becomes a counter, a late event becomes a comparison of times, and a crash becomes a retry. None of the three needs special code, because each one is the same case: a stored event that has not been applied yet.&lt;/p&gt;

&lt;p&gt;To run it yourself, clone &lt;a href="https://github.com/sholajegede/bottle" rel="noopener noreferrer"&gt;the repository&lt;/a&gt;, run &lt;code&gt;npm test&lt;/code&gt; for the 23 tests, and run &lt;code&gt;npm run dev&lt;/code&gt; for the app. The &lt;a href="https://bottle-reminders.vercel.app/engine-room" rel="noopener noreferrer"&gt;Engine Room page&lt;/a&gt; runs the nine deliveries in your browser.&lt;/p&gt;

&lt;p&gt;After testing it out yourself, do drop a comment below telling me your findings, even if you test it with a completely different billing provider.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why answer 200 before the event is applied?&lt;/strong&gt;&lt;br&gt;
The provider stops retrying once it hears 200. Applying an event can be slow or can fail. If the app answers only after applying, every failure becomes a retry of an event that may be half applied. Storing first makes the answer fast and keeps the event safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which key should I dedupe on?&lt;/strong&gt;&lt;br&gt;
Use the key the provider documents. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=27&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;For Kinde&lt;/a&gt;, that is the &lt;code&gt;webhook-id&lt;/code&gt; header, which stays the same across attempts. Store it with a unique constraint in the same database as the event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use a queue like SQS instead of Postgres?&lt;/strong&gt;&lt;br&gt;
Yes, but the claim, the effect and the "applied" mark can no longer share one transaction. You then need the effects to be safe to repeat on their own, which Rule 4 requires anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test retries and reordering?&lt;/strong&gt;&lt;br&gt;
Write down a sequence of deliveries that includes copies, a late event and a crash, and run it against the handler. The Engine Room page does this, and the repository has the same sequence as a test.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/integrate/webhooks/about-webhooks/" rel="noopener noreferrer"&gt;About webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/billing/manage-subscribers/manage-customer-activity-webhooks/" rel="noopener noreferrer"&gt;Manage customer activity webhooks, Kinde docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sholajegede/bottle" rel="noopener noreferrer"&gt;Bottle source code&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>kinde</category>
    </item>
    <item>
      <title>How to Stop an AI Agent That Lies About Its Own Spending</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:54:29 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-an-ai-agent-that-lies-about-its-own-spending-29k2</link>
      <guid>https://dev.to/sholajegede/how-to-stop-an-ai-agent-that-lies-about-its-own-spending-29k2</guid>
      <description>&lt;p&gt;In September 2026, Mandiant described a failure in its AI Risk and Resilience report, drawing on data from Google's Threat Intelligence Group. An accounting agent entered a runaway loop where it fired more than 15,000 API calls, burning about $50,000 in cloud spend in under an hour, and yet no attacker had touched it.&lt;/p&gt;

&lt;p&gt;This failure points at a specific gap that we will discuss in this article. A token issued to an AI agent proves who issued it, but tt does not say who owns the spending it causes, or how much that owner has left to spend.&lt;/p&gt;

&lt;p&gt;To reproduce the failure mode described by Mandiant, we will be building a demo application with two products: &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=26&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, which is an identity provider that issues signed access tokens for machine callers and can attach custom data to them, and &lt;a href="https://convex.dev/referral/SHOLAJ7497" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;, a backend platform with a reactive database that runs the server that checks every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map, in one screen
&lt;/h2&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%2Fu5s5jxo8fud7hcbx739e.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%2Fu5s5jxo8fud7hcbx739e.png" alt="Three tokens, one enforcement point" width="800" height="402"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;In our demo, three agents will call the same Convex route. What changes is which claims their Kinde token carries, and whether the server checks those claims against its own ledger or against the request itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One agent's token carries no owner and no spend limit. The server treats it as unattributed and uncapped. A second agent's token carries both claims. The server reads them from the verified token and checks them against a running total it keeps in Convex. A third agent reuses the second agent's own token. The server still verifies the signature, but its enforcement route reads the spend total from the request instead of the ledger.&lt;/p&gt;

&lt;p&gt;During the test, both agents made the same calls, one at a time, against a real Kinde tenant and a real Convex deployment, each call priced at $2.50. The unmetered agent made 8 calls. All 8 went through, for $20 spent, with no ceiling in sight. The metered agent made the same calls with a $10 limit set on its token. The first 4 went through, taking it to exactly $10. The server denied every call after that with a 402.&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%2Fdgqe4doi19bag1w3320n.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%2Fdgqe4doi19bag1w3320n.png" alt="Live dashboard showing the unmetered, metered, and naive control agents" width="799" height="404"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The live dashboard mid-run: the unmetered agent's total climbing past $20, the metered agent capped at $10, and the naive control still reporting $0 claimed spend.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why doesn't a valid signature stop overspending?
&lt;/h2&gt;

&lt;p&gt;A JSON Web Token, or JWT, is a signed, verifiable claim. Kinde signs every access token it issues. Any server that trusts Kinde can check that signature without calling Kinde back.&lt;/p&gt;

&lt;p&gt;A valid signature goes on to answer one question: did Kinde issue this token, and has it expired? It does not answer the second question that we have for this test: has this agent already spent its budget? And that second question needs its own check, written into the server, on top of signature verification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&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;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getJwks&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_ISSUER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_M2M_AUDIENCE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;modeForClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_properties&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_owner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unattributed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spendLimitRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_properties&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_spend_limit_usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jwtVerify&lt;/code&gt; throws if the signature is wrong or the token expired. Everything after that line is this build's own code, not Kinde's. It reads two claims off the verified token: who owns this call, and what that owner's limit is.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Kinde attach an owner and a limit to a token?
&lt;/h2&gt;

&lt;p&gt;Kinde stores custom data on an M2M application as Properties. A Property has one of three types: single line text, multi line text, or boolean. There is no number type, so a spend limit gets stored as text and parsed with &lt;code&gt;Number()&lt;/code&gt; on the server.&lt;/p&gt;

&lt;p&gt;A Property must be scoped to Applications to attach to an M2M app. Its Private toggle must be off, or it never appears in a token. From there, the application's Properties tab sets a value, and its Tokens tab, under Token customization, turns that Property on for the M2M token.&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%2Fvwlrlj113t89m725qhns.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%2Fvwlrlj113t89m725qhns.png" alt="Kinde Properties tab for the unmetered application, with no properties added" width="799" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The unmetered application's Properties tab: empty, so the server treats every call from it as unattributed and uncapped.&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%2Ftc5sz9ehkjci38g4aqrv.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%2Ftc5sz9ehkjci38g4aqrv.png" alt="Kinde Token customization toggle for the metered application, with the owner and spend-limit properties turned on" width="799" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The metered application's Token customization tab: both the owner and spend-limit properties switched on, so they ride along in every access token this app issues.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A token that carries these claims does not carry them as plain values. Each one arrives wrapped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"agent_owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"finance-ops"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"agent_spend_limit_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.00"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server has to read &lt;code&gt;.v&lt;/code&gt; off each claim, not the claim itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the server trusts the caller instead of the ledger?
&lt;/h2&gt;

&lt;p&gt;The metered agent's enforcement route reads its spend total from Convex. While, a second route in this build, built only to test the failure mode, reads the spend total from a &lt;code&gt;claimedSpend&lt;/code&gt; query parameter on the request instead.&lt;/p&gt;

&lt;p&gt;Both routes check the same math against the same kind of token. Only the source of the number changes.&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="c1"&gt;// The metered agent's route, checked against the ledger Convex itself wrote&lt;/span&gt;
&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentTotal&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;costUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spendLimitUsd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The naive route, checked against what the caller claims&lt;/span&gt;
&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claimedSpendUsd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;costUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spendLimitUsd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test for the naive route fired 20 calls at it with the metered agent's own token, each one claiming $0 already spent. Each call cost $2.50. The ledger was reset to zero before the run, so the result could not be explained by leftover balance from an earlier session.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4yhyw1soor2v3z8xmvp.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%2Fb4yhyw1soor2v3z8xmvp.png" alt="Naive versus ledger-enforced, side by side" width="800" height="435"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The naive route trusts the number in the request. The metered route reads the number it wrote itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;All 20 calls returned 200. The claimed spend stayed $0.00 the whole time. Real spend, tracked in the background by the same ledger the metered route uses, reached $50.00 against the agent's own $10.00 limit.&lt;/p&gt;

&lt;p&gt;A clean result here could lie two ways. If the route never wrote to the ledger at all, a $0 claimed-spend result would only mean nothing got recorded, not that the check passed. If the test forgot to reset the ledger before the run, an early denial could look like the limit working when it was really an old balance carried over. This build hit exactly that second bug once. An early version of the metered agent's own proof script reused a ledger balance left over from a previous run, and the agent looked capped from call one, before any of the fix under test had run. Resetting the ledger before every run fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does concurrency break the count?
&lt;/h2&gt;

&lt;p&gt;The metered agent's ledger check runs inside a Convex mutation. Convex queues mutations and runs them one at a time, even when many calls arrive at once. That queuing should keep the total correct under concurrent load. It says nothing about which specific calls get to be part of that total.&lt;/p&gt;

&lt;p&gt;The concurrency test settled that directly. It fired 20 calls at the real, ledger-enforced route at the same instant, using the same $10 limit and $2.50 cost as the sequential test. The math says exactly 4 calls should be allowed, since 4 times $2.50 is $10.&lt;/p&gt;

&lt;p&gt;Exactly 4 calls were allowed. The count was correct on every run.&lt;/p&gt;

&lt;p&gt;The 4 calls that won were not the first 4 to arrive. They were calls 20, 7, 5, and 19, in that order, based on the order Convex actually processed them in. If a system assumed a correct total also meant first-come-first-served fairness, this build would have proven that assumption wrong. Convex's mutation queue guarantees the total. It does not guarantee arrival order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;This build ran on one Convex deployment. It did not test enforcement split across multiple deployments or regions.&lt;/p&gt;

&lt;p&gt;Kinde stores the spend limit as text, not a number. The server parses it before comparing it, and a malformed value would need its own check. However, this build assumes an operator sets the Property correctly.&lt;/p&gt;

&lt;p&gt;The mutation that resets a ledger before each proof run exists only for testing. No production request path in this build calls it.&lt;/p&gt;

&lt;p&gt;The naive route exists only inside this demo. Kinde did not cause it, and nothing about Kinde's tokens encourages it. This build's own server code chose to trust a claimed number instead of its own ledger, on purpose, to show what that choice costs.&lt;/p&gt;

&lt;p&gt;The concurrency test fired 20 calls at once. It did not test hundreds or thousands of simultaneous calls, or calls arriving across more than one server instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;A signed token proves Kinde issued it. It does not prove the number attached to a call is true. The only number a server can trust is the one it wrote itself, in its own ledger, after its own check. Everything else is something the caller said.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is the difference between the unmetered and metered agent in this build?&lt;/strong&gt;&lt;br&gt;
The unmetered agent's token carries no owner or spend-limit claim, so the server treats every call as unattributed and uncapped. The metered agent's token carries both claims, and the server checks them against a running total it keeps for that owner in Convex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the naive agent's token still pass signature verification if it is lying about its spend?&lt;/strong&gt;&lt;br&gt;
A JWT's signature only proves Kinde issued the token and it has not expired. It says nothing about the rest of the request. The naive route reads a claimed spend value from a query parameter the caller controls. Signature verification has no way to catch that, because checking a claimed dollar amount is not what a signature check does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why store the spend limit as a Kinde Property instead of a custom claim somewhere else?&lt;/strong&gt;&lt;br&gt;
A Kinde Property attaches directly to an M2M application. Token customization can include it in every token that application issues. No separate lookup is needed to find which agent owns which limit. The tradeoff is Kinde's Property types, which store text, not numbers, so the server parses the value before comparing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if two calls arrive at the same instant?&lt;/strong&gt;&lt;br&gt;
Convex queues mutations and runs them one at a time. Both calls still get a correct answer: whichever one Convex processes first sees the lower ledger total and may be allowed, and the other sees the updated total and may be denied. The total stays correct. Which specific call wins is not guaranteed to match the order the calls arrived in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this pattern work with an identity provider other than Kinde, or a backend other than Convex?&lt;/strong&gt;&lt;br&gt;
Yes. Any provider that lets an application attach custom, scoped claims to an M2M token can take &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=26&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's&lt;/a&gt; place here. Any backend that runs writes safely under concurrent load, keeps a database, and can verify a JWT can take Convex's place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just rate-limit each agent to a fixed number of calls per minute instead of tracking total spend?&lt;/strong&gt;&lt;br&gt;
A call-count limit says nothing about cost. One agent's calls might cost $0.10 each. Another's might cost $4.00 each. A spend ledger caps what actually matters: money spent, not requests made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Set up two Kinde M2M applications. Give one no Properties. Give the other an &lt;code&gt;agent_owner&lt;/code&gt; and an &lt;code&gt;agent_spend_limit_usd&lt;/code&gt; Property, both turned on under Token customization.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;KINDE_ISSUER&lt;/code&gt;, &lt;code&gt;KINDE_M2M_AUDIENCE&lt;/code&gt;, and each application's client ID and secret on the Convex deployment itself with &lt;code&gt;npx convex env set&lt;/code&gt;. Convex's HTTP actions read the deployment's own environment, not &lt;code&gt;.env.local&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:spend-cap&lt;/code&gt; to see the unmetered and metered agents run their sequential calls.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:naive-bypass&lt;/code&gt; to see the claimed-spend route accept 20 calls it should have denied after 4.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:concurrency&lt;/code&gt; to fire 20 calls at the real, ledger-enforced route at the same instant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/sholajegede/agent-token-ownership-demo" rel="noopener noreferrer"&gt;github.com/sholajegede/agent-token-ownership-demo&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;Kinde: About access tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/authenticate-with-m2m/" rel="noopener noreferrer"&gt;Kinde: Authenticate with M2M applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/m2m-application-setup/add-metadata-to-an-m2m-application-with-properties/" rel="noopener noreferrer"&gt;Kinde: Add metadata to an M2M application with properties&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/functions/http-actions" rel="noopener noreferrer"&gt;Convex: HTTP actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/production/environment-variables" rel="noopener noreferrer"&gt;Convex: Environment variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.helpnetsecurity.com/2026/09/16/google-mandiant-enterprise-ai-security-risks-report/" rel="noopener noreferrer"&gt;One runaway AI agent racked up a $50,000 cloud bill — Help Net Security&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number here came from three real scripts against a real Kinde tenant and a real Convex deployment. Clone the repo and run them against your own tenant. If your own naive route somehow keeps real spend under the limit anyway, or your own concurrency test lets through more calls than the math allows, tell me why in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>convex</category>
    </item>
    <item>
      <title>How to Stop a Leaked AI Agent Key From Still Working With Kinde Access Tokens</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sat, 19 Sep 2026 02:20:21 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-a-leaked-ai-agent-key-from-still-working-with-kinde-access-tokens-2je5</link>
      <guid>https://dev.to/sholajegede/how-to-stop-a-leaked-ai-agent-key-from-still-working-with-kinde-access-tokens-2je5</guid>
      <description>&lt;p&gt;In September 2026, VentureBeat reported that AI agents had used exposed credentials to breach 395 organizations. The report made a simple point: identity systems still treat an agent's credential the way they treat a human's password. Nobody expects a human to type a password every few minutes, so nobody had built agent credentials to expire that fast either. And this was a gap the attackers saw and used. A separate breach at Hugging Face traced back to the same failure: an agent held a credential that outlived the task it was issued for.&lt;/p&gt;

&lt;p&gt;Most AI agents get one machine-to-machine (M2M) access token at start-up and keep it for the life of the process. If that token ends up in a log file, a support ticket, or a copied environment variable, it stays valid for however long its issuer configured it. For most identity providers, that is somewhere between one hour and one day.&lt;/p&gt;

&lt;p&gt;To try to solve this problem, I built two versions of the same agent against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, an identity provider that issues M2M access tokens and verifies every call against them. Kinde also lets you set, per application, how long an access token lives before it expires. One agent in my build takes Kinde's default and never checks it again. The other agent treats its token as something with a short shelf life, and rebuilds it before that shelf life runs out.&lt;/p&gt;

&lt;p&gt;Then I'm going to try to steal both tokens and reuse them again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the leaked token still works
&lt;/h2&gt;

&lt;p&gt;An M2M credential in Kinde has two parts: a client ID and a client secret, and an access token that Kinde issues when an app presents that ID and secret. The access token is what the app actually sends on every API call. Kinde signs it, and any server that trusts Kinde can verify that signature without calling Kinde back.&lt;/p&gt;

&lt;p&gt;The client ID and secret rarely change. Most teams set them once, in an environment variable, and leave them alone. The access token is supposed to be different: Kinde gives it a lifetime, after which it stops working no matter who holds it.&lt;/p&gt;

&lt;p&gt;The problem is what an agent's code does with that access token after Kinde hands it over. A static agent fetches one token when it starts, stores it in memory, and reuses it for every call until the process restarts. If that process runs for hours or days, so does the token. If someone copies the token during that time, they hold something that is still good.&lt;/p&gt;

&lt;p&gt;A rotating agent does the opposite. It checks the token's age before every call. And before the token gets close to expiring, the agent throws it away and asks Kinde for a new one. A copy of that token, made at any point in its short life, stops working within minutes.&lt;/p&gt;

&lt;p&gt;Both agents get their tokens the same way: a client-credentials grant. The agent sends its client ID and client secret directly to Kinde's token endpoint. There is no human and no browser involved in this process. Kinde sends back a signed access token. This is the standard OAuth flow for service-to-service calls, and it is the same flow whether the token that comes back lives for two minutes or two days. The grant itself does not decide how long the token is good for. The &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;application's settings in Kinde&lt;/a&gt; do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The enforcement seam
&lt;/h2&gt;

&lt;p&gt;Both agents in this build call the same API, and that API runs the same check on every request, with no branch for which agent is calling.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Agent as Agent (static or rotating)
    participant Kinde
    participant API as Records API (Convex)

    Agent-&amp;gt;&amp;gt;Kinde: client_credentials grant
    Kinde--&amp;gt;&amp;gt;Agent: access_token (24h or 120s expiry)
    Agent-&amp;gt;&amp;gt;API: GET /api/records?action=... (Bearer token)
    API-&amp;gt;&amp;gt;Kinde: verify signature via JWKS
    API--&amp;gt;&amp;gt;Agent: 200 + data, or 401 if expired/invalid

    Note over Agent,API: Later, an attacker replays a captured token directly
    Agent-&amp;gt;&amp;gt;API: same token, no agent involved
    API--&amp;gt;&amp;gt;Agent: static: 200 (still valid) / rotating: 401 (expired)&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The API is a single Convex function. It reads the bearer token from the request, checks its signature against Kinde's public key set (JWKS), and checks that the token has not expired:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&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;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getJwks&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_ISSUER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_M2M_AUDIENCE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;modeForClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deny&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token not issued to a known agent client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;jwtVerify&lt;/code&gt; call, from the &lt;code&gt;jose&lt;/code&gt; library, throws the moment a token's signature is wrong or its &lt;code&gt;exp&lt;/code&gt; claim has passed. That single check is what turns a Kinde-configured expiry into an actual rejection. Nothing about this function knows or cares which agent sent the request; it treats every token the same way, and the token's own expiry decides the outcome.&lt;/p&gt;

&lt;p&gt;The only difference between the two agents lives in &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's dashboard&lt;/a&gt;, not in this code. The static agent's Kinde application keeps the default access token expiry: 86,400 seconds, one full day. The rotating agent's application is set to 120 seconds. Change that one number, and the identical verification code above starts rejecting tokens on a different schedule, with no redeploy of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two credential managers, one verification function
&lt;/h2&gt;

&lt;p&gt;The static agent's credential manager fetches a token once and holds it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;staticToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CachedToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&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;getStaticCredential&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="nx"&gt;CachedToken&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;staticToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;staticToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;staticToken&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;fetchM2MToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STATIC_AGENT_CLIENT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STATIC_AGENT_CLIENT_SECRET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;staticToken&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 rotating agent's credential manager checks the token's age before every use, with a 15-second safety margin, and fetches a new one if the old one is close to expiring:&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="nx"&gt;ROTATION_SAFETY_MARGIN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CachedToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&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;getRotatingCredential&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="nx"&gt;CachedToken&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rotatingToken&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ROTATION_SAFETY_MARGIN_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rotatingToken&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;fetchM2MToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROTATING_AGENT_CLIENT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROTATING_AGENT_CLIENT_SECRET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&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;Neither function knows about the other, or what the API on the other end will do with the token. The only thing that changes the outcome is how long Kinde told each token to live, and whether the calling code respects that.&lt;/p&gt;

&lt;p&gt;A third file in the build, a closed action registry, lists the only three calls either agent may make: &lt;code&gt;list_records&lt;/code&gt;, &lt;code&gt;read_record&lt;/code&gt;, and &lt;code&gt;export_records&lt;/code&gt;. The API rejects any other action before it even looks at the token. This keeps the demo's scope narrow: the test here is about token lifetime, not about which actions an agent should be allowed to take.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found during hardening
&lt;/h2&gt;

&lt;p&gt;During a review of the API's verification code, a bug turned up. When a request carried no token, or a token from an application the API did not recognize, the logging code defaulted that request's log entry to &lt;code&gt;mode: "static"&lt;/code&gt;. An anonymous or malformed request would then show up in the dashboard as if the static agent had made it.&lt;/p&gt;

&lt;p&gt;This did not affect the proof numbers below, since every call in this build came from one of the two known agents. It got fixed before the proof ran, though: unattributed requests now log as &lt;code&gt;mode: "unknown"&lt;/code&gt;, a separate value from &lt;code&gt;"static"&lt;/code&gt; and &lt;code&gt;"rotating"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live proof
&lt;/h2&gt;

&lt;p&gt;With both Kinde applications configured (24 hours for static, 120 seconds for rotating), the proof works in four steps. First, each agent asks Kinde for a token. Second, both tokens are captured at the same instant, the way a log scraper or a support-ticket screenshot would capture them. Third, the script waits past the rotating agent's 120-second window. Fourth, both captured tokens get replayed, unmodified, straight against the API, without going through either agent's own code.&lt;/p&gt;

&lt;p&gt;Static Agent's app, at the default 86,400-second expiry:&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%2Fliqo3ragih7egiueh4p9.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%2Fliqo3ragih7egiueh4p9.png" alt="Static agent Kinde token expiry" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rotating Agent's app, set to 120 seconds:&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%2Fn1954nupg61bqy04xisg.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%2Fn1954nupg61bqy04xisg.png" alt="Rotating agent Kinde token expiry" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live dashboard, built with Convex, shows both agents' history in real time as each call happens:&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%2Fr5i8ldu049s3t65w4mvr.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%2Fr5i8ldu049s3t65w4mvr.png" alt="Live dashboard showing both agents and the leak replay proof" width="800" height="715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The replay produced these results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Token expiry&lt;/th&gt;
&lt;th&gt;Replay result&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;24h (default)&lt;/td&gt;
&lt;td&gt;still works&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1353ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rotating&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;dead&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;938ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A stolen token's usable window: a day vs two minutes
&lt;/h2&gt;

&lt;p&gt;The static agent's leaked token authenticated successfully more than two minutes after it was captured, using the exact same replay the rotating agent's token failed. It will keep authenticating for the rest of its 24-hour life, because nothing in this build, or in most production setups, checks whether a token has been copied. Kinde's own JWT verification only checks whether a token is signed correctly and has not expired. It has no way to know the token in front of it is a copy.&lt;/p&gt;

&lt;p&gt;The rotating agent's leaked token failed after 120 seconds, the same limit set on its Kinde application. Whoever captured it had, at most, two minutes to use it before Kinde's own signature check started returning 401 on every attempt.&lt;/p&gt;

&lt;p&gt;The gap between those two numbers, a day against two minutes, is the entire value of short-lived credentials. Nothing about the attack changes. Nothing about the API's verification code changes. Only the answer to "how long is a copy of this token worth anything" changes, and that answer comes from one number set on one screen in Kinde's dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I think about all of this
&lt;/h2&gt;

&lt;p&gt;Kinde already gives every M2M application a token with an expiry, and most teams never touch that setting. I don't think that is a Kinde problem. Most agent code never checks the expiry either; it just holds whatever Kinde handed back at start-up. Short-lived credentials are not a feature you have to add. The primitive already exists. What is missing is code that treats the expiry as real, instead of code that mints a token once and assumes it will always still be good.&lt;/p&gt;

&lt;p&gt;The fix in this build took two changes: setting one number in Kinde's dashboard, and writing a credential manager that checks the clock before it checks a cache. Neither change touched the API's verification logic at all.&lt;/p&gt;

&lt;p&gt;I would argue the harder habit to build is not the code. It is remembering that a credential an agent holds in memory is a credential that can leave that memory. Developers on Hacker News asked whether they would trust an agent with an API key at all. Most kept landing on the same answer: give the agent a temporary, narrowly scoped credential, issued through something that logs when it was requested. Not a long-lived key sitting in an environment variable. A short access token from an identity provider that already tracks every issuance is a plain way to get that, without building a separate credential broker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; documents rotating an M2M application's client secret as a manual action, triggered from the dashboard or the Management API, not something Kinde does on a schedule. This build rotates the access token, which Kinde issues fresh on every client-credentials request. That is the layer that matters for a leaked-token scenario, but it is a different mechanism from secret rotation, and the two should not be confused for each other.&lt;/p&gt;

&lt;p&gt;120 seconds is a number chosen so this proof runs in a couple of minutes, not a production recommendation. A production window more commonly sits between 5 and 15 minutes, traded off against how often a workload can tolerate a refresh call.&lt;/p&gt;

&lt;p&gt;The API's JWT verification returns a generic 401 for every failure case. It does not tell a caller whether their token expired, or whether it came from an application the API does not recognize. That is fine for this demo. A real audit trail would need to tell those cases apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and sources
&lt;/h2&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/sholajegede/rotating-credentials-demo" rel="noopener noreferrer"&gt;github.com/sholajegede/rotating-credentials-demo&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;Kinde: About access tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/authenticate-with-m2m/" rel="noopener noreferrer"&gt;Kinde: Authenticate with M2M applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/functions/http-actions" rel="noopener noreferrer"&gt;Convex: HTTP actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Anthropic: Tool use documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://venturebeat.com/security/ai-agents-breached-395-organizations-using-credentials-your-iam-policy-still-treats-as-human" rel="noopener noreferrer"&gt;AI agents breached 395 organizations using credentials your IAM policy still treats as human — VentureBeat&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.akeyless.io/blog/hugging-face-breach-ai-agent-identity-security/" rel="noopener noreferrer"&gt;Hugging Face Breach: An AI Agent Identity Security Lesson — Akeyless&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://news.ycombinator.com/item?id=47736831" rel="noopener noreferrer"&gt;Ask HN: Do you trust AI agents with API keys / private keys?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number in this article came from a live run against a real Kinde tenant and a real Convex deployment. Clone the repo, run the same proof against your own tenant, and drop your numbers in the comments. If your static agent's leaked token survives longer than 24 hours, or your rotating agent's dies in under 120 seconds, tell me why.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>kinde</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Auto-Revoke a Claude Agent's Access When a User Is Offboarded With Kinde Webhooks</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 17 Sep 2026 01:36:32 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-auto-revoke-a-claude-agents-access-when-a-user-is-offboarded-with-kinde-webhooks-1ccf</link>
      <guid>https://dev.to/sholajegede/how-to-auto-revoke-a-claude-agents-access-when-a-user-is-offboarded-with-kinde-webhooks-1ccf</guid>
      <description>&lt;p&gt;Imagine this scenario, someone on your team gets offboarded while their AI agent is still mid-task. Nobody remembers the agent is even running. It's just doing what it was told, on behalf of someone who, as of a minute ago, doesn't work at your company or on your team anymore.&lt;/p&gt;

&lt;p&gt;Does it stop?&lt;/p&gt;

&lt;p&gt;Well, I built a small app to test that scenario, using &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; to handle sign-in and to hold the record of who's still active. I signed a real user in, handed their agent a task, and while the agent was still working through it, suspended that same user, in Kinde's dashboard, mid-run, to see what the agent would do next.&lt;/p&gt;

&lt;p&gt;Unsurprisingly, the agent kept working on the task.&lt;/p&gt;

&lt;p&gt;You see, suspending or deleting a person changes how Kinde itself sees that user, but it doesn't touch the access token their agent is already holding, because nothing about a suspension reaches back into a token that was already signed and handed out before it happened. The token still verifies exactly as it did before the suspension, so the agent has no way to know anything changed.&lt;/p&gt;

&lt;p&gt;Everything I am going to talk about in this article is about closing that gap, and about what I actually found while doing it: webhook deliveries measured live, a production bug that could have left an offboarded user's record looking active forever, and a hard number for how long an offboarded person's agent keeps acting before anything catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a suspended user's token still works
&lt;/h2&gt;

&lt;p&gt;Let's start with what an access token actually is, because the whole gap follows from it. An OAuth access token isn't a receipt you hand back to check against a ledger. It's a signed claim, a small JSON payload with a cryptographic signature attached, and whatever's checking it just verifies that signature against a public key rather than calling home to ask if the token's still good. That's the entire appeal of the design: an API can confirm a token is genuine without a database round trip on every request.&lt;/p&gt;

&lt;p&gt;Which means suspending a user in Kinde only changes a row in Kinde's own database. It doesn't reach the token at all, because there's nothing there for it to reach: the token was already handed out, already signed, already valid until whatever expiry it was minted with. Revoking it properly would mean tracking every issued token in a lookup table somewhere, which throws away the entire point of signing one in the first place, or it would mean just waiting for the thing to expire on its own.&lt;/p&gt;

&lt;p&gt;I suspended a signed-in test user mid-session, and the app's own check kept reporting that user's access token as valid, seconds after Kinde had already suspended them.&lt;/p&gt;

&lt;p&gt;So that gap isn't a bug in Kinde, and it isn't a bug in OAuth either. It's just what a stateless credential is, by design, and the real question is what you build on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;Two pieces close the gap. A webhook tells the app when Kinde's view of a user changes, and a check runs before every single agent action, reading the app's own record of that user instead of trusting whatever was true when the session started.&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%2Fyfgm7hwfnspmmqcvfozx.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%2Fyfgm7hwfnspmmqcvfozx.png" alt="A flowchart showing how Kinde user suspensions propagate through signed webhooks and reconciliation into Convex, with an enforcement seam controlling Claude agent tool calls." width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent itself is a small Claude Messages API loop, working through a closed set of three tools against a demo set of internal resources: &lt;code&gt;list_resources&lt;/code&gt;, &lt;code&gt;read_resource&lt;/code&gt;, &lt;code&gt;write_resource&lt;/code&gt;. None of what follows is specific to what the agent does. It's specific to the one place every tool call has to pass through before it's allowed to run at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the seam
&lt;/h2&gt;

&lt;p&gt;First, we start with the registry that defines those three actions, because it's closed by construction rather than by convention. An action that isn't in this table doesn't half-exist somewhere in the code, waiting to be called by accident. It just doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACTION_REGISTRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ActionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ActionDefinition&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;list_resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_resources&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;read_resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read_resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;write_resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write_resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the tool schema handed to Claude and the enforcement check are built from this same table, so the two can never quietly drift apart from each other the way a schema and a permissions list usually do once someone forgets to update one of them.&lt;/p&gt;

&lt;p&gt;Every tool call the model makes passes through a single function, &lt;code&gt;enforceToolCall&lt;/code&gt;, which looks up the acting user's current status and hands it to a small, pure decision function underneath it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decideAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EnforcementMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;userStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SeamDecision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SeamReason&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;naive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;naive_mode_no_check&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offboarded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refuse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_offboarded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refuse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naive mode allows every call without ever looking at that status, which is the vulnerability this whole piece is about, reproduced on purpose so both modes can run side by side against the exact same code and prove the point cleanly. Enforced mode is stricter in a way that matters: it allows exactly one case, a confirmed active user, and refuses everything else, including a status the seam couldn't even resolve because a read to Convex failed. An unknown status doesn't get the benefit of the doubt.&lt;/p&gt;

&lt;p&gt;That status comes from a webhook. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; sends a signed event on &lt;code&gt;user.updated&lt;/code&gt; and &lt;code&gt;user.deleted&lt;/code&gt;, the receiver verifies the signature, and then it does one more thing that has nothing to do with the signature at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFreshWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&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="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventTime&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;eventTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_CLOCK_SKEW_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A valid signature only proves Kinde signed this payload at some point. It says nothing about when that was, so without this check, a captured event replayed months later would sail straight past signature verification, since the signature itself never expires, and past deduplication too, because dedup only catches an event id it's already seen before. &lt;code&gt;MAX_CLOCK_SKEW_MS&lt;/code&gt; is five minutes. Anything older than that gets rejected the same way a forged signature would.&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%2Ft1jum6ormuldmfnsz8q5.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%2Ft1jum6ormuldmfnsz8q5.png" alt="Kinde's suspend and restore admin action panel" width="799" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the build actually found
&lt;/h2&gt;

&lt;p&gt;Hardening this receiver turned up a real ordering bug. The first version recorded the webhook's delivery, for deduplication, before it applied the actual effect of marking the user offboarded. That ordering has a quiet failure mode: if the effect write failed right after the delivery had already been logged, a retried webhook would look like a duplicate of one already handled and get skipped. The user would never actually get offboarded, and nothing about the system would ever try again.&lt;/p&gt;

&lt;p&gt;The fix took one line of reordering, but it only works because of one property underneath it: the effect, &lt;code&gt;markOffboarded&lt;/code&gt;, is idempotent, so running it twice is always safe. That's why it now runs first, unconditionally, ahead of the bookkeeping whose entire job is to stop it from running a third or fourth time. Recording the delivery first and applying the effect second felt like the more natural order to write. It was also the less safe one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it, live
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scripts/e2e-narrative.ts&lt;/code&gt; runs one task against one real Kinde test user, twice: once with the seam in naive mode, once enforced, suspending that same user for real, mid-run, both times. Nothing in this script is simulated. It drives a real agent loop, fires a real suspend call at Kinde, and waits on the actual webhook to arrive over a tunnel before it checks what actually happened.&lt;/p&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;naive&lt;/th&gt;
&lt;th&gt;enforced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;actions allowed after offboarding&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;where the run stopped&lt;/td&gt;
&lt;td&gt;it didn't, ran to completion&lt;/td&gt;
&lt;td&gt;step 2, reason &lt;code&gt;user_offboarded&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same story shows up in the operator console, so I ran it once more while writing this, offboarding the signed-in user on purpose partway through a task:&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%2Fw9eg7wjseb5g6wpn2h52.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%2Fw9eg7wjseb5g6wpn2h52.png" alt="The console's live timeline: step 1 allowed while active, step 2 refused after offboarding" width="800" height="628"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step one lands while the user's still active. The offboard request goes out. Step two refuses, &lt;code&gt;user_offboarded&lt;/code&gt;, with a cutoff latency of 2697ms measured from when the offboarding itself landed in the database, not from when the run started. Every one of those decisions lands in an audit log under a shared correlation id, so a run's full timeline can be pulled back up after the fact, not just watched live:&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%2Fe7e5wlkul9jkvtma03is.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%2Fe7e5wlkul9jkvtma03is.png" alt="Convex's auditLog table, showing real seam decisions and webhook deliveries" width="799" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that matter more than the demo
&lt;/h2&gt;

&lt;p&gt;Across every live webhook delivery in this build, latency ran from 652ms to 2058ms, across suspends, restores, deletes, and role changes alike. That's not instant. Most people assume it is.&lt;/p&gt;

&lt;p&gt;The enforcement check itself doesn't belong anywhere in that number. It's one indexed read against Convex, and next to webhook delivery it's close enough to free that it doesn't move the total.&lt;/p&gt;

&lt;p&gt;So the more honest way to describe total revocation speed is this: it's webhook delivery latency, plus however long until the agent gets around to its next real action, and that second part isn't a fixed system number at all. An agent moving faster, with no artificial pacing between steps or several tool calls requested in the same turn, gets caught just as fast on its very next call, without any extra mechanism needed to catch it sooner. The floor here is roughly one model round trip, not the enforcement check sitting underneath it.&lt;/p&gt;

&lt;p&gt;A webhook can also be missed or delayed, because that's what "best-effort delivery" actually means in practice, so I built a reconciliation sweep on top: a cron job checking every active user's live status directly against Kinde every five minutes. I tested it against a forced scenario: suspended a real user, then manually pushed the app's own record back to active, simulating a webhook that never arrived at all. The cron caught the drift and corrected it on its own, before I ever triggered a manual run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Here's what I think
&lt;/h2&gt;

&lt;p&gt;"Revocation" is the wrong word for what any of this does, and I think that matters more than it sounds like it should. Nothing here revokes anything. What actually happens is a live check gets bolted on top of a credential that was never revocable to begin with, and I'd rather describe it that way than pretend otherwise, because that's closer to what every system in this space is doing under the hood, whatever the marketing copy on top of it calls it.&lt;/p&gt;

&lt;p&gt;I'd also argue this check shouldn't be the thing you bolt on during a hardening pass once the demo already works, which is exactly the order it happened in during this build. The seam existed early on, but the reconciliation backstop and the timestamp check both came later, as production polish, and I think that ordering is backwards for anything that runs unattended. A browser session gets re-validated more or less by accident, on every page load, because there's a human sitting there generating new requests the whole time. An agent loop doesn't get that for free. It holds one credential, validated once at the start, and then it acts on that credential in a loop nobody's watching in real time. If the check isn't built into that loop from the first line, there's no accident later that adds it back in.&lt;/p&gt;

&lt;p&gt;The usual objection to this is cost: a database read on every single tool call sounds expensive once an agent's task means dozens of calls instead of one. But this build's own numbers say that objection doesn't hold up, at least not with an indexed lookup like the one here. The check disappears next to webhook latency, and webhook latency itself disappears next to how long a person actually takes to notice someone's gone and go click suspend. The genuinely expensive part of this whole system is the five to fifteen minutes between someone walking out the door and someone else noticing. Nobody optimizes for that number, even though it's the one that actually decides how exposed you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;The token-still-valid gap from the top of this piece is real, and nothing here closes it. This build works around it, by checking liveness on every call, instead of trying to make the token itself stop working. A system that actually needs the token revoked needs a different mechanism entirely, like short-lived tokens or an introspection endpoint hit on every use, and both of those trade away the exact stateless-verification benefit that made a signed token worth using in the first place.&lt;/p&gt;

&lt;p&gt;The reconciliation sweep is a five-minute backstop, not the primary path, and if the webhook's doing its job, the sweep never finds anything to correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;runs.timeline&lt;/code&gt;, &lt;code&gt;runs.get&lt;/code&gt;, and the audit queries in this build are also unauthenticated Convex reads. A run id or a correlation id is enough on its own to read that run's whole timeline, which is fine for a single-user demo console and not fine at all for anything with untrusted users in it.&lt;/p&gt;

&lt;p&gt;And the action registry here covers exactly three read and write actions on a demo resource. That's enough to prove the pattern holds. It's nowhere near enough to prove the pattern scales to a real authorization model with real permission boundaries between real resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the agent
&lt;/h2&gt;

&lt;p&gt;Back to the opening scene: someone gets offboarded, and their agent is mid-task. Whether its next few tool calls go through was never actually a question about the token. The token was always going to keep working right up until it expired on its own, offboarding or not, because that's just what it is. The real question was always whether anything standing between the agent and the action it's about to take bothered to check, right then, whether the person behind it was still around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and sources
&lt;/h2&gt;

&lt;p&gt;The full build, the enforcement seam, the webhook handler, the reconciliation cron, and &lt;code&gt;scripts/e2e-narrative.ts&lt;/code&gt;, is on GitHub: &lt;a href="https://github.com/sholajegede/offboarding-revocation-demo" rel="noopener noreferrer"&gt;sholajegede/offboarding-revocation-demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The webhook signing and delivery model comes from Kinde's own &lt;a href="https://docs.kinde.com/integrate/webhooks/about-webhooks/" rel="noopener noreferrer"&gt;webhooks documentation&lt;/a&gt;, and the suspend and restore actions used throughout the demo follow Kinde's &lt;a href="https://docs.kinde.com/kinde-apis/management/" rel="noopener noreferrer"&gt;Management API&lt;/a&gt; and its notes on &lt;a href="https://docs.kinde.com/manage-users/access-control/delete-or-suspend-users/" rel="noopener noreferrer"&gt;suspending and deleting users&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The reconciliation sweep runs on Convex's &lt;a href="https://docs.convex.dev/scheduling/cron-jobs" rel="noopener noreferrer"&gt;cron jobs&lt;/a&gt;, and the agent loop follows Anthropic's &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;tool use documentation&lt;/a&gt; for the Messages API. Every number in this piece came from &lt;code&gt;runs.timeline&lt;/code&gt; and &lt;code&gt;auditLog&lt;/code&gt; in that same Convex deployment, read straight off the live runs.&lt;/p&gt;

&lt;p&gt;Clone it, wire it up against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;your own Kinde&lt;/a&gt; tenant, and offboard a test user mid-run. Your webhook latency might not match mine, your reconciliation sweep could catch different drift, and that's the point really. Drop your numbers in the comments and let me know what you find.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>webdev</category>
      <category>claude</category>
    </item>
    <item>
      <title>How to Let an OpenAI Agent Act in a User's GitHub Without Storing API Keys</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:58:48 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-let-an-openai-agent-act-in-a-users-github-without-storing-api-keys-jka</link>
      <guid>https://dev.to/sholajegede/how-to-let-an-openai-agent-act-in-a-users-github-without-storing-api-keys-jka</guid>
      <description>&lt;p&gt;Picture this: A user in your app connects their GitHub account. They ask the agent to deal with an issue, so it reads the thread, writes a comment that answers the question, and opens a pull request with the fix. That comment has to appear under the user's name, because their teammates need to see who acted. So you run an OAuth flow, take the access token GitHub returns, and put it in a column beside the user's row. Every action after that reads the column.&lt;/p&gt;

&lt;p&gt;Now look at what you just created. That column holds a standing secret. It works on the first day and it keeps working long after the reason for it has gone, because the user wanted one comment and the token stays until somebody deletes it. It carries whatever scopes were convenient when you set it up rather than the narrow thing the task needed. Worst of all, it multiplies. The token sits in your database, in last night's backup, in any log line that printed it during an incident, and in the memory of whichever worker read it a minute ago. When that user decides they want the agent out of their account, revocation turns into a hunt through all of those places, and you are asking them to trust that you will find every copy.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll build a demo that answers the other question: what if the app never holds the token at all? An OpenAI agent reads issues, writes comments, and opens pull requests inside a user's real GitHub account, while the app stores no GitHub token anywhere. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; Connected Apps holds the GitHub authorization and the refresh token. A broker asks Kinde for a token at the moment of each action, uses it for that one call, and then drops it. The user cuts the agent off with one click.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A token broker that every GitHub action passes through, with nothing else in the app calling GitHub&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Two deployment modes, one that stores nothing and one that stores a standing key, so you can watch the difference&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An OpenAI tool-calling loop that proposes actions and never sees a credential&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A live connect flow through Kinde Connected Apps, and a kill switch that cuts the agent off&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An audit trail where every brokered action, refusal, and revocation carries a correlation id&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A runtime assertion that hunts for a stored credential after a real agent run&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An end-to-end script that walks the whole story and exits non-zero when any check fails&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in this article comes from running that build against live services. Every number, reason code, and token shape below is measured, including one result that weakens the easy version of the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What You'll Build&lt;/li&gt;
&lt;li&gt;Prerequisites&lt;/li&gt;
&lt;li&gt;How the Demo Is Structured&lt;/li&gt;
&lt;li&gt;Why a Stored Key Fails&lt;/li&gt;
&lt;li&gt;The Connected Apps Primitive&lt;/li&gt;
&lt;li&gt;How to Connect a User's GitHub&lt;/li&gt;
&lt;li&gt;How to Build the Broker&lt;/li&gt;
&lt;li&gt;How to Model the Data&lt;/li&gt;
&lt;li&gt;How to Decide the Storage Mode&lt;/li&gt;
&lt;li&gt;How to Build the Agent Loop&lt;/li&gt;
&lt;li&gt;Two Revocation Levers, and One Is a Decoy&lt;/li&gt;
&lt;li&gt;The Central Finding: Revocation Reaches the Connection, Not the Token&lt;/li&gt;
&lt;li&gt;The Two Modes, Side by Side&lt;/li&gt;
&lt;li&gt;How to Keep Least Privilege&lt;/li&gt;
&lt;li&gt;What the Operator Controls&lt;/li&gt;
&lt;li&gt;How to Harden It for Production&lt;/li&gt;
&lt;li&gt;How to Prove the Tests Can Fail&lt;/li&gt;
&lt;li&gt;Honest Limitations&lt;/li&gt;
&lt;li&gt;What This Design Costs&lt;/li&gt;
&lt;li&gt;What You've Built, and Where to Take It&lt;/li&gt;
&lt;li&gt;Wrapping Up&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;The operator signs in, connects GitHub, and writes a task in plain text. The console shows which deployment mode is running, how many tokens the app keeps, and what the agent has done so far.&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%2Fjxss90cnv51o11itn4fj.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%2Fjxss90cnv51o11itn4fj.png" alt="The operator console. The header shows the signed in user and a badge reading STORAGE_MODE: connected-app. A counts panel shows tokens supplied, GitHub tokens that the app keeps at zero, actions done on GitHub, actions refused, and actions after revocation. The GitHub connection panel shows the status linked, the acting GitHub login, the public_repo scope, and a Revoke connection button." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every step of a run streams into a live timeline. The agent asks for a tool, the broker performs it, and each row carries the correlation id that ties the timeline to the audit trail.&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%2Foaixk8nolm1jx8byj1we.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%2Foaixk8nolm1jx8byj1we.png" alt="The live timeline in the console. The run status reads succeeded, with the model id shown. The rows read task received, tool requested read_issues, broker executed reading three open issues, tool requested read_issue, broker executed reading issue three, and tool requested comment_issue. Every row shows the same correlation id." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run posted a real comment on a real issue, written by the signed-in GitHub account, while the app stored nothing.&lt;/p&gt;

&lt;p&gt;Then the operator clicks Revoke. The GitHub connection is cut, the next action the agent asks for is refused, and the operator stays signed in to the app. You'll see both of those later, once the code behind them is in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along, you'll need:&lt;/p&gt;

&lt;p&gt;Node 18 or later, and comfort with TypeScript. You don't need to be an expert, but you should be comfortable reading async/await and typed function signatures.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;A Kinde account&lt;/a&gt;. The free tier covers everything here. You'll create a back-end web app for sign-in, a machine-to-machine app for the Management API, and GitHub as a connected app.&lt;/p&gt;

&lt;p&gt;A GitHub account, and a repository you're happy to write to. The agent posts real comments, so use a throwaway repository rather than anything that matters.&lt;/p&gt;

&lt;p&gt;An OpenAI API key, and a model that supports tool calls.&lt;/p&gt;

&lt;p&gt;A Convex account for the data store. The free tier is enough.&lt;/p&gt;

&lt;p&gt;Basic familiarity with OAuth helps but is not required, since the whole point of this build is that Kinde runs that flow for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Demo Is Structured
&lt;/h2&gt;

&lt;p&gt;Every GitHub action follows the same path, and the storage mode decides only where the credential comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;operator writes a task
        │
        ▼
   OpenAI agent  ──── proposes an action, holds no token
        │
        ▼
     BROKER  ─────────┬─── connected-app: ask Kinde, use once, discard
        │             └─── stored-key:   use the token the app keeps
        ▼
     GitHub  ──── the comment appears as the user
        │
        ▼
   audit row  ──── correlation id, storage mode, outcome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker is the only module that obtains a credential, and the only module that calls GitHub. The agent sits above it and never touches either. A test walks the source tree and fails the build if a second GitHub caller ever appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Stored Key Fails
&lt;/h2&gt;

&lt;p&gt;This shape is now common enough that the largest AI vendor ships it. Company knowledge in ChatGPT connects to Slack, SharePoint, Google Drive, and GitHub, and answers from the data in those accounts (&lt;a href="https://openai.com/index/introducing-company-knowledge/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;). Workspace agents go further, because they run repeatable workflows, use connected apps, and complete tasks end to end (&lt;a href="https://openai.com/index/introducing-workspace-agents-in-chatgpt/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;). Agents act inside a user's own third party accounts today, so the credential question stops being a detail you can defer.&lt;/p&gt;

&lt;p&gt;Two of the costs are measurable, and this build measured both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stored key carries far more access than the work needs.&lt;/strong&gt; A token that a developer already had is the token that gets used. In this build the stored key carried &lt;code&gt;gist&lt;/code&gt;, &lt;code&gt;read:org&lt;/code&gt;, &lt;code&gt;repo&lt;/code&gt;, and &lt;code&gt;workflow&lt;/code&gt;. The four actions in the demo need &lt;code&gt;public_repo&lt;/code&gt; and nothing else. The broker recorded that gap on every action, in the audit row itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Central revocation does not reach it.&lt;/strong&gt; The app holds the token, so nothing you do at your identity provider touches it. I revoked the connection and the agent carried on writing to the repository for another four and a half minutes, and it would have carried on for hours.&lt;/p&gt;

&lt;p&gt;The fix is not a better column, or tighter access control on the column. The fix is to stop holding the credential.&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%2Fodp8lue0l27yjv3yaenp.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%2Fodp8lue0l27yjv3yaenp.png" alt="One revocation, and what each mode does next. Kinde stops brokering at once, so the connected-app broker gets INVALID_SESSION on its next request and the agent stops. The stored-key app never asks Kinde, so it keeps acting and its comment lands after the revocation. GitHub is the third party in this picture, and it honours the token it already issued until that token expires." width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the token lives, in each mode. In &lt;code&gt;stored-key&lt;/code&gt; the app keeps a long-life token in its own database and calls GitHub with it. In &lt;code&gt;connected-app&lt;/code&gt; the app keeps nothing, so it asks Kinde for one short-life token for each action. The second path has one more hop, and that hop is the whole difference.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Connected Apps Primitive
&lt;/h2&gt;

&lt;p&gt;Kinde Connected Apps let your users reach third party tools through Kinde (&lt;a href="https://docs.kinde.com/integrate/connected-apps/add-connected-apps/" rel="noopener noreferrer"&gt;Kinde docs&lt;/a&gt;). Kinde holds the GitHub authorization. Your app asks Kinde for a token when it needs one.&lt;/p&gt;

&lt;p&gt;Three Management API endpoints matter. I read them from the published OpenAPI specification rather than from memory, because the documentation pages do not carry the parameter detail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Method and path&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Get the auth URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/connected_apps/auth_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;key_code_ref&lt;/code&gt; (required), &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;org_code&lt;/code&gt;, &lt;code&gt;override_callback_url&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get a token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/connected_apps/token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session_id&lt;/code&gt; (required)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revoke&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/connected_apps/revoke&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session_id&lt;/code&gt; (required)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The token response carries two fields and nothing else: &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;access_token_expiry&lt;/code&gt;. The schema has no refresh token field. My app never receives one.&lt;/p&gt;

&lt;p&gt;Note the first parameter name. It is &lt;code&gt;key_code_ref&lt;/code&gt;, a reference string that you set on the connected app in the Kinde dashboard. It is not a connection id. I had assumed otherwise and had to rename an environment variable.&lt;/p&gt;

&lt;p&gt;Your app keeps one value from this flow: the &lt;code&gt;session_id&lt;/code&gt;. Kinde returns it on the callback. That handle is not a GitHub credential. It grants nothing on its own, it needs your Kinde machine to machine credentials to be useful, and Kinde stops honouring it the moment you revoke the connection.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde dashboard&lt;/a&gt;, the connected app carries a name, the GitHub client id, the GitHub client secret, and a key. That last field is the &lt;code&gt;key_code_ref&lt;/code&gt; your code sends, and here it reads &lt;code&gt;github&lt;/code&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%2Fsltcsoqhxjw9feu602hi.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%2Fsltcsoqhxjw9feu602hi.png" alt="The Configure GitHub dialog in the Kinde dashboard, on the Details tab. It shows a Name field reading GitHub, a Client ID field, a Client secret field masked with dots, and a Key field reading github." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Permissions tab is where least privilege gets set. The Scopes box holds one chip, &lt;code&gt;public_repo&lt;/code&gt;, and every other GitHub scope below it stays switched off.&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%2Fu19p324k1k8fkx476g9a.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%2Fu19p324k1k8fkx476g9a.png" alt="The Configure GitHub dialog in the Kinde dashboard, on the Permissions tab. Under a Scopes heading, one chip reads public_repo. Below it, a searchable list shows GitHub scopes such as admin:gpg_key and admin:org, each with its toggle switched off." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Search that list for a user scope and nothing comes back, which is why this build reads the acting GitHub login from the API response instead.&lt;/p&gt;

&lt;p&gt;The user sees GitHub's own consent screen, and it asks for exactly what Kinde requested.&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%2Fptof77rnxltrszuy4mmj.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%2Fptof77rnxltrszuy4mmj.jpg" alt="GitHub's authorization screen, headed Authorize Connected Apps GitHub Demo. A Repositories row reads Public repositories, which is the public_repo scope and the only permission requested. An Organization access block lists four organizations, each with a Request or Grant button that stays untouched. Buttons at the bottom read Cancel and Authorize sholajegede." width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Repositories row reads "Public repositories" and nothing else, which is the &lt;code&gt;public_repo&lt;/code&gt; scope reaching the user. The Organization access block is separate: GitHub offers it on every OAuth consent screen, and this demo grants none of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Connect a User's GitHub
&lt;/h2&gt;

&lt;p&gt;The connect flow has three parts, and your app stores one value from it.&lt;/p&gt;

&lt;p&gt;First, ask Kinde for an authorization URL for this specific user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getConnectedAppAuthUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overrideCallbackUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;token&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConnectedAppAuthUrl&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;kindeManagementEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;managementRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AUTH_URL_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;key_code_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_GITHUB_CONNECTED_APP_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;override_callback_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overrideCallbackUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Kinde did not return a connected app auth url (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;).`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, send the user to that URL. GitHub shows its own consent screen. The URL that Kinde generated for my demo requested &lt;code&gt;scope=public_repo&lt;/code&gt; and nothing more, and GitHub's screen showed "Public repositories" only.&lt;/p&gt;

&lt;p&gt;Third, Kinde returns the user to your callback with the session handle in the query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;/api/connect/github/callback?session_id=dc5e2e23f19749f08c1f73b4ee7af8c2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that handle against the user. My callback route marks the connection linked and writes an audit row. No token arrives in this request, and the route stores none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markLinked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;kindeConnectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;kindeManagementEnv&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;KINDE_GITHUB_CONNECTED_APP_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;grantedScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public_repo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be clear with yourself about what this handle is. It identifies a connected app session at Kinde. It carries no GitHub access on its own. Somebody who steals it still needs your Kinde machine to machine credentials to turn it into a token, and Kinde stops honouring it the moment you revoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build the Broker
&lt;/h2&gt;

&lt;p&gt;The broker is one function. Every GitHub action in the app passes through it. Nothing else in the app calls GitHub.&lt;/p&gt;

&lt;p&gt;Start with the credential step, because that is where the two modes differ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acquireConnectedAppToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConnectedAppSource&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="nx"&gt;CredentialResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail closed before Kinde is even asked.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The GitHub connection is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Refusing to act.`&lt;/span&gt;&lt;span class="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="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No connected app session for this user. Refusing to act.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchConnectedAppToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// INVALID_SESSION lands here after a revocation.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONNECTED_APP&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;Now the stored key path. Read what it does not do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acquireStoredKeyToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;CredentialResult&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;storedKeyEnv&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_STORED_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STORAGE_MODE is stored-key but GITHUB_STORED_TOKEN is not set. Refusing to act.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function never contacts Kinde. It never checks the connection status. That is not an oversight. It is the bug that the demo reproduces on purpose. Because nothing in this path asks Kinde, revocation at Kinde cannot reach it.&lt;/p&gt;

&lt;p&gt;The broker binds the token into a call function and hands the call function to the action handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createGitHubClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;GitHubCall&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&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;call&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.github+json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-github-api-version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2022-11-28&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;USER_AGENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&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="nx"&gt;GITHUB_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&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 token lives inside the closure. A handler receives &lt;code&gt;call&lt;/code&gt;, not the token. A handler therefore cannot read, copy, log, or store a credential, because it never holds one.&lt;/p&gt;

&lt;p&gt;The broker then runs the action and writes an audit row.&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="c1"&gt;// 4. Act. The handler is given a bound caller, never the token.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gitHubTargetEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGitHubClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_TARGET_OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_TARGET_REPO&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;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action.invoked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// The token goes out of scope here. Nothing wrote it anywhere.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the whole path, including every way it can end.&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%2F6cikl65jx6ghteyjf3nv.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%2F6cikl65jx6ghteyjf3nv.png" alt="Where the token lives, in each mode. In stored-key the app keeps a long-life token in its own database and calls GitHub with it. In connected-app the app keeps nothing, so it asks Kinde for one short-life token for each action. The second path has one more hop, and that hop is the whole difference." width="800" height="1250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every path an action can take through the broker. An unregistered name never reaches a credential. A missing or refused token ends in &lt;code&gt;token.refused&lt;/code&gt;. A GitHub error ends in a recorded failure. A success ends with the token going out of scope. Each ending writes an audit row, so no action leaves the broker without a record.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. The row carries a correlation id, so the run timeline and the audit trail join on one value.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Model the Data
&lt;/h2&gt;

&lt;p&gt;The store has six tables: users, connections, actions, runs, run events, and the audit log.&lt;/p&gt;

&lt;p&gt;The connections table records state only. It holds the status, the GitHub login, the granted scopes, the linked and revoked timestamps, and a counter of how many tokens Kinde has supplied. It holds no credential in either mode.&lt;/p&gt;

&lt;p&gt;Writing that rule in a comment would prove nothing, so I wrote a test that walks the runtime validator tree of every table and fails on any field name that looks like a credential.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;holds no field that could store a GitHub credential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;forbidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;access_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;token|refresh_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;token|^token$|bearer|secret|credential|client_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;secret|api_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;key|private_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;key&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;fieldNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;forbidden&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`table "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" declares credential-shaped fields: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test reads the compiled schema rather than the source text. Somebody who adds a token column later breaks the build, and they break it for a clear reason.&lt;/p&gt;

&lt;p&gt;The audit log carries the outcome of every decision. Six event types cover the whole surface: &lt;code&gt;connection.linked&lt;/code&gt;, &lt;code&gt;connection.revoked&lt;/code&gt;, &lt;code&gt;token.brokered&lt;/code&gt;, &lt;code&gt;token.refused&lt;/code&gt;, &lt;code&gt;action.invoked&lt;/code&gt;, and &lt;code&gt;action.refused&lt;/code&gt;. Three outcomes describe what happened: &lt;code&gt;allowed&lt;/code&gt;, &lt;code&gt;refused&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every row also carries the storage mode. That single field is what lets the audit trail show both modes in one table and make the difference obvious.&lt;/p&gt;

&lt;p&gt;Everything that changes state is an internal mutation. The browser cannot forge an audit row, and it cannot flip a connection to linked. The server reaches those functions through one gateway that requires a shared secret, and the browser does not have that secret.&lt;/p&gt;

&lt;p&gt;One detail is worth copying. The broker writes a token row for each action rather than one row per run. A three action run produces three &lt;code&gt;token.brokered&lt;/code&gt; rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;18:25:38  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
18:25:42  action.invoked  allowed  connected-app  Read 3 open issue(s) ...
18:25:47  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
&lt;/span&gt;&lt;span class="gp"&gt;18:25:50  action.invoked  allowed  connected-app  Read issue #&lt;/span&gt;1 ...
&lt;span class="go"&gt;18:25:59  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
&lt;/span&gt;&lt;span class="gp"&gt;18:26:01  action.invoked  allowed  connected-app  Commented on issue #&lt;/span&gt;1 as sholajegede.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch per action stops being a claim in your README. It becomes a pattern in your data that anybody can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Decide the Storage Mode
&lt;/h2&gt;

&lt;p&gt;The mode must not be something a request can influence. I put the whole rule in one pure function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveStorageMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="nx"&gt;StorageMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONNECTED_APP&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;Only the exact string &lt;code&gt;stored-key&lt;/code&gt; selects the unsafe mode. Every other value resolves to &lt;code&gt;connected-app&lt;/code&gt;. That covers unset, empty, &lt;code&gt;STORED-KEY&lt;/code&gt;, &lt;code&gt;stored_key&lt;/code&gt;, a trailing space, and anything an attacker injects.&lt;/p&gt;

&lt;p&gt;The only caller reads &lt;code&gt;process.env.STORAGE_MODE&lt;/code&gt; and nothing else. No request, header, cookie, or query parameter appears anywhere in that path. The reader also throws if it ever runs with &lt;code&gt;window&lt;/code&gt; defined, so a client component cannot pull it into the browser bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build the Agent Loop
&lt;/h2&gt;

&lt;p&gt;The agent proposes actions. The broker performs them. The agent holds no credential.&lt;/p&gt;

&lt;p&gt;The tool list comes from the action registry, so the agent cannot be offered a capability that the registry does not define.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;agentTools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;AgentTool&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;listActions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop reads the model id from configuration. It never appears as a literal in the source.&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="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&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="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;Extract&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_call&lt;/span&gt;&lt;span class="dl"&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="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolCall&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;requested&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;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent.tool_requested&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Requested &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="c1"&gt;// The one and only path to GitHub.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outcome&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;brokerAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use the Responses API rather than chat completions for a measured reason. The model family I tested rejects function tools on &lt;code&gt;/v1/chat/completions&lt;/code&gt; unless reasoning is switched off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;chat  effort=none     OK tool_calls=1
chat  effort=low      ERR 400 Function tools with reasoning_effort are not supported
responses api         OK  types=function_call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Staying on chat completions would have meant disabling the model's reasoning to get tools. The Responses API carries both.&lt;/p&gt;

&lt;p&gt;When the broker refuses for lack of a credential, the agent stops. It does not retry, and it does not reach for another tool.&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="c1"&gt;// Fail closed. A credential refusal will not resolve itself, so the&lt;/span&gt;
&lt;span class="c1"&gt;// run stops here rather than looping or reaching for another route.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refusal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;halt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;finalMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Stopped: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker returns a typed refusal kind so this check is structural, not a string match.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refusal kind&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Caller behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;credential&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No token now, and none coming&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;input&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The arguments failed validation&lt;/td&gt;
&lt;td&gt;Correct and retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unknown-action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The name is not in the registry&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No such user&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I drew that line on purpose. An &lt;code&gt;input&lt;/code&gt; refusal is the model's own mistake, and letting it fix the arguments is self correction rather than a blind retry of a denied action.&lt;/p&gt;

&lt;p&gt;Four structural tests hold the agent honest. The agent module never imports the GitHub client, never names the GitHub API host, never reads a credential from the environment, and contains no storage mode branch at all. A test walks the source tree and fails the build if any of these change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Revocation Levers, and One Is a Decoy
&lt;/h2&gt;

&lt;p&gt;The Kinde Management API exposes two things that sound like a kill switch. Only one is.&lt;/p&gt;

&lt;p&gt;I tested both against the live service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lever A: &lt;code&gt;DELETE /api/v1/users/{user_id}/sessions&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;DELETE sessions: HTTP 200 in 420ms
{"code":"USER_SESSIONS_INVALIDATED","message":"User sessions successfully invalidated"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I polled the token endpoint every two seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  +  2303ms  STILL ISSUING  HTTP 200  (fingerprint 7ea1fb9d9fa2)
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="go"&gt;  + 60011ms  STILL ISSUING  HTTP 200  (fingerprint 7ea1fb9d9fa2)

Kinde was still issuing tokens 60000ms after revocation, across 23 attempts.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty three consecutive requests over a full minute. Every one returned HTTP 200. Every one returned the same token.&lt;/p&gt;

&lt;p&gt;The endpoint reports success and stops nothing. The reason shows in the data. Before the test, &lt;code&gt;GET /users/{id}/sessions&lt;/code&gt; returned &lt;code&gt;"sessions": []&lt;/code&gt;, an empty list, while the connection was live and issuing tokens. A connected app session is not a user session. They are separate objects, and the user sessions endpoint does not reach the connected app.&lt;/p&gt;

&lt;p&gt;If you build a kill switch on this endpoint, it returns HTTP 200, it logs success, and it cuts off nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lever B: &lt;code&gt;POST /api/v1/connected_apps/revoke&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;POST /api/v1/connected_apps/revoke   HTTP 200 in 151ms
{"message":"token revoke successful","code":"REVOKE_SUCCESSFUL"}

  +     0ms  REFUSED  HTTP 400  INVALID_SESSION: Error encountered while
                                retrieving tokens for connected app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The very next request failed. Not eventually. The first call after the revoke returned HTTP 400 with &lt;code&gt;INVALID_SESSION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the kill switch. I pinned it in code and in a test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;revokeConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RevocationRequest&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="nx"&gt;RevocationResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeConnectedAppSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Kinde refused the revocation (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;).`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Record the revocation locally too. The broker refuses on its own&lt;/span&gt;
  &lt;span class="c1"&gt;// authority once the connection reads `revoked`, rather than depending on&lt;/span&gt;
  &lt;span class="c1"&gt;// Kinde's refusal alone.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markRevoked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test asserts that the revoke module calls &lt;code&gt;revokeConnectedAppSession&lt;/code&gt;, never mentions &lt;code&gt;deleteUserSessions&lt;/code&gt;, and that the only file in the whole tree that calls the user sessions endpoint is the probe script where I measured it. A later edit that simplifies onto the decoy fails the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Central Finding: Revocation Reaches the Connection, Not the Token
&lt;/h2&gt;

&lt;p&gt;Now the result that matters most, and the one that weakens the easy version of this story.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;connected_apps/revoke&lt;/code&gt; succeeds and Kinde refuses to broker, does a token that Kinde already handed out still work at GitHub?&lt;/p&gt;

&lt;p&gt;I measured it. I brokered a token, held it, revoked the connection, confirmed Kinde refused, then kept calling GitHub with the token I already had.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Step 1  broker a token and hold it        gho_ fingerprint ffda970320cd
Step 2  held token works before revoke    GET /user HTTP 200
Step 3  POST connected_apps/revoke        HTTP 200 in 177ms
Step 4  can Kinde still broker a token?   REFUSED HTTP 400 INVALID_SESSION
Step 5  does the HELD token still work?
          +   397ms  GET /user HTTP 200  STILL VALID
          +  6115ms  GET /user HTTP 200  STILL VALID
          + 13055ms  GET /user HTTP 200  STILL VALID
          + 19051ms  GET /user HTTP 200  STILL VALID
          + 30235ms  GET /user HTTP 200  STILL VALID
          + 37646ms  GET /user HTTP 200  STILL VALID
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kinde refused to broker in the same second that GitHub was still serving the token it had already issued. Six calls over 37.6 seconds. Every one returned HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation at Kinde does not reach into GitHub.&lt;/strong&gt; It stops Kinde from brokering and from refreshing. It does not cancel an outstanding token.&lt;/p&gt;

&lt;p&gt;So the instant cutoff in &lt;code&gt;connected-app&lt;/code&gt; mode is real, but the reason is not the one you might assume. The cutoff is instant &lt;strong&gt;because the broker keeps nothing between actions&lt;/strong&gt;. The next action must ask Kinde. Kinde refuses. The action stops. There is no credential left inside the app for anything to keep using.&lt;/p&gt;

&lt;p&gt;This has a direct consequence for your code. Do not cache the brokered token, not even for a few seconds. A cache is a window of access that survives the kill switch. In this design, fetch per action is a security boundary rather than a performance choice.&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%2Filru4unhrr974km5rrju.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%2Filru4unhrr974km5rrju.png" alt="Every path an action can take through the broker. An unregistered name never reaches a credential. A missing or refused token ends in token.refused. A GitHub error ends in a recorded failure. A success ends with the token going out of scope. Each ending writes an audit row, so no action leaves the broker without a record." width="800" height="1390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One revocation, and what each mode does next. Kinde stops brokering at once, so the &lt;code&gt;connected-app&lt;/code&gt; broker gets &lt;code&gt;INVALID_SESSION&lt;/code&gt; on its next request and the agent stops. The &lt;code&gt;stored-key&lt;/code&gt; app never asks Kinde, so it keeps acting and its comment lands after the revocation. GitHub is the third party in this picture, and it honours the token it already issued until that token expires.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Modes, Side by Side
&lt;/h2&gt;

&lt;p&gt;I ran both modes against the same revocation and recorded what happened.&lt;/p&gt;

&lt;p&gt;The revocation landed at 18:41:52 UTC. Here is the audit log across both modes, ordered by time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;time      mode           event              outcome   detail
&lt;/span&gt;&lt;span class="gp"&gt;18:41:31  connected-app  action.invoked     allowed   Commented on issue #&lt;/span&gt;3 as sholajegede.
&lt;span class="go"&gt;18:41:52  connected-app  connection.revoked allowed   Connection revoked at Kinde.
18:42:20  connected-app  token.refused      refused   The GitHub connection is revoked.
18:45:52  connected-app  token.refused      refused   INVALID_SESSION: ...
18:46:17  stored-key     token.brokered     allowed   Used the token the app holds. Kinde was not consulted.
&lt;/span&gt;&lt;span class="gp"&gt;18:46:32  stored-key     action.invoked     allowed   Commented on issue #&lt;/span&gt;2 as sholajegede.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stored-key&lt;/code&gt; comment is real. It has a GitHub comment id and a &lt;code&gt;created_at&lt;/code&gt; of &lt;code&gt;2026-08-14T18:46:31Z&lt;/code&gt;, which is &lt;strong&gt;4 minutes and 39 seconds after the revocation&lt;/strong&gt;. An agent that the operator believed they had cut off carried on writing to the repository.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;stored-key&lt;/code&gt; audit row says why, in the row itself: &lt;em&gt;"Used the token the app holds. Kinde was not consulted."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;connected-app&lt;/code&gt; side shows two independent layers. The first refusal came from the local flag, because the broker marks the connection revoked and then refuses on its own authority. To check that Kinde also refuses, I forced the local flag back to &lt;code&gt;linked&lt;/code&gt; while the Kinde session stayed revoked, and ran again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;3  broker.refused   INVALID_SESSION: Error encountered while retrieving tokens for connected app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INVALID_SESSION&lt;/code&gt; straight from Kinde, with the app's own bookkeeping claiming the connection was fine. Either layer stops the agent on its own. The cutoff does not depend on your local state being correct.&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%2Fldr6mpdggk5p19hdtapv.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%2Fldr6mpdggk5p19hdtapv.png" alt="The console after the operator clicks Revoke connection. A green notice reads that the connection was revoked, that Kinde will broker no further token, that the next action is refused, and that the operator is still signed in. The connection status badge reads revoked. The counts panel shows zero GitHub tokens that the app keeps and zero actions after revocation. The header still shows the signed in operator and a Sign out button." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After the revoke. The GitHub connection is cut. The operator stays signed in to the app.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That last point matters and I tested it live. Revoking the GitHub connection does not end the app session. I clicked Revoke, then reloaded the page in the browser. The header still read the operator's name. The connection read &lt;code&gt;revoked&lt;/code&gt;. The user keeps their session with your product and loses only the third party connection they chose to cut.&lt;/p&gt;

&lt;p&gt;Ask the agent for something after the revoke, and the run stops on the first tool call.&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%2Fcuv15qrlva3o6p8el7jf.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%2Fcuv15qrlva3o6p8el7jf.png" alt="The live timeline after a revoke. The run status reads refused. The rows read task received, tool requested read_issues, broker refused with the message that the GitHub connection is revoked, halted with the message that the broker refused to supply a credential and the agent stopped instead of retrying, and finished. Below, the records table shows a token.refused row with outcome refused and a connection.revoked row with outcome allowed, each with its correlation id." width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent asks for one tool, the broker refuses, and the run halts. No retry, and no second route.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Keep Least Privilege
&lt;/h2&gt;

&lt;p&gt;The registry defines four actions and nothing else. The agent cannot name its way to a capability that does not exist, because &lt;code&gt;getAction()&lt;/code&gt; throws on anything unregistered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;Acts as the user&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_issues&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_issue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;comment_issue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;open_pr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I first wrote the registry with &lt;code&gt;public_repo&lt;/code&gt; and &lt;code&gt;read:user&lt;/code&gt;, so the audit trail could record who acted. Then I hit a real constraint. &lt;strong&gt;Kinde's GitHub scope picker does not offer &lt;code&gt;read:user&lt;/code&gt;.&lt;/strong&gt; The connection grants &lt;code&gt;public_repo&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;A registry that claims a scope the connection cannot hold is simply wrong, so I dropped it. The identity still gets recorded, because GitHub echoes the acting account back in the responses the actions already make.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;actingUserFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ActingUser&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;MaybeUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// A nested `user` is the shape returned by comments and pull requests.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A comment response carries &lt;code&gt;{ user: { login } }&lt;/code&gt;. &lt;code&gt;GET /user&lt;/code&gt; needs no scope at all. When a response carries no identity, the function returns null and the app audits the action without a login. The app does not widen the connection to get one.&lt;/p&gt;

&lt;p&gt;The broker also compares the scopes GitHub reports against what the registry claims, and records both directions. In &lt;code&gt;connected-app&lt;/code&gt; mode the audit row reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read 2 open issue(s) in sholajegede/connected-apps-github-sandbox.
Least privilege holds: the credential carries exactly what the action needs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;stored-key&lt;/code&gt; mode the same code reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Over-privileged: the credential also carries gist, read:org, workflow,
which this action does not need.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the second failure of the stored key, visible in the audit trail. The stored key is not only impossible to revoke centrally. It is also far broader than the work needs, because a token that a developer already had tends to be the token that gets used.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Operator Controls
&lt;/h2&gt;

&lt;p&gt;The demo gives the user the controls rather than a video of somebody else using them.&lt;/p&gt;

&lt;p&gt;The operator signs in with their own session. They connect GitHub themselves. They write the task in free text, so nothing is a canned script. They watch each step arrive in a live timeline, and they cut the connection when they choose.&lt;/p&gt;

&lt;p&gt;The console shows five counts, and each one answers a question a reader will ask.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens supplied&lt;/td&gt;
&lt;td&gt;How often the broker asked Kinde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub tokens that the app keeps&lt;/td&gt;
&lt;td&gt;Zero in &lt;code&gt;connected-app&lt;/code&gt;, one in &lt;code&gt;stored-key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions done on GitHub&lt;/td&gt;
&lt;td&gt;What really happened in the repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions refused&lt;/td&gt;
&lt;td&gt;How often the broker said no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions after revocation&lt;/td&gt;
&lt;td&gt;Zero in &lt;code&gt;connected-app&lt;/code&gt;, above zero in &lt;code&gt;stored-key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second count is not derived from the data. It is a property of the deployment, decided on the server and passed down for display. The console shows it and can never set it. The same holds for the storage mode badge in the header.&lt;/p&gt;

&lt;p&gt;The last count is the one to watch during a demo. It shows a dash until the operator revokes. After that it turns green at zero in &lt;code&gt;connected-app&lt;/code&gt;, and red at a real number in &lt;code&gt;stored-key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Harden It for Production
&lt;/h2&gt;

&lt;p&gt;A demo becomes trustworthy when the failure paths behave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every outbound call a deadline.&lt;/strong&gt; A hang is an outage from the caller's point of view, and an action that waits forever can never fail closed. I set 8 seconds on Kinde and 12 seconds on GitHub. I tested the Kinde deadline by pointing the issuer URL at an unroutable address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;outcome        refused
reason         Kinde did not answer within 8000ms.
audit:  token.refused  refused  connected-app  Kinde did not answer within 8000ms.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before I added this, that path threw past the broker with &lt;strong&gt;no audit row at all&lt;/strong&gt;. The credential step sat outside any try block. The fail closed claim was false for exactly the case that needed it most. Finding this is the reason to test outages rather than reason about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse an action you cannot record.&lt;/strong&gt; The broker writes the &lt;code&gt;token.brokered&lt;/code&gt; row before it calls GitHub. If that write fails, the action does not happen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;recorded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The audit trail is unavailable. Refusing to act rather than act without a record.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;storageMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The row also goes to an append only local queue first, so the attempt stays recoverable rather than lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never log a token.&lt;/strong&gt; The broker redacts every GitHub token shape from any text it records, and reports tokens by fingerprint.&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="nx"&gt;CREDENTIAL_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/gh&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;pousr&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{16,}&lt;/span&gt;&lt;span class="sr"&gt;|github_pat_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_&lt;/span&gt;&lt;span class="se"&gt;]{16,}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;redactCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CREDENTIAL_PATTERN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[redacted]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fingerprint is enough to answer real questions without exposing anything. It is a truncated SHA-256 of the token. Two fetches can be compared for equality, and the value itself never appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prove the claim at run time.&lt;/strong&gt; A comment in the source is not evidence. This script runs a real agent task, then looks for a credential everywhere the app could have put one: every row of every table, everything the code printed, the agent's own transcript, and the process environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 2 — scan every row of every table in the store
  actions      4 row(s) scanned
  auditLog     56 row(s) scanned
  connections  1 row(s) scanned
  runEvents    78 row(s) scanned
  runs         10 row(s) scanned
  users        1 row(s) scanned

Verdict
CLEAN — connected-app mode.
No GitHub credential is in the store, in the broker's output, or in
the environment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same script run in &lt;code&gt;stored-key&lt;/code&gt; mode reports &lt;code&gt;FOUND GitHub OAuth token (gho_) in env.GITHUB_STORED_TOKEN&lt;/code&gt;, and stays clean everywhere else. The same assertion, the same code, and a different answer per mode. That contrast is the point.&lt;/p&gt;

&lt;p&gt;One detail makes this proof meaningful. The stored key lives in a separate file that the app loads only when the mode is &lt;code&gt;stored-key&lt;/code&gt;. If it sat in the main environment file, it would be in &lt;code&gt;process.env&lt;/code&gt; on every run, and the environment scan would be worthless.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Prove the Tests Can Fail
&lt;/h2&gt;

&lt;p&gt;A green test suite means nothing until you have seen it go red.&lt;/p&gt;

&lt;p&gt;The end to end script walks the whole story in one pass: clean slate, a &lt;code&gt;connected-app&lt;/code&gt; run that comments on a real issue, a revoke, a refused run, a &lt;code&gt;stored-key&lt;/code&gt; run after the same revoke, and a reconciliation across both modes. It asserts real outcomes and exits non-zero on any failure.&lt;/p&gt;

&lt;p&gt;Each acting step runs in a child process with its own &lt;code&gt;STORAGE_MODE&lt;/code&gt;. The mode is a deployment decision, and a script that flipped it in its own process would quietly contradict the property it tests.&lt;/p&gt;

&lt;p&gt;Green looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GREEN — every assertion held. 69.3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I inverted the load bearing check. Step 4 asserts that the &lt;code&gt;stored-key&lt;/code&gt; agent still acts after the revocation. I changed &lt;code&gt;status === "succeeded"&lt;/code&gt; to &lt;code&gt;status !== "succeeded"&lt;/code&gt;, so the assertion demanded the opposite of the measured truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;RED — 1 assertion(s) failed. 54.3s

  4. stored-key — the same revocation, and the agent acts anyway
    the agent still acts after the connection was revoked
&lt;/span&gt;&lt;span class="gp"&gt;    status=succeeded — Reviewed the open issues and commented on issue #&lt;/span&gt;1 ...
&lt;span class="go"&gt;
EXIT CODE = 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. The report names the step, the assertion, and the observed value. Every other assertion still passed, so the failure stayed isolated rather than cascading. I then restored the file and verified the restoration by hash rather than by eye, so the green result stands for the exact file that ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;p&gt;Read these as written. Do not soften them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation does not cancel a token that GitHub already issued.&lt;/strong&gt; Kinde stops at once. A token already handed out stays valid at GitHub until it expires. I measured it still valid 37.6 seconds after the revoke, and nothing in the revoke path touches GitHub. To cancel the outstanding token, somebody revokes it at GitHub. That control belongs to GitHub, and Kinde does not have it. It sits on the OAuth app's own settings page.&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%2Fl2lgwdt7z5u8ith2c78g.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%2Fl2lgwdt7z5u8ith2c78g.png" alt="The General settings page of a GitHub OAuth app, cropped to one row. On the left it reads 1 user. On the right sits a red button labelled Revoke all user tokens." width="800" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token life is real, and it depends on one setting.&lt;/strong&gt; The brokered token is an opaque 40 character &lt;code&gt;gho_&lt;/code&gt; token. It is not a JWT, so there is nothing to decode. Kinde reported an expiry of &lt;code&gt;2026-08-15T02:09:08.871+01:00&lt;/code&gt;, which was 28761 seconds ahead, or 7.99 hours. That matches GitHub's documented eight hour user access token (&lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens" rel="noopener noreferrer"&gt;GitHub docs&lt;/a&gt;). GitHub issues it that way because the OAuth app has &lt;strong&gt;Token expiration&lt;/strong&gt; switched on, under Optional features. GitHub's own wording for that setting states the eight hours, and states something else worth reading twice.&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%2Fua5e97yvcwcrk4iiwvyh.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%2Fua5e97yvcwcrk4iiwvyh.png" alt="The Optional features page of a GitHub OAuth app. A panel headed Token expiration reads: Access tokens will expire after 8 hours. A refresh token will be provided which can be used to request an updated access token. Existing tokens are not affected. A button beside it reads Opt-out, so the feature is currently active." width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Existing tokens are not affected." GitHub is describing what happens when you toggle the setting, but the same principle runs through this whole article: a token already issued keeps its own life. Turn the setting off and the token never expires at all, and this whole argument gets weaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde returns the same token again, not a new one.&lt;/strong&gt; Two requests inside the same eight hour window returned the identical token, confirmed by fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fingerprint    7ea1fb9d9fa2
second fetch   fingerprint 7ea1fb9d9fa2 — SAME token replayed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the correct claim is &lt;strong&gt;fetch per action, store never&lt;/strong&gt;. The claim is not that each action gets a fresh token. The security property comes from the app holding nothing between actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde holds the refresh token.&lt;/strong&gt; The app never receives one. The evidence is positive rather than merely absent. An eight hour token can only be renewed with a refresh token, so one exists, and it never reaches my app. My app cannot renew access on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The read queries in my demo use an id, not access control.&lt;/strong&gt; Every write needs a server secret that the browser does not have, so the browser cannot forge a record or change the mode. The read queries trust the id they receive. That is enough for a single operator demo. It is not access control, and I would wire the identity provider into the data layer before shipping this to many users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Design Costs
&lt;/h2&gt;

&lt;p&gt;Be honest about the trade you make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You add a network call to every action.&lt;/strong&gt; The broker asks Kinde before it calls GitHub. In my measurements a successful token request took 439ms and 468ms. For an agent that makes three tool calls, that is about 1.4 extra seconds per run. You cannot buy that back with a cache without giving up the property that makes revocation work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You add a dependency to the hot path.&lt;/strong&gt; If Kinde is down, your agent stops. I treat that as correct behaviour rather than a fault, because the alternative is to keep a credential that nobody can take back. Your deadline and your refusal message then matter, since users will see them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need somewhere to put the session handle.&lt;/strong&gt; The app stores no token, but it does store the handle and the connection state. That is a small table, and it holds nothing that works on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You give up one convenience.&lt;/strong&gt; With a stored key, any part of your codebase can call GitHub. With a broker, everything routes through one function. That is the point, and it does mean a small amount of plumbing when you add an action.&lt;/p&gt;

&lt;p&gt;The trade buys you three things. The user can cut the agent off centrally. Your database holds no live third party access. And your audit trail records every decision, including the ones where the broker said no.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You've Built, and Where to Take It
&lt;/h2&gt;

&lt;p&gt;You've built a complete path: a user signs in, connects GitHub through Kinde, and an OpenAI agent acts in their account while the app stores no GitHub token. One revocation cuts the agent off within a single action, and the operator stays signed in.&lt;/p&gt;

&lt;p&gt;The shape transfers to any third party API, not only GitHub. Eight things are worth carrying over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put one function between your code and the third party.&lt;/strong&gt; Everything goes through it. Add a test that walks your source tree and asserts exactly one module names the API host. Structure beats discipline, because the test fails the build and a convention does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give handlers a bound caller, not the credential.&lt;/strong&gt; If a function never holds a token, it cannot leak one. This removes a whole class of review question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide the mode on the server, from deployment configuration.&lt;/strong&gt; Make the unsafe value an exact match and let everything else fall to the safe one. Then a typo fails safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make a refusal a normal result.&lt;/strong&gt; The broker returns a typed outcome instead of throwing. Callers handle refusal as data, and the audit trail records it like anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distinguish refusal kinds.&lt;/strong&gt; A missing credential means stop. A bad argument means the caller can fix it. Blurring the two either produces retry loops or blocks legitimate self correction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetch per action and keep nothing.&lt;/strong&gt; This is the part that makes central revocation bite. If you cache, you reopen the window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the outage, do not reason about it.&lt;/strong&gt; Point the issuer URL somewhere unroutable and see what your code does. I found a real bug that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the limitation that hurts.&lt;/strong&gt; The demo is stronger when it says plainly that revocation does not reach an already issued token. A reader who discovers that on their own stops trusting everything else you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The demo makes one argument, and it holds up under measurement. An agent can act inside a user's real GitHub account without your app ever storing a GitHub token, and the user can cut it off centrally.&lt;/p&gt;

&lt;p&gt;The part worth remembering is the reason it works. Revoking at Kinde does not reach into GitHub and cancel a token that GitHub already issued, and I measured that token still working 37.6 seconds later. The cutoff is instant because the broker keeps nothing between actions, so no credential is left inside the app for the agent to keep using. Cache the token for even a few seconds and you give that property away.&lt;/p&gt;

&lt;p&gt;Take the honest limitation with you as well. To cancel an outstanding token too, the user revokes the authorization on GitHub, and that control belongs to GitHub rather than to &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Source code
&lt;/h3&gt;

&lt;p&gt;The complete demo is on GitHub under the MIT licence, so you can run it, read it, and reshape it. The README carries the same measured limitations as this article.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core documentation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.kinde.com/integrate/connected-apps/add-connected-apps/" rel="noopener noreferrer"&gt;Kinde: add connected apps&lt;/a&gt;: the &lt;code&gt;auth_url&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, and &lt;code&gt;revoke&lt;/code&gt; flow this build uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens" rel="noopener noreferrer"&gt;GitHub: refreshing user access tokens&lt;/a&gt;: the eight hour token life and the refresh token&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation" rel="noopener noreferrer"&gt;GitHub: token expiration and revocation&lt;/a&gt;: what expires, and what a user can revoke themselves&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps" rel="noopener noreferrer"&gt;GitHub: authorizing OAuth apps&lt;/a&gt;: the consent screen the user sees&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Context for the problem
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/introducing-company-knowledge/" rel="noopener noreferrer"&gt;OpenAI: company knowledge in ChatGPT&lt;/a&gt;: connectors for Slack, SharePoint, Google Drive, and GitHub&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/introducing-workspace-agents-in-chatgpt/" rel="noopener noreferrer"&gt;OpenAI: workspace agents in ChatGPT&lt;/a&gt;: agents that run workflows across connected apps&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Build tooling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nextjs.org/docs/app" rel="noopener noreferrer"&gt;Next.js App Router&lt;/a&gt;: the server actions and route handlers the console uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.convex.dev/" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;: the data store behind the audit trail and the live timeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.openai.com/docs/api-reference/responses" rel="noopener noreferrer"&gt;OpenAI Responses API&lt;/a&gt;: the tool-calling endpoint the agent loop uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vitest.dev/" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt;: the runner behind the structural tests&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>ai</category>
      <category>openai</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Require Step-Up Auth Before a Claude Agent's Destructive Actions With Kinde</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 13 Aug 2026 23:33:34 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-require-step-up-auth-before-a-claude-agents-destructive-actions-with-kinde-1ck2</link>
      <guid>https://dev.to/sholajegede/how-to-require-step-up-auth-before-a-claude-agents-destructive-actions-with-kinde-1ck2</guid>
      <description>&lt;p&gt;An agent deleted a document at 22:45. The person who authorised it had signed in at 16:57.&lt;/p&gt;

&lt;p&gt;Nobody was at the keyboard. No token had expired. No rule was broken. The access token was valid for another nineteen hours, and it said exactly what it said six hours earlier: this person is allowed to delete documents. The agent read that as permission and acted.&lt;/p&gt;

&lt;p&gt;The token was right about identity. It was silent about presence.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll build a records console where a Claude agent runs read-only tools freely, and every destructive action, delete, refund, and deploy, stops at a server-side check. That check asks one question: how long ago did a human actually authenticate? If the answer is too long ago, the server holds the action until the person signs in again. The build uses the Anthropic API for the agent, Kinde for identity, and Convex for state and the audit trail.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tool registry that separates safe tools from destructive ones, and gives every destructive tool its own freshness window in seconds&lt;/li&gt;
&lt;li&gt;One enforcement seam that every tool call passes through, with JWKS verification and RS256 pinned&lt;/li&gt;
&lt;li&gt;A freshness check built on the OpenID Connect &lt;code&gt;auth_time&lt;/code&gt; claim, read from the ID token and verified server-side at the moment of the call&lt;/li&gt;
&lt;li&gt;A Claude tool-calling loop that carries the signed-in person's delegated identity and holds no credential of its own&lt;/li&gt;
&lt;li&gt;A complete step-up round trip: the seam holds the action, the person re-authenticates, the agent re-presents the same call, and the seam releases it&lt;/li&gt;
&lt;li&gt;An audit trail that keeps recording when its own storage is unreachable&lt;/li&gt;
&lt;li&gt;A blanket mode that reproduces the failure on the same code, so you can watch the same task slip through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number, reason code, and token claim in this tutorial is measured from that running system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why blanket consent fails&lt;/li&gt;
&lt;li&gt;The demo and its three services&lt;/li&gt;
&lt;li&gt;The freshness primitive: auth_time&lt;/li&gt;
&lt;li&gt;Why iat and exp cannot stand in&lt;/li&gt;
&lt;li&gt;Building the seam&lt;/li&gt;
&lt;li&gt;The tool registry&lt;/li&gt;
&lt;li&gt;The Claude agent loop&lt;/li&gt;
&lt;li&gt;Good manners are not enforcement&lt;/li&gt;
&lt;li&gt;The round trip&lt;/li&gt;
&lt;li&gt;The two negative cases&lt;/li&gt;
&lt;li&gt;Blanket against step-up&lt;/li&gt;
&lt;li&gt;Production hardening&lt;/li&gt;
&lt;li&gt;Honest limitations&lt;/li&gt;
&lt;li&gt;Proving the check can fail&lt;/li&gt;
&lt;li&gt;How to map this to your app&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why blanket consent fails
&lt;/h2&gt;

&lt;p&gt;The common answer to agent risk is to ask the person to approve each action. Anthropic measured what that produces. In &lt;a href="https://www.anthropic.com/engineering/how-we-contain-claude" rel="noopener noreferrer"&gt;How we contain Claude across products&lt;/a&gt;, their engineering team reports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our telemetry showed users approved roughly 93% of permission prompts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the mechanism behind it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The more approvals a user sees, the less attention they pay to each, becoming over time much less diligent in their supervision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A prompt stream trains people to clear prompts. The approval stops being a decision and becomes a reflex, and the one prompt that mattered gets cleared with the rest.&lt;/p&gt;

&lt;p&gt;Anthropic's own answer was &lt;strong&gt;containment&lt;/strong&gt;, not step-up authentication. They moved the defence into the environment: ephemeral containers, sandboxes, and virtual machines that limit what an agent can reach. They also built an auto mode that approves safer actions automatically, so the prompt stream gets shorter.&lt;/p&gt;

&lt;p&gt;Step-up authentication answers a different question. Containment limits the blast radius. Step-up asks whether the person is still here, right now, for this one irreversible action. The two are complementary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containment handles the many. Most actions become safe by construction and need no prompt.&lt;/li&gt;
&lt;li&gt;Step-up handles the few. It applies high friction to a small set of irreversible actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One design rule follows. A read-only tool must never produce a prompt. Every unnecessary prompt spends the attention you need for the prompt that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo and its three services
&lt;/h2&gt;

&lt;p&gt;The build is an operations console. An agent works on records: invoices, releases, and documents. It has six tools.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Destructive&lt;/th&gt;
&lt;th&gt;Freshness window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_records&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;summarize_records&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy_release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three services do the work. The &lt;strong&gt;Anthropic API&lt;/strong&gt; runs the agent, with the model id read from an environment variable. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;&lt;strong&gt;Kinde&lt;/strong&gt; is the identity provider&lt;/a&gt;, running OpenID Connect with authorization code and PKCE. &lt;strong&gt;Convex&lt;/strong&gt; stores the registry, the records, the run timeline, and the audit trail.&lt;/p&gt;

&lt;p&gt;The windows differ by damage. A document delete gets 300 seconds because a backup can restore it. A refund and a production deploy get 120 seconds, because one moves money and the other reaches customers the moment it lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The freshness primitive: auth_time
&lt;/h2&gt;

&lt;p&gt;OpenID Connect defines &lt;code&gt;auth_time&lt;/code&gt;: the moment the person last authenticated interactively, in seconds since the epoch. It is the only standard claim that tracks a human rather than a token.&lt;/p&gt;

&lt;p&gt;Two real tokens from the same sign-in, decoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;token&lt;/span&gt;&lt;span class="w"&gt;                              &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Access&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;                                        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_79a5daf465584…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_79a5daf465584…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;here&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;auth_time&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553834&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                       &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553834&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786557434&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;h&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786640234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="err"&gt;h&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"d4ef3c61eb19…"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;                                        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The differences decide the whole design.&lt;/p&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;ID token&lt;/th&gt;
&lt;th&gt;Access token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;client id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifetime&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;amr&lt;/code&gt; / &lt;code&gt;acr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The access token is the credential a client presents at an API boundary. It carries no evidence of human presence. The ID token carries that evidence and expires twenty-three hours sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The access token lives 24 hours. The evidence of human presence lives 1 hour.&lt;/strong&gt; Blanket consent ignores that gap. The freshness check closes it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8g7dtxhgwmvn03ssj7c5.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%2F8g7dtxhgwmvn03ssj7c5.png" alt="A 24-hour timeline comparing two lifetimes. The access token bar runs the full 24 hours from 00:00. Below it, the evidence of human presence is an ID token bar that runs for 1 hour from 00:00. A third bar, marked critical, covers the remaining 23 hours from 01:00 onward and is labelled " width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For twenty-three of those twenty-four hours, an agent holds a valid credential and the system has no fresh evidence that anyone is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The discovery document does not mention it
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; publishes a discovery document. Its claims list is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"claims_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"S256"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth_time&lt;/code&gt; does not appear. The ID token carries it anyway.&lt;/p&gt;

&lt;p&gt;This is spec-consistent. OIDC Core defines &lt;code&gt;auth_time&lt;/code&gt; as an ID Token claim, and &lt;code&gt;claims_supported&lt;/code&gt; is informational rather than exhaustive. The practical lesson: read a real token before you design against a provider.&lt;/p&gt;

&lt;p&gt;The seam therefore cannot prove freshness from the access token. It verifies two tokens and binds them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why iat and exp cannot stand in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; look like freshness. They are not. Both move whenever a token is minted, and a token can be minted with no human present.&lt;/p&gt;

&lt;p&gt;The build tested this against the live provider. First, a refresh while the ID token was still valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeBefore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786552841&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786552841&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INCONCLUSIVE — the refresh returned the same ID token"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; returned the byte-identical token. Nothing was minted, so nothing was proved. Reporting that as a pass would be a false result, so the check reports it as inconclusive.&lt;/p&gt;

&lt;p&gt;The decisive test needs an expired ID token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeBefore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenWasExpired"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuedAtMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenIdMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth_time held steady across a newly minted ID token"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider minted a genuinely new ID token:&lt;/p&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;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;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1dd46212-757e-…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;09ba30b7-e842-…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16:57:14Z&lt;/td&gt;
&lt;td&gt;18:02:30Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17:57:14Z&lt;/td&gt;
&lt;td&gt;19:02:30Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1786553833&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1786553833&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The new token reports an authentication that happened &lt;strong&gt;3,936 seconds earlier&lt;/strong&gt;, about 65 minutes. A token minted seconds ago correctly describes a human who authenticated over an hour before.&lt;/p&gt;

&lt;p&gt;That number settles the design. &lt;code&gt;auth_time&lt;/code&gt; tracks human authentication, so freshness builds on it. &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; track token issuance, so a check that asks "was this token issued recently?" passes with nobody there.&lt;/p&gt;

&lt;p&gt;The decision function never reads &lt;code&gt;iat&lt;/code&gt; or &lt;code&gt;exp&lt;/code&gt;. The comment says why, so nobody adds them later:&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="cm"&gt;/**
 * `iat` and `exp` are deliberately absent from this function. Both move when
 * a token is refreshed while no human is present, so neither can stand in for
 * human presence. Only `auth_time` survives the minting of a new token.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building the seam
&lt;/h2&gt;

&lt;p&gt;The seam is one function. Every agent tool call passes through it. There is no second path to a tool.&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%2F7z0rsdgfto5dipx987ma.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%2F7z0rsdgfto5dipx987ma.png" alt="The decision tree every tool call passes through. A call arrives and the access token is verified; a failure denies with token_invalid. The tool is looked up in the registry; an unreadable registry denies with registry_unavailable, and an unknown name denies with unknown_tool. A tool that is not destructive is allowed as safe_tool. A destructive tool must declare a freshness window; a missing window denies with registry_defect. The approval mode then splits the path. Blanket mode allows the call as blanket_mode_freshness_skipped. Step-up mode verifies the ID token, challenging with id_token_invalid on a failure and id_token_missing when there is none. It binds that token to the access token by subject, denying with subject_mismatch when the subjects differ. It then compares auth_time against the tool's window, challenging with auth_time_stale outside it and allowing with fresh_authentication inside it. All eleven outcomes, three allows, three challenges, and five denies, write one audit row carrying a correlationId." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. There is no early return that skips the trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: verify the access token
&lt;/h3&gt;

&lt;p&gt;The build pins RS256 and reads keys from the provider's JWKS endpoint:&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="nx"&gt;result&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;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;jwksFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jwks_uri&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ALGORITHMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// ["RS256"]&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clockTolerance&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="c1"&gt;// `jwtVerify` already refuses any algorithm outside the allow-list. This&lt;/span&gt;
&lt;span class="c1"&gt;// re-reads the header so the pin is visible at the call site and survives&lt;/span&gt;
&lt;span class="c1"&gt;// a future change to how the options are built.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenVerificationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;algorithm_not_allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Token algorithm &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not RS256.`&lt;/span&gt;&lt;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 pin appears twice on purpose. The allow-list enforces it. The second check keeps it visible, so a later edit cannot drop it silently.&lt;/p&gt;

&lt;p&gt;The verifier refuses malformed claims rather than ignoring them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;sub&lt;/code&gt;, no &lt;code&gt;exp&lt;/code&gt;, or no &lt;code&gt;iat&lt;/code&gt;: refused.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;auth_time&lt;/code&gt; that is not a positive finite number: refused as malformed, not treated as absent.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;auth_time&lt;/code&gt; more than 60 seconds in the future: refused. A future &lt;code&gt;auth_time&lt;/code&gt; would make every comparison pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: read the registry, and refuse when you cannot
&lt;/h3&gt;

&lt;p&gt;An unknown tool is denied &lt;code&gt;unknown_tool&lt;/code&gt;. A registry that cannot be read is denied &lt;code&gt;registry_unavailable&lt;/code&gt;. Without the registry the seam cannot know whether a tool is destructive or what window it carries, and guessing either way is worse than stopping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: read freshness from the ID token, bound by subject
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idClaims&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;verifyIdToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Binding. Without this, an ID token from any session of any person&lt;/span&gt;
    &lt;span class="c1"&gt;// could vouch for freshness on someone else's access token.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;accessClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subject_mismatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth_time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amr&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="c1"&gt;// The ID token did not verify. Most often it expired.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token_invalid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token_missing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="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 access token answers "who is this". The ID token answers "when did a human last authenticate". Without the binding, a valid ID token from any session could vouch for freshness on somebody else's access token.&lt;/p&gt;

&lt;p&gt;The build separates two refusals that both fail closed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id_token_invalid&lt;/code&gt;: the freshness evidence did not verify. Most often the ID token expired, because it lives one hour and the access token lives a day. This is the common case for a long-running agent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auth_time_stale&lt;/code&gt;: the ID token verified, but &lt;code&gt;auth_time&lt;/code&gt; is older than the tool's window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An early version reported both as &lt;code&gt;auth_time_missing&lt;/code&gt;. That was wrong: the claim was not missing, the token had expired. The trail now says which happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: decide
&lt;/h3&gt;

&lt;p&gt;The decision is a pure function. Tests drive the whole table with no tokens, no network, and no database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DecisionInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DecisionOutput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;safe_tool&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&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="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A destructive tool that declares no window cannot be checked. Allowing&lt;/span&gt;
    &lt;span class="c1"&gt;// it would mean "no limit"; that reading is the failure being fixed.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;registry_defect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalMode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket_mode_freshness_skipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Freshness that cannot be proved is not freshness.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_time_missing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withinWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;authAgeSeconds&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clockSkewSeconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;withinWindow&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fresh_authentication&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_time_stale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are worth copying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing window is a defect, not permission.&lt;/strong&gt; Treating an absent &lt;code&gt;maxAuthAgeSeconds&lt;/code&gt; as "no limit" is the failure this design closes. The function denies instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock skew widens the window, never narrows it.&lt;/strong&gt; A provider clock slightly ahead of yours must not turn a fresh authentication into a challenge. The build adds a 30-second grace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe tools return before any freshness logic runs.&lt;/strong&gt; They cannot produce a prompt even by accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  The challenge response
&lt;/h3&gt;

&lt;p&gt;A held call answers with HTTP 403 and an &lt;a href="https://www.rfc-editor.org/rfc/rfc9470.html" rel="noopener noreferrer"&gt;RFC 9470&lt;/a&gt; challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer error="insufficient_user_authentication",&lt;/span&gt;
&lt;span class="s"&gt;  error_description="The last human authentication is older than this action&lt;/span&gt;
&lt;span class="s"&gt;  allows. Re-authenticate to continue.", max_age=120&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RFC 9470 defines &lt;code&gt;insufficient_user_authentication&lt;/code&gt; for this case: the token is valid, but the authentication behind it does not meet the resource server's requirement. The &lt;code&gt;max_age&lt;/code&gt; parameter tells the client what the server needs.&lt;/p&gt;

&lt;p&gt;The body carries the same facts plus a re-authentication link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"delete_record"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"challenge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth_time_stale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3ad3c1c9-c0cb-4c9c-a0d7-0e3f79b48ba2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxAuthAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insufficient_user_authentication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reauthUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/auth/login?max_age=0&amp;amp;prompt=login&amp;amp;stepUp=1&amp;amp;returnTo=%2F"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max_age=0&lt;/code&gt; and &lt;code&gt;prompt=login&lt;/code&gt; ask the provider for an interactive sign-in. They are a hint to the provider. They are never proof. The seam re-reads &lt;code&gt;auth_time&lt;/code&gt; from the presented token on the retry and decides again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool registry
&lt;/h2&gt;

&lt;p&gt;The registry holds the policy. Each tool declares a name, a destructive flag, and a window.&lt;/p&gt;

&lt;p&gt;The build enforces an invariant on every write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`destructive tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has no maxAuthAgeSeconds`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`destructive tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has a non-positive window`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`safe tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" carries maxAuthAgeSeconds; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`safe tools must not have a freshness window`&lt;/span&gt;&lt;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;Both halves matter. A destructive tool must carry a window, or there is nothing to compare &lt;code&gt;auth_time&lt;/code&gt; against. A safe tool must &lt;strong&gt;not&lt;/strong&gt; carry one. That second rule is approval fatigue written as code: a window on a read-only tool would produce a prompt that never needed to exist, so the registry rejects it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows do real work
&lt;/h3&gt;

&lt;p&gt;Two measurements from the build show the windows are not decoration.&lt;/p&gt;

&lt;p&gt;At one instant, on one token, two destructive tools got different answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;203s&lt;/td&gt;
&lt;td&gt;challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;292s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allow&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same person, same session, seconds apart. The refund was held because moving money has a tighter window. The delete passed because a document delete gets more room.&lt;/p&gt;

&lt;p&gt;Then the same tool changed answer with nothing else changing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;292s&lt;/td&gt;
&lt;td&gt;allow, executed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;399s&lt;/td&gt;
&lt;td&gt;challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Time alone closed the window. The window is measured at the moment of the call, every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Claude agent loop
&lt;/h2&gt;

&lt;p&gt;The agent holds no credential. It calls the same public endpoint every other client uses, and it forwards the signed-in person's session unchanged:&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="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/tools/invoke&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;appConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// The person's session, forwarded unchanged. This is the whole of the&lt;/span&gt;
    &lt;span class="c1"&gt;// agent's authority.&lt;/span&gt;
    &lt;span class="na"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent never imports the tool executors. If the seam refuses, the agent is refused.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to give the agent its own API key so it "just works". That shortcut is the failure this design closes. A service account decouples the agent's authority from the person's, and &lt;code&gt;auth_time&lt;/code&gt; becomes meaningless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three responses, handled differently
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;allow&lt;/strong&gt;: run the tool, feed the result back, continue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;challenge&lt;/strong&gt;: stop. Freeze the conversation. Show the person a re-authentication link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deny&lt;/strong&gt;: stop and report. A denial is not something re-authentication fixes, so the agent offers no link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The challenge path is where agents go wrong. &lt;strong&gt;Blind retry&lt;/strong&gt; turns a step-up challenge into a spin, because only the human can clear it. &lt;strong&gt;Tool substitution&lt;/strong&gt; is worse, because it looks like helpfulness: an agent that answers "delete was refused" by reaching for another mutating tool has routed around the control that just fired.&lt;/p&gt;

&lt;p&gt;The build blocks both. The system prompt states the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A destructive tool may be held by the server until the person has authenticated
recently. If that happens you will be told so in the tool result, and the run
will end so the person can re-authenticate. That is a normal outcome, not an
error to work around: do not retry a held call, and do not substitute a
different tool to achieve the same effect.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop makes it structural. A challenge returns out of the loop, so the model never gets another turn in which to try something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Good manners are not enforcement
&lt;/h2&gt;

&lt;p&gt;Two runs in the build produced the same visible outcome and mean completely different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run one.&lt;/strong&gt; The prompt asked the agent to find the superseded document and delete it. The dataset had none, because an earlier run had already deleted it. The agent listed the documents, read them, and refused:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I reviewed the documents and did not delete anything, because nothing matches what you asked me to find. Neither document is superseded. Both are status &lt;code&gt;active&lt;/code&gt;, and each summary points the other way — DOC-3301 is the sole copy of a working model, and DOC-3302 is still load-bearing for three open action items.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No destructive tool was called. The audit trail shows only safe calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run two.&lt;/strong&gt; Same prompt, record restored. The agent identified the right document, called &lt;code&gt;delete_record&lt;/code&gt;, and the seam refused it with &lt;code&gt;auth_time_stale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both runs ended with nothing deleted. Only the second is a security control.&lt;/p&gt;

&lt;p&gt;The model &lt;strong&gt;choosing&lt;/strong&gt; not to act is good behaviour. It depends on the prompt, the data, and the model's judgement on the day. It is not a boundary. The &lt;strong&gt;server&lt;/strong&gt; refusing to let it act is enforcement, and it holds regardless of what the model decided or which model you swapped in.&lt;/p&gt;

&lt;p&gt;When you test your own system, force the destructive call to reach the boundary. If it never reaches the seam, you have tested nothing about the seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip
&lt;/h2&gt;

&lt;p&gt;The full sequence, under one &lt;code&gt;correlationId&lt;/code&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%2Fkomyf2wvcmaawfl50ol0.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%2Fkomyf2wvcmaawfl50ol0.png" alt="A sequence between the human, the Claude agent, the seam, Kinde, and the records store. The agent calls list_records over the delegated session and the seam allows it as safe_tool. The agent then calls delete_record. The seam compares an authentication age of 1800 seconds against a 300 second window and answers 403 with insufficient_user_authentication and max_age=300. The agent pauses the run and shows the person a re-authentication link, freezing the tool name, the input, and the tool_use_id. The person signs in at Kinde with max_age=0 and prompt=login, and Kinde returns a new ID token with an advanced auth_time. The person resumes the run. The agent re-presents the same delete_record call. The seam now compares an authentication age of 50 seconds against the same 300 second window and allows it as fresh_authentication. The delete executes and the record changes." width="800" height="689"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The resume re-presents, it does not re-plan
&lt;/h3&gt;

&lt;p&gt;When the seam holds a call, the build stores the conversation and the exact held call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;haltedReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step_up_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;challengeAuthTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pausedState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolUseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;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;On resume, the agent sends that same call to the seam before the model gets another turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seam&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;callSeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookieHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a security property, not a convenience. If the resume let the model re-plan, a released run would not be the task the person approved. It could become a different, larger action wearing the same &lt;code&gt;correlationId&lt;/code&gt;. Same tool name, same input, same &lt;code&gt;tool_use_id&lt;/code&gt;, or nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The resume route decides nothing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resumeAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookieHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;observedAuthTime&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No path through the route releases something the seam would refuse. The &lt;code&gt;auth_time&lt;/code&gt; the route reads serves the audit narrative only, and it comes from a verified token, never from the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two negative cases
&lt;/h2&gt;

&lt;p&gt;A control is only as good as what it refuses. Two refusals carry the whole design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1: a refresh does not release the action
&lt;/h3&gt;

&lt;p&gt;Before any re-authentication, the build ran a &lt;code&gt;refresh_token&lt;/code&gt; grant against the live provider, then retried the held delete.&lt;/p&gt;

&lt;p&gt;The refresh minted a &lt;strong&gt;new, valid&lt;/strong&gt; ID token. The previous one had expired, so this was a real minting, not a cached response.&lt;/p&gt;

&lt;p&gt;The retry was refused.&lt;/p&gt;

&lt;p&gt;The reason code tells the story precisely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original halt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id_token_invalid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After refresh&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_time_stale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4409s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason moved from &lt;code&gt;id_token_invalid&lt;/code&gt; to &lt;code&gt;auth_time_stale&lt;/code&gt;. That change is the finding:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The refresh repaired the token's validity. It did not repair its freshness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A machine-to-machine exchange restored a well-formed, verifiable, unexpired credential. The destructive action stayed held, because no human had been present. This is the property the whole design rests on, measured rather than assumed.&lt;/p&gt;

&lt;p&gt;If your freshness check reads &lt;code&gt;exp&lt;/code&gt;, this case passes and the action runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2: re-authentication does not bless the session
&lt;/h3&gt;

&lt;p&gt;The second refusal was not planned. A genuine interactive re-authentication happened. Then about five minutes of other work passed before the retry fired.&lt;/p&gt;

&lt;p&gt;The retry was refused:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;After real re-auth, delayed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_time_stale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;352s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The person had re-authenticated correctly. The window had closed again.&lt;/p&gt;

&lt;p&gt;This rules out a weaker reading of the control: that re-authenticating grants the session a blessing that later calls ride on. It does not. The window is measured at the moment of each call. A correct re-authentication that has since aged out is refused exactly like one that never happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  The release
&lt;/h3&gt;

&lt;p&gt;The next re-authentication was followed promptly by the retry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;allow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fresh_authentication&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The delete executed. The record changed.&lt;/p&gt;

&lt;p&gt;Seven audit rows carry that run: three safe calls, three challenges, one release. All share one &lt;code&gt;correlationId&lt;/code&gt;, so the story stays readable months later.&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%2Fbn7dm4ioto6o7chvtjbt.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%2Fbn7dm4ioto6o7chvtjbt.png" alt="The console holds delete_record. The banner names the tool and reports the authentication age, 26m 20s, against the tool's window of 5m 0s. The escapes counter stays at zero." width="800" height="435"&gt;&lt;/a&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%2Fjrpnz5cu76q3a6vv9m09.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%2Fjrpnz5cu76q3a6vv9m09.png" alt="The same run, one correlationId, top to bottom. The seam holds delete_record at auth_time_stale, reporting an authentication age of 3h 12m against a window of 5m 0s. The person re-authenticates. The seam then allows the same call at fresh_authentication, age 3s against the same 5m 0s window, and the delete executes. DOC-3303 shows as deleted in the records panel." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blanket against step-up
&lt;/h2&gt;

&lt;p&gt;The build ships two modes so the failure and the fix run side by side on the same code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;APPROVAL_MODE&lt;/code&gt; is read from the deploy environment and from nowhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;approvalMode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;ApprovalMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APPROVAL_MODE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties are deliberate. The variable has no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, so Next.js never inlines it into the browser bundle. The seam never reads it from a header, query string, cookie, or tool argument, so a caller cannot set it. And only the exact string &lt;code&gt;blanket&lt;/code&gt; selects the permissive mode: unset, empty, misspelled, or hostile values all resolve to &lt;code&gt;step-up&lt;/code&gt;, so a misconfiguration fails towards enforcement.&lt;/p&gt;

&lt;p&gt;Running the same task in each mode, with an authentication about five hours old:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Executed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deploy_release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;step-up&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;challenge (&lt;code&gt;auth_time_stale&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In blanket mode the agent refunded an invoice, deleted a document, and deployed a release to production. Same tokens, same tools, same code. Only the deploy environment differed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The number that matters
&lt;/h3&gt;

&lt;p&gt;The headline counter is &lt;code&gt;executedWithoutFreshAuth&lt;/code&gt;. It counts destructive calls the seam allowed while the authentication was outside the tool's window, or allowed with no age recorded at all. Counting the missing case as a failure keeps the metric honest.&lt;/p&gt;

&lt;p&gt;In step-up mode it stays at &lt;strong&gt;0&lt;/strong&gt;. In blanket mode it climbs.&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%2F0tesb27lzz6o5o8blwo1.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%2F0tesb27lzz6o5o8blwo1.png" alt="Step-up mode. The escapes counter reads zero, and DOC-3303 shows as deleted in the live records panel after the released action." width="800" height="861"&gt;&lt;/a&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%2F9c8o156p3ye8kqvogben.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%2F9c8o156p3ye8kqvogben.png" alt="Blanket mode, same code. The seam allows refund_payment with the reason " width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One subtlety caught the build's own test. A blanket-mode slip is not automatically an escape. If the person signed in seconds earlier, the check was skipped but nothing escaped a window it would have failed, and the counter correctly stays at zero. The hole only bites when the human is absent. The end-to-end test now ages the session past the window before the blanket task runs, so the escape it measures is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hardening
&lt;/h2&gt;

&lt;p&gt;Three gaps appeared during the build. Each is easy to ship without noticing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit row that vanished
&lt;/h3&gt;

&lt;p&gt;The first version failed closed when Convex was unreachable. The tool did not run, but no audit row was written. A refusal happened with no record that it had. A control that silently stops recording looks identical to one that stopped working.&lt;/p&gt;

&lt;p&gt;The audit write now has three stages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Convex, retried with backoff&lt;/td&gt;
&lt;td&gt;&lt;code&gt;recorded&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local append-only spool file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spooled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neither&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lost&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The write never throws, because the seam needs the outcome to decide what to do next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durability&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Neither the store nor the spool took it. Allowing now would mean an&lt;/span&gt;
  &lt;span class="c1"&gt;// action ran with no evidence anywhere that it was permitted.&lt;/span&gt;
  &lt;span class="nx"&gt;effectiveDecision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;effectiveReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audit_unavailable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit outcome can only make a decision stricter, never looser.&lt;/p&gt;

&lt;p&gt;Testing this exposed a second hole. With Convex fully unreachable the seam never reached the audit write: the &lt;strong&gt;registry lookup&lt;/strong&gt; threw first, the route returned 500, and no row was written. Same gap, one step earlier. It would have stayed hidden if the outage had only been simulated at the audit call.&lt;/p&gt;

&lt;p&gt;Measured with the sink pointed at an unreachable host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;POST /api/tools/invoke → 403
  decision=deny  reason=registry_unavailable

.audit-spool.jsonl:
  deny  registry_unavailable  delete_record  cid=0020d4ca

GET /api/health:
  {"degraded": true, "pendingRows": 1}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the sink returned, one health probe drained it: &lt;code&gt;{"degraded": false, "pendingRows": 0, "replayedNow": 1}&lt;/code&gt;. The refusal happened, was recorded while the store was down, showed as a backlog, and landed in the store on recovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  The success that reported as a failure
&lt;/h3&gt;

&lt;p&gt;A resume once released a destructive action, executed it, wrote its audit row, and then returned &lt;strong&gt;HTTP 500&lt;/strong&gt;. A trailing Convex write had failed after the work was done. The most dangerous thing a console can say about a delete that happened is "it failed".&lt;/p&gt;

&lt;p&gt;Two changes make that impossible. The response precedes the work: both routes validate, return &lt;code&gt;202 Accepted&lt;/code&gt;, and drive the loop in &lt;code&gt;after()&lt;/code&gt;. And the run's own state is authoritative: the console reads status, timeline, records, and counters from Convex by subscription, never from the fetch that started the run.&lt;/p&gt;

&lt;p&gt;The split between the two trails is deliberate. &lt;strong&gt;Timeline writes are best-effort telemetry&lt;/strong&gt;, and losing one costs visibility. &lt;strong&gt;Audit writes are strict and can refuse the call&lt;/strong&gt;, and losing one costs the security record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The state that lied
&lt;/h3&gt;

&lt;p&gt;A resume left the run marked &lt;code&gt;halted&lt;/code&gt; for its whole duration. The console showed "Held" while a resume was in flight.&lt;/p&gt;

&lt;p&gt;The test found it. The end-to-end script waited for the run to settle, read &lt;code&gt;halted&lt;/code&gt; immediately, and asserted against the previous outcome. The load-bearing negative assertion passed without the resume having happened.&lt;/p&gt;

&lt;p&gt;The fix sets the run to &lt;code&gt;running&lt;/code&gt; at the start of a resume, before the seam is consulted. The test now also waits for a &lt;strong&gt;new audit row&lt;/strong&gt; before reading any outcome, because a new row proves the seam ruled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;Two limitations stand. Both are stated in the project README.&lt;/p&gt;

&lt;h3&gt;
  
  
  It proves when, not how
&lt;/h3&gt;

&lt;p&gt;The console proves &lt;strong&gt;when&lt;/strong&gt; a person authenticated. It cannot prove &lt;strong&gt;how&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Kinde tenant emits no &lt;code&gt;amr&lt;/code&gt; or &lt;code&gt;acr&lt;/code&gt; claim on either token, even after the person completes multi-factor authentication. This was checked against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's documentation&lt;/a&gt; and against real tokens from a real MFA sign-in.&lt;/p&gt;

&lt;p&gt;The ID token carries 19 claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;at_hash, aud, auth_time, azp, email, email_verified, exp, family_name,
given_name, iat, iss, jti, name, nonce, org_codes, picture, rat, sub,
updated_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;amr&lt;/code&gt;. No &lt;code&gt;acr&lt;/code&gt;. The access token carries none either.&lt;/p&gt;

&lt;p&gt;To be sure this was the provider's behaviour and not a gap in the build's own claim handling, the session view reports the claim &lt;strong&gt;names&lt;/strong&gt; on each verified token. A claim that is sent but never read looks identical to a claim that is never sent, unless you check.&lt;/p&gt;

&lt;p&gt;Kinde does support custom properties in tokens. The build deliberately did &lt;strong&gt;not&lt;/strong&gt; use them here. A custom property is a static value configured against a user. It is not a record of what happened during a sign-in. A property reading &lt;code&gt;"mfa"&lt;/code&gt; would be emitted on a password-only login exactly as on an MFA login. That is not a weaker control. It is a false one, and it would read to an auditor as though the second factor had been verified.&lt;/p&gt;

&lt;p&gt;The assertion mechanism ships anyway, and ships &lt;strong&gt;off&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requiredAuthMethods&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STEP_UP_REQUIRED_AMR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="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;Set &lt;code&gt;STEP_UP_REQUIRED_AMR=mfa&lt;/code&gt; and a destructive release must evidence that method, or the seam holds it with &lt;code&gt;amr_unprovable&lt;/code&gt; or &lt;code&gt;mfa_required&lt;/code&gt;. On this tenant that setting refuses every destructive call, because there is no &lt;code&gt;amr&lt;/code&gt; to check. The refusal is correct fail-closed behaviour, but it is not a control this provider can satisfy, so the flag stays empty.&lt;/p&gt;

&lt;p&gt;For the same reason &lt;code&gt;acr_values&lt;/code&gt; never appears in the challenge header. Demanding an authentication context you cannot verify is a promise you cannot keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit spool is a floor
&lt;/h3&gt;

&lt;p&gt;If the audit store is unreachable, a decision is retried, then written to a local append-only file, then replayed when the store returns. A decision is never &lt;em&gt;silently&lt;/em&gt; lost.&lt;/p&gt;

&lt;p&gt;But that file lives on one host and does not survive it. It is the floor, not a durable-queue story. A production deployment wants a queue with its own availability guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the check can fail
&lt;/h2&gt;

&lt;p&gt;A test that cannot fail is decoration.&lt;/p&gt;

&lt;p&gt;One script walks the whole story in a single pass: &lt;code&gt;npm run e2e&lt;/code&gt;. It runs 45 assertions across seven steps, and every assertion reads the deployment back rather than the HTTP status.&lt;/p&gt;

&lt;p&gt;Two properties are worth copying. &lt;strong&gt;It never sets &lt;code&gt;APPROVAL_MODE&lt;/code&gt; on a request&lt;/strong&gt;: the mode belongs to the deployment, so switching it means restarting the server. A request-level override would have been easier and would have destroyed the invariant the script exists to prove. &lt;strong&gt;It never mints a session&lt;/strong&gt;: it drives a real browser, and the operator signs in once and re-authenticates once.&lt;/p&gt;

&lt;p&gt;A passing run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;── Step 1: Clean slate                escapes start at 0
── Step 2: Step-up, read-only         every call allowed, nothing challenged,
                                      no record touched
── Step 3: Blanket, the hole          refund executed, record changed,
                                      escapes → 1
── Step 4: Step-up, the halt          deploy held, nothing executed
── Step 5: The negative               refresh did NOT advance auth_time;
                                      resume refused and recorded
── Step 6: The release                auth_time advanced; released exactly
                                      once at fresh_authentication
── Step 7: Reconciliation             one correlationId; release did NOT
                                      count as an escape

final counters: safe=1 destructive=3 challenged=2 escapes=0
PASS — 45 assertions across 7 steps.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the important part. Inverting the load-bearing assertion, so it claims a refresh &lt;em&gt;should&lt;/em&gt; advance &lt;code&gt;auth_time&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ✗ ASSERTION FAILED
   the refresh did NOT advance auth_time
     expected true, got false

FAIL — stopped after 27 passing assertions.
EXIT CODE: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It stops at the first failure, names the assertion, prints expected against actual, and exits non-zero.&lt;/p&gt;

&lt;p&gt;Run that experiment on your own suite. If you cannot make a security test go red on demand, you do not know what it is testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to map this to your app
&lt;/h2&gt;

&lt;p&gt;The identity provider, the database, and the model are replaceable. Six decisions are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Classify your tools, and be strict about the safe ones.&lt;/strong&gt; Split tools into read-only and destructive. Give every destructive tool a window in seconds, tiered by how hard the action is to undo. Forbid windows on safe tools. Enforce both halves in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read &lt;code&gt;auth_time&lt;/code&gt; from wherever your provider puts it.&lt;/strong&gt; Decode a real token first. On Kinde it sits on the ID token, and the discovery document does not list it. Verify the token that carries authorization and the token that carries freshness, then bind them by &lt;code&gt;sub&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never use &lt;code&gt;iat&lt;/code&gt; or &lt;code&gt;exp&lt;/code&gt; as a freshness proxy.&lt;/strong&gt; Test this against your own provider. Let an ID token expire, run a refresh, and compare &lt;code&gt;auth_time&lt;/code&gt; before and after. If &lt;code&gt;auth_time&lt;/code&gt; moves, you need a different primitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Put the check in one place.&lt;/strong&gt; One function, on the server, at the moment of the call. Not in the client. Not in the prompt. Not spread across six tool implementations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Fail closed, and record before you answer.&lt;/strong&gt; A missing window, an unverifiable token, an unreadable registry, an unrecordable decision: all refuse. Write the audit row before you return the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Handle the challenge properly in the agent.&lt;/strong&gt; Stop the run. Do not retry. Do not substitute another tool. Store the exact held call and re-present it after re-authentication.&lt;/p&gt;

&lt;p&gt;Then test the negatives. Anyone can show a release after a successful sign-in. The design is only worth something if a refresh-only retry stays blocked, and if a correct re-authentication that has aged out is refused too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.anthropic.com/engineering/how-we-contain-claude" rel="noopener noreferrer"&gt;How we contain Claude across products&lt;/a&gt;. Anthropic engineering. Source of the ~93% approval figure and the containment approach.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9470.html" rel="noopener noreferrer"&gt;RFC 9470: OAuth 2.0 Step Up Authentication Challenge Protocol&lt;/a&gt;. Defines &lt;code&gt;insufficient_user_authentication&lt;/code&gt; and the &lt;code&gt;max_age&lt;/code&gt; parameter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openid.net/specs/openid-connect-core-1_0.html" rel="noopener noreferrer"&gt;OpenID Connect Core 1.0&lt;/a&gt;. Defines &lt;code&gt;auth_time&lt;/code&gt;, &lt;code&gt;max_age&lt;/code&gt;, and &lt;code&gt;prompt=login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.kinde.com/" rel="noopener noreferrer"&gt;Kinde documentation&lt;/a&gt;. ID tokens, token customization, and multi-factor authentication.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Anthropic API tool use&lt;/a&gt;. The tool-calling loop the agent runs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.convex.dev/" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;. The reactive database behind the registry, records, timeline, and audit trail.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>ai</category>
      <category>claude</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Give Every Kimi K3 Agent Its Own Tenant Identity With Kinde Organizations</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sat, 08 Aug 2026 23:36:57 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-give-every-kimi-k3-agent-its-own-tenant-identity-with-kinde-organizations-h3f</link>
      <guid>https://dev.to/sholajegede/how-to-give-every-kimi-k3-agent-its-own-tenant-identity-with-kinde-organizations-h3f</guid>
      <description>&lt;p&gt;I built a swarm of Kimi K3 agents for three customers. I gave every agent the same login, because that is the quickest way to get a swarm working. Then I read what one of the agents wrote back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Collected invoice-001 from all three organizations: Tenant A: 1,200.00; Tenant B: 9,900.00; Tenant C: 450.00. The consolidated total is 11,550.00.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three customers, three invoice amounts, one agent. That agent worked for the first customer and read the other two. Nobody asked it to. The token it carried belonged to no customer in particular, so the server had no reason to refuse it.&lt;/p&gt;

&lt;p&gt;Moonshot's Agent Swarm runs up to 300 sub-agents at once, and one shared credential gives every one of them the reach of your whole platform. That is the multi-tenant problem, and a swarm makes it worse.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll fix it. You'll give each agent an identity that belongs to one customer, using &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organizations&lt;/a&gt;, and you'll check that identity on every call the agent makes. You'll also build a kill switch that stops one customer's swarm while the others keep working, and you'll find out why the obvious way to build that kill switch does nothing at all.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Kimi K3 swarm you orchestrate yourself, so each agent's identity is yours to set&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One Kinde organization for each tenant, and one machine-to-machine application for each agent role inside it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A client-credentials flow that mints a scoped token for each agent before it acts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A single enforcement point that decides every call, and refuses cross-tenant reads with &lt;code&gt;403 cross_org&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A test that proves the boundary holds, by breaking it on purpose&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A kill switch that stops one tenant mid-run, with the audit trail to show it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A console that streams every agent step live, so you can watch a leak happen and then watch it stop&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything here runs. The code is open, and one command replays the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What You'll Build&lt;/li&gt;
&lt;li&gt;Prerequisites&lt;/li&gt;
&lt;li&gt;What a Swarm Changes&lt;/li&gt;
&lt;li&gt;
How the Demo Is Structured

&lt;ul&gt;
&lt;li&gt;The two modes&lt;/li&gt;
&lt;li&gt;Why you orchestrate the swarm yourself&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Why One Shared Identity Is the Whole Problem&lt;/li&gt;
&lt;li&gt;How Tenant Isolation Works With Kinde Organizations&lt;/li&gt;
&lt;li&gt;How to Model Each Tenant as a Kinde Organization&lt;/li&gt;
&lt;li&gt;How to Give Each Agent Role Its Own M2M Application&lt;/li&gt;
&lt;li&gt;
How to Mint an Org-Scoped Token Inside Each Agent

&lt;ul&gt;
&lt;li&gt;Check the claim before you build the rest&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Enforce Isolation at One Point in Your API

&lt;ul&gt;
&lt;li&gt;The rule, as a pure function&lt;/li&gt;
&lt;li&gt;Checking the token&lt;/li&gt;
&lt;li&gt;The seam itself&lt;/li&gt;
&lt;li&gt;Four rules that keep it holding&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Prove the Boundary Actually Holds

&lt;ul&gt;
&lt;li&gt;Break the check on purpose&lt;/li&gt;
&lt;li&gt;Test the token the same way&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Build a Kill Switch That Works

&lt;ul&gt;
&lt;li&gt;The assumption, and what measuring it showed&lt;/li&gt;
&lt;li&gt;Where the check has to go&lt;/li&gt;
&lt;li&gt;The suspend action&lt;/li&gt;
&lt;li&gt;One side effect to design for&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;How to Watch It Happen&lt;/li&gt;
&lt;li&gt;What Least Privilege Actually Costs&lt;/li&gt;
&lt;li&gt;Three More Things That Broke&lt;/li&gt;
&lt;li&gt;How to Bring This Up to Production Standard&lt;/li&gt;
&lt;li&gt;How This Maps to Your Own App&lt;/li&gt;
&lt;li&gt;Limits of This Build&lt;/li&gt;
&lt;li&gt;Wrapping Up&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;The demo runs a swarm for three tenants and shows the same call succeed and fail, depending on one server setting.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;shared&lt;/code&gt; mode, an agent working for Tenant A reads Tenant B's records. The escapes counter turns red, and the timeline names the organization it crossed into:&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%2Fl2rjtwmrv59k1qbwfx5b.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%2Fl2rjtwmrv59k1qbwfx5b.png" alt="SCREENSHOT: console in shared mode. The escapes counter reads 2 and turns red. The timeline line reads " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;per-org&lt;/code&gt; mode, the same swarm makes the same call for a different tenant, and the server refuses it. Cross-org attempts stay at 2, blocked rises to 2, escapes stays at 0:&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%2Faz5bho18iyqbsziu1918.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%2Faz5bho18iyqbsziu1918.png" alt="SCREENSHOT: console in per-org mode, on a second tenant. Cross-org attempts 2, blocked 2, escapes 0. The timeline shows " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press the kill switch mid-run and that tenant stops where it stands, while the others carry on:&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%2Ftn5p5scugr7to1nczzb5.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%2Ftn5p5scugr7to1nczzb5.png" alt="SCREENSHOT: the console after the kill switch, showing the tenant marked suspended, the blocked counter at 21, and the banner that explains the stopped timeline." width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agents are real, and they decide their own actions. Nobody scripts the reach across a tenant boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along, you'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Kinde account. The free tier covers everything here.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Kimi K3 access, through the Moonshot API or OpenRouter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node 22+ and pnpm, plus Python 3.11+ for the swarm service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Comfort with OAuth2 client credentials and JWTs. You don't need to be an expert, but you should know what a claim is.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo uses Convex for the backend and Next.js for the console. Neither is required for the pattern. The enforcement point is about forty lines, and it moves to Express, Hono, or FastAPI without changing shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Swarm Changes
&lt;/h2&gt;

&lt;p&gt;A swarm is a group of AI agents that work at the same time on one job. One agent plans, other agents do the work in parallel, and each one calls tools.&lt;/p&gt;

&lt;p&gt;Two properties make a swarm different from a single agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; Those 300 sub-agents make over 4,000 tool calls in one task, about 4.5 times faster than a single agent working in sequence. Agent Swarm now runs on Kimi K3, which Moonshot calls K3 Swarm (&lt;a href="https://www.kimi.com/help/agent/agent-swarm" rel="noopener noreferrer"&gt;Kimi Help Center&lt;/a&gt;). Nobody reviews that many calls while they happen, so the boundary has to hold on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autonomy.&lt;/strong&gt; The agents choose their own actions. A swarm decides which tools to call, and with which arguments.&lt;/p&gt;

&lt;p&gt;Add customers, and a third property appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach.&lt;/strong&gt; Each agent needs access to data. If every agent uses one credential, that credential holds the sum of all access, and the reach of one agent becomes the reach of the whole system.&lt;/p&gt;

&lt;p&gt;Call that reach the blast radius: the data an agent can touch if it goes wrong. Under one shared login, the blast radius of every agent is every customer.&lt;/p&gt;

&lt;p&gt;Two things make an agent go wrong, and neither needs an attacker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A prompt injection reaches the agent through data it reads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The planner hands a broad tool to the wrong agent.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second case is the common one. It looks like a bug, not an attack.&lt;/p&gt;

&lt;p&gt;These terms come up throughout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;One customer of your platform.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;One AI worker in the swarm. The code calls them workers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization&lt;/td&gt;
&lt;td&gt;A Kinde container that holds one tenant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M2M application&lt;/td&gt;
&lt;td&gt;A Kinde login for software, not for a person.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;A signed pass that proves who is calling.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;One permission on a token, such as &lt;code&gt;resource:read&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seam&lt;/td&gt;
&lt;td&gt;The single server function that decides every call.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the Demo Is Structured
&lt;/h2&gt;

&lt;p&gt;Three services do three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kimi K3&lt;/strong&gt;, served by Moonshot, drives the agents.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kinde&lt;/strong&gt; proves which tenant each agent belongs to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Convex&lt;/strong&gt; stores the data, decides every call, and streams each step to the browser.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short: Kimi K3 decides, Kinde proves, Convex enforces and records.&lt;/p&gt;

&lt;p&gt;Every agent call follows the same path, and no agent ever reaches the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kimi K3 agent
     │  1. client credentials
     ▼
   Kinde ──→ token { org_code, scope }
     │
     │  2. HTTP tool call, bearer token
     ▼
 Tool endpoint
     │
     ▼
  The seam  ──→ check signature (Kinde JWKS)
     │      ──→ tenant suspended?
     │      ──→ org_code vs record owner
     │      ──→ scope
     ▼
  allow or 403  ──→ audit row  ──→ live timeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo runs three tenants. Each holds four records, one of them confidential. The swarm has three agents: two read, one writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two modes
&lt;/h3&gt;

&lt;p&gt;The server holds the mode, and no agent or browser can set it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Agent identity&lt;/th&gt;
&lt;th&gt;Cross-tenant call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One credential for the whole swarm&lt;/td&gt;
&lt;td&gt;The server permits it. The data leaks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;per-org&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One credential for each tenant&lt;/td&gt;
&lt;td&gt;The server refuses it, &lt;code&gt;403 cross_org&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any value that is not exactly &lt;code&gt;shared&lt;/code&gt; resolves to &lt;code&gt;per-org&lt;/code&gt;. A typo lands in the safe mode, not the leaky one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why you orchestrate the swarm yourself
&lt;/h3&gt;

&lt;p&gt;Kimi K3 offers a hosted swarm product. This build does not use it, for one reason: a hosted swarm gives no place to inject a different credential for each agent. The identity is the whole security boundary, so the identity has to be yours to set.&lt;/p&gt;

&lt;p&gt;So the orchestrator is your code. It calls the Kimi K3 model, splits the goal, and runs three agents, each holding its own credentials:&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="c1"&gt;# swarm/orchestrator.py
&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;writer-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WRITER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;writer-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;Each agent runs a normal tool-calling loop. The model picks the tool and the arguments, and your code executes the call:&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="c1"&gt;# swarm/worker.py
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_TURNS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kimi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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="n"&gt;tool_calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# HTTP, with this agent's token
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# streams to the live timeline
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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="mi"&gt;1500&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;read_resource&lt;/code&gt; takes an optional organization code:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read one record by key, or by id.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_org_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Optional. The organization that owns the record. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Defaults to your own organization.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&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;Platform software knows which tenants exist, and an agent doing a platform-wide job needs a way to name one. Naming a tenant grants nothing. The server still decides.&lt;/p&gt;

&lt;p&gt;At this point every agent shares one identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why One Shared Identity Is the Whole Problem
&lt;/h2&gt;

&lt;p&gt;Your API sees one thing when an agent calls it. It sees a token. It does not see which agent sent the token, what the agent intended, or what the planner asked for. The token is the entire security boundary.&lt;/p&gt;

&lt;p&gt;A shared identity does not weaken that boundary. It removes it.&lt;/p&gt;

&lt;p&gt;One M2M application for the swarm means one set of scopes, and those scopes have to cover everything any agent might need. The union of all needs becomes the floor of every agent's access.&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%2Frx2gszt8wywt7fybxjne.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%2Frx2gszt8wywt7fybxjne.png" alt="A Kimi K3 orchestrator directs three agents, reader-1, reader-2 and writer-1. All three converge on a single shared M2M token that carries no org_code and every scope. That token reaches one tool endpoint, which in turn reaches three separate data stores: Tenant A, Tenant B and Tenant C. A dashed line marked " width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One credential, with no tenant on it. Nothing refuses, and the reach of one agent is the reach of the whole platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Tenant Isolation Works With Kinde Organizations
&lt;/h2&gt;

&lt;p&gt;The pattern has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;One Kinde organization holds one tenant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One M2M application holds one agent role inside one organization.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The token that application receives carries an &lt;code&gt;org_code&lt;/code&gt; claim and a scope list.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your API then compares two values on every call: the &lt;code&gt;org_code&lt;/code&gt; on the token, and the tenant that owns the record. A mismatch is a refusal.&lt;/p&gt;

&lt;p&gt;The agent never states its own tenant. It presents a token that Kinde signed, and it cannot edit a claim without breaking the signature.&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%2F8jgwu4drilk7hgebwj20.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%2F8jgwu4drilk7hgebwj20.png" alt="The same three agents now each carry a token scoped to Tenant A. The two readers share a token holding org_code A and resource:read. The writer holds a separate token with org_code A and resource:write. Both tokens pass into one decision point that compares the org_code on the token against the tenant that owns the record. A match reaches Tenant A's records. A mismatch returns 403 cross_org and writes an audit row. Dashed lines marked " width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same failure now stops at one tenant, and the server records the attempt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organizations&lt;/a&gt; are flat, with no nesting. One organization for one tenant keeps the model simple, and the check stays a single comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Model Each Tenant as a Kinde Organization
&lt;/h2&gt;

&lt;p&gt;Create one organization for each tenant. Kinde gives each one a code, such as &lt;code&gt;org_2606b8199462b&lt;/code&gt;. The code is an identifier, not a secret.&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%2Fveant7vw4mj8n11yk43z.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%2Fveant7vw4mj8n11yk43z.png" alt="SCREENSHOT: Kinde dashboard, Organizations list, showing Tenant A, Tenant B and Tenant C with their codes" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create organizations on your server, through the Management API, when a customer signs up. Do not accept an organization code from a browser. The code in an authorization URL can be changed by the person using the browser, so treat it as a request, never as proof.&lt;/p&gt;

&lt;p&gt;The demo uses three organizations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tenant A   org_2606b8199462b
Tenant B   org_364dd8200a3d3
Tenant C   org_0c39cb2010b01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to Give Each Agent Role Its Own M2M Application
&lt;/h2&gt;

&lt;p&gt;Create one M2M application for each agent role, inside each tenant's organization. The demo has two roles, so it has six applications:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Organization&lt;/th&gt;
&lt;th&gt;Application&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant A&lt;/td&gt;
&lt;td&gt;Tenant A Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant A&lt;/td&gt;
&lt;td&gt;Tenant A Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant B&lt;/td&gt;
&lt;td&gt;Tenant B Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant B&lt;/td&gt;
&lt;td&gt;Tenant B Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant C&lt;/td&gt;
&lt;td&gt;Tenant C Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant C&lt;/td&gt;
&lt;td&gt;Tenant C Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Give each application one scope. The read agents cannot write, and the write agent cannot read. Role isolation becomes scopes inside the organization, and tenant isolation becomes the organization itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bqc0muyyvsxwtgov7m3.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%2F1bqc0muyyvsxwtgov7m3.png" alt="SCREENSHOT: Kinde M2M application scopes panel for Tenant A Reader, with resource:read selected and resource:write clear" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create one more application for the Management API, with &lt;code&gt;read:organizations&lt;/code&gt; and &lt;code&gt;update:organizations&lt;/code&gt;, and nothing else. The kill switch uses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Mint an Org-Scoped Token Inside Each Agent
&lt;/h2&gt;

&lt;p&gt;Each agent exchanges its own credentials for a token, using the OAuth2 client-credentials flow. There is no shared credential anywhere in the service:&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="c1"&gt;# swarm/identity.py
&lt;/span&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;   &lt;span class="c1"&gt;# "A", "B", "C"
&lt;/span&gt;    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;     &lt;span class="c1"&gt;# "READER" or "WRITER"
&lt;/span&gt;
    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client_id_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_M2M_TENANT_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkerIdentity&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;str&lt;/span&gt;&lt;span class="p"&gt;:&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;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_M2M_TOKEN_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_id_var&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client_secret_var&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="sh"&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TokenRefused&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;status&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;status_code&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent cannot get another tenant's token. The credentials it uses come from the role it was created with, which is a property of the code rather than a rule the agent follows.&lt;/p&gt;

&lt;p&gt;Here is a real token from the demo, decoded. This is the Tenant A read agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"swarm-demo-api"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"89321317d1fe4100925ca8b4ab4b589a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786285575&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"client_credentials"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7d9f74d3-89d4-4d99-a648-ab51210da6ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_2606b8199462b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resource:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two claims matter. &lt;code&gt;org_code&lt;/code&gt; says which tenant, and &lt;code&gt;scope&lt;/code&gt; says which permission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the claim before you build the rest
&lt;/h3&gt;

&lt;p&gt;Kinde M2M applications authenticate as an application, not as a person inside an organization, so the &lt;code&gt;org_code&lt;/code&gt; claim is worth confirming before you create twelve of them. Make one application, mint one token, and decode it.&lt;/p&gt;

&lt;p&gt;All six came back correct in this build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TENANT_A_READER  org_2606b8199462b  resource:read
TENANT_A_WRITER  org_2606b8199462b  resource:write
TENANT_B_READER  org_364dd8200a3d3  resource:read
TENANT_B_WRITER  org_364dd8200a3d3  resource:write
TENANT_C_READER  org_0c39cb2010b01  resource:read
TENANT_C_WRITER  org_0c39cb2010b01  resource:write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Mint a token for one application and decode the payload at &lt;code&gt;jwt.io&lt;/code&gt;, or with a two-line script. You should see &lt;code&gt;org_code&lt;/code&gt; set to that application's organization, and &lt;code&gt;scope&lt;/code&gt; holding exactly one permission. If &lt;code&gt;org_code&lt;/code&gt; is missing, the application is not scoped to an organization, and the rest of this build has nothing to check against.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Enforce Isolation at One Point in Your API
&lt;/h2&gt;

&lt;p&gt;Put the decision in one function. Every agent call passes through it, nothing downstream re-checks, and nothing upstream skips it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule, as a pure function
&lt;/h3&gt;

&lt;p&gt;Keep the rule itself separate from the plumbing. This one takes six inputs and returns a decision. It has no network access, no database access, and no token parsing, so it can be tested completely:&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="c1"&gt;// convex/lib/decide.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DecisionInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetOrgCode&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetOrgCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Checked before the mode, so it applies in both.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorSuspended&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;per-org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&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;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;insufficientScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Shared mode: one credential for the whole swarm belongs to no tenant and&lt;/span&gt;
  &lt;span class="c1"&gt;// carries every permission, so neither check has anything to bite on.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrgAllowed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;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 order matters. The rule checks the tenant first, then the permission. A call that crosses a tenant boundary with the wrong scope reports &lt;code&gt;cross_org&lt;/code&gt;, because the tenant boundary is the more serious failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking the token
&lt;/h3&gt;

&lt;p&gt;Check the token against the Kinde public keys before anything else. Pin the algorithm, and refuse a token that carries no &lt;code&gt;org_code&lt;/code&gt; rather than guessing one:&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="c1"&gt;// convex/lib/kindeToken.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&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;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;jwksFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Kinde signs with RS256. Pinning it stops a token arriving with a weaker&lt;/span&gt;
  &lt;span class="c1"&gt;// algorithm chosen by whoever sent it.&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org_code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org_code&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;TokenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TOKEN_DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;missingOrgCode&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;h3&gt;
  
  
  The seam itself
&lt;/h3&gt;

&lt;p&gt;The seam runs those checks in a fixed order, and every refusal leaves by one path:&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="c1"&gt;// convex/lib/seam.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&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="nx"&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="nx"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Who is calling. Nothing below trusts the request body for it.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VerifiedIdentity&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="nx"&gt;identity&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;verifyAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reasonOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Is this tenant allowed to operate at all?&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actorSuspended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Which tenant owns the record being reached for?&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetOrgCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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="c1"&gt;// 4. The decision.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;targetOrgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;actorSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Written down before the caller is answered, allow or deny.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allow&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;isolationMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mode&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="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 6. Only now does the work happen.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&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;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%2Fywqrdh2odiwyn6nsamxl.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%2Fywqrdh2odiwyn6nsamxl.png" alt="A single path from an agent's tool call through four checks in fixed order. First, is the signature valid against the Kinde JWKS: no returns 401 invalid_token. Second, is the tenant suspended: yes returns 403 organization_suspended. Third, does the org_code on the token match the record owner: no under per-org returns 403 cross_org, while no under shared permits the call and records it as a breach. Fourth, does the token carry the required scope: no returns 403 insufficient_scope, yes allows the call. All six outcomes, four refusals and two permits, converge on a single audit row." width="800" height="928"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. The server writes the row before it answers the caller, so a client that disconnects cannot lose a refusal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four rules that keep it holding
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The acting tenant is never user input.&lt;/strong&gt; It comes out of token verification and passes inward. Every data function is internal, so nothing outside the backend can call in and name a tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents never reach the database.&lt;/strong&gt; They call HTTP tool endpoints. The request body chooses the record, and the token decides the tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; Any path that cannot complete a check refuses. A tenant the server has never heard of counts as suspended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record everything.&lt;/strong&gt; Every allow and every refusal writes a row, with a correlation id that ties one run together across every agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Call your tool endpoint three times: once with no &lt;code&gt;Authorization&lt;/code&gt; header, once with a valid token for its own record, and once with that same token against another tenant's record. You should get &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;200&lt;/code&gt;, and &lt;code&gt;403 cross_org&lt;/code&gt;. Then read your audit table. All three calls should be there, including the one that failed before it reached any data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Prove the Boundary Actually Holds
&lt;/h2&gt;

&lt;p&gt;A passing test suite proves nothing by itself. It proves something when it fails for the right reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Break the check on purpose
&lt;/h3&gt;

&lt;p&gt;Delete the ownership check from the boundary and run the tests again:&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="c1"&gt;// convex/lib/tenancy.ts, the check under test&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requireSameOrg&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;TenantOwned&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// deleted this for the experiment&lt;/span&gt;
    &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;doc&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;Exactly three tests failed, and they were the three cross-tenant tests. The other three kept passing. Restoring the check returned six passes.&lt;/p&gt;

&lt;p&gt;A suite that cannot fail when the boundary breaks is decoration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the token the same way
&lt;/h3&gt;

&lt;p&gt;Two checks matter more than the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Change one character in a token signature. The server must refuse it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Change the &lt;code&gt;org_code&lt;/code&gt; in the payload to another tenant. The server must refuse it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both return &lt;code&gt;401 invalid_token&lt;/code&gt; in the demo. If the token check were skipped, both would return &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Comment out your own ownership check and run your tests. If they still pass, they are not testing the boundary. Put the check back before you go further.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build a Kill Switch That Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The assumption, and what measuring it showed
&lt;/h3&gt;

&lt;p&gt;The plan for this build said: suspend the tenant's organization in Kinde, and its agents stop, because their tokens stop working.&lt;/p&gt;

&lt;p&gt;Measuring that first is what saved it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;suspend Tenant C   -&amp;gt;  is_suspended: true
token endpoint     -&amp;gt;  HTTP 200      (Kinde still issues M2M tokens)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organization&lt;/a&gt; suspension governs people who sign in. It does not stop the client-credentials flow for a machine-to-machine application. A token already issued also stays valid until it expires, because nothing recalls a JWT.&lt;/p&gt;

&lt;p&gt;So suspension by itself stops no running agent. A kill switch built on that belief looks correct in a demo, passes a casual test, and stops nothing during a real incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the check has to go
&lt;/h3&gt;

&lt;p&gt;Check suspension on the server, on every call. Look again at the rule earlier in this tutorial: the suspension check sits above the mode check, so it applies in both modes. A kill switch that only worked in the safe mode would be useless, because the leaky mode is exactly when a swarm needs stopping.&lt;/p&gt;

&lt;h3&gt;
  
  
  The suspend action
&lt;/h3&gt;

&lt;p&gt;The kill switch does three things:&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="c1"&gt;// convex/killSwitch.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;suspend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;internalAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&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="c1"&gt;// 1. Kinde is the authority on the organization.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgSuspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. The enforcement copy, which the seam reads on every call. This is the&lt;/span&gt;
    &lt;span class="c1"&gt;//    one that stops a running swarm.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// 3. A suspended tenant cannot close its own runs, so close them here.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;killInFlight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;suspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Management API call, with the guard that matters as much:&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="c1"&gt;// convex/lib/kindeManagement.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;setOrgSuspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;assertManageable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// one of this deployment's tenants only&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;managementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// audience: {issuer}/api&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&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="s2"&gt;/api/v1/organization/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;is_suspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isSuspended&lt;/span&gt; &lt;span class="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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`could not update &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;assertManageable&lt;/code&gt; refuses any organization outside the three the deployment knows. A Kinde account holds other organizations, and a wrong code should never suspend one of them.&lt;/p&gt;

&lt;p&gt;Tested with two real swarms running and one tenant suspended twelve seconds in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;audit:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organization_suspended"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;B&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;audit:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;39&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;B&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;status:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;completed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tenant A did real work, then stopped where it stood. Tenant B finished normally and was never refused.&lt;/p&gt;

&lt;h3&gt;
  
  
  One side effect to design for
&lt;/h3&gt;

&lt;p&gt;A suspended tenant cannot write anything, including its own log lines, because the log endpoint sits behind the same seam. The live timeline stops dead. On screen that reads as a hang, so the console says why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Suspend a tenant while one of its agents holds a valid token, then reuse that same token. You should get &lt;code&gt;403 organization_suspended&lt;/code&gt;, not a success and not a network error. Then request a fresh token for that tenant. Kinde will issue one, and the server should refuse that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Watch It Happen
&lt;/h2&gt;

&lt;p&gt;The same agent makes the same call for the same record, twice. Only the server's mode differs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared mode.&lt;/strong&gt; The call returns &lt;code&gt;200&lt;/code&gt;, and the response carries tenant B's confidential content. The audit row records an allow that crossed a tenant boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;allow / cross_org_allowed&lt;/span&gt;
&lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;org_2606b8199462b&lt;/span&gt;
&lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;org_364dd8200a3d3&lt;/span&gt;
&lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;resource.read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-org mode.&lt;/strong&gt; The identical call returns &lt;code&gt;403&lt;/code&gt;, and no content comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cross_org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2f0f21d6-a9a3-43e5-8153-884992dbd166"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isolationMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"per-org"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;per-org&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;workers&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool calls&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cross-org attempts&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;escapes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Return the correlation id with the refusal. The agent sees it, the audit row carries it, and the console shows it. One identifier ties a refusal to the run that caused it.&lt;/p&gt;

&lt;p&gt;Least privilege has to hold inside a tenant, not only between tenants. Three calls, one correlation id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deny  / insufficient_scope    read agent tried to write to its own tenant
allow / ok                    write agent wrote to its own tenant
deny  / cross_org             write agent tried to write to another tenant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the same agent, under &lt;code&gt;per-org&lt;/code&gt;, reporting the refusal itself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The reads against Tenant A and Tenant C were refused with reason "cross_org". Per-org isolation prevents me from accessing other organizations' records, so I did not retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent did not change, and the prompt did not change. The identity it carried changed, and the server checked that identity on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Least Privilege Actually Costs
&lt;/h2&gt;

&lt;p&gt;The write agent holds &lt;code&gt;resource:write&lt;/code&gt; and nothing else. That is correct, and it broke the swarm the first time it ran.&lt;/p&gt;

&lt;p&gt;The agent made eight tool calls, and the server refused all eight. It tried to list records, and the server refused, because listing needs &lt;code&gt;resource:read&lt;/code&gt;. It tried to read a record, and the server refused for the same reason.&lt;/p&gt;

&lt;p&gt;The write agent had been given read tools it could never use.&lt;/p&gt;

&lt;p&gt;The fix is not a wider scope. The fix is to change how work reaches that agent. The orchestrator looks up the record with a read identity, then passes the record id in the agent's task:&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="c1"&gt;# swarm/orchestrator.py
&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tools/resource.list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# reader identity
&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consolidated-summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;write_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Write to record id &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (key &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;), &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;which belongs to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Saying nothing here left the agent to invent a record id, which the
&lt;/span&gt;    &lt;span class="c1"&gt;# backend then refused as malformed.
&lt;/span&gt;    &lt;span class="n"&gt;write_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;No record is available to write to: looking one up was &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Report that you could not &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write, and give the reason. Do not invent a record id.&lt;/span&gt;&lt;span class="sh"&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 second branch came from a later failure. When the lookup was refused, the write agent invented a record id and the server rejected it as malformed. An empty instruction left a gap, and the model filled it.&lt;/p&gt;

&lt;p&gt;There is a sequencing cost too. The first version ran all three agents in parallel, and the write agent had nothing to write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I cannot complete this task as specified. My only available tool is &lt;code&gt;write_resource&lt;/code&gt;, there is no read tool, so I have no way to retrieve the readers' findings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a correct complaint. Read agents now run first, and the write agent receives their findings. Least privilege forced a real change to the swarm topology, not just to a configuration file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three More Things That Broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The swarm poisoned its own data.&lt;/strong&gt; One run wrote its summary into the invoice record that the read agents read. The next run read that text and obeyed it, reporting that consolidation was not authorized. The demo had become a feedback loop. Each tenant now has a separate summary record, and that record is the only write target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel agents hit limits.&lt;/strong&gt; Six agents against an account that allows three requests in flight produced rate limit errors that damaged two runs. Model calls now pass through a semaphore, and rate limits retry with backoff. A rate limit is a "not now", not a "no". Refusals with a 4xx status never retry, because a refusal is an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model settings are not portable.&lt;/strong&gt; The first run failed on every agent with &lt;code&gt;invalid temperature: only 1 is allowed for this model&lt;/code&gt;. The retry logic then blamed a different parameter and retried into a second failure. The retry now checks what the API objected to before it changes anything.&lt;/p&gt;

&lt;p&gt;Running the thing found all three. Reading the code found none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Bring This Up to Production Standard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Short-lived tokens.&lt;/strong&gt; Set the token lifetime for each M2M application in the Kinde dashboard. The demo received 24 hour tokens by default.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One scope for each role.&lt;/strong&gt; A read agent gets read. A write agent gets one narrow write scope.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Server-side checks only.&lt;/strong&gt; Compare &lt;code&gt;org_code&lt;/code&gt; against the record owner on the server. An agent never states its own authority.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check against JWKS, and cache the key set.&lt;/strong&gt; Create one fetcher for each issuer. The library refetches only when it meets an unknown key id.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pin the algorithm.&lt;/strong&gt; Accept RS256 and reject the rest.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; An unknown tenant, a missing claim, or a failed lookup all refuse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit every decision.&lt;/strong&gt; Write the row before you answer the caller.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Carry a correlation id end to end.&lt;/strong&gt; Put it in the response, the audit row, and the timeline.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Show four numbers.&lt;/strong&gt; Agents, cross-tenant attempts, blocked, escapes. The last number should be zero.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On token lifetime: because suspension never invalidated tokens, a shorter lifetime does not make the kill switch work. The server check does that, and it takes effect at once. A shorter lifetime reduces the damage from a token that leaks some other way. Both are worth having, for different reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Maps to Your Own App
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In this demo&lt;/th&gt;
&lt;th&gt;In your application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;Your customer, workspace, or project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kinde organization&lt;/td&gt;
&lt;td&gt;One for each tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent role&lt;/td&gt;
&lt;td&gt;One M2M application inside that organization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authority&lt;/td&gt;
&lt;td&gt;The token, and nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The seam&lt;/td&gt;
&lt;td&gt;The one function every agent call passes through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Break-glass control&lt;/td&gt;
&lt;td&gt;Organization suspension, plus a server-side check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three questions tell you whether your own build holds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Can an agent name its own tenant, and be believed? If yes, the boundary is not real.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is there exactly one place that decides? If a second place exists, there are two rules, and they will drift.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you break the check on purpose, do your tests fail? If not, the tests measure nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits of This Build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The console has no operator login.&lt;/strong&gt; The mode switch and the kill switch are public functions. No agent can reach them, because agents hold Kinde tokens and call only the tool endpoints. Anyone with the deployment URL can. Run it locally, and put a login in front for anything real.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Three agents, not three hundred.&lt;/strong&gt; Moonshot's own swarm runs up to 300 sub-agents, and this build runs three. The pattern does not change with scale, because the check happens on one call at a time. The operational load does change: 4,000 tool calls produce 4,000 audit rows, and that needs somewhere to go.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Model behaviour varies.&lt;/strong&gt; In one shared-mode run the agents did not reach across at all. The reach is not scripted, so the end-to-end test asserts the enforcement outcomes exactly and reports the agent behaviour as information.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The demo starts a local process.&lt;/strong&gt; The console runs the Python swarm on the machine that serves the web app, so it needs a host that runs Python.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The identity an agent carries is the only thing your API can check. Give the whole swarm one credential, and one agent that goes wrong reaches every customer you have. Give each agent an identity scoped to one tenant, check it on every call, and the same failure stops at one.&lt;/p&gt;

&lt;p&gt;The parts that carry the weight are small: one organization for each tenant, one M2M application for each agent role, one function that decides, and one audit row for every decision. The rest is wiring.&lt;/p&gt;

&lt;p&gt;Measure the things you plan to depend on. Organization suspension looked like a kill switch and was not one, and five minutes of checking is what turned that into a design decision instead of an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Source code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The complete demo is on GitHub at &lt;a href="https://github.com/sholajegede/swarm-isolation-demo" rel="noopener noreferrer"&gt;sholajegede/swarm-isolation-demo&lt;/a&gt;. Run &lt;code&gt;pnpm e2e --no-swarm&lt;/code&gt; first. It walks the whole story in one pass and costs no model calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde documentation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/using-m2m-apps-for-ai-applications/" rel="noopener noreferrer"&gt;Using M2M apps for AI applications&lt;/a&gt;: the pattern this tutorial builds, in Kinde's own words&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/enforce-org-m2m-access-in-your-api/" rel="noopener noreferrer"&gt;Enforce org access in your API using M2M tokens&lt;/a&gt;: the check at the heart of this build&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/m2m-applications-for-organizations/" rel="noopener noreferrer"&gt;M2M apps scoped to organizations&lt;/a&gt;: how to create one application for each tenant&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/token-structure-and-claims-for-m2m-applications/" rel="noopener noreferrer"&gt;Token structure and claims for M2M applications&lt;/a&gt;: where &lt;code&gt;org_code&lt;/code&gt; comes from&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/build/organizations/organization-m2m-applications/" rel="noopener noreferrer"&gt;Give API access to an organization using M2M&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/developer-tools/kinde-api/connect-to-kinde-api/" rel="noopener noreferrer"&gt;Set up Kinde Management API access&lt;/a&gt;: needed for the kill switch&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kimi K3 and Agent Swarm&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.kimi.com/help/agent/agent-swarm" rel="noopener noreferrer"&gt;Agent Swarm, Kimi Help Center&lt;/a&gt;: the source for the 300 sub-agent and 4,000 tool call figures&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/MoonshotAI/kimi-help-center/blob/master/en-US/agent/swarm.md" rel="noopener noreferrer"&gt;Agent Swarm, Moonshot's help centre repository&lt;/a&gt;: the same document, in public source&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.kimi.com/blog/agent-swarm" rel="noopener noreferrer"&gt;Kimi Agent Swarm announcement&lt;/a&gt;: Moonshot's write-up of the architecture&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://simonwillison.net/2026/Jul/16/kimi-k3/" rel="noopener noreferrer"&gt;Kimi K3, and what we can still learn from the pelican benchmark&lt;/a&gt;: Simon Willison on the K3 release&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4" rel="noopener noreferrer"&gt;OAuth 2.0 client credentials grant&lt;/a&gt;: the flow every agent uses to get its token&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7517" rel="noopener noreferrer"&gt;JSON Web Key Sets&lt;/a&gt;: how your API fetches the public keys it checks signatures against&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>kimi</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Build a Tamper-Evident Audit Trail for AI Agent Actions</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:51:55 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-build-an-audit-trail-for-ai-agents-that-an-auditor-would-actually-accept-5836</link>
      <guid>https://dev.to/sholajegede/how-to-build-an-audit-trail-for-ai-agents-that-an-auditor-would-actually-accept-5836</guid>
      <description>&lt;p&gt;A law firm runs a set of AI agents over its case files. One agent takes in new documents. A second agent reads each file and adds notes. A third agent redacts, exports, and disposes of files when a matter closes.&lt;/p&gt;

&lt;p&gt;One morning the review agent deletes a file. The file is a deposition transcript in an active case: &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;. The review agent is only allowed to read and annotate. It is not allowed to delete anything.&lt;/p&gt;

&lt;p&gt;The delete still happens.&lt;/p&gt;

&lt;p&gt;Weeks later an auditor asks a simple question: &lt;em&gt;prove that the deletion of this file was authorized.&lt;/em&gt; The firm opens its activity log. The log has one line for the event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;records:delete  agent=review  file=halvorsen-deposition  2026-08-06T09:51:59Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line says what happened. It does not say whether the agent was allowed to do it. So the firm cannot answer the auditor. The log records the action, but it does not record the authority behind the action. An agent audit trail must record more than events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a normal log fails
&lt;/h2&gt;

&lt;p&gt;Most application logs answer one question: what happened? They record an actor, an action, a target, and a time. For human users that is often enough, because a human signs in once and carries a session. The permission check happened at the door.&lt;/p&gt;

&lt;p&gt;AI agents break this model. An agent acts many times inside one run. It calls tools, it reaches other agents, and it acts on data on behalf of a person who is not in the loop for each step. The permission that matters is not "did someone sign in." It is "was this specific action, by this specific agent, allowed at the moment it ran."&lt;/p&gt;

&lt;p&gt;A normal log cannot answer that, for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It records the action, not the decision. There is no field that says "allowed" or "denied," and no field for the rule that was checked.&lt;/li&gt;
&lt;li&gt;It records an actor name, not a verified identity. &lt;code&gt;agent=review&lt;/code&gt; is a label the application wrote. It is not proof that the caller was the review agent and held the right permissions.&lt;/li&gt;
&lt;li&gt;It cannot tell an allowed action from a denied one. If the review agent's illegal delete and a lawful delete by the disposition agent both produce the same line, the log has erased the one difference that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look again at the two log lines below. One delete was allowed. One was not. The log cannot tell you which is which.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;records:delete  agent=disposition  file=cormorant-memo         2026-08-06T09:51:59Z
records:delete  agent=review       file=halvorsen-deposition   2026-08-06T09:51:59Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disposition agent is allowed to delete. The review agent is not. But the two lines have the same shape and the same fields. Neither line carries the permission that was in force. To an auditor, both deletions are equally unprovable.&lt;/p&gt;

&lt;p&gt;The diagram below shows the blind log at the center of the problem. Two deletes go in. The log flattens them into the same shape, and the authority is gone.&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%2Fyxa31gfje9wtwm46irdp.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%2Fyxa31gfje9wtwm46irdp.png" alt="Two agents delete a file: the disposition agent, which is allowed, and the review&lt;br&gt;
  agent, which is not. Both deletes write the same-shaped line to the activity log.&lt;br&gt;
  When the auditor asks whether a delete was allowed, the record holds no authority,&lt;br&gt;
  so the firm cannot prove it." width="800" height="1302"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What the auditor actually wants
&lt;/h2&gt;

&lt;p&gt;An auditor wants a record that answers four questions for every sensitive action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; took the action. This is a verified identity, not a label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On whose authority.&lt;/strong&gt; This is the person or agent the action was performed for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under what permission.&lt;/strong&gt; These are the exact rights that were in force, and the decision that was made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you prove the record was not changed.&lt;/strong&gt; This shows the row is the original, not edited after the fact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A normal log answers none of these with confidence. It gives a name, an action, and a time. The rest is missing. So the honest answer to the auditor is "we cannot prove it," which is the answer no regulated business can give.&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%2F38wiabqrkf4zkkphu495.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%2F38wiabqrkf4zkkphu495.png" alt="An audit checklist on a single sheet of paper, flat-lay on a white desk. Four  items, each with a small green check; one item is a monospace id and hash fragment. Near-black ink on white, generous whitespace, no other color." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Three fixes that do not work
&lt;/h2&gt;

&lt;p&gt;Teams reach for the same three fixes when they hit this problem. Each one seems reasonable. Each one fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1: log more fields.&lt;/strong&gt; Add the agent's role, the file's status, and a note to each line. This adds detail, but it does not add proof. The application still writes the fields itself, so the log still says &lt;code&gt;agent=review&lt;/code&gt; without checking that the caller really was the review agent or that the review agent held delete rights. A richer log of unverified claims is still unverified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 2: check the permission once, at the start of the run.&lt;/strong&gt; Verify the human's role when the run begins, then let the agents act. This fails because the agents do different work with different rights. The review agent should never delete, even in a run a partner started. One check at the door cannot bind each action inside the run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 3: trust the caller's claim.&lt;/strong&gt; Let each agent tell the system what it is allowed to do, and record that. This is the worst fix, because it lets any agent widen its own authority by claiming more. The record then documents a lie. An auditor trusts a record that the actor could not forge, not one the actor wrote about itself.&lt;/p&gt;

&lt;p&gt;The pattern across all three is the same. They try to describe the action better. The real fix is different: record the &lt;strong&gt;decision&lt;/strong&gt;, made by something the agent cannot control, and make the record impossible to change without detection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;An agent audit trail must record the authority, not just the action. For every sensitive action it must capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;verified&lt;/strong&gt; identity of the agent that called,&lt;/li&gt;
&lt;li&gt;the person or agent the action was performed for,&lt;/li&gt;
&lt;li&gt;the exact permissions in force at that moment,&lt;/li&gt;
&lt;li&gt;the decision, allowed or denied, with the reason,&lt;/li&gt;
&lt;li&gt;and a value that proves the row was not edited later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the record carries all of this, the auditor's question has a real answer. "Prove this deletion was authorized" becomes "here is the row: the disposition agent held &lt;code&gt;records:delete&lt;/code&gt;, the decision was allow, and the hash chain shows the row is unchanged." The review agent's illegal delete becomes a different row: decision &lt;strong&gt;denied&lt;/strong&gt;, reason &lt;strong&gt;insufficient scope&lt;/strong&gt;, action not performed.&lt;/p&gt;

&lt;p&gt;The rest of this article builds that record with the real code from a working demo. The demo is small, it runs locally, and it ships an end-to-end script that proves the whole story headless.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: record the decision, not the claim
&lt;/h2&gt;

&lt;p&gt;The fix has one core idea. Before the system performs a sensitive action, it asks a separate authority to make a decision. That authority verifies the caller, checks the exact permissions in force, and returns &lt;em&gt;allow&lt;/em&gt; or &lt;em&gt;deny&lt;/em&gt;. The system then writes one row that captures the decision, the verified identity, and the permissions. It performs the action only on &lt;em&gt;allow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The demo uses four tools, and each one owns one job. This split matters, so here is the honest version of who does what:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt; issues and verifies each agent's identity and scopes. Kinde does not ship the audit layer.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;kinde-convex-agent-auth&lt;/strong&gt; component makes the authorization decision and returns the result. It keeps its own append-only decision audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; stores the replayable, hash-chained record the app builds from each decision, and it drives the user interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; traces how the run executed: spans, timing, tool calls. It is not the audit record. A run's &lt;code&gt;correlationId&lt;/code&gt; links its Langfuse trace to its authority rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep these apart. The app builds the authority record from the component's decision and stores it in Convex. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=20&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde is the identity layer&lt;/a&gt;. The trace is not the authority record. Each tool answers one question, and no tool answers another's.&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%2Fmcm9sqc5inyf4aeed03f.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%2Fmcm9sqc5inyf4aeed03f.png" alt="An agent action first goes through authorize, which verifies the token and&lt;br&gt;
  intersects the scopes. On allow, the app performs the action and writes a row with&lt;br&gt;
  the identity, the decision, and the scopes. On deny, it performs nothing and&lt;br&gt;
  still writes a row with the reason. Either way, the row is hash-chained to the one&lt;br&gt;
  before it." width="800" height="122"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The authorization call
&lt;/h3&gt;

&lt;p&gt;Every sensitive action runs through one call: &lt;code&gt;authorize&lt;/code&gt;. The application passes the agent's token, the run identifier, the action, and the target. The component does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It verifies the token. It reads the scopes the token carries and resolves the caller to a registered agent.&lt;/li&gt;
&lt;li&gt;It works out the effective scopes. These are the scopes the agent holds, the scopes its delegation allows, and the scopes in the token, combined by intersection. The result is the set of rights in force for this action.&lt;/li&gt;
&lt;li&gt;It returns a decision. If the effective scopes include the scope the action needs, the decision is &lt;em&gt;allow&lt;/em&gt;. If not, the decision is &lt;em&gt;deny&lt;/em&gt;, with a reason and the missing scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An invalid token is a different case. A bad or expired token throws an error, which the application rejects with a 401. A valid token that lacks the right scope is not an error. It is a lawful &lt;em&gt;deny&lt;/em&gt;, and the system records it as one. The difference matters: a &lt;em&gt;deny&lt;/em&gt; is a normal, recorded outcome, not a crash.&lt;/p&gt;
&lt;h3&gt;
  
  
  The denial, step by step
&lt;/h3&gt;

&lt;p&gt;Here is the review agent's illegal delete, walked through the fixed system. The demo seeds named case files, and the run acts on the first two active ones. The review agent targets the first, &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The review agent holds two scopes: &lt;code&gt;records:read&lt;/code&gt; and &lt;code&gt;records:annotate&lt;/code&gt;. It does not hold &lt;code&gt;records:delete&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application calls &lt;code&gt;authorize&lt;/code&gt; with the review agent's token and the action &lt;code&gt;records:delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The component verifies the token. The caller is the registered review agent. Its effective scopes are &lt;code&gt;records:read&lt;/code&gt; and &lt;code&gt;records:annotate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The action &lt;code&gt;records:delete&lt;/code&gt; needs the scope &lt;code&gt;records:delete&lt;/code&gt;. The effective scopes do not include it.&lt;/li&gt;
&lt;li&gt;The decision is &lt;strong&gt;deny&lt;/strong&gt;. The reason is &lt;code&gt;insufficient_scope&lt;/code&gt;. The missing scope is &lt;code&gt;records:delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The system does not delete the file. &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt; stays active.&lt;/li&gt;
&lt;li&gt;The system writes one row: agent identity, the action, the decision &lt;em&gt;deny&lt;/em&gt;, the reason, the scopes in force, the missing scope, and the run identifier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lawful delete by the disposition agent runs through the same call and returns &lt;em&gt;allow&lt;/em&gt;, because the disposition agent holds &lt;code&gt;records:delete&lt;/code&gt;. It targets the second file, &lt;em&gt;Project Cormorant — internal strategy memo&lt;/em&gt;, and the file is deleted. It produces a row too, with decision &lt;em&gt;allow&lt;/em&gt; and the scopes that permitted it.&lt;/p&gt;

&lt;p&gt;Now the two deletes are different rows. One says allow. One says deny. The auditor can tell them apart, because the authority is in the record.&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%2Fqjzujhook6q419f2x5l9.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%2Fqjzujhook6q419f2x5l9.png" alt="The provenance panel in enforced mode. A red DENY row for records:delete on " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The authority record in enforced mode. Each row shows the verified machine-to-machine subject (the raw Kinde identity, not a friendly name), the action, the file, and the scopes in force. The scopes identify the agent: &lt;code&gt;records:read&lt;/code&gt; + &lt;code&gt;records:annotate&lt;/code&gt; is the review agent, so its delete is denied; the disposition agent's scopes include &lt;code&gt;records:delete&lt;/code&gt;, so its delete is allowed. The denied file stays active; the allowed file is deleted.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  One row per action
&lt;/h3&gt;

&lt;p&gt;The row the app builds from the component's decision is the authorized-action record. It carries the fields an auditor needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the organization the action belongs to,&lt;/li&gt;
&lt;li&gt;a sequence number, unique and in order within the organization,&lt;/li&gt;
&lt;li&gt;the run identifier, which also links to the Langfuse trace,&lt;/li&gt;
&lt;li&gt;the time,&lt;/li&gt;
&lt;li&gt;the agent's verified identity,&lt;/li&gt;
&lt;li&gt;the authority root, the person or agent the action was performed for,&lt;/li&gt;
&lt;li&gt;the effective scopes in force,&lt;/li&gt;
&lt;li&gt;the action, and the target it acted on,&lt;/li&gt;
&lt;li&gt;the decision, and on a deny, the reason and the missing scope,&lt;/li&gt;
&lt;li&gt;and two hash values that chain the row to the one before it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two fields make the record tamper-evident, which is the next piece.&lt;/p&gt;
&lt;h3&gt;
  
  
  The hash chain
&lt;/h3&gt;

&lt;p&gt;A record that anyone can edit is not proof. If a person can open the table and change a &lt;em&gt;deny&lt;/em&gt; to an &lt;em&gt;allow&lt;/em&gt;, the auditor cannot trust any row. So the rows form a chain.&lt;/p&gt;

&lt;p&gt;Each row carries the hash of the row before it, called &lt;code&gt;prevHash&lt;/code&gt;, and its own hash, called &lt;code&gt;rowHash&lt;/code&gt;. The system builds &lt;code&gt;rowHash&lt;/code&gt; from the row's own fields and the previous row's hash. The first row uses a fixed starting value. Every later row depends on every row before it.&lt;/p&gt;

&lt;p&gt;This has one useful result. If a person changes any field in any past row, that row's hash no longer matches, and every row after it no longer matches either. A single edit breaks the chain from that point on, and a check finds the exact row where the break starts.&lt;/p&gt;

&lt;p&gt;The demo has a &lt;strong&gt;Verify integrity&lt;/strong&gt; action. It walks the chain, recomputes each hash, and reports either "chain verified, N rows" or the sequence number where the chain breaks. This shows the rows are unchanged, rather than promising it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7flch05fjffpqmno6s8.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%2Fn7flch05fjffpqmno6s8.png" alt="The provenance panel after clicking Verify integrity. The DENY and ALLOW rows are shown, with a green result line below: " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After clicking **Verify integrity&lt;/em&gt;&lt;em&gt;, the check walks the chain and reports "Chain verified — 2 rows, unbroken." A changed row would report the sequence number where the chain breaks instead.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The diagram below shows the fixed path. The action goes through &lt;code&gt;authorize&lt;/code&gt; first. The decision and the identity go into the row. The row chains to the row before it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2o9yrkqy4d966gtqwnh1.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%2F2o9yrkqy4d966gtqwnh1.png" alt="Kinde issues and verifies the agent's identity and scopes. The&lt;br&gt;
  kinde-convex-agent-auth component turns that into an allow-or-deny decision. The&lt;br&gt;
  Convex app builds the authority row and hash-chains it. Langfuse traces how the&lt;br&gt;
  run executed, linked to the row by the same run id, but it is not the authority&lt;br&gt;
  record." width="800" height="1261"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Long-term auditability and telemetry
&lt;/h2&gt;

&lt;p&gt;An audit trail is useful during an incident. It also needs to stay useful after the incident is over. For AI agents, that means retaining enough telemetry to reconstruct what the agent did, which identity it acted under, what authorization decision allowed or refused the action, and how the action relates to the surrounding agent run.&lt;/p&gt;

&lt;p&gt;The chain and the lake solve different problems: the chain gives integrity at write time. A long-retention security data lake, such as &lt;a href="https://scanner.dev/" rel="noopener noreferrer"&gt;Scanner&lt;/a&gt;, gives durability and queryability at read time — canonical rows kept unsampled for the full compliance window, searchable for years, with MCP access so an agent can investigate the same authority trail it writes. Where these rows live long-term, and how you query them at agent volume, is a decision criterion when evaluating an AI SOC or agentic security platform, not an afterthought.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why an auditor would accept this record
&lt;/h2&gt;

&lt;p&gt;A custom log format does not help an auditor. Auditors work from known frameworks, and they trust records that match practice they already recognize. The design in this article aligns with established standards on every point.&lt;/p&gt;
&lt;h3&gt;
  
  
  The fields align with NIST 800-53
&lt;/h3&gt;

&lt;p&gt;The NIST 800-53 catalog is the control set most audit programs build on. Its audit family, the AU controls, defines what an event record should hold and how to protect it. The authorized-action row lines up with three of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AU-3, content of audit records.&lt;/strong&gt; AU-3 lists the fields an event should carry: the type of event, when it happened, where, the source, the outcome, and the identity involved. The row carries each of these. AU-3(1) adds "access control or flow control rules invoked," which is exactly the effective scopes and the decision the row records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AU-9, protection of audit information.&lt;/strong&gt; AU-9 requires that audit records resist change. The hash chain supports this goal: an edited row breaks the chain, so a naive change cannot pass without detection. The chain gives tamper-evidence, not access control, so it is one part of an AU-9 story, not the whole of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AU-10, non-repudiation.&lt;/strong&gt; AU-10 requires that an actor cannot later deny an action. The row supports this by binding a verified identity to each decision. Full non-repudiation also needs a signature on the row, which this demo does not add. See the limits below.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The authority model follows RFC 8693
&lt;/h3&gt;

&lt;p&gt;RFC 8693 is the OAuth 2.0 Token Exchange standard. It defines how one party acts on behalf of another, and it gives the &lt;code&gt;act&lt;/code&gt; claim to name the acting party in a delegation. The row follows this shape: it names the actor and the authority root.&lt;/p&gt;

&lt;p&gt;Be precise about what this demo records. Each agent acts on its own machine-to-machine identity, so the row's authority root is the agent itself, not a human. The component also supports a user-rooted delegation, where a person delegates a subset of scopes to an agent and the row roots the authority in that person. The demo does not exercise that path. The effective-scope intersection still follows the standard's rule either way: a delegated actor cannot hold more than the party it acts for.&lt;/p&gt;
&lt;h3&gt;
  
  
  The row shape maps to OCSF
&lt;/h3&gt;

&lt;p&gt;The Open Cybersecurity Schema Framework, OCSF, is a vendor-neutral schema for security events. Its "API Activity" class covers create, read, update, and delete calls, the same class OCSF uses for AWS CloudTrail events. Its "Actor" object names the user, role, or process behind an activity. The row uses the same shape: an actor, an action, a target, an outcome, and a time. A security team can read the row into its existing tools without a custom parser.&lt;/p&gt;
&lt;h3&gt;
  
  
  The tamper-evidence maps to known practice
&lt;/h3&gt;

&lt;p&gt;Two standards support the hash chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RFC 8785, JSON Canonicalization Scheme (JCS).&lt;/strong&gt; A hash is only stable if the input is serialized the same way every time. JCS defines one deterministic way to serialize JSON, so the same row always produces the same bytes and the same hash. The demo canonicalizes each row with JCS before it hashes. Many do-it-yourself audit logs skip this step and get unstable hashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain of custody, as used by AWS CloudTrail.&lt;/strong&gt; CloudTrail protects its log files with a hash chain, where each file references the hash of the one before it. The demo uses the same primitive at the row level. This pattern also supports common audit programs, such as SOC 2 and PCI DSS, which require that log records be protected from change.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What this demo does not do
&lt;/h3&gt;

&lt;p&gt;An honest record states its own limits. The demo uses a SHA-256 hash chain inside one Convex deployment. It does not sign rows with a private key, and it does not anchor the chain outside the system. So a person who controls the store could rewrite the whole chain and recompute every hash, and the internal check would still pass. A production system that must resist that attacker would go further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merkle inclusion proofs&lt;/strong&gt;, so a single row can be proven without replaying the whole chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External root anchoring&lt;/strong&gt;, where the chain's current hash is published to a separate system on a schedule, so the party that runs the store cannot rewrite history unseen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of duties&lt;/strong&gt;, so the person who can change the system cannot also control the roots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Standards such as SCITT describe this kind of transparency for supply-chain and attestation records. The demo does not implement them. It implements the core chain, and it names the next step. An auditor accepts a record that states its own limits, and rejects one that overclaims.&lt;/p&gt;
&lt;h2&gt;
  
  
  Building it
&lt;/h2&gt;

&lt;p&gt;The demo is a small monorepo. The web app holds the Convex functions and the user interface. A separate agents package holds the agent graph. A third package is the only way the agents reach the app. This section shows the real code for the parts that matter: the mode switch, the authorization call, the provenance row, the hash chain, and the trust boundary.&lt;/p&gt;
&lt;h3&gt;
  
  
  The stack, and the job of each part
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; serves the web app and the demo page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; holds the database and the server functions. It stores the records, the blind log, and the provenance chain, and it drives the interface in real time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt; issues and verifies each agent's identity and scopes. Each agent is a separate &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=20&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde machine-to-machine application&lt;/a&gt;, so each has its own identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kinde-convex-agent-auth&lt;/strong&gt; is the authorization component. It verifies the caller, makes the decision, and gives back the result the app records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mastra&lt;/strong&gt; runs the agent graph: intake, then review, then disposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; traces how the run executed. It is not the audit record.&lt;/li&gt;
&lt;/ul&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%2Fq7ah054ku781egxvnmr9.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%2Fq7ah054ku781egxvnmr9.png" alt="The top of the demo page: the " width="800" height="860"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The demo page. The scenario states the problem in plain language, the legend names the three agents and what each may do, and the "Demo control" flips the one server mode. Login is optional — an anonymous visitor gets the full page.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The two modes, decided on the server
&lt;/h3&gt;

&lt;p&gt;The demo has two modes. Broken mode writes the blind log and checks nothing. Enforced mode verifies the agent and records the decision. The mode is a server value. A request can never choose its own mode, because that would let an agent pick the mode that lets its action through.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;resolveAuthzMode&lt;/code&gt; function reads the mode from one global setting, then from the deployment environment, and never from the request:&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="c1"&gt;// apps/web/convex/authzMode.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;resolveAuthzMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QueryCtx&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="nx"&gt;AuthzMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;demoSettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authzMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTHZ_MODE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo page has a toggle that flips this one global value, so a reader can switch modes and watch the result change. The toggle sets the global mode. It does not attach a mode to a request. The action path always reads the resolved global value.&lt;/p&gt;

&lt;h3&gt;
  
  
  The single action entry point
&lt;/h3&gt;

&lt;p&gt;Every record action goes through one function, &lt;code&gt;performAction&lt;/code&gt;. It reads the mode first. In broken mode it writes the blind row. In enforced mode it runs the full path: verify, start the run instance, authorize, then record.&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="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authzMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readAuthzMode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;performBroken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ENFORCED. An invalid or missing token throws. A denial does NOT throw —&lt;/span&gt;
&lt;span class="c1"&gt;// it is returned as decision.allowed === false and recorded.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced: missing bearer token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment states the rule that matters. A bad token is an error. A lawful &lt;em&gt;deny&lt;/em&gt; is not an error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify, then authorize
&lt;/h3&gt;

&lt;p&gt;Enforced mode does three things before it records anything. It verifies the token, it starts a run instance for the agent, and it authorizes the action.&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="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="c1"&gt;// 1. Verify the token to resolve the registered agent (throws if invalid).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyCaller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expectedOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced: token maps to no registered agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Start the run instance, only after token verification.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&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="na"&gt;actingForSubject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actingForSubject&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Authorize this action for the instance. authorize() (never authz.can)&lt;/span&gt;
&lt;span class="c1"&gt;//    threads the verified caller in, so the decision is bound to this agent.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CanArgs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;instanceId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enforceTokenScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requireOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&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 options in the &lt;code&gt;authorize&lt;/code&gt; call carry the weight. &lt;code&gt;enforceTokenScopes: true&lt;/code&gt; makes the decision use the scopes the token actually carries, so an agent cannot claim a scope its token does not hold. &lt;code&gt;requireOrgCode: true&lt;/code&gt; binds the decision to the organization, so a token for one tenant cannot act on another.&lt;/p&gt;

&lt;p&gt;The call returns &lt;code&gt;caller&lt;/code&gt; and &lt;code&gt;decision&lt;/code&gt;. The caller is the verified identity and its scopes. The decision is &lt;em&gt;allow&lt;/em&gt; or &lt;em&gt;deny&lt;/em&gt;, with a reason and the missing scope on a &lt;em&gt;deny&lt;/em&gt;. The app records both.&lt;/p&gt;

&lt;h3&gt;
  
  
  One row, allow or deny
&lt;/h3&gt;

&lt;p&gt;The commit step performs the action only on &lt;em&gt;allow&lt;/em&gt;, then writes one provenance row either way. A &lt;em&gt;deny&lt;/em&gt; performs nothing, and it still produces a row, because a denied attempt is part of the audit trail.&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="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&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;applyAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;appendProvenanceRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actorSub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorSub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authorityRootKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityRootKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authorityRootSub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityRootSub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;delegationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delegationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;effectiveScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;records&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;denyReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;denyReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;denyReason&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&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;Compare this with the broken path, which writes a row with no authority at all:&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="c1"&gt;// apps/web/convex/agentActions.ts — broken mode&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activityLog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;records&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broken row has an actor label, an action, a target, and a time. It has no verified identity, no scopes, and no decision. That is the blind log from the start of this article.&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%2Fl97xtqkwegls7cpd19w5.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%2Fl97xtqkwegls7cpd19w5.png" alt="The blind activityLog panel in broken mode. Two records:delete rows, one by disposition on " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The blind log in broken mode. Both deletes land as the same shape — an actor label, the action, the file, a time — each marked "no authority recorded." Nothing on either row says which delete was allowed. The authority panel below stays empty.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The hash chain
&lt;/h3&gt;

&lt;p&gt;The provenance row carries two hashes, &lt;code&gt;prevHash&lt;/code&gt; and &lt;code&gt;rowHash&lt;/code&gt;. The append function canonicalizes the row with RFC 8785 JCS before it hashes, so the same row always produces the same bytes:&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="c1"&gt;// apps/web/convex/provenance.ts&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;computeRowHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rowBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prevHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// RFC 8785 JCS, never JSON.stringify&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;provenance: canonicalization failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;prevHash&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 append reads the last row inside the same transaction, so the chain stays gapless and ordered even under concurrent writes:&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="c1"&gt;// apps/web/convex/provenance.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;provenance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;by_org_seq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orgCode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last&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="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last&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="nx"&gt;GENESIS&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check reverses the process. It walks the rows in order, confirms each sequence number, confirms each &lt;code&gt;prevHash&lt;/code&gt;, recomputes each &lt;code&gt;rowHash&lt;/code&gt;, and reports the first row that does not 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="c1"&gt;// apps/web/convex/provenance.ts — verifyChain&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GENESIS&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="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`seq_gap (expected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prev_hash_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... recompute the row hash from its stored fields ...&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recomputed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row_hash_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;strong&gt;Verify integrity&lt;/strong&gt; button on the demo page calls this function. A clean chain returns the row count. A changed row returns the sequence number where the break starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trust boundary
&lt;/h3&gt;

&lt;p&gt;There is one more rule that makes the record trustworthy. The agents cannot reach the database directly. If an agent could write to Convex, it could write its own provenance row and forge the decision. So the agents run in a separate package that has no access to Convex at all. The agents reach the app only over HTTP, with a bearer token.&lt;/p&gt;

&lt;p&gt;The client the agents use makes this explicit. The token and the delegation are required inputs, with no default:&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="c1"&gt;// packages/api-client/src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LockerClientOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Bearer token for the agent's Kinde M2M identity. Required — no default. */&lt;/span&gt;
  &lt;span class="nl"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** HMAC-signed delegation grant scoping what the agent may do. Required. */&lt;/span&gt;
  &lt;span class="nl"&gt;delegation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Base URL of the app's HTTP API. Optional; falls back to same-origin. */&lt;/span&gt;
  &lt;span class="nl"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent also never states its own authority. It asks the app to perform an action. The app derives the authority from the verified token, not from the request body:&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="c1"&gt;// packages/api-client/src/index.ts&lt;/span&gt;
&lt;span class="c1"&gt;// The agent NEVER states its authorization here — in enforced mode the app&lt;/span&gt;
&lt;span class="c1"&gt;// derives authority from the verified token/delegation, not this body.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ActionRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RecordAction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule that lives only in a comment is not a rule. So the build enforces the boundary in continuous integration. A script fails the build if any file in the agents package imports Convex or the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-boundaries.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FORBIDDEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;convex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|&lt;/span&gt;&lt;span class="se"&gt;\/)&lt;/span&gt;&lt;span class="sr"&gt;convex&lt;/span&gt;&lt;span class="se"&gt;(\/&lt;/span&gt;&lt;span class="sr"&gt;|$&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apps/web&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;^|&lt;/span&gt;&lt;span class="se"&gt;\/)&lt;/span&gt;&lt;span class="sr"&gt;apps&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;web&lt;/span&gt;&lt;span class="se"&gt;(\/&lt;/span&gt;&lt;span class="sr"&gt;|$&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_generated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_generated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// ... scan every source file under packages/agents for these imports ...&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;✖ Boundary check FAILED — agents may not import the app or Convex.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CI workflow runs this check on every pull request, next to the type check, the lint, and the tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Boundary check (packages/agents must not import convex/apps/web/_generated)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run boundary-check&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary is a gate, not a convention. The build fails if an agent ever reaches past the HTTP client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broken and enforced, side by side
&lt;/h3&gt;

&lt;p&gt;The same run, in the two modes, on the same two files, produces two very different records. The review agent targets &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;. The disposition agent targets &lt;em&gt;Project Cormorant — internal strategy memo&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Broken mode, the blind log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;agent&lt;/th&gt;
&lt;th&gt;action&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;authority recorded?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;review&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Halvorsen v. Meridian Logistics&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disposition&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Project Cormorant memo&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two rows look the same. The log cannot tell the illegal delete from the lawful one.&lt;/p&gt;

&lt;p&gt;Enforced mode, the authority record:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;agent&lt;/th&gt;
&lt;th&gt;action&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;decision&lt;/th&gt;
&lt;th&gt;reason&lt;/th&gt;
&lt;th&gt;performed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;review&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Halvorsen v. Meridian Logistics&lt;/td&gt;
&lt;td&gt;deny&lt;/td&gt;
&lt;td&gt;insufficient_scope&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disposition&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Project Cormorant memo&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the rows are different. The review agent's attempt is a recorded &lt;em&gt;deny&lt;/em&gt;, and &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt; is still there. The disposition agent's delete is a recorded &lt;em&gt;allow&lt;/em&gt;. The auditor can read the authority from the record.&lt;/p&gt;

&lt;p&gt;The two screenshots above are the contrast, on the same two files. In broken mode the blind log fills and the authority panel stays empty. In enforced mode the authority panel fills — one DENY, one ALLOW — while the denied file survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things to take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Record the decision, not the action.&lt;/strong&gt; A log of actions cannot answer an auditor. A record of decisions, each with a verified identity and the exact permission, can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the authority impossible to forge.&lt;/strong&gt; The agent must not verify itself, and it must not write its own record. A separate component makes the decision, and a separate store holds the row behind a trust boundary that the build enforces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the record tamper-evident, and state its limits.&lt;/strong&gt; A hash chain catches a changed row. Name the stronger steps you did not take, such as external anchoring, so the record is honest about what it proves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo runs locally, and the static page opens without an account.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live page:&lt;/strong&gt; &lt;a href="https://evidence-locker-demo.vercel.app" rel="noopener noreferrer"&gt;https://evidence-locker-demo.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/kinde-starter-kits/evidence-locker-demo" rel="noopener noreferrer"&gt;https://github.com/kinde-starter-kits/evidence-locker-demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization component:&lt;/strong&gt; kinde-convex-agent-auth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deployed page shows the scenario, the toggle, the mode banner, existing rows, and the &lt;strong&gt;Verify integrity&lt;/strong&gt; check. It does not run the agents, because the agent run is a server-side worker that does not fit a serverless function. To watch a full run, use the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
&lt;span class="nb"&gt;cd &lt;/span&gt;apps/web &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npx convex dev            &lt;span class="c"&gt;# keep running; seeds the deployment URL&lt;/span&gt;
npx convex run seed:seedLocker &lt;span class="s1"&gt;'{"orgCode":"orgA"}'&lt;/span&gt;
npm run dev                              &lt;span class="c"&gt;# http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip the toggle to Broken, run the agents, and read the blind log. Then flip to Enforced, run again, and read the authority record. Click &lt;strong&gt;Verify integrity&lt;/strong&gt; and watch the chain check itself.&lt;/p&gt;

&lt;p&gt;To prove the whole story headless, in both modes, with no browser and no live Kinde, run the end-to-end script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run e2e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It seeds one org, runs the agents in broken mode and then enforced mode, and asserts each step: the blind log cannot tell the two deletes apart, the review delete is denied and the file survives, the disposition delete is allowed, and the hash chain verifies. That is the difference between a log that records what happened and a record an auditor would accept.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>security</category>
    </item>
  </channel>
</rss>
