<?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: Jude</title>
    <description>The latest articles on DEV Community by Jude (@judezh).</description>
    <link>https://dev.to/judezh</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%2F4097535%2F4ae4de71-2102-47b7-adf5-dd91d529e410.png</url>
      <title>DEV Community: Jude</title>
      <link>https://dev.to/judezh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/judezh"/>
    <language>en</language>
    <item>
      <title>Our tool gateway says max_retries=2. It calls your tool exactly once.</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Fri, 18 Sep 2026 16:41:06 +0000</pubDate>
      <link>https://dev.to/judezh/our-tool-gateway-says-maxretries2-it-calls-your-tool-exactly-once-5cj2</link>
      <guid>https://dev.to/judezh/our-tool-gateway-says-maxretries2-it-calls-your-tool-exactly-once-5cj2</guid>
      <description>&lt;h3&gt;
  
  
  The short answer
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How many times does one tool call actually execute in our agent runtime?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactly once&lt;/strong&gt; — while the policy gateway's constructor says &lt;code&gt;max_retries: int = 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those two facts do not contradict each other. Three independent reasons stack up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;That &lt;code&gt;2&lt;/code&gt; is unreachable in the shipped wiring.&lt;/strong&gt; Every construction site passes a
&lt;code&gt;trace_writer&lt;/code&gt;, so every call gets an idempotency key, and the gateway clamps the
attempt count to 1 whenever it sees one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Even if it were reachable, it does not mean "retry twice."&lt;/strong&gt; The shared helper
hands the number to tenacity's &lt;code&gt;stop_after_attempt&lt;/code&gt;, which counts &lt;em&gt;attempts&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Even if a retry happened, almost nothing would trigger it.&lt;/strong&gt; All three tool
adapters swallow their exceptions into &lt;code&gt;ToolResponse(success=False)&lt;/code&gt;, and tenacity
only retries on exceptions that are actually &lt;em&gt;raised&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What really keeps a side effect from happening twice is none of the above. It is the&lt;br&gt;
layer below: a durable tool-call ledger with a lease and a column called&lt;br&gt;
&lt;code&gt;outbound_started_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For contrast, the other boundary in the same repository — outbound events — defaults&lt;br&gt;
to &lt;strong&gt;at-least-once, up to 64 attempts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is not a bug list. Most of it is deliberate. The naming and the documentation&lt;br&gt;
are the parts that genuinely do not line up, and they are all in the second-to-last&lt;br&gt;
section.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. This started as a reply to a comment
&lt;/h3&gt;

&lt;p&gt;On 2026-09-07, under our post about agent frameworks and agent runtimes being&lt;br&gt;
different layers, &lt;code&gt;reidmarlow&lt;/code&gt; (Reid Marlow) left a comment. Along with agreeing that&lt;br&gt;
splitting resolved from redacted parameters at the port layer is the cleanest part of&lt;br&gt;
the architecture, he added an argument we had not made ourselves:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pushing the retry clamp down into the policy gateway also solves a second problem —&lt;br&gt;
when a downstream service cannot honor an idempotency key, a timeout retry fires the&lt;br&gt;
external side effect twice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Good argument. It is also why this post exists: before replying, I wanted to check&lt;br&gt;
whether our code actually works the way he read it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It does not. The direction is inverted.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our gateway does not say "the downstream has no idempotency key, so retry less." It&lt;br&gt;
says "&lt;strong&gt;this call carries an idempotency key, so try it exactly once&lt;/strong&gt;." The path&lt;br&gt;
&lt;em&gt;without&lt;/em&gt; a key is the one that keeps the &lt;code&gt;max_retries&lt;/code&gt; default.&lt;/p&gt;

&lt;p&gt;His conclusion still holds here — side effects do not fire twice — but it holds for a&lt;br&gt;
reason one layer further down. Explaining that requires counting all three layers.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Layer one: the ternary that decides everything
&lt;/h3&gt;

&lt;p&gt;Before the gateway reaches an adapter it resolves secrets, claims a ledger row, checks&lt;br&gt;
egress policy, and checks rate limits and quota. Then it reaches this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;response = await run_with_timeout_retry(
    _invoke,
    timeout_seconds=timeout_seconds,
    # Durable Agent calls are at-most-once at this boundary.
    # Not every downstream adapter can honor an idempotency key.
    max_retries=1 if kwargs.get("idempotency_key") else self.max_retries,
    timeout_factory=lambda: TimeoutError(
        f"Tool invocation timed out after {timeout_seconds} seconds",
        {"timeout_seconds": timeout_seconds, "tool_ref": tool_ref},
    ),
    wait_min=1,
    wait_max=5,
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;server/app/kernel/ports/tools/policy.py:349–361&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;The two comment lines state the intent plainly: &lt;strong&gt;durable agent calls are at-most-once&lt;br&gt;
at this boundary&lt;/strong&gt;, because not every downstream adapter honors an idempotency key.&lt;/p&gt;

&lt;p&gt;Which means the &lt;code&gt;idempotency_key&lt;/code&gt; in that ternary &lt;strong&gt;is not a capability signal — it is&lt;br&gt;
a path marker&lt;/strong&gt;. It marks "this call is the kind that has a ledger behind it." That is&lt;br&gt;
a different thing from what Reid read it as, and the difference is exactly what decides&lt;br&gt;
which way the clamp points.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. &lt;code&gt;max_retries&lt;/code&gt; counts attempts, not retries
&lt;/h3&gt;

&lt;p&gt;Five ports — tools, storage, vector, secrets, plugins — share one helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async def run_with_timeout_retry(
    operation, *, timeout_seconds, max_retries, timeout_factory,
    wait_multiplier=1, wait_min=1, wait_max=10,
):
    @retry(
        stop=stop_after_attempt(max_retries),
        wait=wait_exponential(multiplier=wait_multiplier, min=wait_min, max=wait_max),
    )
    async def _with_retry():
        return await operation()

    try:
        return await asyncio.wait_for(_with_retry(), timeout=timeout_seconds)
    except TimeoutError:
        raise timeout_factory()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;server/app/kernel/ports/common/policy.py:52–74&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stop_after_attempt&lt;/code&gt; counts attempts. &lt;code&gt;stop_after_attempt(2)&lt;/code&gt; means two calls total,&lt;br&gt;
i.e. one retry. The parameter is named &lt;code&gt;max_retries&lt;/code&gt;, its docstring says "Maximum&lt;br&gt;
retries", and the number it actually carries is one less than its name suggests.&lt;/p&gt;

&lt;p&gt;I am not inferring this. Our own unit test pins it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;result = await run_with_timeout_retry(
    operation, timeout_seconds=5, max_retries=2, ...
)
assert result == "ok"
assert attempts == 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;server/tests/unit/test_port_policy_common.py:32–52&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;The direct consequence: &lt;strong&gt;&lt;code&gt;max_retries=1&lt;/code&gt; means "do not retry."&lt;/strong&gt; And the repository&lt;br&gt;
passes a literal &lt;code&gt;1&lt;/code&gt; in six places:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Actual meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/secrets/policy.py:35&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fetch one secret&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/plugins/policy.py:112&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;invoke a plugin tool&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/vector/policy.py:93&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ensure a collection exists&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/storage/policy.py:338&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;check whether an object exists&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/storage/policy.py:403&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mint a download URL&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ports/storage/policy.py:441&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mint an upload URL&lt;/td&gt;
&lt;td&gt;no retry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those is read-only or naturally idempotent — precisely the category that&lt;br&gt;
&lt;em&gt;should&lt;/em&gt; be retried. I do not think this was intentional. I think someone read &lt;code&gt;1&lt;/code&gt; as&lt;br&gt;
"retry once."&lt;/p&gt;

&lt;p&gt;For reference: storage defaults to &lt;code&gt;max_retries: int = 3&lt;/code&gt; (three attempts), vector to&lt;br&gt;
&lt;code&gt;2&lt;/code&gt;, tools to &lt;code&gt;2&lt;/code&gt;. All three docstrings say "Maximum retries."&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Which makes the shipped answer 1
&lt;/h3&gt;

&lt;p&gt;Back to the ternary. When does &lt;code&gt;kwargs&lt;/code&gt; contain an &lt;code&gt;idempotency_key&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The gateway puts one there itself, as long as it was constructed with a &lt;code&gt;trace_writer&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;tool_call_id = str(kwargs.get("tool_call_id") or f"call_{generate_ulid()}")
idempotency_key = str(
    kwargs.get("idempotency_key") or f"tool:{run_id}:{tool_call_id}"
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;policy.py:261–264&lt;/code&gt;, written back into &lt;code&gt;kwargs&lt;/code&gt; at &lt;code&gt;:298–303&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;So: can a shipped tool port ever be built &lt;em&gt;without&lt;/em&gt; a &lt;code&gt;trace_writer&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;There is exactly one place that constructs this gateway&lt;br&gt;
(&lt;code&gt;app/wiring/container.py:487&lt;/code&gt;), and all four of its call sites pass a &lt;code&gt;TraceWriter&lt;/code&gt;&lt;br&gt;
constructed on the spot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;app/wiring/services.py:414–416&lt;/code&gt; (agent application service)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/api/v1/agent/dependencies.py:47–49&lt;/code&gt; (HTTP dependency)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app/modules/workflow/runtime/engine.py:558–561&lt;/code&gt;, &lt;code&gt;:606&lt;/code&gt;, &lt;code&gt;:693–696&lt;/code&gt; (workflow engine)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;No production path leaves &lt;code&gt;trace_writer&lt;/code&gt; as &lt;code&gt;None&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;self.max_retries&lt;/code&gt; branch&lt;br&gt;
is unreachable in the shipped wiring, and that default of &lt;code&gt;2&lt;/code&gt; is dead code today.&lt;/p&gt;

&lt;p&gt;It is also deliberate. The test says so in its name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async def test_tool_policy_does_not_retry_a_durable_agent_invocation(request_ctx):
    ...
    policy = ToolPolicyGateway(gateway=SideEffectingTool(), ctx=request_ctx,
                               max_retries=2, enable_egress_check=False)
    with pytest.raises(RetryError):
        await policy.invoke(..., idempotency_key="agent-tool:run_side_effect_once:call_once")
    assert attempts == 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;server/tests/unit/test_port_policy_enforcement.py:375–399&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;It passes &lt;code&gt;max_retries=2&lt;/code&gt; explicitly and asserts &lt;code&gt;attempts == 1&lt;/code&gt;. &lt;strong&gt;This is a tested&lt;br&gt;
at-most-once boundary&lt;/strong&gt;, not an oversight.&lt;/p&gt;

&lt;p&gt;One more thing worth saying out loud: this gateway's &lt;code&gt;timeout_seconds&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;max_retries&lt;/code&gt; have &lt;strong&gt;no configuration entry at all&lt;/strong&gt;. The wiring does not pass them and&lt;br&gt;
&lt;code&gt;settings.py&lt;/code&gt; contains no setting starting with &lt;code&gt;tool_&lt;/code&gt;. Changing either means changing&lt;br&gt;
code. (Rate limit and quota do have an entry — they come from the request context, as&lt;br&gt;
&lt;code&gt;ctx.tool_rate_limit_per_minute&lt;/code&gt; and &lt;code&gt;ctx.tool_daily_quota&lt;/code&gt;.)&lt;/p&gt;
&lt;h3&gt;
  
  
  5. And even that &lt;code&gt;2&lt;/code&gt; would rarely fire
&lt;/h3&gt;

&lt;p&gt;tenacity's &lt;code&gt;@retry&lt;/code&gt; only retries on exceptions that get &lt;strong&gt;raised&lt;/strong&gt;. None of our three&lt;br&gt;
tool adapters raise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# adapters/tools/http.py:97–101
except httpx.HTTPError as e:
    return ToolResponse(result=None, success=False, error=str(e))

# adapters/tools/function.py:41–42
except Exception as exc:
    return ToolResponse(result=None, success=False, error=str(exc))

# adapters/tools/mcp.py:323–325
except Exception as exc:
    logger.warning("MCP tool invocation failed for %s: %s", tool_ref, type(exc).__name__)
    return ToolResponse(result=None, success=False, error=str(exc))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;httpx.HTTPError&lt;/code&gt; is the base class for that library's errors: connection failures,&lt;br&gt;
read timeouts, and everything &lt;code&gt;raise_for_status()&lt;/code&gt; raises for 4xx and 5xx.&lt;br&gt;
&lt;strong&gt;All of it becomes a return value rather than an exception.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So an HTTP 503, a connection timeout, or an MCP server falling over — the textbook&lt;br&gt;
"worth one retry" transients — &lt;strong&gt;are never retried on this path&lt;/strong&gt;, not even on the&lt;br&gt;
branch that allows retries.&lt;/p&gt;

&lt;p&gt;What does get retried? Whatever the tool router raises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raise ValidationError(f"Tool not registered for tenant/workspace: {tool_ref}")   # router.py:364
raise ValidationError(f"HTTP tool '{tool_ref}' missing url (tool_spec.http.url)") # router.py:454
raise ValidationError(f"Function tool '{tool_ref}' missing entrypoint")          # router.py:512
raise ForbiddenError(...)                                                        # router.py:301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configuration errors and permission errors&lt;/strong&gt; — the ones no amount of retrying will&lt;br&gt;
fix. With a &lt;code&gt;wait_exponential(min=1, max=5)&lt;/code&gt; sleep in between.&lt;/p&gt;

&lt;p&gt;This is not inevitable. The LLM port next door has an &lt;code&gt;_is_retryable&lt;/code&gt; predicate and a&lt;br&gt;
test that asserts validation errors are not retried&lt;br&gt;
(&lt;code&gt;test_validation_errors_are_not_retried&lt;/code&gt;, &lt;code&gt;test_port_policy_enforcement.py:236–250&lt;/code&gt;,&lt;br&gt;
asserting &lt;code&gt;await_count == 1&lt;/code&gt;). The tool port has no such predicate.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. The timeout is a budget for the whole sequence
&lt;/h3&gt;

&lt;p&gt;Look at the last line of that helper again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;return await asyncio.wait_for(_with_retry(), timeout=timeout_seconds)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wait_for&lt;/code&gt; wraps the &lt;strong&gt;entire retry sequence&lt;/strong&gt;, not a single attempt. So the tool&lt;br&gt;
port's default 30 seconds is the budget for every attempt &lt;em&gt;plus&lt;/em&gt; the backoff between&lt;br&gt;
them.&lt;/p&gt;

&lt;p&gt;Meaning: if the first attempt burns the full 30 seconds, a second attempt never&lt;br&gt;
happens — the timeout lands first and gets converted into our own &lt;code&gt;TimeoutError&lt;/code&gt; by&lt;br&gt;
&lt;code&gt;timeout_factory()&lt;/code&gt;. The already-hard-to-reach retry path only has room when the first&lt;br&gt;
attempt fails fast.&lt;/p&gt;

&lt;p&gt;The parameter is called &lt;code&gt;timeout_seconds&lt;/code&gt;, and the tool gateway's docstring calls it&lt;br&gt;
"Request timeout" (&lt;code&gt;policy.py:75&lt;/code&gt;). It is not a request timeout. It is a total&lt;br&gt;
timeout.&lt;/p&gt;

&lt;p&gt;For contrast, the LLM port's streaming path wraps each attempt in its own &lt;code&gt;wait_for&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;ports/llm/policy.py:932–937&lt;/code&gt;). Same repository, two shapes.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. Failures leave the gateway wearing a different type
&lt;/h3&gt;

&lt;p&gt;tenacity's &lt;code&gt;@retry&lt;/code&gt; defaults to &lt;code&gt;reraise=False&lt;/code&gt;: once the stop condition is met and the&lt;br&gt;
last attempt still failed, it raises &lt;code&gt;RetryError&lt;/code&gt; with the original exception inside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is independent of the retry count.&lt;/strong&gt; Even with &lt;code&gt;stop_after_attempt(1)&lt;/code&gt;, a failed&lt;br&gt;
first attempt comes out wrapped. So in the shipped wiring — which always takes the&lt;br&gt;
at-most-once branch — &lt;strong&gt;every failure leaving the tool gateway is a &lt;code&gt;RetryError&lt;/code&gt;&lt;/strong&gt;, not&lt;br&gt;
a &lt;code&gt;ValidationError&lt;/code&gt;, not a &lt;code&gt;ForbiddenError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is an &lt;code&gt;unwrap_retry_error&lt;/code&gt; helper for exactly this&lt;br&gt;
(&lt;code&gt;ports/common/policy.py:38–49&lt;/code&gt;), but it is only used by &lt;code&gt;error_details&lt;/code&gt; to write trace&lt;br&gt;
detail. &lt;strong&gt;The copy that gets unwrapped is the one for the books; the exception actually&lt;br&gt;
propagating upward is not swapped back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two unit tests have already frozen this into assertions:&lt;br&gt;
&lt;code&gt;test_port_policy_enforcement.py:391&lt;/code&gt; and &lt;code&gt;test_tool_secret_injection.py:215&lt;/code&gt;, both&lt;br&gt;
&lt;code&gt;with pytest.raises(RetryError)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The impact is concrete: any caller upstream writing &lt;code&gt;except ValidationError:&lt;/code&gt; to turn&lt;br&gt;
"tool not registered" into a 400 will not catch it on this path.&lt;/p&gt;
&lt;h3&gt;
  
  
  8. Layer two: &lt;code&gt;tool_call_id&lt;/code&gt; is the identity, &lt;code&gt;idempotency_key&lt;/code&gt; is an assertion
&lt;/h3&gt;

&lt;p&gt;Before touching an adapter, the gateway claims a row in &lt;code&gt;run_step_tool_calls&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That table carries three unique constraints&lt;br&gt;
(&lt;code&gt;kernel/runtime/db/models/runs.py:185–203&lt;/code&gt;); two of them matter here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Columns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uq_run_step_tool_calls_scope_run_call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tenant + workspace + run_id + tool_call_id&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uq_run_step_tool_calls_scope_idempotency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tenant + workspace + idempotency_key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Only the first is used for lookup.&lt;/strong&gt; &lt;code&gt;_call_statement&lt;/code&gt; finds an existing row by&lt;br&gt;
&lt;code&gt;tenant + workspace + run_id + tool_call_id&lt;/code&gt; (&lt;code&gt;runtime/runs/tool_calls.py:172–180&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;idempotency_key&lt;/code&gt;'s role here is an &lt;strong&gt;assertion, not a key&lt;/strong&gt;: once a row is found,&lt;br&gt;
a mismatching &lt;code&gt;tool_ref&lt;/code&gt; or &lt;code&gt;idempotency_key&lt;/code&gt; raises&lt;br&gt;
&lt;code&gt;ConflictError("Tool call identity was reused with different input")&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;tool_calls.py:215–219&lt;/code&gt;). The arguments have to match too — compared through&lt;br&gt;
&lt;code&gt;canonical_request_hash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The default key looks like &lt;code&gt;tool:{run_id}:{tool_call_id}&lt;/code&gt;, so on the default path the&lt;br&gt;
two constraints never fight.&lt;/p&gt;

&lt;p&gt;One more detail worth noting: &lt;strong&gt;what lands in the ledger is the redacted payload&lt;/strong&gt;&lt;br&gt;
(&lt;code&gt;policy.py:283&lt;/code&gt; passes &lt;code&gt;redacted_parameters&lt;/code&gt;); a secret reference keeps only its&lt;br&gt;
&lt;code&gt;secret_id&lt;/code&gt;, never the plaintext.&lt;/p&gt;
&lt;h3&gt;
  
  
  9. How at-most-once actually reaches the disk
&lt;/h3&gt;

&lt;p&gt;The real guarantee lives in three columns: &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;lease_expires_at&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;outbound_started_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;claim()&lt;/code&gt; writes a row with &lt;code&gt;status="claimed"&lt;/code&gt;, &lt;code&gt;attempt_count=1&lt;/code&gt;, and a lease owned
by this worker for &lt;code&gt;max(60, ceil(timeout) + 10)&lt;/code&gt; seconds (&lt;code&gt;policy.py:274&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mark_running()&lt;/code&gt; stamps &lt;code&gt;outbound_started_at&lt;/code&gt; &lt;strong&gt;before crossing the boundary&lt;/strong&gt;
(&lt;code&gt;tool_calls.py:473–495&lt;/code&gt;). That single write is the pivot of the whole mechanism.&lt;/li&gt;
&lt;li&gt;The adapter is called. Each attempt starts by renewing the lease
(&lt;code&gt;policy.py:328–329&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;complete()&lt;/code&gt; writes the terminal state:
&lt;code&gt;status = "succeeded" if response.success else "failed"&lt;/code&gt; (&lt;code&gt;tool_calls.py:543&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the process dies during step 3, the next worker to claim finds a row with an expired&lt;br&gt;
lease, and splits on one column:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;outbound_started_at&lt;/code&gt; is null&lt;/strong&gt; — the request never left. Safe. Re-claim it and
bump &lt;code&gt;attempt_count&lt;/code&gt; (&lt;code&gt;tool_calls.py:322–332&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;outbound_started_at&lt;/code&gt; is set&lt;/strong&gt; — we went out and do not know what happened. Do
&lt;strong&gt;not&lt;/strong&gt; re-run. Mark the row &lt;code&gt;in_doubt&lt;/code&gt;, mark the step &lt;code&gt;paused&lt;/code&gt;, raise
&lt;code&gt;ConflictError("Tool call outcome is in doubt")&lt;/code&gt; (&lt;code&gt;tool_calls.py:297–320&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;This is where "the side effect does not fire twice" actually comes from.&lt;/strong&gt; It does&lt;br&gt;
not depend on downstream idempotency support, and it does not depend on how many times&lt;br&gt;
the gateway retries. It depends on the fact that &lt;em&gt;going out the door is itself written&lt;br&gt;
down&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Reid's conclusion holds here because of this layer, not because of that ternary.&lt;/p&gt;
&lt;h3&gt;
  
  
  10. Replay and re-run are one boolean apart
&lt;/h3&gt;

&lt;p&gt;When a row that already reached a terminal state is claimed again, the default is&lt;br&gt;
&lt;strong&gt;replay&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if existing.status in {"succeeded", "failed"}:
    ...
    return ToolExecutionClaim(
        record=existing, run_step=step, replayed=True,
        cached_response=ToolResponse(
            result=payload.get("result"),
            success=existing.status == "succeeded",
            error=existing.error_message,
            metadata={..., "idempotent_replay": True},
        ),
    )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;tool_calls.py:270–289&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;{"succeeded", "failed"}&lt;/code&gt; contains both: &lt;strong&gt;a failure is replayed verbatim&lt;/strong&gt;.&lt;br&gt;
The gateway sees &lt;code&gt;replayed=True&lt;/code&gt; and returns the cached response without ever reaching&lt;br&gt;
an adapter (&lt;code&gt;policy.py:291–296&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Making it actually re-run requires passing &lt;code&gt;retry_failed=True&lt;/code&gt;, which clears the&lt;br&gt;
result, bumps &lt;code&gt;attempt_count&lt;/code&gt;, marks the step &lt;code&gt;retrying&lt;/code&gt;, and starts over&lt;br&gt;
(&lt;code&gt;tool_calls.py:249–269&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;And that boolean differs between the two execution paths:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Passes &lt;code&gt;retry_failed&lt;/code&gt;?&lt;/th&gt;
&lt;th&gt;Consequence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workflow node&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;True&lt;/code&gt; (&lt;code&gt;executors/tool.py:374&lt;/code&gt;, &lt;code&gt;executors/http.py:55&lt;/code&gt;, &lt;code&gt;executors/node.py:100&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;A node retry &lt;strong&gt;really re-sends&lt;/strong&gt; the tool call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent loop&lt;/td&gt;
&lt;td&gt;not passed (&lt;code&gt;modules/agent/runtime/executor.py:29–42&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;A failed call is &lt;strong&gt;replayed as a failure&lt;/strong&gt;, never re-sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both sides have decent comments (the workflow one says the identity is attempt-stable,&lt;br&gt;
so a node retry lands on the previous attempt's record: replay it when it succeeded,&lt;br&gt;
re-execute it when it failed). &lt;strong&gt;The divergence itself is written down nowhere.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  11. Layer three: workflow nodes, and one name with three meanings
&lt;/h3&gt;

&lt;p&gt;The workflow engine carries a retry loop of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retry_policy = node.get("retry_policy") or policy.get("default_retry_policy") or {}
max_retries = int(retry_policy.get("max_retries", 0) or 0)
...
attempts += 1
if attempts &amp;gt; max_retries:
    final_error_recorded = True
    raise
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;modules/workflow/runtime/executor.py:427–428&lt;/code&gt; and &lt;code&gt;:483–485&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Here &lt;code&gt;max_retries&lt;/code&gt; really does count retries: the default &lt;code&gt;0&lt;/code&gt; means no retry, and &lt;code&gt;2&lt;/code&gt;&lt;br&gt;
means at most three executions. Each retry also creates a new step, suffixed&lt;br&gt;
&lt;code&gt;_retry{attempt}&lt;/code&gt; (&lt;code&gt;executor.py:380&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;So the same name means three different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;How it is written&lt;/th&gt;
&lt;th&gt;Total executions at &lt;code&gt;max_retries=2&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool port (via the shared helper)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stop_after_attempt(max_retries)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;for attempt in range(route.max_retries + 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow node&lt;/td&gt;
&lt;td&gt;&lt;code&gt;if attempts &amp;gt; max_retries: raise&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LLM line is at &lt;code&gt;ports/llm/policy.py:921&lt;/code&gt;, and its count is pinned by a test as&lt;br&gt;
well: &lt;code&gt;test_max_retries_counts_additional_attempts&lt;/code&gt; asserts&lt;br&gt;
&lt;code&gt;port.chat.await_count == 3&lt;/code&gt; for &lt;code&gt;max_retries=2&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;test_port_policy_enforcement.py:219–232&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;In other words: &lt;strong&gt;the tool port is the only one of the three that is off by one&lt;/strong&gt;, and&lt;br&gt;
it happens to be the only one that produces external side effects. On the bright side,&lt;br&gt;
that is the safe direction to be wrong in.&lt;/p&gt;
&lt;h3&gt;
  
  
  12. How many times the downstream saw it, the ledger counted it, and the bill counted it
&lt;/h3&gt;

&lt;p&gt;These three numbers are not necessarily equal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The idempotency key does reach the downstream — on four methods:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;idempotency_key = kwargs.get("idempotency_key")
if idempotency_key and method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
    headers.setdefault("Idempotency-Key", str(idempotency_key))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;adapters/tools/http.py:34–36&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;GET does not carry it. Reasonable — but "we pass the idempotency key downstream" needs&lt;br&gt;
an "except on GET" attached to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The MCP adapter retries once on its own.&lt;/strong&gt; When an MCP server answers 401 or 403 with&lt;br&gt;
a &lt;code&gt;WWW-Authenticate&lt;/code&gt; challenge, the adapter re-derives a token from the scopes in that&lt;br&gt;
challenge and calls again (&lt;code&gt;adapters/tools/mcp.py:77–94&lt;/code&gt;). A test asserts it:&lt;br&gt;
&lt;code&gt;assert len(factory.calls) == 2&lt;/code&gt; (&lt;code&gt;tests/unit/test_mcp_official_sdk_adapter.py:272–312&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That retry is invisible upward&lt;/strong&gt;: the gateway counts one invocation, the ledger's&lt;br&gt;
&lt;code&gt;attempt_count&lt;/code&gt; stays at 1, and the MCP server saw two sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limit and quota are checked once, before any retry.&lt;/strong&gt; Both &lt;code&gt;check_rate_limit&lt;/code&gt;&lt;br&gt;
calls sit at &lt;code&gt;policy.py:311–325&lt;/code&gt;; the retry happens at &lt;code&gt;:349&lt;/code&gt;. So on that (currently&lt;br&gt;
unreachable) non-durable branch, two downstream requests would consume one unit of&lt;br&gt;
quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Billing counts once too&lt;/strong&gt;: &lt;code&gt;record_cost(..., billed_quantity=1, ..., request_count=1)&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;policy.py:419–430&lt;/code&gt;), one entry per &lt;code&gt;invoke&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The platform has already thought carefully about this exact class of problem in one&lt;br&gt;
other place, and that docstring deserves quoting in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llm_image_max_retries: int = 0
"""
Retry ceiling for image generation, capping the per-route retry budget.

Image generation is billed per generated image and is not idempotent: a
platform-side timeout does not cancel the provider-side generation, so a
retry can bill the workspace again while the platform records a single
usage fact. Zero keeps recorded cost aligned with provider charges.
"""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;settings.py:240–247&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Every word of that transfers to tool calls. Tool calls have no equivalent note and no&lt;br&gt;
equivalent knob.&lt;/p&gt;
&lt;h3&gt;
  
  
  13. The other boundary defaults the opposite way
&lt;/h3&gt;

&lt;p&gt;Outbound events in the same repository chose the mirror image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max_dispatch_attempts: int = 64
...
next_attempt = int(row_fresh.attempt_count or 0) + 1
if next_attempt &amp;gt;= self.max_dispatch_attempts:
    await self.repo.mark_failed(row_id, msg, consumer_name=consumer_name)
else:
    await self.repo.mark_retry(row_id, msg)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;kernel/events/dispatcher.py:50&lt;/code&gt; and &lt;code&gt;:76–88&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At-least-once, up to 64 attempts&lt;/strong&gt;, with backoff handled by the outbox repository.&lt;br&gt;
Deduplication is the consumer's job: a &lt;code&gt;(consumer_name, event_id)&lt;/code&gt; checkpoint table&lt;br&gt;
where the unique constraint absorbs the duplicate&lt;br&gt;
(&lt;code&gt;kernel/events/checkpoint.py&lt;/code&gt;; an insert conflict returns &lt;code&gt;False&lt;/code&gt;, meaning "already&lt;br&gt;
handled").&lt;/p&gt;

&lt;p&gt;The contrast is defensible. Events are ours, replayable, and consumers can be made&lt;br&gt;
idempotent; tool calls belong to someone else and cannot be taken back. &lt;strong&gt;Two&lt;br&gt;
boundaries defaulting opposite ways is evidence that somebody thought about it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One thing does not line up, though. The event side is documented — "provides&lt;br&gt;
at-least-once delivery; consumers remain responsible for idempotent side effects"&lt;br&gt;
(&lt;code&gt;README.md:77&lt;/code&gt;). &lt;strong&gt;The tool side's at-most-once contract appears nowhere in the&lt;br&gt;
docs.&lt;/strong&gt; It lives in two comment lines at &lt;code&gt;policy.py:352–353&lt;/code&gt; and in the name of one&lt;br&gt;
test.&lt;/p&gt;

&lt;h3&gt;
  
  
  14. So was the comment right?
&lt;/h3&gt;

&lt;p&gt;Three sentences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The direction is inverted.&lt;/strong&gt; We do not clamp because the downstream lacks an
idempotency key; we clamp because this call &lt;em&gt;has&lt;/em&gt; one, which is our marker for "this
one has a ledger."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The conclusion holds.&lt;/strong&gt; Side effects do not fire twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It holds one layer down.&lt;/strong&gt; It comes from the table with &lt;code&gt;outbound_started_at&lt;/code&gt;, and
from that table choosing &lt;code&gt;in_doubt&lt;/code&gt; over a re-run when the outcome is unknown.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are building something similar, two takeaways from this exercise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decouple "how many attempts" from "is there an idempotency key."&lt;/strong&gt; The key is a
signal about the downstream's capability; the retry budget is your own risk appetite.
Binding them together with a ternary means nine readers out of ten read it backwards
— and one reader did, in a direction more intuitive than what our code actually does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real duplicate protection lives in a table, not in a counter.&lt;/strong&gt; A counter only
decides how many times you try. Only a row can tell you whether the last attempt made
it out the door.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  15. Twelve things that do not line up
&lt;/h3&gt;

&lt;p&gt;House rule: whatever I found, I list. Each one gets its impact and a workaround.&lt;/p&gt;

&lt;p&gt;① &lt;strong&gt;&lt;code&gt;max_retries&lt;/code&gt; counts attempts while its name and docstring say retries.&lt;/strong&gt; Five&lt;br&gt;
ports share the helper; six call sites pass a literal &lt;code&gt;1&lt;/code&gt; (see the table in section 3),&lt;br&gt;
which means "no retry", and all six are read-only or idempotent operations. Impact:&lt;br&gt;
readers overestimate the system's retry behavior. Workaround: read it as&lt;br&gt;
&lt;code&gt;max_attempts&lt;/code&gt;. &lt;strong&gt;I plan to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;② &lt;strong&gt;The tool boundary's at-most-once contract only exists in two comment lines and a&lt;br&gt;
test name.&lt;/strong&gt; The README documents at-least-once for events and says nothing about&lt;br&gt;
tools. Impact: a self-hoster cannot tell from the docs whether their tool call can be&lt;br&gt;
re-sent. &lt;strong&gt;I plan to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;③ &lt;strong&gt;Three adapters swallow exceptions into &lt;code&gt;success=False&lt;/code&gt;, so the gateway's retry is&lt;br&gt;
dead for all of them — while configuration errors from the router do get retried.&lt;/strong&gt;&lt;br&gt;
Impact: the transients most worth retrying are not retried, and the config errors least&lt;br&gt;
worth retrying burn 1–5 seconds of backoff. Workaround: everything takes the&lt;br&gt;
at-most-once branch today, so the practical damage is limited to the retry semantics&lt;br&gt;
being nominal.&lt;/p&gt;

&lt;p&gt;④ &lt;strong&gt;Failures leave the gateway as &lt;code&gt;RetryError&lt;/code&gt; with the original type buried inside.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;unwrap_retry_error&lt;/code&gt; is only applied to trace details, not to the propagating&lt;br&gt;
exception; two unit tests have frozen this as an assertion. Impact: upstream code that&lt;br&gt;
maps exception types to HTTP status codes cannot catch it. &lt;strong&gt;I plan to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;⑤ &lt;strong&gt;&lt;code&gt;timeout_seconds&lt;/code&gt; wraps the whole retry sequence while its docstring calls it&lt;br&gt;
"Request timeout."&lt;/strong&gt; Impact: when the first attempt eats the budget, the second never&lt;br&gt;
happens, and the parameter name does not say so. The LLM streaming path is per-attempt&lt;br&gt;
— two shapes in one repository.&lt;/p&gt;

&lt;p&gt;⑥ &lt;strong&gt;The tool gateway's timeout and attempt count have no configuration entry at all.&lt;/strong&gt;&lt;br&gt;
The wiring does not pass them and no setting starts with &lt;code&gt;tool_&lt;/code&gt;. Impact: self-hosters&lt;br&gt;
have to edit code. &lt;strong&gt;I plan to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;⑦ 🔍 &lt;strong&gt;Inference, not observation&lt;/strong&gt;: the HTTP adapter passes &lt;code&gt;timeout=timeout_s&lt;/code&gt;&lt;br&gt;
explicitly to httpx, and that &lt;code&gt;timeout_s&lt;/code&gt; is only populated when the tool spec declares&lt;br&gt;
&lt;code&gt;policy.timeout_ms&lt;/code&gt; or &lt;code&gt;http.timeout_ms&lt;/code&gt; (&lt;code&gt;adapters/tools/router.py:489–502&lt;/code&gt;);&lt;br&gt;
otherwise it is &lt;code&gt;None&lt;/code&gt;. Per httpx's API, omitting the argument uses the&lt;br&gt;
&lt;code&gt;USE_CLIENT_DEFAULT&lt;/code&gt; sentinel, while &lt;strong&gt;passing &lt;code&gt;None&lt;/code&gt; explicitly means &lt;code&gt;Timeout(None)&lt;/code&gt;,&lt;br&gt;
i.e. "No timeouts"&lt;/strong&gt; (&lt;code&gt;httpx/_client.py:352&lt;/code&gt;, &lt;code&gt;:370–374&lt;/code&gt;; &lt;code&gt;httpx/_config.py:72–84&lt;/code&gt;,&lt;br&gt;
read from the library source in our own virtualenv). So a registered tool with no&lt;br&gt;
declared timeout bypasses the client's 30-second timeout and is bounded only by the&lt;br&gt;
gateway's 30-second &lt;code&gt;wait_for&lt;/code&gt;. &lt;strong&gt;I did not construct this call to prove it&lt;/strong&gt;, hence&lt;br&gt;
"inference".&lt;/p&gt;

&lt;p&gt;⑧ &lt;strong&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt; is only set on POST/PUT/PATCH/DELETE.&lt;/strong&gt; Probably correct, but the&lt;br&gt;
sentence "we forward the idempotency key" needs that qualifier.&lt;/p&gt;

&lt;p&gt;⑨ 🔍 &lt;strong&gt;Inference, not observation&lt;/strong&gt;: the ledger has two relevant unique constraints, and&lt;br&gt;
&lt;code&gt;claim()&lt;/code&gt; only re-queries by &lt;code&gt;run_id + tool_call_id&lt;/code&gt; after catching an &lt;code&gt;IntegrityError&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;tool_calls.py:384–389&lt;/code&gt;). If two different runs supplied the same &lt;strong&gt;explicit&lt;/strong&gt;&lt;br&gt;
idempotency key, the collision would hit the second constraint, the re-query would come&lt;br&gt;
back empty, and a raw &lt;code&gt;IntegrityError&lt;/code&gt; would propagate instead of a &lt;code&gt;ConflictError&lt;/code&gt;.&lt;br&gt;
The default key embeds the run id, so it cannot collide — &lt;strong&gt;which is why I could not&lt;br&gt;
construct this one either&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;⑩ &lt;strong&gt;The agent path does not pass &lt;code&gt;retry_failed&lt;/code&gt;; the workflow path passes &lt;code&gt;True&lt;/code&gt;.&lt;/strong&gt; The&lt;br&gt;
same failed tool call means different things on the two paths: one replays the failure&lt;br&gt;
forever, the other really re-sends. This is plausibly deliberate (an agent loop can&lt;br&gt;
decide to pick a different tool), but &lt;strong&gt;it is documented nowhere&lt;/strong&gt;. &lt;strong&gt;I plan to open an&lt;br&gt;
issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;⑪ &lt;strong&gt;The MCP adapter's 401 retry is invisible upward.&lt;/strong&gt; The downstream sees two&lt;br&gt;
requests while &lt;code&gt;attempt_count&lt;/code&gt; stays at 1. Impact: reconciling call volume against the&lt;br&gt;
ledger will not add up. Workaround: that retry only happens on an auth challenge, and&lt;br&gt;
business side effects usually happen after auth, so the exposure is small.&lt;/p&gt;

&lt;p&gt;⑫ &lt;strong&gt;Quota is decremented once and cost is recorded once, while (on the currently&lt;br&gt;
unreachable branch) the downstream could have been hit twice.&lt;/strong&gt; The platform already&lt;br&gt;
recognized and handled the same shape for image generation&lt;br&gt;
(&lt;code&gt;llm_image_max_retries: int = 0&lt;/code&gt;); the tool path has no equivalent note. Impact: zero&lt;br&gt;
today, because that branch is unreachable — but it returns the moment someone&lt;br&gt;
constructs the gateway without a &lt;code&gt;trace_writer&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full disclosure
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing was executed for this post.&lt;/strong&gt; Not one command. Every claim comes from
reading source and tests in the &lt;code&gt;soit/&lt;/code&gt; repository at commit &lt;code&gt;abf3dc3&lt;/code&gt;, plus the
httpx library source in our own virtualenv. I did not stand up an environment to
watch a crashed row turn into &lt;code&gt;in_doubt&lt;/code&gt;, even though the code path is clear.
&lt;strong&gt;Items ⑦ and ⑨ above are explicitly marked as inference.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I only read the community edition.&lt;/strong&gt; If the Enterprise or Cloud editions wire
retries differently, that is out of scope here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Unreachable in the shipped wiring" is a claim about the current wiring&lt;/strong&gt;, not
about every possible caller. The evidence is that the gateway is constructed in one
place and all four call sites pass a &lt;code&gt;trace_writer&lt;/code&gt;. Construct one yourself without
it and that branch comes straight back to life.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of the twelve items is a security incident.&lt;/strong&gt; They are naming, documentation,
and layering mismatches — not "somebody got in."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This post started as someone else's comment.&lt;/strong&gt; The argument was Reid's, not mine.
All I did was go back and check, and find that the direction was reversed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disclosure: I maintain SOIT.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One-sentence takeaway
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"How many attempts" and "is there an idempotency key" are two different questions,&lt;br&gt;
and binding them together in a ternary guarantees that readers get it backwards; what&lt;br&gt;
actually keeps a side effect from happening twice is never the counter, it is a row&lt;br&gt;
that remembers whether the request made it out the door.&lt;/strong&gt;&lt;br&gt;
Our answer happens to be 1 — but it is a 1 stacked out of three independent reasons,&lt;br&gt;
and only one of them was on purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Come and find the holes
&lt;/h3&gt;

&lt;p&gt;The repository is &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;. Every&lt;br&gt;
claim here is checkable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The ternary is at &lt;code&gt;server/app/kernel/ports/tools/policy.py:349–361&lt;/code&gt;; read it
together with the two comment lines above it.&lt;/li&gt;
&lt;li&gt;For the counting semantics, run our own test:
&lt;code&gt;server/tests/unit/test_port_policy_common.py&lt;/code&gt; asserts &lt;code&gt;attempts == 2&lt;/code&gt; for
&lt;code&gt;max_retries=2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The lease state machine from section 9 is in
&lt;code&gt;server/app/kernel/runtime/runs/tool_calls.py&lt;/code&gt; — grep for &lt;code&gt;outbound_started_at&lt;/code&gt; and
read downward; the &lt;code&gt;in_doubt&lt;/code&gt; branch is the pivot of the whole piece.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If I got something wrong — especially if one of the twelve items in section 15 is me&lt;br&gt;
misreading an implementation — please open an issue and say so. I would much rather&lt;br&gt;
hear where it does not line up than be told the design looks clean.&lt;/p&gt;

</description>
      <category>distributedsystems</category>
      <category>reliability</category>
      <category>softwareengineering</category>
      <category>debugging</category>
    </item>
    <item>
      <title>We changed one timeout from 30 to 480 and back to 30. In between, it stopped meaning the same thing</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:14:22 +0000</pubDate>
      <link>https://dev.to/judezh/we-changed-one-timeout-from-30-to-480-and-back-to-30-in-between-it-stopped-meaning-the-same-thing-15jh</link>
      <guid>https://dev.to/judezh/we-changed-one-timeout-from-30-to-480-and-back-to-30-in-between-it-stopped-meaning-the-same-thing-15jh</guid>
      <description>&lt;h3&gt;
  
  
  The short version
&lt;/h3&gt;

&lt;p&gt;In a self-hosted deployment, "how long does a login last" is never one number. In ours it is&lt;br&gt;
&lt;strong&gt;two knobs you can turn, six hard-coded deadlines you cannot, and one revocation path&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Lifetime&lt;/th&gt;
&lt;th&gt;Configurable&lt;/th&gt;
&lt;th&gt;What happens at the end&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access token&lt;/td&gt;
&lt;td&gt;30 minutes&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The browser silently swaps it; nobody notices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session (refresh token)&lt;/td&gt;
&lt;td&gt;14 days&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Password required again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second-factor challenge&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;❌ hard-coded&lt;/td&gt;
&lt;td&gt;Start the sign-in over&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password reset link&lt;/td&gt;
&lt;td&gt;30 minutes&lt;/td&gt;
&lt;td&gt;❌ hard-coded&lt;/td&gt;
&lt;td&gt;Request another mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email verification link&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;td&gt;❌ hard-coded&lt;/td&gt;
&lt;td&gt;Request another mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workspace invitation&lt;/td&gt;
&lt;td&gt;14 days&lt;/td&gt;
&lt;td&gt;❌ hard-coded&lt;/td&gt;
&lt;td&gt;The inviter resends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API key&lt;/td&gt;
&lt;td&gt;1–365 days, &lt;strong&gt;mandatory at creation&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;per key&lt;/td&gt;
&lt;td&gt;The call gets a 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ending one device&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;That device's next request is a 401&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the second of the two knobs is the session length. The first one is not — even though&lt;br&gt;
our own documentation still explains it as if it were. That is item ③ in the last section.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. The commit that set it to eight hours
&lt;/h3&gt;

&lt;p&gt;2026-08-06, commit &lt;code&gt;69be399&lt;/code&gt;, titled &lt;code&gt;fix(deploy): default the access token lifetime to one&lt;br&gt;
workday&lt;/code&gt;. The message says exactly why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access tokens expired after a fixed 30 minutes and there is no refresh
flow, so self-hosted users were silently logged out mid-session.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no refresh flow, the access token &lt;strong&gt;is&lt;/strong&gt; the session. When it expires the person is out,&lt;br&gt;
and not politely — there is no "your session ended" dialog, just a 401 on the next click. So&lt;br&gt;
the default became 480 minutes, one working day, threaded through both compose profiles.&lt;/p&gt;

&lt;p&gt;That was the right call at the time, and the message also names the cost: &lt;strong&gt;every sign-in&lt;br&gt;
became an eight-hour grant that nobody could take back&lt;/strong&gt;. A JWT that has been signed is not&lt;br&gt;
something the server knows about. There is no row to flip. Your options are to wait it out or&lt;br&gt;
to rotate the signing key and drop everybody at once.&lt;/p&gt;

&lt;p&gt;For a self-hosted internal platform that cost is not academic. Someone leaves the company. A&lt;br&gt;
laptop goes missing. An engineer signs in on a customer's machine to demo something. In each&lt;br&gt;
of those the thing you want is "end that one login", and the system's only answer was: eight&lt;br&gt;
hours.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The commit that set it back to thirty minutes
&lt;/h3&gt;

&lt;p&gt;2026-08-30, commit &lt;code&gt;09dbf7e&lt;/code&gt;, titled &lt;code&gt;feat(identity): make a sign-out mean something, and stop&lt;br&gt;
logging people out&lt;/code&gt;. 38 files, +1439/-56. Four things, reordered by how much they matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A sign-in opens a session row&lt;/strong&gt;, and the access token carries a &lt;code&gt;sid&lt;/code&gt; claim naming it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every authenticated request checks that the session is still live&lt;/strong&gt;, so revocation lands
on the next request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens rotate on every use&lt;/strong&gt;, and only the hash is stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The console renews silently&lt;/strong&gt;, one renewal serving every request that failed together.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The moment 1 and 2 landed, &lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt; changed meaning. It no longer decides&lt;br&gt;
how often a person gets kicked out — &lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS&lt;/code&gt; plus the refresh flow decides&lt;br&gt;
that. It now decides exactly one thing: &lt;strong&gt;how long a revoked login keeps limping&lt;/strong&gt;. So it went&lt;br&gt;
back to 30, and the commit message says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access tokens drop to 30 minutes because they no longer bound the session --
they bound how long a revoked session keeps working.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One setting, two commits, two meanings.&lt;/strong&gt; That is the reason for this post. If you see&lt;br&gt;
&lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt; in a deployment, work out which of the two systems you are&lt;br&gt;
looking at before you decide whether to raise or lower it.&lt;/p&gt;

&lt;p&gt;(That commit message now needs a small asterisk of its own — see item ③. For a token that&lt;br&gt;
carries a &lt;code&gt;sid&lt;/code&gt;, revocation is immediate; the sentence describes the tokens that do not.)&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Why checking the session on every request is nearly free
&lt;/h3&gt;

&lt;p&gt;"JWTs cannot be revoked" is shorthand for "&lt;strong&gt;signature-only&lt;/strong&gt; JWTs cannot be revoked". The&lt;br&gt;
moment your request path contains any server-side lookup, revocation is back on the table. The&lt;br&gt;
lookup is the price.&lt;/p&gt;

&lt;p&gt;We did not add a lookup. We put the check on one that was already happening&lt;br&gt;
(&lt;code&gt;server/app/modules/identity/infra/workspace_access.py:30–37&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;When the caller's token names a session, that session must still be
live. It is checked here rather than in a separate lookup because this
is already the one database read every authenticated request makes, and
checking it at refresh time alone would leave a signed-out token working
until it expired.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The precondition — true for more systems than people assume — is that authentication here was&lt;br&gt;
never "decode the JWT and wave it through". Every authenticated request has to answer &lt;em&gt;is this&lt;br&gt;
person still a member of this tenant and this workspace, and what are their quotas&lt;/em&gt;. That&lt;br&gt;
means reading the database. Given that the connection is open and the transaction has started,&lt;br&gt;
one more primary-key lookup costs approximately nothing.&lt;/p&gt;

&lt;p&gt;Counted in the order &lt;code&gt;DatabaseWorkspaceAccessResolver.resolve&lt;/code&gt; performs them&lt;br&gt;
(&lt;code&gt;workspace_access.py:23–97&lt;/code&gt;):&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;Read&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user_sessions&lt;/code&gt; by primary key&lt;/td&gt;
&lt;td&gt;Is the session still live (only when the token has a &lt;code&gt;sid&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Tenant membership&lt;/td&gt;
&lt;td&gt;Still in this tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Workspace membership&lt;/td&gt;
&lt;td&gt;Still in this workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;Tenant-level quotas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Workspace&lt;/td&gt;
&lt;td&gt;Workspace-level quotas, overriding the tenant's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;MFA enrolment (conditional)&lt;/td&gt;
&lt;td&gt;Only when the workspace requires a second factor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The session check is the &lt;strong&gt;first&lt;/strong&gt; of those, and it is an equality read on a primary key. What&lt;br&gt;
it buys: end a device in the console and that device's next request is a 401. No 30-minute&lt;br&gt;
wait, no denylist to keep warm.&lt;/p&gt;

&lt;p&gt;One detail from row 6 worth stealing: when a workspace requires a second factor and the caller&lt;br&gt;
has not enrolled, the resolver does not raise "you are not a member". It raises a 403 carrying&lt;br&gt;
&lt;code&gt;reason: mfa_required&lt;/code&gt; (&lt;code&gt;workspace_access.py:63–72&lt;/code&gt;). The two look alike from the server and&lt;br&gt;
are completely different from the browser — the first can only render "no access", the second&lt;br&gt;
can send the person to the enrolment page.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Why shipping this did not sign everybody out
&lt;/h3&gt;

&lt;p&gt;The scariest part of changing authentication is the instant it deploys. The handling here is&lt;br&gt;
simple and worth copying: &lt;strong&gt;&lt;code&gt;sid&lt;/code&gt; is an optional claim, and a token without one is honoured&lt;br&gt;
until it expires.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is written into the docstring of &lt;code&gt;create_access_token&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;server/app/kernel/identity/auth.py:53–58&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;session_id: Session this token belongs to. Naming it lets a
    sign-out end the access it granted; a token without one is
    accepted until it expires, so upgrading does not sign
    everybody out.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;_require_live_session&lt;/code&gt; is only reached when &lt;code&gt;session_id&lt;/code&gt; is present&lt;br&gt;
(&lt;code&gt;workspace_access.py:45–46&lt;/code&gt;, &lt;code&gt;:100–107&lt;/code&gt;). The migration window closes by itself: an old token&lt;br&gt;
lives at most &lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt; longer, then the client trades its refresh token&lt;br&gt;
for a new one — and the new one carries a &lt;code&gt;sid&lt;/code&gt;. &lt;strong&gt;Nobody is kicked, and nobody is stuck under&lt;br&gt;
the old rules forever.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is a precondition: at the moment of the upgrade, clients need a refresh token to trade.&lt;br&gt;
Before 08-30 there was no refresh flow at all, so in practice everyone signed in again within&lt;br&gt;
eight hours and picked up a session naturally. Acceptable.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Fourteen days is a hard cap, not a sliding window
&lt;/h3&gt;

&lt;p&gt;This is the one people assume wrong. A session's &lt;code&gt;expires_at&lt;/code&gt; is computed once, at issue time&lt;br&gt;
(&lt;code&gt;service.py:405–429&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;expires_at=utc_now() + timedelta(days=self._refresh_token_days()),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went looking for every other write to that column on the session path. There is none.&lt;br&gt;
&lt;code&gt;_issue_session&lt;/code&gt; is the only writer; everything else reads it. &lt;strong&gt;Refreshing does not extend&lt;br&gt;
it.&lt;/strong&gt; &lt;code&gt;refresh_session&lt;/code&gt; rotates the token and updates &lt;code&gt;last_seen_at&lt;/code&gt;, &lt;code&gt;workspace_id&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;user_agent&lt;/code&gt; and &lt;code&gt;ip_address&lt;/code&gt; (&lt;code&gt;service.py:490–498&lt;/code&gt;) — and deliberately not &lt;code&gt;expires_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS=14&lt;/code&gt; means: &lt;strong&gt;fourteen days from the last time you typed your&lt;br&gt;
password, no matter how active you were in between.&lt;/strong&gt; It does not mean "expires after fourteen&lt;br&gt;
idle days".&lt;/p&gt;

&lt;p&gt;That is a trade-off, and I think it was chosen correctly. A sliding window means a machine&lt;br&gt;
that is never turned off never has to prove again that it is still you. A hard cap gives the&lt;br&gt;
whole system an upper bound: &lt;strong&gt;no login lives longer than fourteen days.&lt;/strong&gt; The cost is that&lt;br&gt;
active users get interrupted too, at a moment nobody can predict — it depends on what time of&lt;br&gt;
day they signed in two weeks ago.&lt;/p&gt;

&lt;p&gt;Want a tighter bound? Lower it. Want "everybody signs in again on Monday morning"? There is no&lt;br&gt;
way to express that today (item ⑥).&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Refresh tokens: hash only, rotated every time
&lt;/h3&gt;

&lt;p&gt;The session row does not store the refresh token. It stores its SHA-256&lt;br&gt;
(&lt;code&gt;service.py:400–402&lt;/code&gt;, &lt;code&gt;:424&lt;/code&gt;), the same rule API keys follow. The plaintext is handed to the&lt;br&gt;
client once and the server can never produce it again — the model comment states the purpose&lt;br&gt;
outright: a stolen database cannot be replayed as a sign-in (&lt;code&gt;models.py:365–367&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Every refresh mints a new one (&lt;code&gt;service.py:490–491&lt;/code&gt;). The old hash is overwritten, so the old&lt;br&gt;
token stops matching immediately. Which gives a genuinely useful property: &lt;strong&gt;a refresh token&lt;br&gt;
is good for exactly one use.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What happens on the &lt;em&gt;second&lt;/em&gt; use is where the docstring, the deployment guide, the commit&lt;br&gt;
message and even the test name all say one thing and the code does another. That is item ④,&lt;br&gt;
and it is the most valuable thing I found this time.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. How the browser hides the expiry
&lt;/h3&gt;

&lt;p&gt;The other half lives in &lt;code&gt;web/app/utils/request.ts&lt;/code&gt;. Three pieces add up to silent renewal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Retry once after a 401&lt;/strong&gt; (&lt;code&gt;:206–230&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;async function retryWithRefreshedToken(error: any): Promise&amp;lt;any | null&amp;gt; {
  const config = error?.config as (...)
  if (!config || error?.response?.status !== 401) return null
  if (config.skipAuthRefresh || config._retriedAfterRefresh) return null
  if (!storedRefreshToken()) return null
  const token = await refreshAccessToken()
  if (!token) return null
  config._retriedAfterRefresh = true
  config.headers = { ...(config.headers || {}), Authorization: `Bearer ${token}` }
  return request.request(config)
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two loop guards: the refresh call carries &lt;code&gt;skipAuthRefresh&lt;/code&gt;, and a replayed request carries&lt;br&gt;
&lt;code&gt;_retriedAfterRefresh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) One refresh at a time&lt;/strong&gt; (&lt;code&gt;:145–183&lt;/code&gt;). The comment explains why it is mandatory rather&lt;br&gt;
than nice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A page loads a dozen requests at once, and an expired token fails all of
them together. Without this, each failure would spend the same refresh
token, and every attempt after the first would look like a replay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A page fires a dozen requests; an expired token fails all of them at once. If each failure&lt;br&gt;
refreshed on its own, they would spend the same refresh token a dozen times, and rotation&lt;br&gt;
guarantees only the first can win. A module-level &lt;code&gt;refreshInFlight&lt;/code&gt; promise collapses them&lt;br&gt;
into one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) Give up and go to the sign-in page&lt;/strong&gt; (&lt;code&gt;:186–203&lt;/code&gt;). &lt;code&gt;AUTO_REDIRECT_UNAUTHORIZED&lt;/code&gt; is a&lt;br&gt;
hard-coded &lt;code&gt;true&lt;/code&gt; (&lt;code&gt;:20&lt;/code&gt;); after a 401 it waits a second, clears local storage, and navigates&lt;br&gt;
to &lt;code&gt;/sign-in&lt;/code&gt; with the current route in a &lt;code&gt;redirect&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;Put that together with the server-side check from section 3 and you can trace exactly what&lt;br&gt;
happens on a device you just ended from the console: next request 401 (session revoked) →&lt;br&gt;
interceptor tries to refresh → the refresh is refused too (&lt;code&gt;_session_is_live&lt;/code&gt; fails) → null →&lt;br&gt;
the 401 surfaces → local storage cleared, sign-in page. &lt;strong&gt;One request cycle. No token has to&lt;br&gt;
expire for any of it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While we are here, the honest version of the storage trade-off: both tokens live in&lt;br&gt;
&lt;code&gt;localStorage&lt;/code&gt; (&lt;code&gt;auth-session.ts:1–22&lt;/code&gt;). That survives a page reload and is simple to&lt;br&gt;
implement; it also means &lt;strong&gt;one XSS is worth a session of up to fourteen days&lt;/strong&gt;. HttpOnly&lt;br&gt;
cookies would close that and open CSRF and cross-site deployment questions instead. We picked&lt;br&gt;
the first, and that is a choice that deserves to be written down rather than defaulted into.&lt;/p&gt;
&lt;h3&gt;
  
  
  8. One path does not get any of this: SSE
&lt;/h3&gt;

&lt;p&gt;This one matters specifically for an agent runtime, so it gets its own section.&lt;/p&gt;

&lt;p&gt;Running an agent or following a workflow in the console is a Server-Sent Events stream&lt;br&gt;
(&lt;code&gt;@microsoft/fetch-event-source&lt;/code&gt;), and that path &lt;strong&gt;does not go through the axios interceptor&lt;br&gt;
above&lt;/strong&gt;. In &lt;code&gt;request.ts:430–470&lt;/code&gt;, &lt;code&gt;buildAuthHeaders()&lt;/code&gt; is called once while opening the&lt;br&gt;
connection, and that header is then fixed for the life of the stream. &lt;code&gt;onerror&lt;/code&gt; records the&lt;br&gt;
error and rethrows (&lt;code&gt;:479–487&lt;/code&gt;) — and in fetch-event-source, throwing from &lt;code&gt;onerror&lt;/code&gt; means&lt;br&gt;
&lt;em&gt;stop retrying&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Three consequences, pointing in different directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An open stream does not die when the token expires.&lt;/strong&gt; Authentication happens when the
request is accepted; the connection is not re-checked afterwards. A 40-minute agent run does
not drop halfway because of a 30-minute token. That is good.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But ending that device does not cut the stream either.&lt;/strong&gt; Revocation only affects new
requests; a stream already pushing events keeps pushing until it finishes on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And if the token is already expired when the stream opens, the stream just fails — no
refresh, no retry.&lt;/strong&gt; A tab that sat open for half an hour hits exactly this on the first
click of "Run". An ordinary request would have been rescued silently. This one is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third is a real gap. The workaround is blunt and works: fire one cheap ordinary request to&lt;br&gt;
renew the token before opening the stream.&lt;/p&gt;
&lt;h3&gt;
  
  
  9. If you are self-hosting, which number should you touch
&lt;/h3&gt;

&lt;p&gt;A table by threat model beats a recommended value:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What worries you&lt;/th&gt;
&lt;th&gt;Which knob&lt;/th&gt;
&lt;th&gt;Set it to&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A lost device must be cut off now&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;End that device in the console&lt;/td&gt;
&lt;td&gt;None; its next request fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nobody will click that button&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5–15 minutes&lt;/td&gt;
&lt;td&gt;More refreshes, each one a write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Re-authenticate every N days" compliance&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;N&lt;/td&gt;
&lt;td&gt;Interrupts at an unpredictable moment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared or kiosk machines&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Password every day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Busy server, fewer database reads&lt;/td&gt;
&lt;td&gt;raise &lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;⚠ Does not work: the session check runs &lt;strong&gt;per request&lt;/strong&gt;, not per refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one people get wrong. Lengthening the access token does not reduce&lt;br&gt;
database reads, because every request reads membership anyway and the session check rides on&lt;br&gt;
that read. The only thing lengthening it does is keep pre-upgrade tokens without a &lt;code&gt;sid&lt;/code&gt; alive&lt;br&gt;
longer, which is the opposite of what you want.&lt;/p&gt;

&lt;p&gt;Two operational notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECRET_KEY&lt;/code&gt; must be changed, and production will stop you.&lt;/strong&gt; Shorter than 32 characters,
or equal to either known placeholder, and &lt;code&gt;validate_runtime_requirements()&lt;/code&gt; fails at startup
(&lt;code&gt;settings.py:550–555&lt;/code&gt;). Fail-closed, not a warning in a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mail is off by default&lt;/strong&gt; (&lt;code&gt;SYSTEM_MAIL_ENABLED=false&lt;/code&gt;), and that interacts directly with
session length. Fourteen days in, everyone must type their password again — and the only
route back from a forgotten password is a reset mail. With mail off, &lt;code&gt;bootstrap_admin.py&lt;/code&gt;
prints &lt;code&gt;User already exists. Skipping bootstrap.&lt;/code&gt; and exits (&lt;code&gt;:30–33&lt;/code&gt;). &lt;strong&gt;There is no second
path inside the product.&lt;/strong&gt; Either turn mail on, or accept that "forgot password" means
"write SQL".&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  10. The six deadlines you never see in config
&lt;/h3&gt;

&lt;p&gt;All of them are constants (&lt;code&gt;service.py:91–103&lt;/code&gt;), nothing to do with your deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MFA_CHALLENGE_PURPOSE = "mfa_challenge"
MFA_CHALLENGE_MINUTES = 5
PASSWORD_RESET_MINUTES = 30
EMAIL_VERIFICATION_MINUTES = 60 * 24
INVITATION_DAYS = 14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second-factor challenge is the interesting one. The state between "password accepted" and&lt;br&gt;
"second factor proved" is carried by a &lt;strong&gt;stateless JWT&lt;/strong&gt; with a &lt;code&gt;purpose: mfa_challenge&lt;/code&gt; claim&lt;br&gt;
(&lt;code&gt;service.py:1131–1143&lt;/code&gt;). The point is that this ticket &lt;strong&gt;cannot be used as an access token&lt;/strong&gt; —&lt;br&gt;
the authentication entry point rejects any token carrying a &lt;code&gt;purpose&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;context_resolver.py:88–92&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;# A token minted for one step of sign-in authorizes nothing. Without
# this, presenting the challenge token as a bearer would make the
# second factor optional for anyone who noticed.
if payload.get("purpose"):
    raise UnauthorizedError("Token cannot be used to authorize a request")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"A credential for a step of sign-in is not a credential for being signed in" is the box most&lt;br&gt;
hand-rolled MFA implementations forget to tick. The price is that this ticket is &lt;strong&gt;not&lt;br&gt;
revocable&lt;/strong&gt; — it is stateless, with nowhere to mark it spent, and it is good for the full five&lt;br&gt;
minutes. That is item ⑨.&lt;/p&gt;

&lt;p&gt;API keys are a separate path entirely: no session, their own row lookup, their own expiry&lt;br&gt;
check, their own scopes (&lt;code&gt;context_resolver.py:145–175&lt;/code&gt;). And the lifetime is &lt;strong&gt;mandatory at&lt;br&gt;
creation, between 1 and 365 days&lt;/strong&gt; (&lt;code&gt;schemas.py:408–412&lt;/code&gt;). There is no "never expires" option.&lt;br&gt;
Mildly annoying for automation; "long-lived credentials get reissued" is a default I will&lt;br&gt;
defend.&lt;/p&gt;
&lt;h3&gt;
  
  
  11. Rotating &lt;code&gt;SECRET_KEY&lt;/code&gt; no longer signs everybody out
&lt;/h3&gt;

&lt;p&gt;An operational note that flipped when the session table landed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;: every access token was signed with &lt;code&gt;SECRET_KEY&lt;/code&gt;. Rotate it and every token fails&lt;br&gt;
verification — that &lt;em&gt;was&lt;/em&gt; the global sign-out button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now&lt;/strong&gt;: rotate it and every access token still fails at once. But the client takes the 401&lt;br&gt;
and refreshes, and &lt;code&gt;refresh_session&lt;/code&gt; &lt;strong&gt;never looks at the access token&lt;/strong&gt; — it takes the&lt;br&gt;
refresh token and queries the database (&lt;code&gt;service.py:460–468&lt;/code&gt;). Refresh tokens are random&lt;br&gt;
strings stored as hashes; they have nothing to do with the signing key. The client gets back a&lt;br&gt;
token signed with the new key and &lt;strong&gt;the user notices nothing at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The good news is that key rotation is no longer a mass interruption. The bad news is that you&lt;br&gt;
can no longer use it as an emergency eject button. And a real "sign everybody out" does not&lt;br&gt;
exist: &lt;code&gt;revoke_all_sessions&lt;/code&gt; only touches the caller's own sessions (&lt;code&gt;service.py:536–551&lt;/code&gt;),&lt;br&gt;
and the only routes are the three under &lt;code&gt;/me/sessions&lt;/code&gt; (&lt;code&gt;router.py:257–277&lt;/code&gt;). To cut off&lt;br&gt;
someone who left the company today you close their account (&lt;code&gt;execute_account_deletion&lt;/code&gt; ends&lt;br&gt;
all their sessions, &lt;code&gt;service.py:640–660&lt;/code&gt;), remove them from the tenant (membership is read on&lt;br&gt;
every request, so the next one is a 403), or go into the database.&lt;/p&gt;
&lt;h3&gt;
  
  
  12. What you can see: the security pane
&lt;/h3&gt;

&lt;p&gt;With sessions in place, the console lists signed-in devices: user agent, IP, last activity,&lt;br&gt;
each with an End button — except the current one, which deliberately has no button (the commit&lt;br&gt;
message: &lt;em&gt;signing yourself out of the page you are on is a trap&lt;/em&gt;). "Sign out everywhere"&lt;br&gt;
keeps the current device by default (&lt;code&gt;settings.tsx:309–317&lt;/code&gt; calls &lt;code&gt;revokeAllSessions(true)&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The "last active" column in the member list comes from the same rows:&lt;br&gt;
&lt;code&gt;last_seen_for_users&lt;/code&gt; takes the maximum &lt;code&gt;last_seen_at&lt;/code&gt; across a user's sessions&lt;br&gt;
(&lt;code&gt;repository.py:468–483&lt;/code&gt;). Mind the resolution — &lt;code&gt;last_seen_at&lt;/code&gt; is written &lt;strong&gt;only on refresh&lt;/strong&gt;,&lt;br&gt;
so it is accurate to roughly one access token lifetime (30 minutes), not to the last click.&lt;/p&gt;

&lt;p&gt;What you cannot see, so nobody misreads the pane: &lt;strong&gt;ended sessions are not listed&lt;/strong&gt;&lt;br&gt;
(&lt;code&gt;list_by_user&lt;/code&gt; defaults to &lt;code&gt;include_ended=False&lt;/code&gt;, and &lt;code&gt;include_ended=True&lt;/code&gt; has no call site&lt;br&gt;
anywhere in the repository). This is a list of currently signed-in devices, not a sign-in&lt;br&gt;
history. And &lt;strong&gt;signing in is not audited at all&lt;/strong&gt; — the audit table has&lt;br&gt;
&lt;code&gt;identity.session.revoked&lt;/code&gt; and no event type for a login.&lt;/p&gt;
&lt;h3&gt;
  
  
  13. Ten things that do not line up
&lt;/h3&gt;

&lt;p&gt;By convention, this section is about our own problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① The "Session timeout" dropdown in the console is decorative.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;web/app/console/routes/settings.tsx:1105–1116&lt;/code&gt; renders a 12 hours / 24 hours / 7 days select&lt;br&gt;
with &lt;code&gt;defaultValue="12 hours"&lt;/code&gt; and &lt;strong&gt;no onChange, no save, no read from the backend&lt;/strong&gt;. The&lt;br&gt;
comment beside it is honest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BACKEND-PENDING: session lifetime is an instance setting
(ACCESS_TOKEN_EXPIRE_MINUTES), not a workspace one. Not built
rather than withheld: it needs a per-workspace override the
token issuer would have to read.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worse than unwired: &lt;strong&gt;none of the three options matches either real knob&lt;/strong&gt;. The real values&lt;br&gt;
are 30 minutes and 14 days; the dropdown offers 12 hours, 24 hours, 7 days. An administrator&lt;br&gt;
reading that screen will reasonably conclude their sessions last twelve hours. Impact:&lt;br&gt;
misinformation. Workaround: edit &lt;code&gt;.env&lt;/code&gt;. &lt;strong&gt;I intend to open an issue for this&lt;/strong&gt;; it was not&lt;br&gt;
filed when this was written, so there is no link in the text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② "Log out" in the account menu only clears the browser.&lt;/strong&gt; Both entry points&lt;br&gt;
(&lt;code&gt;web/app/components/common/nav-user.tsx:31–36&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;web/app/console/shell/icon-rail.tsx:81–86&lt;/code&gt;) do the same four things: clear the query cache,&lt;br&gt;
clear the user store, clear local storage, navigate to &lt;code&gt;/sign-in&lt;/code&gt;. &lt;strong&gt;Neither calls&lt;br&gt;
&lt;code&gt;revokeSession&lt;/code&gt; or &lt;code&gt;/me/sessions/revoke-all&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So: you click log out, and the session row stays &lt;code&gt;active&lt;/code&gt;. It keeps appearing in the security&lt;br&gt;
pane's device list. Its refresh token stays valid for the rest of the fourteen days — that&lt;br&gt;
string was merely deleted from this browser's local storage. Impact: &lt;strong&gt;"sign out" means two&lt;br&gt;
different things in two places&lt;/strong&gt; — the End button in the security pane really revokes, the Log&lt;br&gt;
out item does not. Workaround: use the security pane. &lt;strong&gt;Also intend to open an issue&lt;/strong&gt;; the&lt;br&gt;
fix looks like a handful of lines (fire a revoke first, proceed either way).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Four places in docs and comments still describe the old revocation semantics.&lt;/strong&gt; All four&lt;br&gt;
say the same thing: after revocation, an already-issued access token keeps working until it&lt;br&gt;
expires.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;server/app/settings/settings.py:73–78&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env.example:15–18&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;server/.env.example:33–36&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/deployment/production-profile.md:46–50&lt;/code&gt; — verbatim: &lt;em&gt;an access token already issued
keeps working until it expires, even after the session behind it was ended&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;And the code in section 3 exists precisely to make that untrue.&lt;/strong&gt; A token carrying a &lt;code&gt;sid&lt;/code&gt;&lt;br&gt;
gets a 401 on the next request after its session is revoked. Those four notes were written&lt;br&gt;
before or alongside the session table and never updated. Impact: a reader shortens the access&lt;br&gt;
token lifetime to reduce a "revocation delay" that is already zero. Workaround: trust the&lt;br&gt;
code. &lt;strong&gt;Also intend to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ "Presenting a spent refresh token ends the session" is documented and not implemented —&lt;br&gt;
and the test is named after the behaviour it does not have.&lt;/strong&gt; This is the big one, so here it&lt;br&gt;
is in full.&lt;/p&gt;

&lt;p&gt;Three places promise it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;service.py:454–457&lt;/code&gt;: &lt;em&gt;presenting a rotated-out token ends the session&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/deployment/production-profile.md:52–55&lt;/code&gt;: &lt;em&gt;presenting a spent one ends the session, so
a stolen token is usable at most until the real client next renews&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;commit &lt;code&gt;09dbf7e&lt;/code&gt;: &lt;em&gt;A spent one is a replay, and the answer to a replay is to end the session&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code (&lt;code&gt;service.py:462–468&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;session = self.session_repo.get_by_refresh_hash(
    self._hash_refresh_token(refresh_token)
)
if session is None:
    raise UnauthorizedError("Invalid refresh token")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rotation the old token's hash &lt;strong&gt;has been overwritten&lt;/strong&gt;. No row in the database knows it&lt;br&gt;
any more. Presenting it produces "no match → 401", the &lt;strong&gt;session is untouched&lt;/strong&gt;, and nothing&lt;br&gt;
anywhere records that a replay happened.&lt;/p&gt;

&lt;p&gt;The test file is blunter about it (&lt;code&gt;server/tests/unit/test_user_sessions.py:65–77&lt;/code&gt;): the&lt;br&gt;
function is called &lt;code&gt;test_replaying_a_rotated_token_ends_the_session&lt;/code&gt;, the assertion is&lt;br&gt;
&lt;code&gt;assert len(service.session_repo.list_by_user(user.id)) == 1&lt;/code&gt;, and the comment reads &lt;em&gt;the live&lt;br&gt;
session is untouched by a failed replay of the old one&lt;/em&gt;. &lt;strong&gt;The name and the body contradict&lt;br&gt;
each other, and the body is the accurate one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Impact: when a refresh token is stolen, &lt;strong&gt;whoever uses it first wins&lt;/strong&gt;. If the attacker&lt;br&gt;
refreshes first, they hold the rotated token and the session; the real client's next refresh&lt;br&gt;
fails with a 401 and they end up signing in again, believing they were simply "logged out".&lt;br&gt;
Nobody is told a replay occurred. Workaround: real replay detection needs a stored&lt;br&gt;
previous-generation hash, or a monotonic counter per session. &lt;strong&gt;Also intend to open an issue&lt;/strong&gt; —&lt;br&gt;
and I consider it more serious than ① or ②, because a security property written into&lt;br&gt;
deployment documentation is something other people will cite as fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ The &lt;code&gt;expired&lt;/code&gt; status is never written, and session rows are never cleaned up.&lt;/strong&gt; The model&lt;br&gt;
documents three values for &lt;code&gt;status&lt;/code&gt; — active, revoked, expired (&lt;code&gt;models.py:385–386&lt;/code&gt;) — and&lt;br&gt;
nothing in the repository ever assigns &lt;code&gt;"expired"&lt;/code&gt;. Expiry is evaluated at read time, in two&lt;br&gt;
places (&lt;code&gt;_session_is_live&lt;/code&gt;, &lt;code&gt;_require_live_session&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Alongside it sits &lt;code&gt;ix_user_sessions_expiry&lt;/code&gt;, a composite index on &lt;code&gt;(status, expires_at)&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;models.py:373&lt;/code&gt;): &lt;strong&gt;an index built for a sweeper that does not exist.&lt;/strong&gt; No worker or script&lt;br&gt;
deletes or archives expired sessions. Impact: &lt;code&gt;user_sessions&lt;/code&gt; grows monotonically with&lt;br&gt;
sign-ins; an instance a year old carries a pile of rows that died a fortnight in. Workaround:&lt;br&gt;
a scheduled &lt;code&gt;DELETE&lt;/code&gt; of long-expired rows is safe — nothing reads them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ There is no idle timeout, and no way to say "everyone signs in on Monday".&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;last_seen_at&lt;/code&gt; is recorded (on refresh) but &lt;strong&gt;participates in no decision&lt;/strong&gt; — it drives&lt;br&gt;
ordering and the "last active" column, nothing else. So the common compliance line "a login&lt;br&gt;
unused for 30 days must expire" can only be approximated by shortening&lt;br&gt;
&lt;code&gt;REFRESH_TOKEN_EXPIRE_DAYS&lt;/code&gt;, which is an absolute cap rather than an idle window; the two hit&lt;br&gt;
active users very differently. Impact: deployments with that requirement cannot express it.&lt;br&gt;
Workaround: shorten the cap, or revoke stale rows on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ Changing your password does not end other sessions; resetting it does.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;complete_password_reset&lt;/code&gt; (&lt;code&gt;service.py:808–821&lt;/code&gt;) walks every session for that user and ends
them, with the reasoning in the docstring: a reset is what you do when you think the account
is compromised.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;change_password&lt;/code&gt; (&lt;code&gt;service.py:1517–1528&lt;/code&gt;) verifies the old password, writes the new hash,
and returns. &lt;strong&gt;It touches no sessions at all.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Impact: someone who changes their password &lt;em&gt;because they think somebody saw it&lt;/em&gt; leaves that&lt;br&gt;
somebody signed in — while almost certainly believing the change kicked them out. Workaround:&lt;br&gt;
click "sign out of other devices" right after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑧ Signing in is not audited; signing out is.&lt;/strong&gt; The audit table carries&lt;br&gt;
&lt;code&gt;identity.session.revoked&lt;/code&gt; (&lt;code&gt;service.py:553–575&lt;/code&gt;), &lt;code&gt;identity.mfa.changed&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;identity.account.closed&lt;/code&gt; and others. &lt;strong&gt;There is no event type for a login or a new session.&lt;/strong&gt;&lt;br&gt;
For a platform whose pitch is governance that asymmetry stands out: you can find out who ended&lt;br&gt;
whose session, but not who signed in, when, or from where. The session row holds exactly that&lt;br&gt;
information (&lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;ip_address&lt;/code&gt;, &lt;code&gt;user_agent&lt;/code&gt;) — it just never reaches the audit&lt;br&gt;
stream. Impact: sign-in activity is outside audit search and export. Workaround: query&lt;br&gt;
&lt;code&gt;user_sessions&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑨ &lt;code&gt;/login&lt;/code&gt; and &lt;code&gt;/refresh&lt;/code&gt; are not rate limited.&lt;/strong&gt; The application registers four middlewares&lt;br&gt;
(tracing, error handling, response envelope, request id) plus CORS (&lt;code&gt;main.py:274–292&lt;/code&gt;) — none&lt;br&gt;
of them a limiter. The production Caddyfile is 28 lines and contains no &lt;code&gt;limit_req&lt;/code&gt;. Every&lt;br&gt;
setting in the system named &lt;code&gt;rate_limit&lt;/code&gt; is an LLM or tool-call quota and has nothing to do&lt;br&gt;
with sign-in.&lt;/p&gt;

&lt;p&gt;Related, same item: the second-factor challenge is a five-minute stateless JWT with &lt;strong&gt;no&lt;br&gt;
spent-marker&lt;/strong&gt;, and TOTP codes themselves are not replay-protected (&lt;code&gt;totp.py:63–86&lt;/code&gt; compares&lt;br&gt;
the candidates within ±drift steps and never records which step was used). Impact: brute-force&lt;br&gt;
and code-replay protection in our default stack is &lt;strong&gt;entirely the deployer's reverse proxy&lt;/strong&gt;,&lt;br&gt;
and the documentation does not say so. Workaround: rate-limit &lt;code&gt;/api/v1/login&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;/api/v1/refresh&lt;/code&gt; and &lt;code&gt;/api/v1/login/mfa&lt;/code&gt; at the gateway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑩ A multi-tab refresh race can bounce someone to the sign-in page. This is an inference, not&lt;br&gt;
an observation.&lt;/strong&gt; The single-flight lock from section 7 is a &lt;strong&gt;module-level variable&lt;/strong&gt;&lt;br&gt;
(&lt;code&gt;request.ts:153&lt;/code&gt;), so its scope is one tab. Open two console tabs, let the token expire in&lt;br&gt;
both, then use both: each reads the same refresh token from local storage, the first wins and&lt;br&gt;
writes the new one back, the second gets a 401, &lt;code&gt;refreshAccessToken&lt;/code&gt; returns null, the 401&lt;br&gt;
surfaces, and &lt;code&gt;AUTO_REDIRECT_UNAUTHORIZED&lt;/code&gt; sends that tab to the sign-in page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I did not reproduce this, so it is a conclusion from reading code.&lt;/strong&gt; It is here because it&lt;br&gt;
is exactly the problem item ④ would amplify if the documented behaviour were actually&lt;br&gt;
implemented — at that point the loser of the race would not be one tab, it would be the whole&lt;br&gt;
session. Workaround, if it does happen: share the refresh result between tabs via a &lt;code&gt;storage&lt;/code&gt;&lt;br&gt;
event or a BroadcastChannel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disclosure
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing was run for this piece.&lt;/strong&gt; Every conclusion comes from reading code, git history
and tests at commit &lt;code&gt;fb46f20&lt;/code&gt;. I did not stand up an instance to watch a revoked session get
a 401 on the next request, even though I believe the code path is unambiguous. &lt;strong&gt;Item ⑩ is
explicitly an inference.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community edition only.&lt;/strong&gt; Whatever the Enterprise and Cloud builds add on top of this is
out of scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Nearly free" is an architectural judgement, not a measurement.&lt;/strong&gt; Section 3 argues the
marginal cost is close to zero because the check rides on a read that already happens and is
a primary-key lookup. I did not benchmark it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of the ten items is a security incident.&lt;/strong&gt; They are documentation drifting from
implementation, and features that are not finished.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disclosure: I maintain SOIT.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One sentence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Without a server-side session record, &lt;code&gt;ACCESS_TOKEN_EXPIRE_MINUTES&lt;/code&gt; is your session length&lt;br&gt;
and every value hurts; with one, it is only the ceiling on revocation delay, so shorter is&lt;br&gt;
better and the session length belongs to a different setting.&lt;/strong&gt; Which of the two you have is&lt;br&gt;
decided not by the setting but by whether your request path already performs a lookup — and if&lt;br&gt;
it already reads membership, you have paid for that lookup already.&lt;/p&gt;

&lt;h3&gt;
  
  
  Come and find the holes
&lt;/h3&gt;

&lt;p&gt;The repository is &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;, and every claim above is checkable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;git show 69be399&lt;/code&gt; and &lt;code&gt;git show 09dbf7e&lt;/code&gt; are the two commits; both messages are shorter
and sharper than this post.&lt;/li&gt;
&lt;li&gt;The "riding along" comment is in
&lt;code&gt;server/app/modules/identity/infra/workspace_access.py&lt;/code&gt;; read it together with &lt;code&gt;resolve&lt;/code&gt;
underneath.&lt;/li&gt;
&lt;li&gt;The self-contradicting test in item ④ is in &lt;code&gt;server/tests/unit/test_user_sessions.py&lt;/code&gt; —
the function name and the assertion are three lines apart.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of this is wrong — especially if one of those ten items is me missing an implementation&lt;br&gt;
that does exist — please open an issue and say so. I would rather learn which parts do not&lt;br&gt;
line up than be told the design reads well.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>jwt</category>
      <category>webdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>`gh attestation verify` said yes to an image we never released</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Sun, 13 Sep 2026 22:38:41 +0000</pubDate>
      <link>https://dev.to/judezh/gh-attestation-verify-said-yes-to-an-image-we-never-released-c82</link>
      <guid>https://dev.to/judezh/gh-attestation-verify-said-yes-to-an-image-we-never-released-c82</guid>
      <description>&lt;h3&gt;
  
  
  The four questions, and the one nobody asks
&lt;/h3&gt;

&lt;p&gt;Pulling a container image somebody else built asks four separate questions. Most&lt;br&gt;
people answer three of them and skip the fourth:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;If you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fetch by digest&lt;/td&gt;
&lt;td&gt;are these the bytes I asked for&lt;/td&gt;
&lt;td&gt;you are gambling on a mutable tag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify provenance&lt;/td&gt;
&lt;td&gt;who built these bytes, from what, where&lt;/td&gt;
&lt;td&gt;unknown origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read the SBOM&lt;/td&gt;
&lt;td&gt;what is inside&lt;/td&gt;
&lt;td&gt;a CVE lands and you cannot tell if you are affected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check the release manifest&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;is this digest the one that version shipped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;you may be verifying a different build&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fourth is the one that has no substitute, and it is the one that is almost&lt;br&gt;
always skipped. What follows is all four, run for real against our own &lt;code&gt;v1.0.0&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. An image wears more than one sha256
&lt;/h3&gt;

&lt;p&gt;This is where the confusion starts. Get an anonymous pull token — a public package&lt;br&gt;
needs no account:&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;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://ghcr.io/token?scope=repository:soit-ai/soit/server:pull&amp;amp;service=ghcr.io"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'s/.*"token":"\([^"]*\)".*/\1/p'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask what the &lt;code&gt;v1.0.0&lt;/code&gt; tag resolves to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/vnd.oci.image.index.v1+json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://ghcr.io/v2/soit-ai/soit/server/manifests/v1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker-content-digest: sha256:96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929
Content-Type: application/vnd.oci.image.index.v1+json
Content-Length: 856
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;856 bytes: this is an image index, not the image. Inside it, two entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifests[0]: sha256:84e7f539...d32f  2589 bytes  linux/amd64
manifests[1]: sha256:a6bd430d...e1be   565 bytes  unknown/unknown
              vnd.docker.reference.type:   attestation-manifest
              vnd.docker.reference.digest: sha256:84e7f539...d32f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;unknown/unknown&lt;/code&gt; entry is not corruption — it is BuildKit's own attestation&lt;br&gt;
for the amd64 manifest, and it is &lt;strong&gt;a different mechanism from the Sigstore&lt;br&gt;
signatures&lt;/strong&gt; we are about to read. First thing people conflate.&lt;/p&gt;

&lt;p&gt;One level down, the amd64 manifest says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config: sha256:021814982fcc214a02a2b322cbc3729b3ce43bd6e191aa0fa39da540a3e3cd1f  8769 bytes
layers: 12, 684216450 bytes compressed (about 652 MiB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three hashes so far, three different meanings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value for server v1.0.0&lt;/th&gt;
&lt;th&gt;Refers to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;index digest&lt;/td&gt;
&lt;td&gt;&lt;code&gt;96b80ae1…5929&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the multi-platform index itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;platform manifest digest&lt;/td&gt;
&lt;td&gt;&lt;code&gt;84e7f539…d32f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the linux/amd64 image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;config digest&lt;/td&gt;
&lt;td&gt;&lt;code&gt;02181498…cd1f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the image config JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The signature covers the first one.&lt;/strong&gt; A fourth hash shows up in section 8, and it&lt;br&gt;
is none of these.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Pulling the signatures without Docker and without logging in
&lt;/h3&gt;

&lt;p&gt;The OCI spec defines a referrers API for "things attached to this thing":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://ghcr.io/v2/soit-ai/soit/server/referrers/sha256:96b80ae1...5929
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same error for the platform manifest digest. &lt;strong&gt;Do not conclude "unsigned" from&lt;br&gt;
this.&lt;/strong&gt; The spec has a fallback: replace the colon with a dash and treat the digest&lt;br&gt;
as a tag. List the tags and there it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"name":"soit-ai/soit/server","tags":[
  "v1.0.0",
  "sha256-3a5b3b1a2d14e0646298826602124ba22e63f8c078f653e506a0a042bfd18246",
  "sha256-236201a5a3ee861ffdb5fdd7ec134454619eb1ab0e777439c4a22a65002f874f",
  "sha256-96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"]}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last one is ours. &lt;strong&gt;Remember the first two — section 9 is entirely about them.&lt;/strong&gt;&lt;br&gt;
Fetching &lt;code&gt;sha256-96b80ae1…&lt;/code&gt; returns a small index holding two Sigstore bundles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sha256:658b3428...80b9   812 bytes
  artifactType : application/vnd.dev.sigstore.bundle.v0.3+json
  predicateType: https://slsa.dev/provenance/v1
  created      : 2026-08-05T16:14:12.946Z

sha256:59bac8c5...d9cf   814 bytes
  predicateType: https://spdx.dev/Document/v2.3
  created      : 2026-08-05T16:14:22.004Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build provenance and an SBOM attestation, signed nine seconds apart. Both manifests&lt;br&gt;
carry &lt;code&gt;subject&lt;/code&gt; pointing back at &lt;code&gt;sha256:96b80ae1…5929&lt;/code&gt; — &lt;strong&gt;the single place where&lt;br&gt;
"this signature belongs to that image" is actually written down.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pull the bundle blob and hash it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://ghcr.io/v2/soit-ai/soit/server/blobs/sha256:eae5d902...dd86 &lt;span class="nt"&gt;-o&lt;/span&gt; bundle.json
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;bundle.json
&lt;span class="c"&gt;# eae5d902186b490570aba6f702f7ce03c46f935e403bf881ec27d99480d7dd86 *bundle.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash equals the digest I asked for. A content-addressed registry means this step&lt;br&gt;
verifies itself; no trust required yet.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. What the signature actually covers
&lt;/h3&gt;

&lt;p&gt;The bundle has three top-level fields — &lt;code&gt;mediaType&lt;/code&gt;, &lt;code&gt;verificationMaterial&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;dsseEnvelope&lt;/code&gt;. The envelope's &lt;code&gt;payloadType&lt;/code&gt; is &lt;code&gt;application/vnd.in-toto+json&lt;/code&gt;, and&lt;br&gt;
the base64 payload is the only thing the signature covers:&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;"https://in-toto.io/Statement/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghcr.io/soit-ai/soit/server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"digest"&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="nl"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"&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;"predicateType"&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://slsa.dev/provenance/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"predicate"&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;"buildDefinition"&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;"buildType"&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://actions.github.io/buildtypes/workflow/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"externalParameters"&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;"workflow"&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;"ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refs/tags/v1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"repository"&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://github.com/soit-ai/soit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".github/workflows/release.yml"&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;"internalParameters"&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;"github"&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;"event_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"push"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"repository_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;"910429753"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"repository_owner_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;"193298865"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"runner_environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github-hosted"&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;"resolvedDependencies"&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;"uri"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"git+https://github.com/soit-ai/soit@refs/tags/v1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"digest"&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="nl"&gt;"gitCommit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8105cae074f1f27d7916acfe02f9d4eabb63169f"&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;span class="nl"&gt;"runDetails"&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;"builder"&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="nl"&gt;"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;"https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&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="nl"&gt;"invocationId"&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://github.com/soit-ai/soit/actions/runs/31023642816/attempts/1"&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;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;One complete sentence: &lt;strong&gt;commit &lt;code&gt;8105cae…&lt;/code&gt; on &lt;code&gt;refs/tags/v1.0.0&lt;/code&gt;, built by&lt;br&gt;
&lt;code&gt;.github/workflows/release.yml&lt;/code&gt; on a GitHub-hosted runner in run 31023642816,&lt;br&gt;
produced the image with digest &lt;code&gt;96b80ae1…&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had the repository on disk, so I closed the loop immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rev-parse v1.0.0^&lt;span class="o"&gt;{&lt;/span&gt;commit&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# 8105cae074f1f27d7916acfe02f9d4eabb63169f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match. Note &lt;code&gt;repository_id&lt;/code&gt; and &lt;code&gt;repository_owner_id&lt;/code&gt;: numeric IDs are worth more&lt;br&gt;
than the repository name, because &lt;strong&gt;names can be renamed and transferred, IDs cannot.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  4. The certificate that lived for ten minutes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;verificationMaterial.certificate.rawBytes&lt;/code&gt; is a 1735-byte DER certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;issuer=O = sigstore.dev, CN = sigstore-intermediate
subject=
notBefore=Aug  5 16:14:11 2026 GMT
notAfter =Aug  5 16:24:11 2026 GMT
X509v3 Subject Alternative Name: critical
    URI:https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth stopping on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The subject is empty.&lt;/strong&gt; The field that traditionally names the holder is blank; the&lt;br&gt;
identity moved entirely into that SAN URI, and it is not a person or an organisation —&lt;br&gt;
&lt;strong&gt;it is a workflow at a ref.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The validity window is exactly ten minutes.&lt;/strong&gt; That is keyless signing: the pipeline&lt;br&gt;
trades an OIDC token for a short-lived certificate, signs, and throws the key away.&lt;br&gt;
Nothing to store, nothing to rotate, nothing to steal later. Ten minutes on, the&lt;br&gt;
certificate is expired — and the signature still verifies, for the reason in section 5.&lt;/p&gt;

&lt;p&gt;The certificate also carries a block of Sigstore extensions describing the build. The&lt;br&gt;
values below are what I read; &lt;strong&gt;I am deliberately not putting names on the OIDs&lt;/strong&gt; —&lt;br&gt;
those live in Fulcio's OID documentation, and the values speak for themselves:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OID suffix&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.1&lt;/code&gt; / &lt;code&gt;.1.8&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.2&lt;/code&gt; / &lt;code&gt;.1.20&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;push&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.3&lt;/code&gt; / &lt;code&gt;.1.10&lt;/code&gt; / &lt;code&gt;.1.13&lt;/code&gt; / &lt;code&gt;.1.19&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8105cae074f1f27d7916acfe02f9d4eabb63169f&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;release&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;soit-ai/soit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.6&lt;/code&gt; / &lt;code&gt;.1.14&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refs/tags/v1.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.9&lt;/code&gt; / &lt;code&gt;.1.18&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;…/release.yml@refs/tags/v1.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.11&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github-hosted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.12&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://github.com/soit-ai/soit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.1.15&lt;/code&gt; / &lt;code&gt;.1.17&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;910429753&lt;/code&gt; / &lt;code&gt;193298865&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://github.com/soit-ai&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.21&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;…/actions/runs/31023642816/attempts/1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.22&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1.24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;repo:soit-ai/soit:ref:refs/tags/v1.0.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Extended Key Usage is &lt;code&gt;Code Signing&lt;/code&gt;, Key Usage is &lt;code&gt;Digital Signature&lt;/code&gt; only, and&lt;br&gt;
there is a CT precertificate SCT timestamped &lt;code&gt;Aug 5 16:14:11.958 2026 GMT&lt;/code&gt; — the act&lt;br&gt;
of issuing this certificate was itself logged publicly.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Why an expired certificate still counts
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;verificationMaterial.tlogEntries&lt;/code&gt; holds exactly one record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;logIndex        : 2346649359
integratedTime  : 1785946452   -&amp;gt;  2026-08-05T16:14:12Z
kindVersion     : {kind: dsse, version: 0.0.1}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That record lives in Rekor, the public append-only transparency log, and its job is&lt;br&gt;
to witness &lt;em&gt;when&lt;/em&gt;. The question a verifier asks is not "is this certificate valid&lt;br&gt;
now" — it expired long ago — but "&lt;strong&gt;was it valid at the moment of signing&lt;/strong&gt;". The log&lt;br&gt;
pins that moment at &lt;code&gt;16:14:12Z&lt;/code&gt;, inside the &lt;code&gt;16:14:11&lt;/code&gt;–&lt;code&gt;16:24:11&lt;/code&gt; window.&lt;/p&gt;

&lt;p&gt;A detail from the actual bundle: &lt;code&gt;timestampVerificationData&lt;/code&gt; is an &lt;strong&gt;empty object&lt;/strong&gt;.&lt;br&gt;
No RFC 3161 timestamp; the Rekor entry carries the time on its own. Not wrong, just&lt;br&gt;
worth knowing what your trust actually rests on.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Where the SBOM lives and what is in it
&lt;/h3&gt;

&lt;p&gt;The second bundle's predicate is a complete SPDX document. The bundle blob is&lt;br&gt;
3437645 bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spdxVersion   : SPDX-2.3
dataLicense   : CC0-1.0
name          : ghcr.io/soit-ai/soit/server
creationInfo  : creators ["Organization: Anchore, Inc", "Tool: syft-1.42.3"],
                created "2026-08-05T16:14:08Z"
packages      : 710
relationships : 2840
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those 710 packages by purl type: &lt;strong&gt;469 &lt;code&gt;pkg:deb&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;217 &lt;code&gt;pkg:pypi&lt;/code&gt;&lt;/strong&gt;, one&lt;br&gt;
&lt;code&gt;pkg:generic&lt;/code&gt; (&lt;code&gt;python 3.11.15&lt;/code&gt;) and one &lt;code&gt;pkg:oci&lt;/code&gt; (the image itself).&lt;/p&gt;

&lt;p&gt;The 469 Debian packages come from the base image — &lt;code&gt;server/Dockerfile&lt;/code&gt; starts at&lt;br&gt;
&lt;code&gt;python:3.11&lt;/code&gt; — and only 217 are our own Python dependencies. That ratio is itself a&lt;br&gt;
finding: &lt;strong&gt;two thirds of what you just signed, you did not write.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All three images side by side show the split clearly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Packages&lt;/th&gt;
&lt;th&gt;Composition&lt;/th&gt;
&lt;th&gt;SBOM generated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;710&lt;/td&gt;
&lt;td&gt;469 deb + 217 pypi&lt;/td&gt;
&lt;td&gt;16:14:08Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;knowledge-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;775&lt;/td&gt;
&lt;td&gt;469 deb + 281 pypi&lt;/td&gt;
&lt;td&gt;16:22:23Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1331&lt;/td&gt;
&lt;td&gt;1311 npm + 18 apk&lt;/td&gt;
&lt;td&gt;16:15:01Z&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 64 extra pypi packages in &lt;code&gt;knowledge-worker&lt;/code&gt; include &lt;code&gt;torch&lt;/code&gt;, &lt;code&gt;transformers&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;nvidia-cublas-cu12&lt;/code&gt;. The two images are two targets of one Dockerfile; the entire&lt;br&gt;
difference is &lt;code&gt;uv sync --extra knowledge-worker&lt;/code&gt; (&lt;code&gt;server/Dockerfile:19&lt;/code&gt; vs &lt;code&gt;:27&lt;/code&gt;).&lt;br&gt;
&lt;code&gt;web&lt;/code&gt; is &lt;code&gt;node:24-alpine&lt;/code&gt;, so the Debian packages become 18 apk ones.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. The 16 MiB ceiling, and what it cost us
&lt;/h3&gt;

&lt;p&gt;An SPDX document normally carries a &lt;code&gt;files&lt;/code&gt; section recording which files each&lt;br&gt;
package was identified from. All three of ours have none — and it is deliberate,&lt;br&gt;
right there in the workflow (&lt;code&gt;.github/workflows/release.yml:132–149&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="s1"&gt;'del(.files)
    | .packages |= map(del(.hasFiles))
    | .relationships |= map(select(
        ((.spdxElementId // "") | startswith("SPDXRef-File") | not)
        and ((.relatedSpdxElement // "") | startswith("SPDXRef-File") | not)))'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;%s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; 16000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment above it gives the reason: per-file SPDX entries push an ML-heavy image's&lt;br&gt;
SBOM past the 16 MiB subject limit of &lt;code&gt;actions/attest&lt;/code&gt;. In plain terms, &lt;strong&gt;the SBOM had&lt;br&gt;
to be shrunk until it could be signed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cost is real. You keep the package inventory and the package-to-package&lt;br&gt;
relationships; you lose "which file did this package come from", which is exactly what&lt;br&gt;
you want when a CVE lands and you need to locate it. The &lt;code&gt;test -le 16000000&lt;/code&gt; line is a&lt;br&gt;
hard gate: if a future image blows past the ceiling the release &lt;strong&gt;fails there&lt;/strong&gt; rather&lt;br&gt;
than shipping an SBOM that cannot be signed. That part I think is right — better a&lt;br&gt;
broken build than a half-signed artifact.&lt;/p&gt;
&lt;h3&gt;
  
  
  8. An SBOM is a scan result, not the truth
&lt;/h3&gt;

&lt;p&gt;Two things I found in ours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five Windows launchers in a Linux image.&lt;/strong&gt; Among the 710 packages,&lt;br&gt;
&lt;code&gt;Simple Launcher 1.1.0.14&lt;/code&gt; appears &lt;strong&gt;five times&lt;/strong&gt;, each with a CPE and no purl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SPDXRef-Package-binary-Simple-Launcher-d60858f6579e7bb1
  versionInfo : 1.1.0.14
  externalRefs: cpe:2.3:a:Simple_Launcher:Simple_Launcher:1.1.0.14:*:*:*:*:*:*:*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Syft's binary classifier found them: Python packaging wheels ship small Windows&lt;br&gt;
launcher executables, which will never execute inside this Linux image but are&lt;br&gt;
inventoried all the same. And because the &lt;code&gt;files&lt;/code&gt; section was stripped (section 7),&lt;br&gt;
you cannot even find out where they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fourth hash that matches nothing.&lt;/strong&gt; The &lt;code&gt;pkg:oci&lt;/code&gt; entry representing the image&lt;br&gt;
itself reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pkg:oci/ghcr.io%2Fsoit-ai%2Fsoit%2Fserver@sha256%3Ae7c993b9ac5d7322058e0169c678b4ce21fe42fac72c5d3374404bf4642939ea?arch=amd64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;e7c993b9…&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; the index digest, &lt;strong&gt;not&lt;/strong&gt; the platform manifest digest and&lt;br&gt;
&lt;strong&gt;not&lt;/strong&gt; the config digest. Asked for directly, the registry returns 404. I could not&lt;br&gt;
determine what it is — the reasonable guess is an identifier the scanner computes&lt;br&gt;
locally — so I am reporting the observation and not a conclusion. The practical rule&lt;br&gt;
is clear enough: &lt;strong&gt;do not reconcile an SBOM's internal digest against the signature's&lt;br&gt;
subject.&lt;/strong&gt; The binding lives in exactly one place, &lt;code&gt;subject.digest&lt;/code&gt;, and that value is&lt;br&gt;
&lt;code&gt;96b80ae1…&lt;/code&gt;, matching the registry exactly.&lt;/p&gt;
&lt;h3&gt;
  
  
  9. Three &lt;code&gt;v1.0.0&lt;/code&gt; images, all of them verifiable
&lt;/h3&gt;

&lt;p&gt;Back to those two extra tags. Fetched the same way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image digest behind the fallback tag&lt;/th&gt;
&lt;th&gt;Attestations attached&lt;/th&gt;
&lt;th&gt;Signed at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3a5b3b1a…8246&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;provenance only&lt;/td&gt;
&lt;td&gt;2026-08-05T15:41:15Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;236201a5…f874f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;provenance + SBOM&lt;/td&gt;
&lt;td&gt;15:52:32Z / 15:52:44Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;96b80ae1…5929&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;provenance + SBOM&lt;/td&gt;
&lt;td&gt;16:14:12Z / 16:14:22Z&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three provenance statements name &lt;code&gt;refs/tags/v1.0.0&lt;/code&gt; and the same signing identity.&lt;br&gt;
&lt;strong&gt;The commits differ:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image digest&lt;/th&gt;
&lt;th&gt;Commit in provenance&lt;/th&gt;
&lt;th&gt;Actions run&lt;/th&gt;
&lt;th&gt;Commit subject&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3a5b3b1a…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0dacfc52…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31020921135&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ci(release): create the artifacts directory before image SBOM generation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;236201a5…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec822c63…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31021873557&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ci(release): catalog packages only in image SBOMs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;96b80ae1…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8105cae0…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31023642816&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ci(release): trim image SBOMs to package level before attestation&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three commits are on &lt;code&gt;main&lt;/code&gt; locally, 23:33 / 23:44 / 00:05 in my timezone. The&lt;br&gt;
story reads itself off the commit subjects: &lt;strong&gt;the tag was re-pointed twice that&lt;br&gt;
night&lt;/strong&gt; while the SBOM step was being fixed — and each failed attempt had already&lt;br&gt;
pushed and signed its images before failing.&lt;/p&gt;

&lt;p&gt;So this happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh attestation verify &lt;span class="se"&gt;\&lt;/span&gt;
  oci://ghcr.io/soit-ai/soit/server@sha256:3a5b3b1a...8246 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repo&lt;/span&gt; soit-ai/soit &lt;span class="nt"&gt;--format&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exit code : 0
subject   : 3a5b3b1a...8246
predicate : https://slsa.dev/provenance/v1
ref       : refs/tags/v1.0.0
commit    : 0dacfc5219c4ae57d024f9cb4208e1efad5f0d17
san       : https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0
tlog      : rekor.sigstore.dev @ 2026-08-05T23:41:11+08:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Exit 0.&lt;/strong&gt; Nothing is wrong with the signature. It states honestly what it is: an&lt;br&gt;
image built by our release workflow, on &lt;code&gt;refs/tags/v1.0.0&lt;/code&gt;, from commit &lt;code&gt;0dacfc52…&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;The one thing it does not say is that it is not the image we shipped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No attack here — we built all three. But swap in a nastier scenario and it holds&lt;br&gt;
immediately: a pipeline compromised once, fixed, re-tagged, with the poisoned&lt;br&gt;
intermediate still sitting in the registry. &lt;strong&gt;Attestation will not catch that.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;gh attestation verify&lt;/code&gt; answers &lt;em&gt;"did this come out of that pipeline"&lt;/em&gt;. It never&lt;br&gt;
promised to answer &lt;em&gt;"is this the release"&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  10. The missing link: a manifest that pins tag, commit and digest
&lt;/h3&gt;

&lt;p&gt;That answer is not in the signature. It is an asset on the GitHub Release, which&lt;br&gt;
carries seven files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;soit-1.0.0.tar.gz          3148831
SHA256SUMS                     433
release-artifacts.json        2877
server.spdx.json           3313874
knowledge-worker.spdx.json 4004516
web.spdx.json              3862614
source.spdx.json           3694033
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;release-artifacts.json&lt;/code&gt; is the manifest:&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;"featureKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"release.artifacts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemaVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"release_tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"commit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8105cae074f1f27d7916acfe02f9d4eabb63169f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"clean_worktree_at_tag"&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;"images"&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;"component"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghcr.io/soit-ai/soit/server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"release_tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghcr.io/soit-ai/soit/server@sha256:96b80ae1...5929"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sbom"&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="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spdx-json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"server.spdx.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b83c3bc7...ef68"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"attestation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"provenance_attestation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;It says the thing the signature would not: &lt;strong&gt;&lt;code&gt;v1.0.0&lt;/code&gt; is &lt;code&gt;96b80ae1…&lt;/code&gt;, not the other&lt;br&gt;
two.&lt;/strong&gt; It is assembled by &lt;code&gt;jq&lt;/code&gt; inside the &lt;code&gt;publish-release&lt;/code&gt; job&lt;br&gt;
(&lt;code&gt;release.yml:300–347&lt;/code&gt;) and validated on the spot. I fed the copy downloaded from the&lt;br&gt;
public internet straight into the same script that lives in the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "commit": "8105cae074f1f27d7916acfe02f9d4eabb63169f",
  "images": ["knowledge-worker", "server", "web"],
  "passed": true,
  "release_tag": "v1.0.0"
}
exit=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;178 lines, standard library only, no network&lt;br&gt;
(&lt;code&gt;server/scripts/verify_release_artifacts.py&lt;/code&gt;). It insists that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;release_tag&lt;/code&gt; equals &lt;code&gt;v&lt;/code&gt; + &lt;code&gt;version&lt;/code&gt; (&lt;code&gt;:42&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;commit&lt;/code&gt; is 40 lowercase hex characters and not all zeroes (&lt;code&gt;:45&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clean_worktree_at_tag&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt; (&lt;code&gt;:49&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;every image &lt;code&gt;reference&lt;/code&gt; &lt;strong&gt;equals &lt;code&gt;name@digest&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;:91&lt;/code&gt;) — a &lt;code&gt;name:tag&lt;/code&gt; reference is
rejected outright;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;digest&lt;/code&gt; matches &lt;code&gt;^sha256:[0-9a-f]{64}$&lt;/code&gt; (&lt;code&gt;:88&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;exactly the three expected components are present (&lt;code&gt;:15&lt;/code&gt;, &lt;code&gt;:114&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;no attestation URL is reused across entries (&lt;code&gt;:107–112&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A test guards that last-but-two rule: it rewrites the example's &lt;code&gt;reference&lt;/code&gt; into tag&lt;br&gt;
form and asserts the script fails with &lt;code&gt;digest-pinned&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;server/tests/unit/test_release_operations_contract.py:85–88&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;
  
  
  11. I reproduced the source tarball, byte for byte, on Windows
&lt;/h3&gt;

&lt;p&gt;The release also ships a &lt;code&gt;git archive&lt;/code&gt; tarball (&lt;code&gt;release.yml:253–263&lt;/code&gt;) described as&lt;br&gt;
deterministic. Worth testing rather than believing. First, the checksum:&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;grep&lt;/span&gt; &lt;span class="s2"&gt;"soit-1.0.0.tar.gz"&lt;/span&gt; SHA256SUMS | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; -
&lt;span class="c"&gt;# ./soit-1.0.0.tar.gz: OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then rebuild it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git archive &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tar.gz &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"soit-1.0.0/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;local.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;c11179c379ba7390c215133f342c92a7517a548f1536959cb43e187b16a7bab3 *local.tar.gz
5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *soit-1.0.0.tar.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different — and not just at the gzip layer; the inner tars differ too. Comparing&lt;br&gt;
members explains it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;members   : 1551 local / 1551 released   -- same
only-one-side: 0 / 0                     -- same
differing : 1280 members
mtime     : 1785945959 everywhere        -- same
mode      : 0664 everywhere              -- same
example: soit-1.0.0/.github/workflows/quality.yml   local 15270  released 14803
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sizes only, and each delta equals that file's line count. &lt;strong&gt;CRLF.&lt;/strong&gt; My machine has&lt;br&gt;
&lt;code&gt;core.autocrlf=true&lt;/code&gt;, so &lt;code&gt;git archive&lt;/code&gt; helpfully rewrote the line endings. Turn it&lt;br&gt;
off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nt"&gt;-c&lt;/span&gt; core.autocrlf&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;archive &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tar.gz &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"soit-1.0.0/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;local2.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
&lt;span class="c"&gt;# 5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *local2.tar.gz&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Identical.&lt;/strong&gt; A Windows box, git 2.55.0, a month later, reproducing the tarball&lt;br&gt;
GitHub Actions produced.&lt;/p&gt;

&lt;p&gt;Both halves matter. Determinism is real — &lt;code&gt;git archive&lt;/code&gt; writes no build timestamp and&lt;br&gt;
takes mtimes from the commit — &lt;strong&gt;but it is sensitive to local git configuration&lt;/strong&gt;, and&lt;br&gt;
the first suspect when reproduction fails is &lt;code&gt;core.autocrlf&lt;/code&gt;, not the publisher.&lt;/p&gt;
&lt;h3&gt;
  
  
  12. The checklist
&lt;/h3&gt;

&lt;p&gt;Everything above, compressed into something you can paste:&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="c"&gt;# 1. Resolve the digest (never keep books against a tag)&lt;/span&gt;
docker buildx imagetools inspect ghcr.io/soit-ai/soit/server:v1.0.0 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt;

&lt;span class="c"&gt;# 2. Verify build provenance&lt;/span&gt;
gh attestation verify oci://ghcr.io/soit-ai/soit/server:v1.0.0 &lt;span class="nt"&gt;--repo&lt;/span&gt; soit-ai/soit

&lt;span class="c"&gt;# 3. Verify the SBOM attestation (NOT checked by default)&lt;/span&gt;
gh attestation verify oci://ghcr.io/soit-ai/soit/server:v1.0.0 &lt;span class="nt"&gt;--repo&lt;/span&gt; soit-ai/soit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--predicate-type&lt;/span&gt; https://spdx.dev/Document/v2.3

&lt;span class="c"&gt;# 4. Check the release manifest: is this digest the one that shipped&lt;/span&gt;
curl &lt;span class="nt"&gt;-sLO&lt;/span&gt; https://github.com/soit-ai/soit/releases/download/v1.0.0/release-artifacts.json
curl &lt;span class="nt"&gt;-sLO&lt;/span&gt; https://github.com/soit-ai/soit/releases/download/v1.0.0/SHA256SUMS
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; SHA256SUMS

&lt;span class="c"&gt;# 5. Run it by digest, not by tag&lt;/span&gt;
docker pull ghcr.io/soit-ai/soit/server@sha256:96b80ae1...5929
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 deserves emphasis: &lt;strong&gt;&lt;code&gt;gh attestation verify&lt;/code&gt; checks provenance only by&lt;br&gt;
default.&lt;/strong&gt; Without &lt;code&gt;--predicate-type&lt;/code&gt; it returned exactly one result here, the SLSA&lt;br&gt;
provenance. With it, the SPDX attestation comes back — 6022876 bytes of JSON holding&lt;br&gt;
the same 710 packages counted in section 6. A lot of people assume "verified" includes&lt;br&gt;
the SBOM. It does not.&lt;/p&gt;

&lt;p&gt;Step 4 is the point of this whole piece. Without it, the green checkmark from step 2&lt;br&gt;
shines just as brightly on the &lt;code&gt;3a5b3b1a…&lt;/code&gt; image from section 9.&lt;/p&gt;

&lt;h3&gt;
  
  
  13. Eight things that still do not line up
&lt;/h3&gt;

&lt;p&gt;House rule: the last section is about our own problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Our own compose pins tags, not digests.&lt;/strong&gt; All six services in&lt;br&gt;
&lt;code&gt;docker/docker-compose.images.yml&lt;/code&gt; use &lt;code&gt;:${SOIT_IMAGE_TAG:-v1.0.0}&lt;/code&gt; (&lt;code&gt;:17&lt;/code&gt;–&lt;code&gt;:32&lt;/code&gt;), and&lt;br&gt;
the README says the same (&lt;code&gt;README.md:146&lt;/code&gt;). We demand digest-pinned references inside&lt;br&gt;
&lt;code&gt;release-artifacts.json&lt;/code&gt; and then hand users a tag. Impact: a tag can be re-pointed.&lt;br&gt;
Workaround: step 5 of the checklist. &lt;strong&gt;I plan to open an issue&lt;/strong&gt;; none filed at the&lt;br&gt;
time of writing, hence no link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;SHA256SUMS&lt;/code&gt; does not cover &lt;code&gt;release-artifacts.json&lt;/code&gt;.&lt;/strong&gt; The 433-byte file lists&lt;br&gt;
five entries — four SPDX documents and the tarball. Neither the manifest nor&lt;br&gt;
&lt;code&gt;SHA256SUMS&lt;/code&gt; itself is in there (the latter cannot be, by construction). The manifest&lt;br&gt;
does get its own attestation (&lt;code&gt;release.yml:350–353&lt;/code&gt;), so it is not unprotected, but&lt;br&gt;
&lt;strong&gt;the instinctive "download everything and run &lt;code&gt;sha256sum -c&lt;/code&gt;" does not reach it.&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Also going to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;docs/release-process.md&lt;/code&gt; lags the pipeline.&lt;/strong&gt; It tells you to &lt;em&gt;copy&lt;/em&gt; the real&lt;br&gt;
tag, commit, digests, SBOM checksums and attestation URLs into an evidence document&lt;br&gt;
and then run the verifier (&lt;code&gt;:24&lt;/code&gt;–&lt;code&gt;:32&lt;/code&gt;). The pipeline has been doing that&lt;br&gt;
automatically for a while, and uploads the result as a release asset. Impact: a reader&lt;br&gt;
concludes the manifest is hand-written after the fact, which makes it look weaker than&lt;br&gt;
it is. &lt;strong&gt;Also going to open an issue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Superseded builds are never cleaned up.&lt;/strong&gt; The two images from section 9 are still&lt;br&gt;
pullable, still verifiable, still runnable. We have no "delete same-tag intermediates&lt;br&gt;
after a successful release" step. Impact: as described. Workaround: pull the digest&lt;br&gt;
from &lt;code&gt;release-artifacts.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The verifier checks shape, not truth.&lt;/strong&gt; &lt;code&gt;provenance_attestation&lt;/code&gt; only has to be a&lt;br&gt;
non-empty string (&lt;code&gt;:58&lt;/code&gt;, &lt;code&gt;:64&lt;/code&gt;, &lt;code&gt;:106&lt;/code&gt;, all via &lt;code&gt;_require_text&lt;/code&gt;). The script never&lt;br&gt;
fetches the URL and never compares the attested subject digest to the one in the&lt;br&gt;
manifest. Impact: a well-formed manifest full of fabricated URLs passes. Workaround:&lt;br&gt;
it is a &lt;strong&gt;structural&lt;/strong&gt; check, not a &lt;strong&gt;trust&lt;/strong&gt; check — the trust comes from&lt;br&gt;
&lt;code&gt;gh attestation verify&lt;/code&gt;, and you need both. Defensible division of labour, but the&lt;br&gt;
docs never say so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. &lt;code&gt;REQUIRED_IMAGES&lt;/code&gt; is hard-coded.&lt;/strong&gt; &lt;code&gt;verify_release_artifacts.py:15&lt;/code&gt; pins&lt;br&gt;
&lt;code&gt;{"server", "knowledge-worker", "web"}&lt;/code&gt; and line 114 demands exact equality. Add a&lt;br&gt;
fourth image and the release breaks at the final step. Impact: a bad surprise on&lt;br&gt;
release day. Workaround: know it is there — and the case it blocks (silently shipping&lt;br&gt;
with an image missing) is worth more than the nuisance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. No process for SBOM false positives.&lt;/strong&gt; Sections 7 and 8. Nobody owns annotating&lt;br&gt;
&lt;code&gt;Simple Launcher&lt;/code&gt; as noise, so the next reader has to work it out again. Impact: the&lt;br&gt;
signal-to-noise ratio degrades as images grow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. I could not identify &lt;code&gt;e7c993b9…&lt;/code&gt;.&lt;/strong&gt; That is where section 8 stops. &lt;strong&gt;This entry&lt;br&gt;
exists so I do not pretend otherwise&lt;/strong&gt;: I can confirm it is absent from the registry&lt;br&gt;
and differs from the other three digests; I cannot tell you how it is derived.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disclosures
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is a verification walkthrough, not a runtime one.&lt;/strong&gt; I did not pull the images
and stand up a stack; every conclusion stops at the bytes-and-signatures layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 9 is not a security incident.&lt;/strong&gt; We built all three images ourselves on the
same night. It illustrates the &lt;em&gt;semantics&lt;/em&gt; of attestation, not a breach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Section 8's mismatched digest is an observation, not a conclusion.&lt;/strong&gt; The guess is
labelled as a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All numbers belong to the &lt;code&gt;v1.0.0&lt;/code&gt; release&lt;/strong&gt; (tag commit &lt;code&gt;8105cae…&lt;/code&gt;). Later
releases will have different values; the method does not change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I only exercised the success path of &lt;code&gt;gh attestation verify&lt;/code&gt;&lt;/strong&gt; (exit 0). I did not
construct a tampered image to see how it fails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disclosure: I maintain SOIT.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One-line takeaway
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A passing attestation says "these bytes came out of that pipeline"; it does not say&lt;br&gt;
"this is the release."&lt;/strong&gt; What joins the two is a manifest binding tag, commit and&lt;br&gt;
digest — ours is &lt;code&gt;release-artifacts.json&lt;/code&gt;, watched by a 178-line script whose hardest&lt;br&gt;
rule is a single line: the reference must be &lt;code&gt;name@digest&lt;/code&gt;, and a tag reference fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it, and please poke holes
&lt;/h3&gt;

&lt;p&gt;The repository is &lt;code&gt;github.com/soit-ai/soit&lt;/code&gt;, and every step above is reproducible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the &lt;code&gt;curl&lt;/code&gt; calls in section 2 need no account — an anonymous token is enough;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;3a5b3b1a…&lt;/code&gt; image from section 9 is still there; verify it yourself and watch
it exit 0;&lt;/li&gt;
&lt;li&gt;for the byte-identical rebuild in section 11, remember &lt;code&gt;-c core.autocrlf=false&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any claim here is wrong — especially the digest in section 8 that I failed to&lt;br&gt;
identify — open an issue and say so. I would rather learn where this is wrong than be&lt;br&gt;
told the pipeline looks thorough.&lt;/p&gt;

</description>
      <category>devsecops</category>
      <category>containers</category>
      <category>cicd</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>Four different things are called "replay" in our agent runtime. I read the ledger.</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Thu, 10 Sep 2026 01:06:34 +0000</pubDate>
      <link>https://dev.to/judezh/four-different-things-are-called-replay-in-our-agent-runtime-i-read-the-ledger-3oj9</link>
      <guid>https://dev.to/judezh/four-different-things-are-called-replay-in-our-agent-runtime-i-read-the-ledger-3oj9</guid>
      <description>&lt;h3&gt;
  
  
  The short version
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;replay&lt;/code&gt; means four different things in our codebase. Here they are up front:&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;What it is&lt;/th&gt;
&lt;th&gt;Entry point&lt;/th&gt;
&lt;th&gt;Re-executes?&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Evidence replay&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/observe/runs/{run_id}/replay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;One database read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Catch-up after a dropped connection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/runs/{run_id}/stream?last_event_id=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;One database read, then resubscribe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Idempotent replay of a tool call&lt;/td&gt;
&lt;td&gt;Inside the tool gateway, same idempotency key arriving twice&lt;/td&gt;
&lt;td&gt;No — returns last time's result&lt;/td&gt;
&lt;td&gt;One database read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Actual re-execution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST /api/v1/workflows/{id}/runs/{run_id}/replay&lt;/code&gt; and two other paths&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs again, spends money again&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four rest on one thing: &lt;strong&gt;the ledger that hits the database first is the authority.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not the logs. Not the event stream. Not the SSE feed scrolling in your console.&lt;br&gt;
The rows in the tables. That sounds unremarkable until you notice it is what lets three&lt;br&gt;
of those four survive a process restart.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Why the word needs splitting
&lt;/h3&gt;

&lt;p&gt;In a demo, these three sentences look like one feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Here's every step of that run."&lt;/li&gt;
&lt;li&gt;"Lost your connection? Refresh — the missing steps come back."&lt;/li&gt;
&lt;li&gt;"Bad answer? Hit replay."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Engineering-wise they are nothing alike. The first is a &lt;strong&gt;read&lt;/strong&gt;. The second is a&lt;br&gt;
&lt;strong&gt;read plus a subscription&lt;/strong&gt;. The third is a &lt;strong&gt;write&lt;/strong&gt; — it calls the model again,&lt;br&gt;
sends the HTTP request again, spends the money again.&lt;/p&gt;

&lt;p&gt;The cost of collapsing them into one word is a user who assumes "replay" is safe&lt;br&gt;
and sends two emails.&lt;/p&gt;

&lt;p&gt;So the order below goes from cheapest to most expensive.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The tables, and one number that shows up three times
&lt;/h3&gt;

&lt;p&gt;Five tables, all in one file (&lt;code&gt;server/app/kernel/runtime/db/models/runs.py&lt;/code&gt;, 396 lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;runs                    one execution                        Run           :25
run_steps               one step inside it                   RunStep       :120
run_step_tool_calls     execution control for one tool call                :180
run_artifacts           files this execution produced        RunArtifact   :242
run_cost_entries        usage and cost for one metered call                :284
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three more cover long-running work (&lt;code&gt;models/tasks.py&lt;/code&gt;, 98 lines): &lt;code&gt;tasks&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;task_checkpoints&lt;/code&gt;, &lt;code&gt;task_events&lt;/code&gt;. Section 7 uses them.&lt;/p&gt;

&lt;p&gt;Now the detail worth stopping on: &lt;strong&gt;8192 appears three times in this ledger,&lt;br&gt;
and it means something different each time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Twice on the run and the step, where summaries are &lt;strong&gt;truncated&lt;/strong&gt;:&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="n"&gt;input_summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;input_summary&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8192&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;input_summary&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once on a tool call result, where anything larger is &lt;strong&gt;offloaded to object storage&lt;/strong&gt;&lt;br&gt;
and the ledger keeps a pointer, a byte count and a sha256:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoded_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;artifact&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;trace_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_artifact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;step_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;artifact_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;storage_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;storage_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;mime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;size_bytes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoded_result&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoded_result&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;meta&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;kind&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_result&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;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_call_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;&lt;strong&gt;The asymmetry is deliberate.&lt;/strong&gt; Summaries are for humans; losing the tail is fine.&lt;br&gt;
Tool results get reconciled and replayed; losing a byte is not fine.&lt;/p&gt;

&lt;p&gt;Section 12 covers a consequence of that asymmetry we have not handled well yet.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Replay #1: reassembling the evidence
&lt;/h3&gt;

&lt;p&gt;The cheap one. A GET:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/observe/runs/{run_id}/replay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One sentence of behaviour: &lt;strong&gt;query the five record types by run id, add approvals and&lt;br&gt;
feedback, return the bundle.&lt;/strong&gt; The implementation&lt;br&gt;
(&lt;code&gt;server/app/modules/observe/application/service.py:212&lt;/code&gt;) returns seven keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;steps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifacts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;costs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approvals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feedback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_spec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;to_runtrace_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costs&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;Six raw record sets, plus &lt;code&gt;trace_spec&lt;/code&gt; — the same data flattened into something you can&lt;br&gt;
hand to a tracing backend (&lt;code&gt;kernel/runtime/runs/exporter.py:88&lt;/code&gt;). That spec carries two&lt;br&gt;
rollups alongside the timeline: &lt;code&gt;usage_summary&lt;/code&gt; (prompt tokens, completion tokens,&lt;br&gt;
embeddings, reranks, milliseconds, storage bytes, requests, vectors) and &lt;code&gt;charge_summary&lt;/code&gt;&lt;br&gt;
(amounts grouped by currency).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing here executes.&lt;/strong&gt; No model call, no tool call, no cost. It is a database read,&lt;br&gt;
so you can call it at any point after the run ended, and the ten-thousandth call costs&lt;br&gt;
what the first one did.&lt;/p&gt;

&lt;p&gt;Every query carries &lt;code&gt;tenant_id&lt;/code&gt; and &lt;code&gt;workspace_id&lt;/code&gt; in its &lt;code&gt;where&lt;/code&gt; clause — reading another&lt;br&gt;
workspace's ledger is closed off at the SQL level, not at a middleware you can misconfigure.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Replay #2: catching up after the connection drops
&lt;/h3&gt;

&lt;p&gt;The second-cheapest, for the "tab is open, wifi died" case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/runs/{run_id}/stream?last_event_id=st_xxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handled at &lt;code&gt;server/app/api/v1/workflow/streaming.py:401&lt;/code&gt;. The part that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;step_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;and_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last_event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="bp"&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;last_step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_query&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="n"&gt;last_step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;last_step_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;
        &lt;span class="n"&gt;known_step_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;steps_query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;and_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="n"&gt;RunStep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;last_step_time&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_step_time&lt;/span&gt; &lt;span class="k"&gt;else&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;span class="nf"&gt;order_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RunStep&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at where it reads from: &lt;code&gt;select(RunStep)&lt;/code&gt;. The database. Not an in-memory ring&lt;br&gt;
buffer, not a broker offset.&lt;/p&gt;

&lt;p&gt;That choice buys a specific property: &lt;strong&gt;you can reconnect an hour after the run finished,&lt;br&gt;
hand over your &lt;code&gt;last_event_id&lt;/code&gt;, and still get the steps you missed.&lt;/strong&gt; An in-memory buffer&lt;br&gt;
cannot do that — a restart empties it. A broker can, but then you need a broker.&lt;/p&gt;

&lt;p&gt;The SSE &lt;code&gt;id:&lt;/code&gt; field is the step's primary key (&lt;code&gt;streaming.py:432&lt;/code&gt;), so the &lt;code&gt;Last-Event-ID&lt;/code&gt;&lt;br&gt;
that browsers resend automatically is already a row id in the ledger. No second cursor&lt;br&gt;
scheme to keep in sync.&lt;/p&gt;

&lt;p&gt;One more detail worth borrowing: that query sets &lt;code&gt;populate_existing=True&lt;/code&gt;, with a comment&lt;br&gt;
explaining why — the execution side writes from its own session, so this tailer has to&lt;br&gt;
bypass anything its own session cached earlier. That is the kind of line nobody can&lt;br&gt;
reconstruct three months later without the comment.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Replay #3: the same idempotency key, twice
&lt;/h3&gt;

&lt;p&gt;This one happens below the surface, inside the tool gateway.&lt;/p&gt;

&lt;p&gt;Every tool call gets a &lt;code&gt;run_step_tool_calls&lt;/code&gt; row. The table carries three unique&lt;br&gt;
constraints (&lt;code&gt;models/runs.py:182&lt;/code&gt;):&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="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;run_step_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="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;run_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;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="p"&gt;...)&lt;/span&gt;
&lt;span class="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;idempotency_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third is the interesting one. When the same key arrives again and the row is already&lt;br&gt;
terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&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;failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_json&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ToolExecutionClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;run_step&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;replayed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cached_response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;ToolResponse&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;payload&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;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;succeeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;metadata&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;idempotent_replay&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="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;strong&gt;Last time's result comes back; nothing leaves the process.&lt;/strong&gt; The metadata carries&lt;br&gt;
&lt;code&gt;idempotent_replay: True&lt;/code&gt; so callers can tell this apart from a fresh execution.&lt;/p&gt;

&lt;p&gt;If the earlier result was large enough to live in object storage,&lt;br&gt;
&lt;code&gt;load_cached_response&lt;/code&gt; (&lt;code&gt;tool_calls.py:636&lt;/code&gt;) fetches the artifact — after checking tenant,&lt;br&gt;
workspace, run and step all match, and raising &lt;code&gt;Tool result artifact scope mismatch&lt;/code&gt;&lt;br&gt;
if any of them does not.&lt;/p&gt;

&lt;p&gt;The point of this layer: &lt;strong&gt;replay #4 is only safe to offer because this one exists.&lt;/strong&gt;&lt;br&gt;
When you re-run, the tool calls whose idempotency keys did not change are not actually&lt;br&gt;
executed a second time.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. A status that admits we don't know
&lt;/h3&gt;

&lt;p&gt;This is the design I would point at first if someone asked what is unusual about this&lt;br&gt;
ledger.&lt;/p&gt;

&lt;p&gt;Claiming a tool call takes a lease (60 seconds by default, widened by the gateway to the&lt;br&gt;
tool's timeout). An expired lease means the executor may be dead. Retry or not?&lt;/p&gt;

&lt;p&gt;The code answers by asking whether the request actually left (&lt;code&gt;tool_calls.py:309&lt;/code&gt;):&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="n"&gt;lease_expired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;_aware_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&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;lease_expired&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbound_started_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;existing&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_doubt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ConflictError&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 outcome is in doubt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lease_expired&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbound_started_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;existing&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claimed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two branches, split on one field, &lt;code&gt;outbound_started_at&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Died before going out&lt;/strong&gt; — safe. Re-claim, bump the attempt count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Died after going out&lt;/strong&gt; — mark it &lt;code&gt;in_doubt&lt;/code&gt;, &lt;strong&gt;do not retry&lt;/strong&gt;, park the step at
&lt;code&gt;paused&lt;/code&gt;, raise a conflict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second branch is the honest one. On the other end is a real system: an order endpoint,&lt;br&gt;
an email, a transfer. The request left and no response came back.&lt;br&gt;
&lt;strong&gt;We don't know whether it happened, so we don't guess.&lt;/strong&gt; The ledger records "in doubt"&lt;br&gt;
and a human decides.&lt;/p&gt;

&lt;p&gt;Auto-retrying here is wrong in the specific way that only surfaces when someone gets two&lt;br&gt;
copies of the same email.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. Replay #4: actually running it again
&lt;/h3&gt;

&lt;p&gt;The expensive one. Three separate paths, three different mechanisms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) Workflows: replay and retry&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v1/workflows/{workflow_id}/runs/{run_id}/retry
POST /api/v1/workflows/{workflow_id}/runs/{run_id}/replay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two implementations differ by one check&lt;br&gt;
(&lt;code&gt;modules/workflow/application/service.py:804&lt;/code&gt; and &lt;code&gt;:821&lt;/code&gt;): retry requires the source run&lt;br&gt;
to be &lt;code&gt;failed&lt;/code&gt; or &lt;code&gt;canceled&lt;/code&gt;; replay does not. Both load the original inputs, execute&lt;br&gt;
again, and put &lt;code&gt;source_run_id&lt;/code&gt; and &lt;code&gt;control_action&lt;/code&gt; in the response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(b) Agent tasks: replaying a persisted snapshot&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;More interesting (&lt;code&gt;server/app/wiring/task_drivers.py:82&lt;/code&gt;). Rather than "take the inputs and&lt;br&gt;
run", it loads the previous &lt;code&gt;ResponseInteraction&lt;/code&gt; snapshot and &lt;strong&gt;deliberately strips the&lt;br&gt;
identifiers that belonged to the failed attempt&lt;/strong&gt; before queueing a new one:&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="n"&gt;execution_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;assistant_message_id&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="nf"&gt;generate_thread_message_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;execution_json&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;payload&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="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Drop identifiers that belong to the attempt being replaced so the
&lt;/span&gt;    &lt;span class="c1"&gt;# replay creates its own response, run and task.
&lt;/span&gt;    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&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="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old task is then moved to &lt;code&gt;CANCELED&lt;/code&gt; with a forward pointer,&lt;br&gt;
&lt;code&gt;retried_as_interaction_id&lt;/code&gt;, in its progress payload. The comment is blunt about why:&lt;br&gt;
leaving it queued would report work this task will never perform.&lt;/p&gt;

&lt;p&gt;If there is no snapshot, it does not improvise — it fails explicitly with a dedicated&lt;br&gt;
error code, &lt;code&gt;SNAPSHOT_MISSING_ERROR_CODE&lt;/code&gt;. &lt;strong&gt;No evidence, no replay.&lt;/strong&gt; I like that one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) Knowledge ingestion: lineage that actually lands in the ledger&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The only one of the three that writes the lineage into &lt;code&gt;runs&lt;/code&gt;&lt;br&gt;
(&lt;code&gt;modules/knowledge/application/runtime_service.py:848&lt;/code&gt;):&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="n"&gt;run&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;trace_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;source_run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;previous_run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempt_no&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previous_run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempt_no&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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_count&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;request_id&lt;/span&gt;&lt;span class="o"&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;knowledge-ingest:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&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;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runs&lt;/code&gt; has both &lt;code&gt;source_run_id&lt;/code&gt; and &lt;code&gt;attempt_no&lt;/code&gt;, plus a dedicated index,&lt;br&gt;
&lt;code&gt;ix_runs_scope_source_created&lt;/code&gt;. This path uses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other two do not.&lt;/strong&gt; That is item ① in section 12.&lt;/p&gt;
&lt;h3&gt;
  
  
  8. Why the ledger is trustworthy
&lt;/h3&gt;

&lt;p&gt;Three reasons, all in the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status changes are conditional UPDATEs, not read-modify-write.&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;where&lt;/code&gt; clause at &lt;code&gt;writer.py:375&lt;/code&gt; carries the old value:&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="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="n"&gt;db&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workspace_id&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;workspace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Run&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;old_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&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="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeTransitionError&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;Concurrent run transition rejected: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;old_status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target_status&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two executors racing to change the same run: one wins, the other sees &lt;code&gt;rowcount != 1&lt;/code&gt;&lt;br&gt;
and is rejected. Not last-write-wins — &lt;strong&gt;someone jumped the queue, so error out&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Success is an irreversible terminal state.&lt;/strong&gt;&lt;br&gt;
From the transition table in &lt;code&gt;kernel/runtime/status.py&lt;/code&gt;:&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="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SUCCEEDED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRYING&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CANCELED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRYING&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXPIRED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;ExecutionStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRYING&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;SUCCEEDED&lt;/code&gt; reaches nothing. &lt;strong&gt;A success written into the ledger cannot be walked back&lt;/strong&gt;,&lt;br&gt;
not even to failed. Failures can move to &lt;code&gt;retrying&lt;/code&gt;; successes go nowhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound notification goes through a transactional outbox, not a live broadcast.&lt;/strong&gt;&lt;br&gt;
Creating a run and every status change write an outbox row (&lt;code&gt;writer.py:282&lt;/code&gt; and four other&lt;br&gt;
sites) inside the same database transaction as the business data.&lt;/p&gt;

&lt;p&gt;The live event bus, by contrast, is &lt;strong&gt;best-effort&lt;/strong&gt; — the last line of &lt;code&gt;_emit_event&lt;/code&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swallowed. That is the right call: &lt;strong&gt;a failed notification must never block the ledger&lt;br&gt;
write.&lt;/strong&gt; It also means one thing for anyone verifying behaviour —&lt;br&gt;
&lt;strong&gt;reconcile against the ledger, not against what you saw on the event stream.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  9. The ledger belongs to the ports, not to the loop
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;previous piece&lt;/a&gt; argued that governance is a property&lt;br&gt;
of the port rather than of the agent loop. This one adds a parallel claim.&lt;/p&gt;

&lt;p&gt;Count who writes to &lt;code&gt;TraceWriter&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Mentions of &lt;code&gt;trace_writer&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kernel/ports/llm/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kernel/ports/storage/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kernel/ports/vector/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kernel/ports/tools/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kernel/ports/plugins/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five kernel ports, five policy gateways, one ledger.&lt;/p&gt;

&lt;p&gt;Which means: &lt;strong&gt;you do not instrument the agent loop, and you do not instrument the&lt;br&gt;
workflow engine.&lt;/strong&gt; If an operation left through a port, it left a row. The agent loop&lt;br&gt;
calling &lt;code&gt;tool_port.invoke&lt;/code&gt; leaves one; a DAG workflow's tool node calling the same&lt;br&gt;
&lt;code&gt;tool_port.invoke&lt;/code&gt; leaves one — in the same table, with the same schema.&lt;/p&gt;

&lt;p&gt;The converse holds too, and it is &lt;strong&gt;the real boundary of this design&lt;/strong&gt;:&lt;br&gt;
&lt;strong&gt;a call that bypasses the ports leaves nothing in the ledger.&lt;/strong&gt; That is not a bug, it is&lt;br&gt;
what layering means. The ledger records governed operations, not everything the process&lt;br&gt;
happened to do.&lt;/p&gt;
&lt;h3&gt;
  
  
  10. The boolean the platform computes for you
&lt;/h3&gt;

&lt;p&gt;Mechanism aside, the question a user actually has is simpler: &lt;strong&gt;is there enough evidence&lt;br&gt;
for this run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/runs/{run_id}&lt;/code&gt; returns thirteen governance evidence items&lt;br&gt;
(&lt;code&gt;kernel/runtime/runs/service.py:530&lt;/code&gt; onward):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;actor_scope        subject_version     capability_binding   permission_scope
secret_boundary    egress_policy       audit_record         cost_attribution
trace_timeline     tool_call           knowledge_citation   child_workflow
replay_ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last one is the boolean. Its criteria are at &lt;code&gt;service.py:516&lt;/code&gt;:&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="n"&gt;replay_missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;replay_missing&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;steps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response_timeline_applicable&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;response_events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;replay_missing&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;response_events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cost_attribution_applicable&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cost_entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;replay_missing&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;costs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;knowledge_citation_applicable&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;citations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;replay_missing&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;citations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_governance_applicable&lt;/span&gt; &lt;span class="ow"&gt;and&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;replay_missing&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;tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_governance_applicable&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;audits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;replay_missing&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;audits&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;Note the &lt;code&gt;_applicable&lt;/code&gt; guards: &lt;strong&gt;it judges against what this run actually did.&lt;/strong&gt; A pure&lt;br&gt;
chat run has no tool calls and is not marked deficient for lacking them. A run that did&lt;br&gt;
call a tool, but has no matching audit rows, comes back &lt;code&gt;fail&lt;/code&gt; — and names the missing&lt;br&gt;
category in &lt;code&gt;missing&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I find that more useful than a docs page promising "full replay". &lt;strong&gt;It is a field you can&lt;br&gt;
query, not an adjective.&lt;/strong&gt; And it can fail — a check that always returns pass is not a&lt;br&gt;
check.&lt;/p&gt;
&lt;h3&gt;
  
  
  11. Fingerprints in the ledger, not payloads
&lt;/h3&gt;

&lt;p&gt;A ledger you keep for a long time is at risk of becoming a disclosure surface.&lt;/p&gt;

&lt;p&gt;The handling starts at &lt;code&gt;tool_calls.py:59&lt;/code&gt;. Arguments are redacted before they are&lt;br&gt;
persisted; a key matching one of sixteen sensitive names becomes &lt;code&gt;[REDACTED]&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;api_key       apikey          access_token   authorization
client_secret cookie          credential     password
private_key   refresh_token   secret         secret_access_key
session_token token           x_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keys are normalized before comparison — camel case split, non-alphanumerics folded to&lt;br&gt;
underscores — so &lt;code&gt;apiKey&lt;/code&gt;, &lt;code&gt;API-KEY&lt;/code&gt; and &lt;code&gt;api_key&lt;/code&gt; are treated alike.&lt;/p&gt;

&lt;p&gt;One exception is worth knowing: if the value is a dict carrying a &lt;code&gt;secret_id&lt;/code&gt;, it is&lt;br&gt;
&lt;strong&gt;not&lt;/strong&gt; redacted. It is already a reference rather than a plaintext, and blanking it would&lt;br&gt;
destroy the one thing you want later: which secret this call used.&lt;/p&gt;

&lt;p&gt;Then size. Arguments over 8192 bytes are not stored; three things are kept instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;truncated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size_bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size_bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_hash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload_hash&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;argument_names&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&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;strong&gt;&lt;code&gt;request_hash&lt;/code&gt; is computed over the pre-redaction original&lt;/strong&gt; — &lt;code&gt;canonical_request_hash&lt;/code&gt;&lt;br&gt;
does a sorted, compact JSON dump and sha256s it.&lt;/p&gt;

&lt;p&gt;The effect: the ledger holds no payload, but &lt;strong&gt;idempotency still works&lt;/strong&gt;. The same key&lt;br&gt;
arriving twice is compared on &lt;code&gt;request_hash&lt;/code&gt;; a mismatch raises&lt;br&gt;
&lt;code&gt;Tool call identity was reused with different input&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconcilable, but not leaky.&lt;/strong&gt; Second-nicest thing in this ledger, after &lt;code&gt;in_doubt&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  12. Seven things that don't line up yet
&lt;/h3&gt;

&lt;p&gt;This section is entirely about our own problems, ordered by impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Workflow replay/retry never writes lineage into the ledger.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;runs&lt;/code&gt; has &lt;code&gt;source_run_id&lt;/code&gt; and &lt;code&gt;attempt_no&lt;/code&gt;, plus an index built for them. Across the&lt;br&gt;
whole repo there are 19 &lt;code&gt;create_run(&lt;/code&gt; call sites and &lt;strong&gt;exactly one passes&lt;br&gt;
&lt;code&gt;source_run_id&lt;/code&gt;&lt;/strong&gt; — the knowledge ingestion path from section 7.&lt;/p&gt;

&lt;p&gt;Workflow replay goes through &lt;code&gt;execute_workflow&lt;/code&gt; into &lt;code&gt;engine.execute&lt;/code&gt;, and the engine&lt;br&gt;
creates the run like this (&lt;code&gt;modules/workflow/runtime/engine.py:145&lt;/code&gt;):&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="n"&gt;run&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;trace_writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;subject_kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;subject_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;subject_version_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subject_version_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;input_summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;input_summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run_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;No &lt;code&gt;source_run_id&lt;/code&gt;. No &lt;code&gt;attempt_no&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: &lt;code&gt;source_run_id&lt;/code&gt; exists only in the HTTP &lt;strong&gt;response body&lt;/strong&gt;. If the caller does&lt;br&gt;
not store it, the "B is a replay of A" relationship is gone — unqueryable in the ledger,&lt;br&gt;
and &lt;code&gt;ix_runs_scope_source_created&lt;/code&gt; indexes nothing useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workaround today&lt;/strong&gt;: have the caller keep the &lt;code&gt;source_run_id&lt;/code&gt; it got back.&lt;br&gt;
&lt;strong&gt;Intended fix&lt;/strong&gt;: thread &lt;code&gt;source_run_id&lt;/code&gt; and &lt;code&gt;attempt_no&lt;/code&gt; through those two paths into&lt;br&gt;
&lt;code&gt;create_run&lt;/code&gt;. I intend to open an issue for this; I had not filed it when this was&lt;br&gt;
written, so there is no link here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② Re-execution reads a truncated copy of the inputs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Section 2 noted that &lt;code&gt;input_summary&lt;/code&gt; is cut at 8192 bytes. Workflow replay loads inputs&lt;br&gt;
like this (&lt;code&gt;modules/workflow/application/service.py:151&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_load_run_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Run&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;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;parsed&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;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It &lt;code&gt;json.loads&lt;/code&gt; the summary.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So a run whose inputs exceeded 8KB will fail to parse on replay (truncated JSON generally&lt;br&gt;
is not valid) and return &lt;code&gt;Replay requires inputs or a parseable run input_summary&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workaround today&lt;/strong&gt;: pass &lt;code&gt;inputs&lt;/code&gt; explicitly instead of letting it read from the ledger —&lt;br&gt;
both endpoints accept an override.&lt;br&gt;
&lt;strong&gt;Intended fix&lt;/strong&gt;: send large inputs to an artifact and keep a pointer, exactly like tool&lt;br&gt;
results in section 2. Same status: issue intended, not yet filed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ &lt;code&gt;generate_ulid()&lt;/code&gt; does not generate a ULID.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The source says so itself (&lt;code&gt;kernel/commons/ids.py:9&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_ulid&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate a ULID-like sortable ID.

    For now, we use UUID4 with prefix. In production, consider using
    python-ulid or similar library for true ULID generation.
    &lt;/span&gt;&lt;span class="sh"&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;id_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nb"&gt;hex&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UUID4 is random. &lt;strong&gt;Not sortable at all&lt;/strong&gt; — neither the name nor the "sortable" in the&lt;br&gt;
docstring holds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounded but real impact&lt;/strong&gt;: everything that needs chronological order has to use&lt;br&gt;
&lt;code&gt;created_at&lt;/code&gt; rather than the id. The catch-up in section 4 does exactly that. Arguably&lt;br&gt;
forced into the correct implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ Catch-up uses a strict greater-than.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Following from ③: &lt;code&gt;created_at&lt;/code&gt; comes from Python's &lt;code&gt;datetime.now(UTC)&lt;/code&gt;, and the filter is&lt;br&gt;
&lt;code&gt;RunStep.created_at &amp;gt; last_step_time&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theoretical consequence&lt;/strong&gt;: if two steps land on an identical timestamp and the client's&lt;br&gt;
last received event was one of them, the other is skipped by the strict comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I did not reproduce this.&lt;/strong&gt; &lt;code&gt;datetime.now()&lt;/code&gt; resolves to microseconds on modern Linux,&lt;br&gt;
and two steps in one run colliding on the same microsecond takes unusual conditions.&lt;br&gt;
It is listed as a design fragility, &lt;strong&gt;not an observed bug — please don't repeat it as&lt;br&gt;
one.&lt;/strong&gt; The fix is easy once ③ is done: order by &lt;code&gt;(created_at, id)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ Ids in the ledger come in three shapes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;generate_ulid()&lt;/code&gt; already returns an &lt;code&gt;id_&lt;/code&gt;-prefixed string, anything that adds its&lt;br&gt;
own prefix ends up double-prefixed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;How it is generated&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_step_tool_calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f"rstc_{generate_ulid()}"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rstc_id_xxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tasks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f"task_{generate_ulid()}"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;task_id_xxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_cost_entries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default_factory=generate_ulid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id_xxxx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three work. The third just gives no hint which table the row belongs to.&lt;br&gt;
&lt;strong&gt;Cosmetic, no functional impact&lt;/strong&gt; — but you notice it the moment you start reading rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑥ The &lt;code&gt;Run.status&lt;/code&gt; docstring lists 6 statuses; there are 11.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On the model (&lt;code&gt;models/runs.py:79&lt;/code&gt;):&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Status: queued, running, paused, succeeded, failed, canceled.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ExecutionStatus&lt;/code&gt; has eleven: those six plus &lt;code&gt;preparing&lt;/code&gt;, &lt;code&gt;waiting_input&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;waiting_approval&lt;/code&gt;, &lt;code&gt;retrying&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;. Steps add &lt;code&gt;skipped&lt;/code&gt; on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: anyone writing a client from that comment misses five states. Documentation&lt;br&gt;
drift; a one-line fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑦ The &lt;code&gt;soit runs replay&lt;/code&gt; line in the console is display copy — that CLI does not exist.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;From the run detail adapter (&lt;code&gt;web/app/console/adapters/run-detail.ts:227&lt;/code&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="nx"&gt;ledger_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`soit runs replay &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; --dry-run`&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="s2"&gt;`replaying &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; steps · verdict on record: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;run&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It renders as a code sample explaining what that panel shows.&lt;br&gt;
&lt;strong&gt;But there is no &lt;code&gt;soit&lt;/code&gt; CLI in the open-source repo&lt;/strong&gt; — &lt;code&gt;server/pyproject.toml&lt;/code&gt; has no&lt;br&gt;
&lt;code&gt;[project.scripts]&lt;/code&gt;, and &lt;code&gt;server/scripts/&lt;/code&gt; has no matching entry point.&lt;/p&gt;

&lt;p&gt;The thing that does work is the HTTP endpoint from section 3. There is a replay script in&lt;br&gt;
the repo, but it is for the outbox (&lt;code&gt;server/scripts/replay_outbox_event.py&lt;/code&gt;, 41 lines —&lt;br&gt;
it returns one terminally failed domain event to the pending queue), which is a different&lt;br&gt;
thing entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I went back and forth on including this.&lt;/strong&gt; Including it says we haven't kept our own&lt;br&gt;
console copy honest. Leaving it out means a reader types the command from a screenshot and&lt;br&gt;
gets nothing. Included, in the end — &lt;strong&gt;the gap between demo copy and real capability is&lt;br&gt;
exactly the kind of thing a reader is entitled to know.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coming clean
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No fresh live run behind this piece.&lt;/strong&gt; Every conclusion comes from reading &lt;code&gt;soit/&lt;/code&gt; at
commit &lt;code&gt;fb46f20&lt;/code&gt;, plus the tests already in the repo. I did not stand up an environment,
execute a run and then call the replay endpoint. Every claim carries a file and a line
number; go check them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Item ④ in section 12 is an inference, not an observation.&lt;/strong&gt; I did not construct the
colliding-timestamp case. It is listed because a design should not depend on timestamp
uniqueness, not because we have seen it break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay does not promise identical output.&lt;/strong&gt; Replay #4 genuinely runs again — models
have temperature, tools talk to real systems, external data moves. What is promised is
the same inputs, the same governance policy and complete evidence. There is no
record-and-stub harness for tools in the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is all the community edition.&lt;/strong&gt; Every path above is in
&lt;code&gt;github.com/soit-ai/soit&lt;/code&gt; and readable right now.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disclosure: I maintain SOIT.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One-line version
&lt;/h3&gt;

&lt;p&gt;"Replayable" here is not an adjective. It is a field the platform computes, that you can&lt;br&gt;
query, and that &lt;strong&gt;can come back fail&lt;/strong&gt; — backed by five database tables, four replay paths&lt;br&gt;
with very different costs, and one status willing to admit we don't know whether the other&lt;br&gt;
side did the thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it, and come argue
&lt;/h3&gt;

&lt;p&gt;The repo is &lt;code&gt;github.com/soit-ai/soit&lt;/code&gt;. To check the claims above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start it, send any message, take the &lt;code&gt;run_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /api/v1/runs/{run_id}&lt;/code&gt; and look at &lt;code&gt;replay_ready&lt;/code&gt; among the thirteen evidence
items — if it is &lt;code&gt;fail&lt;/code&gt;, &lt;code&gt;missing&lt;/code&gt; names what is absent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /api/v1/observe/runs/{run_id}/replay&lt;/code&gt; and see what the seven keys hold.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of the seven items in section 12 is wrong, open an issue and say so. I would rather&lt;br&gt;
learn where this doesn't line up than be told the design is nice.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>database</category>
      <category>sre</category>
      <category>api</category>
    </item>
    <item>
      <title>I counted our agent loop: 239 lines. Frameworks and runtimes are not the same layer.</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Sun, 06 Sep 2026 23:39:03 +0000</pubDate>
      <link>https://dev.to/judezh/i-counted-our-agent-loop-239-lines-frameworks-and-runtimes-are-not-the-same-layer-3bia</link>
      <guid>https://dev.to/judezh/i-counted-our-agent-loop-239-lines-frameworks-and-runtimes-are-not-the-same-layer-3bia</guid>
      <description>&lt;h3&gt;
  
  
  The short version
&lt;/h3&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;Agent framework&lt;/th&gt;
&lt;th&gt;Agent runtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Operates during&lt;/td&gt;
&lt;td&gt;the days you write code&lt;/td&gt;
&lt;td&gt;every execution after that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core question&lt;/td&gt;
&lt;td&gt;how should the agent think, compose, call&lt;/td&gt;
&lt;td&gt;is this call allowed, is it written down, who pays for it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical surface&lt;/td&gt;
&lt;td&gt;prompt composition, chain/graph authoring, model and tool wrappers, developer experience&lt;/td&gt;
&lt;td&gt;permission checks, secret boundaries, egress policy, ledger and replay, cost attribution, approval interrupts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Judged by&lt;/td&gt;
&lt;td&gt;expressiveness, time to first agent, ecosystem breadth&lt;/td&gt;
&lt;td&gt;can you reconstruct what happened, can you stop it, can you replay it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it lives in our repo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;modules/agent/runtime/&lt;/code&gt; (planner + executor + verifier)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kernel/ports/&lt;/code&gt;, &lt;code&gt;kernel/runtime/&lt;/code&gt;, &lt;code&gt;kernel/security/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;239 lines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the policy gateway on one port alone is &lt;strong&gt;475&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows are what this post argues, and they are not a diagram I drew. They are &lt;code&gt;wc -l&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this needs a whole post
&lt;/h3&gt;

&lt;p&gt;"Agent platform" currently swallows at least three separate things: &lt;strong&gt;the library you write&lt;br&gt;
an agent with&lt;/strong&gt;, &lt;strong&gt;the engine that runs it&lt;/strong&gt;, and &lt;strong&gt;the control plane that governs it&lt;/strong&gt;. Once&lt;br&gt;
those three share a word, every discussion becomes two people answering different questions —&lt;br&gt;
one is saying "I built an agent in three lines", the other is saying "I need to know who&lt;br&gt;
approved that call at 3pm yesterday". Both are right. Neither is talking to the other.&lt;/p&gt;

&lt;p&gt;I am not going to characterize anyone else's internals. That would require me to have read&lt;br&gt;
their code closely enough to be accountable for the claim, and every claim in this post has to&lt;br&gt;
come with a line number. So the post does two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;describes what the framework layer generally owns — consensus, no assertions about anyone;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;uses our own repo as the specimen&lt;/strong&gt; to locate what the runtime layer owns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The specimen is &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;, Apache 2.0. Every&lt;br&gt;
line number below refers to commit &lt;code&gt;3a57ae1&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Counting our own agent loop: 239 lines
&lt;/h3&gt;

&lt;p&gt;An agent loop is three things: decide the next step, do it, check whether it is done. In our&lt;br&gt;
repo those are three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server/app/modules/agent/runtime/planner.py    98 lines
server/app/modules/agent/runtime/executor.py   42 lines
server/app/modules/agent/runtime/verifier.py   99 lines
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The executor is 42 lines and nearly all of it fits here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentExecutor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute tool actions for agent.&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ToolPort&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool_port&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_step_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_approval&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="n"&gt;lease_owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&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="n"&gt;ToolResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Execute tool call.&lt;/span&gt;&lt;span class="sh"&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;tool_ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tool_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tool_call_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;idempotency_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;run_step_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_step_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resume_approval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resume_approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;lease_owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lease_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strict_registry&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This class has no logic.&lt;/strong&gt; It hands the call to a port. That is not laziness; it is the&lt;br&gt;
thesis of this post: &lt;strong&gt;the rules of execution are not written in the loop, they are written on&lt;br&gt;
the port.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The planner is equally plain. It hands messages to the model using native function calling and&lt;br&gt;
gets back either tool calls or text — there is no third case:&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="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_port&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="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;planning_messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;temperature&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;tool_definitions&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_definitions&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool_definitions&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasoning_effort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reasoning_effort&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;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PlanResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="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="n"&gt;tool_calls&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;tool_calls&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="nc"&gt;PlanResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;respond&lt;/span&gt;&lt;span class="sh"&gt;"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No prompt template DSL. No chain or graph authoring language. No regex pulling &lt;code&gt;Action:&lt;/code&gt; out of&lt;br&gt;
model output. The verifier is the same shape: one structured-output tool definition&lt;br&gt;
(&lt;code&gt;verify_response&lt;/code&gt;, fields &lt;code&gt;ok&lt;/code&gt; and &lt;code&gt;reason&lt;/code&gt;) asking the model whether the answer is adequate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This layer is the framework's home turf, and we deliberately built almost nothing here.&lt;/strong&gt;&lt;br&gt;
Thin is neither a virtue nor a flaw. It just means we are not competing on expressiveness.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. So what are the 1,617 lines wrapped around it doing?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;server/app/modules/agent/application/service.py&lt;/code&gt; is 1,617 lines. Its import block answers the&lt;br&gt;
question on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.identity.guard&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;workspace_guard&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.ports.approvals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApprovalLedgerPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ApprovalRecord&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.ports.common.rate_limiter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RateLimiter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.runtime.runs.tool_calls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RuntimeToolExecutionService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ToolExecutionCommand&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.runtime.runs.writer&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TraceWriter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.runtime.tools.approval&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool_approval_rule&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.kernel.runtime.tools.resolver&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolResolver&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workspace guard, approval ledger, rate limiter, tool-execution ledger (with leases and&lt;br&gt;
idempotency), trace writer, approval rules, tool resolver. Plus a dedicated control signal for&lt;br&gt;
human approval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;_AgentApprovalInterrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Internal control signal for a durable human approval checkpoint.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop itself is one &lt;code&gt;while&lt;/code&gt; (&lt;code&gt;service.py:653&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;pending_tool_calls&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_iterations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One line of loop, sixteen hundred lines around it.&lt;/strong&gt; Almost none of those lines are about how&lt;br&gt;
the agent thinks. They are about whether this call is allowed, whether it is written down, who&lt;br&gt;
it is billed to, and how to resume when a human interrupts it.&lt;/p&gt;

&lt;p&gt;If nine tenths of a codebase answers the second set of questions, calling it "another agent&lt;br&gt;
framework" describes the least important tenth of it.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Where the line actually is: ten gates on one tool call
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;server/app/kernel/ports/tools/interface.py&lt;/code&gt; is 54 lines: an abstract &lt;code&gt;ToolPort&lt;/code&gt; with a single&lt;br&gt;
abstract &lt;code&gt;invoke()&lt;/code&gt;. That is the seam.&lt;/p&gt;

&lt;p&gt;The thing that does the work is &lt;code&gt;ToolPolicyGateway&lt;/code&gt; in&lt;br&gt;
&lt;code&gt;server/app/kernel/ports/tools/policy.py&lt;/code&gt;, 475 lines, implementing that same &lt;code&gt;ToolPort&lt;/code&gt;. Which&lt;br&gt;
means &lt;strong&gt;calling a tool raw and calling a tool through governance look identical to the caller&lt;/strong&gt; —&lt;br&gt;
the only difference is which implementation got injected.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ToolPolicyGateway.invoke()&lt;/code&gt; starts at &lt;code&gt;policy.py:231&lt;/code&gt;. One tool call passes, in order:&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;Gate&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Secret resolution and redaction&lt;/td&gt;
&lt;td&gt;Secret references in the parameters are resolved through &lt;code&gt;secrets_port&lt;/code&gt;, and a &lt;strong&gt;redacted copy&lt;/strong&gt; is produced for everything downstream that records&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:253–256&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Ledger claim&lt;/td&gt;
&lt;td&gt;The call is claimed in the run ledger; &lt;code&gt;tool_call_id&lt;/code&gt; and an idempotency key are minted or reused&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:264–290&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Idempotent replay&lt;/td&gt;
&lt;td&gt;If the claim comes back &lt;code&gt;replayed&lt;/code&gt;, the cached response is returned and &lt;strong&gt;the call is not made again&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:291–296&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Lease&lt;/td&gt;
&lt;td&gt;The execution takes a lease of &lt;code&gt;max(60, ceil(timeout) + 10)&lt;/code&gt; seconds, renewed while it runs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;policy.py:281&lt;/code&gt;, &lt;code&gt;policy.py:326&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Egress policy&lt;/td&gt;
&lt;td&gt;Every http URL found anywhere in the parameters goes through &lt;code&gt;check_egress_policy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:307–309&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Rate limit&lt;/td&gt;
&lt;td&gt;Per-minute limit keyed on &lt;code&gt;tool_ref&lt;/code&gt; + tenant + workspace + user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:311–318&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Daily quota&lt;/td&gt;
&lt;td&gt;86,400-second window keyed on &lt;code&gt;tool_ref&lt;/code&gt; + tenant + workspace&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:319–324&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;soit.tool.invoke&lt;/code&gt; OTel span carrying tenant, workspace, run and step attributes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:336–348&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Timeout and retry&lt;/td&gt;
&lt;td&gt;Shared timeout/retry; &lt;strong&gt;with an idempotency key, &lt;code&gt;max_retries=1&lt;/code&gt;&lt;/strong&gt;, and the comment says why&lt;/td&gt;
&lt;td&gt;&lt;code&gt;policy.py:349–360&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Audit and settlement&lt;/td&gt;
&lt;td&gt;Audit log (written with the &lt;strong&gt;redacted&lt;/strong&gt; parameters), step status and metrics, cost&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;policy.py:367–383&lt;/code&gt;, &lt;code&gt;410–425&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Watch how gate 1 and gate 10 cooperate: &lt;strong&gt;real values only ever reach the call; the redacted&lt;br&gt;
copy is the only thing that reaches the record.&lt;/strong&gt; The code carries &lt;code&gt;resolved_parameters&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;redacted_parameters&lt;/code&gt; side by side precisely so that audit and metrics can never accidentally&lt;br&gt;
take the wrong one. That is not a discipline you can maintain by being careful in application&lt;br&gt;
code. It only works if it sits on the path everything must take.&lt;/p&gt;

&lt;p&gt;Gate 9's comment is the tell:&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;# Durable Agent calls are at-most-once at this boundary.
# Not every downstream adapter can honor an idempotency key.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a runtime-layer concern in one sentence. It does not care whether your agent logic is&lt;br&gt;
elegant. It cares whether a retry files the same ticket twice.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. The fact that proves governance does not live in the loop
&lt;/h3&gt;

&lt;p&gt;At this point you could reasonably say: you just pushed the governance code downstream of your&lt;br&gt;
agent loop, that proves nothing.&lt;/p&gt;

&lt;p&gt;So look at a second execution model. Besides the agent loop, the repo has a workflow engine —&lt;br&gt;
&lt;code&gt;modules/workflow/&lt;/code&gt;, 6,269 lines, including a 969-line &lt;code&gt;engine.py&lt;/code&gt; and an 855-line&lt;br&gt;
&lt;code&gt;executor.py&lt;/code&gt;. It is a DAG. It has nothing structurally in common with an agent loop.&lt;/p&gt;

&lt;p&gt;How does it call a tool?&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;# server/app/modules/workflow/runtime/executors/tool.py:428
&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;

&lt;span class="c1"&gt;# server/app/modules/workflow/runtime/executors/llm.py:105
&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;ChatResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_port&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The same &lt;code&gt;tool_port.invoke&lt;/code&gt;. The same &lt;code&gt;llm_port.chat&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two unrelated execution models, one set of gates. That is the operational definition of a&lt;br&gt;
layer: governance is a property &lt;strong&gt;of the port&lt;/strong&gt;, not of any particular loop. Swap the execution&lt;br&gt;
model above and not one of the ten gates below goes away.&lt;/p&gt;

&lt;p&gt;Which is also the technical reason the two layers do not conflict: &lt;strong&gt;anything that calls&lt;br&gt;
through these ports gets governed&lt;/strong&gt; — our loop, a DAG engine, or something else entirely. The&lt;br&gt;
layer underneath cannot tell the difference and does not need to.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. The dependency list is the most honest positioning statement a project has
&lt;/h3&gt;

&lt;p&gt;What a project says it is, you read in the README. What it actually is, you read in its&lt;br&gt;
dependencies.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;server/pyproject.toml&lt;/code&gt;, exactly three core dependencies relate to agents at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;"mcp&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.28&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;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="s"&gt;",          # tool protocol&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;"ag-ui-protocol=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="s"&gt;",  # front-end interaction event protocol&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;"litellm=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.91&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="s"&gt;",         # model invocation&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All three are protocols or call layers. There is no agent framework in the core&lt;br&gt;
dependencies.&lt;/strong&gt; That is not a manifesto, it is &lt;code&gt;pyproject.toml&lt;/code&gt; lines 68 to 71.&lt;/p&gt;

&lt;p&gt;And LangChain? It is there. Here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project.optional-dependencies]&lt;/span&gt;
&lt;span class="py"&gt;local-embedding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="py"&gt;"sentence-transformers&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;4.1&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"langchain-huggingface&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="err"&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;pyproject.toml:75–83&lt;/code&gt; — an &lt;strong&gt;optional&lt;/strong&gt; extra named &lt;code&gt;local-embedding&lt;/code&gt;, for running embedding&lt;br&gt;
models locally. Nothing to do with agent orchestration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;While I am here, a correction about us.&lt;/strong&gt; Our README's tech-stack table lists the LLM row as&lt;br&gt;
&lt;code&gt;OpenAI · Anthropic · DeepSeek · Qwen · LangChain (adapter layer)&lt;/code&gt; (&lt;code&gt;README.md:240&lt;/code&gt;). That does&lt;br&gt;
not match the dependencies. LangChain is not an LLM adapter layer here; it is an optional local&lt;br&gt;
embedding dependency. That is our documentation misleading readers, and I intend to open an&lt;br&gt;
issue to fix it — I had not filed it when this was written, so there is no link to give you.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. The seams are other people's protocols, not shapes we invented
&lt;/h3&gt;

&lt;p&gt;A layer boundary is only real if the seam is public. Three seams:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requests in.&lt;/strong&gt; &lt;code&gt;POST /api/v1/responses&lt;/code&gt; accepts AG-UI's &lt;code&gt;RunAgentInput&lt;/code&gt; directly:&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;# server/app/api/v1/responses/router.py:222
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_response&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;RunAgentInput&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ResponseCreateRequest&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 front end does not have to learn a SOIT-specific message shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools in.&lt;/strong&gt; Tool references are namespaced strings; &lt;code&gt;adapters/tools/router.py&lt;/code&gt; shows three&lt;br&gt;
prefixes — &lt;code&gt;tool:http:*&lt;/code&gt;, &lt;code&gt;tool:function:*&lt;/code&gt;, and &lt;code&gt;mcp_tool:*&lt;/code&gt;. Any MCP server resolves into the&lt;br&gt;
tool registry without a code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Events out.&lt;/strong&gt; The run is persisted and streamed to the front end as AG-UI interaction events&lt;br&gt;
(&lt;code&gt;adapters/agui/agent.py&lt;/code&gt; and &lt;code&gt;responses.py&lt;/code&gt;, 909 lines together).&lt;/p&gt;

&lt;p&gt;Three protocols, no dialect of our own. That is the precondition for two layers being able to&lt;br&gt;
snap together at all.&lt;/p&gt;
&lt;h3&gt;
  
  
  7. We did not write a framework at the model layer either
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;adapters/llm/&lt;/code&gt; is 2,619 lines, of which &lt;code&gt;router.py&lt;/code&gt; is 534. Those 534 lines do routing,&lt;br&gt;
credential resolution and egress guarding — not prompt composition:&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;# server/app/adapters/llm/router.py:192 (inside _authorize_provider_target)
&lt;/span&gt;&lt;span class="k"&gt;await&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;egress_guard&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="n"&gt;ctx&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;model-provider:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;provider_slug&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;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both provider-resolution paths (&lt;code&gt;router.py:368&lt;/code&gt; and &lt;code&gt;router.py:422&lt;/code&gt;) go through it first. In&lt;br&gt;
other words, &lt;strong&gt;calling a model is itself subject to egress policy&lt;/strong&gt;. If the target host is not&lt;br&gt;
in policy, the call does not leave the box.&lt;/p&gt;

&lt;p&gt;One more that is easy to miss: in production, a provider with no configured credential is&lt;br&gt;
rejected outright (&lt;code&gt;router.py:316–324&lt;/code&gt;, &lt;code&gt;MODEL_PROVIDER_CREDENTIAL_REQUIRED&lt;/code&gt;). "Production mode&lt;br&gt;
refuses to let you cut corners" is a runtime-layer job description. It does nothing for your&lt;br&gt;
developer experience. It exists to stop development-time convenience from reaching production.&lt;/p&gt;
&lt;h3&gt;
  
  
  8. The boundary is welded shut by CI, not asserted in a doc
&lt;/h3&gt;

&lt;p&gt;Layering usually dies by being true in the documentation and false in the code. So we handed&lt;br&gt;
this one to a tool. The first contract in &lt;code&gt;server/importlinter.ini&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[importlinter:contract:kernel_isolation]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Kernel is isolated&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;forbidden&lt;/span&gt;
&lt;span class="py"&gt;source_modules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="err"&gt;app.kernel&lt;/span&gt;
&lt;span class="py"&gt;forbidden_modules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="err"&gt;app.api&lt;/span&gt;
    &lt;span class="err"&gt;app.modules&lt;/span&gt;
    &lt;span class="err"&gt;app.adapters&lt;/span&gt;
    &lt;span class="err"&gt;app.infra&lt;/span&gt;
    &lt;span class="err"&gt;app.wiring&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The kernel may not import anything above it.&lt;/strong&gt; The moment the governance kernel depends&lt;br&gt;
backwards on a product module, "swap the execution model and every gate survives" stops being&lt;br&gt;
true. That is not something to leave to good intentions; let CI hit the wall instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;server/app/kernel/README.md&lt;/code&gt; states the rule in prose, and one line of it is worth quoting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Kernel extension points that need product or infrastructure data must use&lt;br&gt;
provider interfaces registered from &lt;code&gt;wiring/&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the kernel needs outside data it does not reach for it; it takes a provider interface&lt;br&gt;
registered in &lt;code&gt;wiring/&lt;/code&gt;. That is the standing cost of keeping a layer boundary alive.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. The honest part: those 6,269 workflow lines do overlap
&lt;/h3&gt;

&lt;p&gt;So far I have made us sound very tidy: we only do runtime, not framework.&lt;/p&gt;

&lt;p&gt;That is not quite true.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modules/workflow/&lt;/code&gt; is 6,269 lines, with a compiler (&lt;code&gt;compiler.py&lt;/code&gt;, 259), variable resolution&lt;br&gt;
(&lt;code&gt;variable_resolver.py&lt;/code&gt;, 212), node executors (the tool node alone is 610), resume and reaper&lt;br&gt;
paths. &lt;strong&gt;It is an orchestration engine, and it overlaps in function with orchestration&lt;br&gt;
frameworks people already use.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I am not going to argue that ours is somehow a different species. It overlaps. The only&lt;br&gt;
distinction is the one from section 4: it calls tools through the same &lt;code&gt;tool_port.invoke&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the accurate statement is not "we don't build framework things". It is: &lt;strong&gt;we built the&lt;br&gt;
minimum of it we needed, and we made it obey the same rules as everybody else.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  10. What "not competitors" concretely means — including what you cannot do today
&lt;/h3&gt;

&lt;p&gt;Three things you can do, and one you cannot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bring framework-side tools in over MCP and have them governed.&lt;/strong&gt; Any MCP server resolves
into the tool registry, and from then on every call it makes goes through the ten gates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring framework-side services in as HTTP plugins.&lt;/strong&gt; &lt;code&gt;adapters/plugins/http_runtime.py&lt;/code&gt; and
&lt;code&gt;skill_runtime.py&lt;/code&gt; are the two plugin runtime implementations (129 and 85 lines).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep your front end.&lt;/strong&gt; Interaction is an AG-UI event stream, and the request shape coming
in is AG-UI's &lt;code&gt;RunAgentInput&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;You cannot (and this matters more than the three above):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no entry point today for handing us a framework-written agent to host wholesale.&lt;/strong&gt;&lt;br&gt;
Your loop still runs in your process. What SOIT governs is &lt;strong&gt;the hand it reaches out with&lt;/strong&gt; —&lt;br&gt;
tool calls, model calls, egress — not the reasoning inside it.&lt;/p&gt;

&lt;p&gt;That is the real boundary right now. Do not size it up as a universal container. If what you&lt;br&gt;
want is "host my existing agent code as-is and get the whole governance stack for free", this&lt;br&gt;
repo does not do that today. What it does is make every hand that code reaches out with sign&lt;br&gt;
its name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confession
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is a read-the-code and read-the-config piece with no new end-to-end run.&lt;/strong&gt; Every claim
can be opened and checked at commit &lt;code&gt;3a57ae1&lt;/code&gt;; whether it behaves this way at runtime is
outside what this post verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All line counts are &lt;code&gt;wc -l&lt;/code&gt;&lt;/strong&gt;, including blanks, comments and docstrings. Fine for orders of
magnitude, wrong for estimating effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The README inaccuracy in section 5 is real.&lt;/strong&gt; Our own documentation misled readers about
this. I intend to file an issue; it was not filed when this was written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I made no claims about any specific framework's internals.&lt;/strong&gt; "What the framework layer owns"
here is the industry-consensus description. Everything with a line number is our own repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SandboxToolPort&lt;/code&gt; is not a security sandbox.&lt;/strong&gt; The 61 lines in
&lt;code&gt;kernel/ports/tools/sandbox.py&lt;/code&gt; are a dry run for pre-release rehearsal: the run exercises the
full decision path while the side effect is stopped at the boundary, so rehearsing a release
does not actually file a pile of tickets. &lt;strong&gt;It stops side effects, not hostile code.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The section 4 argument has a precondition.&lt;/strong&gt; "Swap the execution model and every gate
survives" holds only for execution models that call through the ports. Code that opens its own
socket is not governed by any of this — the import contract in section 8 keeps the kernel
clean; it does not stop application code from going around.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  One sentence
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A framework decides how the agent thinks. A runtime decides whether the hand it reaches out&lt;br&gt;
with counts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These do not compete, because they do not even operate on the same timescale — one acts on the&lt;br&gt;
few days you spend writing code, the other on every execution afterwards.&lt;/p&gt;

&lt;p&gt;If they compete for anything, it is &lt;strong&gt;attention&lt;/strong&gt;. Somewhere on the path from demo to&lt;br&gt;
production, a team's attention has to move from the first to the second, and it usually moves&lt;br&gt;
too late — as in, after the first incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it, or take it apart
&lt;/h3&gt;

&lt;p&gt;Code at &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;, Apache 2.0. Every line&lt;br&gt;
number in this post refers to commit &lt;code&gt;3a57ae1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three files are worth opening, because they carry the entire argument:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;server/app/modules/agent/runtime/executor.py&lt;/code&gt; — 42 lines, see how empty it is&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server/app/kernel/ports/tools/policy.py&lt;/code&gt; — 475 lines, the ten gates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;server/importlinter.ini&lt;/code&gt; — how the boundary is welded shut&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you think the section 4 argument has a hole in it, or you have seen a better way to draw this&lt;br&gt;
line in another project, say so in an issue. The failure mode for a post like this is talking&lt;br&gt;
to myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure: I maintain SOIT.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>backend</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your agent request touches 12 containers. Only one of them runs a model.</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Wed, 02 Sep 2026 16:13:18 +0000</pubDate>
      <link>https://dev.to/judezh/your-agent-request-touches-12-containers-only-one-of-them-runs-a-model-587b</link>
      <guid>https://dev.to/judezh/your-agent-request-touches-12-containers-only-one-of-them-runs-a-model-587b</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/judezh/i-tried-to-cut-our-12-container-stack-down-to-4-two-of-my-three-conclusions-were-wrong-49i1"&gt;the last post&lt;/a&gt; I cut this stack down to four long-running containers. The most common follow-up was not "how did you cut it" but the inverse:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So what are the rest of them actually for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That deserves a straight answer. When a self-hosted project's quickstart starts a dozen containers, the default reading is either "the architecture never converged" or "someone is cosplaying enterprise." Neither reading is unfair — plenty of projects earn it. So this post does exactly one thing: &lt;strong&gt;it walks a single agent run from arrival to completion, and every time the run touches a service, says what that service did at that moment and which guarantee disappears without it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the least intuitive part of the answer up front: &lt;strong&gt;of the twelve containers, exactly one runs a model.&lt;/strong&gt; Everything else buys the same thing — turning "run an agent" into "run an agent such that afterwards you can audit it, reconcile it, and replay it, and a crashed process does not leave half a state behind."&lt;/p&gt;

&lt;h3&gt;
  
  
  The short answer
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Where it sits in a run&lt;/th&gt;
&lt;th&gt;What it carries&lt;/th&gt;
&lt;th&gt;What you lose without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;throughout&lt;/td&gt;
&lt;td&gt;the ledger: run / step / tool_call / artifact / cost&lt;/td&gt;
&lt;td&gt;the physical basis for observability and replay; hard readiness gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;at authz, and on cross-instance broadcast&lt;/td&gt;
&lt;td&gt;permission cache (5-minute TTL), rate limiter, cross-instance event bus&lt;/td&gt;
&lt;td&gt;replicas stop seeing each other's events; every authz check hits the DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when a run produces something large&lt;/td&gt;
&lt;td&gt;artifact bytes; the DB keeps only &lt;code&gt;storage_key&lt;/code&gt; + &lt;code&gt;sha256&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;hard readiness gate; nowhere to put outputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;milvus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the retrieval step&lt;/td&gt;
&lt;td&gt;vector store&lt;/td&gt;
&lt;td&gt;retrieval calls fail (but readiness stays green)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;etcd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;never directly&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Milvus's own metadata store&lt;/strong&gt;, not the platform's dependency&lt;/td&gt;
&lt;td&gt;goes wherever Milvus goes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vault&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;when a secret is needed&lt;/td&gt;
&lt;td&gt;KV v2 store; credentials stay out of the process and out of &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;falls back to an in-process store, lost on restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;migrate&lt;/code&gt; / &lt;code&gt;bootstrap&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;before the run&lt;/td&gt;
&lt;td&gt;one-shot: schema, first admin, first tenant&lt;/td&gt;
&lt;td&gt;— (they exit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minio-init&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;one-shot: create the bucket, disable anonymous access&lt;/td&gt;
&lt;td&gt;— (it exits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;throughout&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the one process that actually runs a model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;it is the thing being demoed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;throughout&lt;/td&gt;
&lt;td&gt;the front end&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outbox-dispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;after the response is sent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;delivers events committed inside the run's transaction, with per-consumer checkpoints&lt;/td&gt;
&lt;td&gt;events sit at &lt;code&gt;pending&lt;/code&gt; forever; nothing downstream ever fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;knowledge-ingest-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unrelated to a run&lt;/td&gt;
&lt;td&gt;document parsing and indexing&lt;/td&gt;
&lt;td&gt;uploads never reach the knowledge base&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scheduler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unrelated to a run&lt;/td&gt;
&lt;td&gt;fires due schedules&lt;/td&gt;
&lt;td&gt;⚠ &lt;strong&gt;the quickstart never starts it&lt;/strong&gt; — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  1. First, a correction: it is not 12, it is 14
&lt;/h3&gt;

&lt;p&gt;The quickstart command names twelve services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  postgres redis minio etcd milvus vault migrate bootstrap api web &lt;span class="se"&gt;\&lt;/span&gt;
  knowledge-ingest-worker outbox-dispatcher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker/docker-compose.yml&lt;/code&gt; defines &lt;strong&gt;fourteen&lt;/strong&gt;. The two extras behave very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minio-init&lt;/code&gt;&lt;/strong&gt; is not in the command, but &lt;code&gt;api&lt;/code&gt; declares &lt;code&gt;depends_on: minio-init: service_completed_successfully&lt;/code&gt;, so Compose starts it anyway, it creates the bucket and runs &lt;code&gt;mc anonymous set none&lt;/code&gt;, and exits. &lt;strong&gt;Thirteen containers actually start.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scheduler&lt;/code&gt;&lt;/strong&gt; is not in the command and nothing depends on it — &lt;strong&gt;it never starts in the quickstart topology at all.&lt;/strong&gt; That one gets its own section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The discrepancy itself is trivial. What matters is that "how many services are defined" and "how many you actually run" are two different numbers, and arguments about whether a stack is heavy tend to conflate them.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Before the run: the two containers that exit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;migrate&lt;/code&gt; and &lt;code&gt;bootstrap&lt;/code&gt; are one-shot jobs — &lt;code&gt;restart: "no"&lt;/code&gt;, and a healthy result is &lt;code&gt;Exited (0)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;migrate&lt;/code&gt; runs &lt;code&gt;sh scripts/migrate.sh&lt;/code&gt; once &lt;code&gt;postgres&lt;/code&gt; is healthy. &lt;code&gt;bootstrap&lt;/code&gt; runs &lt;code&gt;scripts/bootstrap_admin.py&lt;/code&gt; once &lt;code&gt;migrate&lt;/code&gt; has &lt;strong&gt;exited successfully&lt;/strong&gt;, creating the first admin and tenant (&lt;code&gt;admin@example.com&lt;/code&gt; / &lt;code&gt;changeme123&lt;/code&gt; / tenant &lt;code&gt;default&lt;/code&gt; by default).&lt;/p&gt;

&lt;p&gt;Note the dependency condition: &lt;code&gt;service_completed_successfully&lt;/code&gt;, not &lt;code&gt;service_healthy&lt;/code&gt;. "Finished, and succeeded" is a different claim from "came up," and getting it wrong gives you a stack where every container is present and the schema is half-applied. &lt;code&gt;api&lt;/code&gt; waits on both of these completing before it boots.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;docker compose ps&lt;/code&gt; shows those two as &lt;code&gt;Exited&lt;/code&gt;, that is the correct state, not a failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Authz: Postgres is the authority, Redis is a cache
&lt;/h3&gt;

&lt;p&gt;The first thing a request hits is authorization. Redis appears here, but strictly as a cache — the authority is always Postgres.&lt;/p&gt;

&lt;p&gt;From &lt;code&gt;server/app/kernel/identity/permissions.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PermissionCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Permission cache using Redis.&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redis_async&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redis_async&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis_client&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;_redis_pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redis_async&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionPool&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;_cache_ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;  &lt;span class="c1"&gt;# 5 minutes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth knowing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The TTL is hard-coded at 300 seconds&lt;/strong&gt;, not configurable. A permission change can therefore take up to five minutes to propagate everywhere unless something calls &lt;code&gt;invalidate&lt;/code&gt; explicitly (it exists — pattern-matched &lt;code&gt;scan_iter&lt;/code&gt; + &lt;code&gt;delete&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Losing Redis degrades rather than fails.&lt;/strong&gt; &lt;code&gt;_get_redis()&lt;/code&gt; returns &lt;code&gt;None&lt;/code&gt; when &lt;code&gt;settings.redis_url&lt;/code&gt; is empty or contains &lt;code&gt;"None"&lt;/code&gt;; the caller treats that as a cache miss and falls through to the database. No Redis means slower, not broken.&lt;/li&gt;
&lt;li&gt;The same Redis backs the rate limiter (&lt;code&gt;server/app/kernel/ports/common/rate_limiter.py&lt;/code&gt;), implemented as a Lua script: &lt;code&gt;ZREMRANGEBYSCORE&lt;/code&gt; to drop the expired window, &lt;code&gt;ZCARD&lt;/code&gt; to count, then &lt;code&gt;ZADD&lt;/code&gt; + &lt;code&gt;EXPIRE&lt;/code&gt; if under the limit. Sliding-window counting in a single &lt;code&gt;eval&lt;/code&gt;, so there is no read-modify-write race.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So "can I drop Redis?" resolves to: &lt;strong&gt;in a demo yes, in production no&lt;/strong&gt; — and the reason is not the cache, it is the event bus in section 8.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The run gets written down: five ledger tables
&lt;/h3&gt;

&lt;p&gt;Authorization passes, the run starts. This is the bulk of what Postgres carries, and it is the heaviest single piece of design in the stack.&lt;/p&gt;

&lt;p&gt;One execution writes to five tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;One row is&lt;/th&gt;
&lt;th&gt;Notable columns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;runs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one execution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;status&lt;/code&gt; / &lt;code&gt;trace_id&lt;/code&gt; / &lt;code&gt;request_id&lt;/code&gt; / &lt;code&gt;parent_run_id&lt;/code&gt; / &lt;code&gt;source_run_id&lt;/code&gt; / &lt;code&gt;attempt_no&lt;/code&gt; / &lt;code&gt;sandbox&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_steps&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one step inside it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;step_type&lt;/code&gt; (llm / retrieval / rerank / tool / workflow_node / agent_plan / memory_write / io) / &lt;code&gt;metrics_json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_step_tool_calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one tool call&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;idempotency_key&lt;/code&gt; / &lt;code&gt;request_hash&lt;/code&gt; / &lt;code&gt;lease_owner&lt;/code&gt; / &lt;code&gt;attempt_count&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_artifacts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one produced artifact&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;storage_key&lt;/code&gt; / &lt;code&gt;sha256&lt;/code&gt; / &lt;code&gt;size_bytes&lt;/code&gt; / &lt;code&gt;mime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run_cost_entries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one metered invocation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;billed_quantity&lt;/code&gt; / &lt;code&gt;amount&lt;/code&gt; / &lt;code&gt;currency&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few columns explain why this is not just two lines in a log file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;parent_run_id&lt;/code&gt; / &lt;code&gt;source_run_id&lt;/code&gt; / &lt;code&gt;attempt_no&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;server/app/kernel/runtime/db/models/runs.py&lt;/code&gt;). The first is parent-child; the other two are a retry and replay lineage — which run this one was derived from, and which attempt it is. That is what makes "replayable" a mechanism rather than a slogan: a replay is a &lt;em&gt;new run&lt;/em&gt; pointing back at its source, not a re-read of a log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sandbox&lt;/code&gt;&lt;/strong&gt;. Marks a run as a rehearsal rather than real work. The field's own comment is blunt about why: pre-release regression executes real agents, and without the flag their cost and evidence inflate real activity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;input_summary&lt;/code&gt; / &lt;code&gt;output_summary&lt;/code&gt; capped at 8KB&lt;/strong&gt;, with &lt;code&gt;metrics_json&lt;/code&gt; as a JSON column. The ledger stores &lt;em&gt;summaries&lt;/em&gt;; the full payload lives in object storage behind &lt;code&gt;run_artifacts&lt;/code&gt;. That split is deliberate — the relational store holds queryable structure, object storage holds bulk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Why tool calls get their own table
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;run_step_tool_calls&lt;/code&gt; is the most heavily constrained of the five. It carries &lt;strong&gt;three unique constraints&lt;/strong&gt;:&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="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;run_step_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="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;run_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;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="p"&gt;...)&lt;/span&gt;
&lt;span class="nc"&gt;UniqueConstraint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;workspace_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;idempotency_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus an index on &lt;code&gt;("status", "lease_expires_at")&lt;/code&gt;. Together they say one thing: &lt;strong&gt;tool calls have side effects, so they have to be at-most-once.&lt;/strong&gt; One step maps to one tool call; a &lt;code&gt;tool_call_id&lt;/code&gt; cannot land twice within a run; the idempotency key is globally unique — that key is the &lt;code&gt;tool:{run_id}:{tool_call_id}&lt;/code&gt; from the earlier governed-MCP post.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lease_owner&lt;/code&gt; / &lt;code&gt;lease_expires_at&lt;/code&gt; pair is crash recovery: a worker that dies stops renewing, and the row becomes claimable again once the lease expires. This semantic is factored into a shared module (&lt;code&gt;server/app/kernel/runtime/common/lease.py&lt;/code&gt;) whose docstring is explicit that every runtime domain executing work outside a request must use the same claim / renew / orphan-recovery primitives. Two constants and one implementation detail are worth remembering: &lt;code&gt;MIN_LEASE_SECONDS = 30&lt;/code&gt; (a smaller configured value is clamped up), &lt;code&gt;LEASE_RENEWALS_PER_LEASE = 3&lt;/code&gt; (the heartbeat interval is a third of the lease), and claims use &lt;code&gt;SKIP LOCKED&lt;/code&gt; so concurrent workers do not contend — the comment openly notes SQLite ignores the clause, which is fine for single-worker tests.&lt;/p&gt;

&lt;p&gt;This section is the whole article in miniature: &lt;strong&gt;these containers exist not because AI is complicated, but because "side-effecting operations must happen exactly once" is expensive in a distributed system, and always has been.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Retrieval: what Milvus does, and why etcd tags along
&lt;/h3&gt;

&lt;p&gt;If the run includes a retrieval step, &lt;code&gt;api&lt;/code&gt; queries Milvus. The adapter is &lt;code&gt;server/app/adapters/vector/milvus.py&lt;/code&gt;, and the index parameters are fixed:&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="n"&gt;index_params&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;index_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;IVF_FLAT&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;metric_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;metric_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&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;nlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&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;etcd&lt;/code&gt; deserves an explicit correction, because it is the container most often misread as padding:&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="na"&gt;milvus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ETCD_ENDPOINTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;etcd:2379&lt;/span&gt;
    &lt;span class="na"&gt;MINIO_ADDRESS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio:9000&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;etcd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_healthy&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;service_healthy&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;etcd is not the platform's dependency; it is Milvus's.&lt;/strong&gt; Milvus standalone keeps its metadata in etcd and its data files in MinIO. No application code in the repo talks to etcd at all. The honest way to read the topology is therefore: &lt;strong&gt;"vector retrieval" is one capability that costs two and a half containers&lt;/strong&gt; (etcd + Milvus, sharing MinIO). Whether a demo should pay that is a clear trade-off, not a mystery.&lt;/p&gt;

&lt;p&gt;One fact carried over from the previous post, because it matters here: &lt;strong&gt;the vector store does not gate readiness.&lt;/strong&gt; In &lt;code&gt;server/app/api/v1/health/router.py&lt;/code&gt;, the database and object storage raise 503 when probing fails; the vector store is probed and reported only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;vector_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;vector_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docstring gives the reasoning: non-vector endpoints keep serving during a vector outage, so pulling the instance out of rotation would be an overreaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Where the big objects go: MinIO
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;run_artifacts&lt;/code&gt; stores &lt;code&gt;storage_key&lt;/code&gt;, &lt;code&gt;sha256&lt;/code&gt;, &lt;code&gt;size_bytes&lt;/code&gt;, &lt;code&gt;mime&lt;/code&gt; — &lt;strong&gt;not the content&lt;/strong&gt;. The content is in MinIO.&lt;/p&gt;

&lt;p&gt;The one-shot &lt;code&gt;minio-init&lt;/code&gt; container does two things: &lt;code&gt;mc mb -p local/soit-artifacts&lt;/code&gt; to create the bucket, then &lt;code&gt;mc anonymous set none&lt;/code&gt; to close anonymous access. The second is a small correct default: &lt;strong&gt;the artifact bucket is not anonymously readable out of the box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Object storage &lt;em&gt;is&lt;/em&gt; a hard readiness gate (probe fails → 503). This is exactly where the previous post's live run broke: on paper you can swap in the local-filesystem adapter, and in practice it does not work inside the official image because the root path is &lt;code&gt;strip("/")&lt;/code&gt;-ed into a relative path (filed as issue #43). So the practical verdict stands: &lt;strong&gt;MinIO cannot be dropped.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  8. The part that starts after the response is sent
&lt;/h3&gt;

&lt;p&gt;By now the run is finished and the response has gone back to the caller. One container is only now getting to work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;api&lt;/code&gt; writes domain events into the &lt;code&gt;event_outbox&lt;/code&gt; table &lt;strong&gt;inside the same transaction as the business data&lt;/strong&gt; (&lt;code&gt;server/app/kernel/runtime/db/models/events.py&lt;/code&gt;). That is the transactional outbox: if the business change committed, the event exists; if it rolled back, the event does not. There is no window where the database changed but the message never went out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;outbox-dispatcher&lt;/code&gt; then polls that table as its own process. The row's columns are effectively its state machine: &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;available_at&lt;/code&gt;, &lt;code&gt;locked_at&lt;/code&gt;, &lt;code&gt;lock_owner&lt;/code&gt;, &lt;code&gt;lock_expires_at&lt;/code&gt;, &lt;code&gt;attempt_count&lt;/code&gt;, &lt;code&gt;last_error&lt;/code&gt;, &lt;code&gt;processed_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The module docstring of &lt;code&gt;server/app/kernel/events/dispatcher.py&lt;/code&gt; states the whole flow in one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;claim rows, run registered handlers with checkpoint idempotency&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Checkpoint idempotency" is a second table, &lt;code&gt;event_consumer_checkpoint&lt;/code&gt;, unique on &lt;code&gt;(consumer_name, event_id)&lt;/code&gt;. Before dispatching, the service asks &lt;code&gt;checkpoints.is_processed(consumer_name, event_id)&lt;/code&gt;; on success it calls &lt;code&gt;try_record_success&lt;/code&gt;. &lt;strong&gt;So idempotency is per consumer per event, not per event&lt;/strong&gt; — if the second of three handlers fails and the row is retried, the first is not re-executed.&lt;/p&gt;

&lt;p&gt;That container also exposes its own Prometheus endpoint (&lt;code&gt;expose: 9201&lt;/code&gt;, started via &lt;code&gt;start_http_server&lt;/code&gt;), and its healthcheck is a scrape of &lt;code&gt;/metrics&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Four of these containers are the same code as &lt;code&gt;api&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the part most easily mistaken for microservice sprawl, and it is the opposite. &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;bootstrap&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;outbox-dispatcher&lt;/code&gt; and &lt;code&gt;scheduler&lt;/code&gt; all share one build context (&lt;code&gt;build: context: ../server&lt;/code&gt;). The released-image path makes it starker: &lt;code&gt;docker/docker-compose.images.yml&lt;/code&gt; points &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;bootstrap&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt; and &lt;code&gt;outbox-dispatcher&lt;/code&gt; at the &lt;strong&gt;same image&lt;/strong&gt;, &lt;code&gt;ghcr.io/soit-ai/soit/server&lt;/code&gt;. Only &lt;code&gt;knowledge-worker&lt;/code&gt; and &lt;code&gt;web&lt;/code&gt; are separate. &lt;strong&gt;Three images cover twelve containers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference is the entrypoint, plus which background loops are switched on. The lifespan in &lt;code&gt;server/app/main.py&lt;/code&gt; has &lt;strong&gt;six flags&lt;/strong&gt;, each folding one loop into the API process:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Code default&lt;/th&gt;
&lt;th&gt;Loop it folds in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;workflow_orphan_reaper_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;False&lt;/code&gt; (compose sets &lt;code&gt;true&lt;/code&gt; for &lt;code&gt;api&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;reaping orphaned workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;schedule_worker_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;False&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;firing due schedules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;account_deletion_sweeper_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;False&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;account deletion sweep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;knowledge_ingest_worker_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;False&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;knowledge ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outbox_dispatcher_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;False&lt;/code&gt; (compose hard-codes &lt;code&gt;"false"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;outbox dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;response_interaction_worker_enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;False&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;durable chat interactions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which means &lt;strong&gt;"how many containers" is largely a deployment decision, not an architectural one.&lt;/strong&gt; The same code can run as one process or five. Compose splits them, and &lt;code&gt;server/scripts/schedule_worker.py&lt;/code&gt; states the reason more plainly than I could:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Separate from the API for the same reason the outbox dispatcher is: a scheduler that shares a process with request handling competes with it, and an API restart should not be a gap in when jobs fire.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  10. Production mode refuses to let you cut corners
&lt;/h3&gt;

&lt;p&gt;Those flags look like a matter of taste. Half of them are not, once &lt;code&gt;ENVIRONMENT=production&lt;/code&gt;. &lt;code&gt;validate_runtime_requirements()&lt;/code&gt; in &lt;code&gt;server/app/settings/settings.py&lt;/code&gt; fails closed on each of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the database URL must carry host, database name, username and password;&lt;/li&gt;
&lt;li&gt;the event bus &lt;strong&gt;must&lt;/strong&gt; be &lt;code&gt;redis&lt;/code&gt; (the code default is actually &lt;code&gt;memory&lt;/code&gt;; compose supplies &lt;code&gt;redis&lt;/code&gt;);&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;outbox_dispatcher_enabled&lt;/code&gt; being true is an error — &lt;strong&gt;production forbids folding the dispatcher into the API process&lt;/strong&gt;, it has to be its own;&lt;/li&gt;
&lt;li&gt;inline chat-interaction execution is forbidden, and the durable interaction worker is required;&lt;/li&gt;
&lt;li&gt;plugin signature verification is required, &lt;strong&gt;and at least one public key must be configured&lt;/strong&gt; — the comment explains why that is checked separately: requiring signatures with no trusted key rejects every package, which reads as a gate but is really a total block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the part I'd most want a skeptical reader to notice. &lt;strong&gt;Which services are optional is not an opinion in this repo; it is code that refuses to boot.&lt;/strong&gt; You may drop Redis and fold the dispatcher into the API for a demo. You cannot do that and also claim to be running production.&lt;/p&gt;

&lt;h3&gt;
  
  
  One gap I found while writing this
&lt;/h3&gt;

&lt;p&gt;One thing I turned up is not a trade-off, it is a hole:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; defines a &lt;code&gt;scheduler&lt;/code&gt; service that sets &lt;code&gt;SCHEDULE_WORKER_ENABLED: "true"&lt;/code&gt; and runs &lt;code&gt;scripts/schedule_worker.py&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the quickstart command does not include it, and nothing &lt;code&gt;depends_on&lt;/code&gt; it;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;api&lt;/code&gt; container does not set &lt;code&gt;SCHEDULE_WORKER_ENABLED&lt;/code&gt;, and the code default is &lt;code&gt;False&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env.example&lt;/code&gt; does not mention the variable;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docs/&lt;/code&gt; never mentions &lt;code&gt;scheduler&lt;/code&gt; at all&lt;/strong&gt; — across the whole repo only the two compose files do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Net effect: &lt;strong&gt;in a stack started per the quickstart, schedules never fire on their own.&lt;/strong&gt; &lt;code&gt;POST /schedules&lt;/code&gt; creates one, the preview endpoint tells you when it would next run, and &lt;code&gt;POST /schedules/{id}/run&lt;/code&gt; triggers it by hand — but nothing is polling to claim it when its time comes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker-compose.production.yml&lt;/code&gt; does include a &lt;code&gt;scheduler&lt;/code&gt;, so this is a quickstart coverage gap rather than a missing feature. There is a second-order problem too: the released-image overlay covers six services and &lt;code&gt;scheduler&lt;/code&gt; is not one of them, so adding a &lt;code&gt;scheduler&lt;/code&gt; to the images-based path silently falls back to a local build.&lt;/p&gt;

&lt;p&gt;I plan to file an issue for both halves of this: add &lt;code&gt;scheduler&lt;/code&gt; to the quickstart command, and cover it in the released-image overlay. It was not filed yet when I wrote this, so there is no link here — if you want to confirm it yourself, walking the four bullets above in order is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this post is not
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is no new end-to-end run behind it.&lt;/strong&gt; The minimal-topology post was executed end to end; this one is a static read of the code and compose files. So the scheduler finding above rests on a code-and-config chain of evidence (service never started + flag defaults to False + no documentation) — I did &lt;strong&gt;not&lt;/strong&gt; stand up the quickstart, create a schedule, and watch it fail to fire. Falsifying it is easy if you want to: start the quickstart stack, create a schedule one minute out, and see whether it runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No resource numbers.&lt;/strong&gt; This is about responsibilities, not footprint. The image-size figures are in the previous post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing about wiring it into your existing observability.&lt;/strong&gt; &lt;code&gt;OTEL_ENABLED&lt;/code&gt; (default &lt;code&gt;false&lt;/code&gt;) and an OTLP endpoint are in compose, and the production file ships an otel-collector, but that is its own article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Redis conclusion is conditional.&lt;/strong&gt; "Fine to drop in a demo" holds because a demo runs a single &lt;code&gt;api&lt;/code&gt; replica. Add replicas and the in-process bus stops crossing processes. That is what the production check is protecting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  So where is the weight?
&lt;/h3&gt;

&lt;p&gt;Regroup the twelve by what they guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2 are the thing being demoed&lt;/strong&gt;: &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;web&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 are one-shot jobs&lt;/strong&gt;: &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;bootstrap&lt;/code&gt;, &lt;code&gt;minio-init&lt;/code&gt; — they exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 are where the ledger and the artifacts physically live&lt;/strong&gt;: &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;minio&lt;/code&gt; — also the only two hard readiness gates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2½ are one capability, vector retrieval&lt;/strong&gt;: &lt;code&gt;milvus&lt;/code&gt; + &lt;code&gt;etcd&lt;/code&gt; (etcd being Milvus's dependency, not ours).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 is secret isolation&lt;/strong&gt;: &lt;code&gt;vault&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 is cross-replica broadcast and caching&lt;/strong&gt;: &lt;code&gt;redis&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 are background processes split out of the same codebase&lt;/strong&gt;: &lt;code&gt;outbox-dispatcher&lt;/code&gt;, &lt;code&gt;knowledge-ingest-worker&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exactly one of them runs a model. The rest of the weight buys one thing: &lt;strong&gt;the execution leaves evidence behind, and a crash mid-run does not leave half a state.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whether that is worth it depends entirely on what you are doing. If you just want to see whether an agent runs at all, this stack is too heavy for you — the previous post shows how to get it to four containers. If you need to put an agent inside a process someone will later have to reconcile, these containers are the things you would end up writing yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full topology: &lt;code&gt;docker/docker-compose.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The four-container version: &lt;a href="https://dev.to/judezh/i-tried-to-cut-our-12-container-stack-down-to-4-two-of-my-three-conclusions-were-wrong-49i1"&gt;I tried to cut our 12-container stack down to 4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;If you think any one of these trade-offs is wrong, open an issue and say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure: I maintain SOIT.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>python</category>
      <category>postgres</category>
      <category>microservices</category>
    </item>
    <item>
      <title>I tried to cut our 12-container stack down to 4. Two of my three conclusions were wrong.</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Sat, 29 Aug 2026 16:10:58 +0000</pubDate>
      <link>https://dev.to/judezh/i-tried-to-cut-our-12-container-stack-down-to-4-two-of-my-three-conclusions-were-wrong-49i1</link>
      <guid>https://dev.to/judezh/i-tried-to-cut-our-12-container-stack-down-to-4-two-of-my-three-conclusions-were-wrong-49i1</guid>
      <description>&lt;p&gt;Self-hosted projects lose most of their prospective users at the first command in the README. Here is ours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  postgres redis minio etcd milvus vault migrate bootstrap api web &lt;span class="se"&gt;\&lt;/span&gt;
  knowledge-ingest-worker outbox-dispatcher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve service names. Issue #25 asked the obvious question: &lt;strong&gt;I just want to look at it — do I really need all of them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. This post takes the twelve apart: which ones genuinely cannot go, which group leaves as a unit, which one you fold into the API process instead of deleting, and what breaks with each removal.&lt;/p&gt;

&lt;p&gt;More importantly: &lt;strong&gt;I actually ran the trimmed stack.&lt;/strong&gt; That matters, because two of the three conclusions I had drawn from reading the code turned out to be wrong. Had I published the paper version, you would have followed it and ended up with a stack that starts and then refuses to show you a UI. I left both mistakes in, because they carry more information than the correct answers do.&lt;/p&gt;

&lt;h3&gt;
  
  
  The short answer
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Can it go?&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Hard readiness gate — 503 without it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;minio&lt;/code&gt; + &lt;code&gt;minio-init&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; (see section 2)&lt;/td&gt;
&lt;td&gt;On paper the local filesystem replaces it. &lt;strong&gt;In the published image that does not work.&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;migrate&lt;/code&gt; / &lt;code&gt;bootstrap&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;One-shot jobs: schema and the admin account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;api&lt;/code&gt; / &lt;code&gt;web&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;They are the thing being demoed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;milvus&lt;/code&gt; + &lt;code&gt;etcd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, as a group — &lt;strong&gt;with a side effect&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Vector search raises at call time, and readiness gets slow enough to mark the API unhealthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vault&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Secrets move to an in-process store, gone on restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes, for a demo&lt;/td&gt;
&lt;td&gt;No permission cache, single-process event bus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;knowledge-ingest-worker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No document ingestion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outbox-dispatcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fold it in&lt;/td&gt;
&lt;td&gt;One environment variable moves it into the API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twelve becomes seven, and since &lt;code&gt;minio-init&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt; and &lt;code&gt;bootstrap&lt;/code&gt; exit when they finish, &lt;strong&gt;four containers stay running&lt;/strong&gt;: postgres, minio, api, web.&lt;/p&gt;

&lt;p&gt;What you save is Milvus (a 2.6GB image), etcd, Vault, Redis, the ingestion worker and the outbox container.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The readiness endpoint tells you which dependencies are real
&lt;/h3&gt;

&lt;p&gt;The fastest way to find out whether a dependency is hard is not the deployment guide — it is the health check. &lt;strong&gt;A deployment guide documents intent; a health check documents behaviour.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;server/app/api/v1/health/router.py&lt;/code&gt;, three backends are treated differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 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;db_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;HTTPException&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;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Database is unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ensure_ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;storage_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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;HTTPException&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;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Object storage is unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_ready&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;vector_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;vector_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Database or object storage down means 503. Vector store down means the field reads &lt;code&gt;unavailable&lt;/code&gt; and the endpoint still returns 200. The docstring gives the reason: the platform degrades gracefully when the vector store is down, so a vector outage should be surfaced rather than pull the instance out of rotation.&lt;/p&gt;

&lt;p&gt;That leaves two hard requirements: &lt;strong&gt;a reachable Postgres, and a writable storage root&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The second one says storage root, not MinIO — and I assumed that distinction meant MinIO could go. That is wrong conclusion number one.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Wrong conclusion #1: local filesystem storage does not work inside the image
&lt;/h3&gt;

&lt;p&gt;The storage adapter is built on fsspec (&lt;code&gt;server/app/adapters/storage/fsspec.py&lt;/code&gt;), and the base URL resolves in this order:&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;storage_url&lt;/span&gt; &lt;span class="ow"&gt;or&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;_default_local_base_url&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;_default_local_base_url()&lt;/code&gt; returns a &lt;code&gt;file://&lt;/code&gt; URI under the repository root. So point &lt;code&gt;STORAGE_URL&lt;/code&gt; at a local directory (or leave it unset) and storage should land on disk with no object store at all.&lt;/p&gt;

&lt;p&gt;I configured exactly that, and the API came up returning 503:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"success":false,"code":"SERVICE_UNAVAILABLE","message":"Object storage is unavailable"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Constructing the adapter directly inside the container gave the real error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PermissionError: [Errno 13] Permission denied: '/app/home'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/app/home&lt;/code&gt; is a strange path, given that I passed &lt;code&gt;/home/appuser/soit-storage&lt;/code&gt;. The cause is this function:&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="nd"&gt;@staticmethod&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_normalize_root_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="n"&gt;root_path&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="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strip("/")&lt;/code&gt; removes the &lt;strong&gt;leading&lt;/strong&gt; slash too, so the absolute path &lt;code&gt;/home/appuser/soit-storage&lt;/code&gt; becomes the relative path &lt;code&gt;home/appuser/soit-storage&lt;/code&gt;, which fsspec's LocalFileSystem then resolves against the process working directory. The image sets &lt;code&gt;WORKDIR /app/&lt;/code&gt;, so it lands in &lt;code&gt;/app/home/...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;/app&lt;/code&gt; is not writable: &lt;code&gt;server/Dockerfile&lt;/code&gt; does &lt;code&gt;COPY ./ /app/&lt;/code&gt; &lt;strong&gt;without &lt;code&gt;--chown&lt;/code&gt;&lt;/strong&gt;, leaving it owned by root, while the final instruction is &lt;code&gt;USER appuser&lt;/code&gt; (uid 10001).&lt;/p&gt;

&lt;p&gt;So inside the published image the local-filesystem path is effectively dead: whatever you pass ends up under &lt;code&gt;/app&lt;/code&gt;, where a non-root process cannot create directories. Mounting a volume does not rescue it either — Docker creates the mount point owned by root as well. Making it work would mean running the API as root or pre-chowning a mount, and neither belongs in a guide aimed at people trying the project for the first time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So MinIO stays.&lt;/strong&gt; It is cheap, at least: one long-running container plus a &lt;code&gt;minio-init&lt;/code&gt; that exits, on a couple hundred megabytes — an order of magnitude smaller than the Milvus group.&lt;/p&gt;

&lt;p&gt;(For the record, MinIO wears &lt;strong&gt;two hats&lt;/strong&gt; in the full topology: the platform's artifact store, and Milvus's object backend. It was never separable from Milvus anyway.)&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The vector group leaves as a unit — and it is not a graceful degradation
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;milvus&lt;/code&gt; depends on &lt;code&gt;etcd&lt;/code&gt; (metadata) and &lt;code&gt;minio&lt;/code&gt; (data). Does the API still start without milvus and etcd? Yes, and the reason is in the adapter's constructor docstring (&lt;code&gt;server/app/adapters/vector/milvus.py&lt;/code&gt;): the connection is established &lt;strong&gt;lazily on first use&lt;/strong&gt;, so building the port during dependency injection does not fail when the vector store is unavailable.&lt;/p&gt;

&lt;p&gt;But do not expect it to degrade into empty results, because &lt;strong&gt;the vector port has no environment-level fallback&lt;/strong&gt;. From &lt;code&gt;server/app/wiring/container.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_create_vector_port&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="n"&gt;VectorPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PYTEST_CURRENT_TEST&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="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SOIT_TESTING&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;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.adapters.vector.memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemoryVectorPort&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InMemoryVectorPort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.adapters.vector.milvus&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;MilvusVectorPort&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;MilvusVectorPort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-memory implementation is reserved for test runs; unlike the secrets port, it never consults &lt;code&gt;ENVIRONMENT&lt;/code&gt;. The real effect: the platform boots, non-vector features work, readiness honestly reports &lt;code&gt;vector: "unavailable"&lt;/code&gt;, and knowledge retrieval raises the moment you use it.&lt;/p&gt;

&lt;p&gt;The readiness response from the actual run says exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"vector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unavailable"&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;That conclusion held. But the same run surfaced something the code does not show you, which is the next section.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Wrong conclusion #2: without Milvus, the web container never starts
&lt;/h3&gt;

&lt;p&gt;That readiness response took &lt;strong&gt;34 seconds&lt;/strong&gt; to come back.&lt;/p&gt;

&lt;p&gt;The reason is not hard to guess: &lt;code&gt;vector.check_ready()&lt;/code&gt; has to resolve the &lt;code&gt;milvus&lt;/code&gt; hostname and open a connection, and the container is not there, so every request waits out DNS and connect timeouts. The vector probe is fail-soft, but &lt;strong&gt;it is not fail-fast&lt;/strong&gt; — nothing bounds how long it may take.&lt;/p&gt;

&lt;p&gt;Which runs straight into Compose's own health check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"Test"&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;"CMD-SHELL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python -c &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;...urlopen('http://localhost:9200/health/ready', timeout=3)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;"Interval"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Retries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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 probe times out after 3 seconds, Compose gives it 5, and the endpoint needs 34. It &lt;strong&gt;cannot&lt;/strong&gt; pass. In the run, the API container sat permanently at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;soit-api-1   Up 3 minutes (unhealthy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service itself is fine — I logged into it. Only the health check fails. But &lt;code&gt;web&lt;/code&gt; declares &lt;code&gt;depends_on: api: condition: service_healthy&lt;/code&gt;, so a normal &lt;code&gt;up -d web&lt;/code&gt; means &lt;strong&gt;web never starts at all&lt;/strong&gt;. You get a stack with a perfectly working API and no UI, and very little to tell you why.&lt;/p&gt;

&lt;p&gt;The fix is small: pass &lt;code&gt;--no-deps&lt;/code&gt; for &lt;code&gt;web&lt;/code&gt; as well. It is a static frontend; it only needs the browser to reach the API, not Compose's opinion about the API's health.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ... up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Started that way, web comes up &lt;code&gt;healthy&lt;/code&gt; and serves HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the one finding in this post that reading the code could never produce.&lt;/strong&gt; On paper you get a guide that looks right and leaves you staring at a dead URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Vault genuinely does degrade
&lt;/h3&gt;

&lt;p&gt;The secrets port is wired differently (same &lt;code&gt;container.py&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vault_url&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vault_token&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_allows_in_memory_adapters&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;app.adapters.secrets.memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;InMemorySecretValueStore&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InMemorySecretValueStore&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Production requires Vault URL and token for the secrets adapter&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;&lt;code&gt;_allows_in_memory_adapters()&lt;/code&gt; accepts &lt;code&gt;ENVIRONMENT&lt;/code&gt; values &lt;code&gt;dev / development / local / test / testing&lt;/code&gt;, and compose defaults to &lt;code&gt;development&lt;/code&gt;. So &lt;strong&gt;leaving &lt;code&gt;VAULT_URL&lt;/code&gt; and &lt;code&gt;VAULT_TOKEN&lt;/code&gt; empty swaps in the in-process secret store&lt;/strong&gt; and the Vault container can stay down. Verified in the run: migrate, bootstrap and api all worked with no Vault anywhere.&lt;/p&gt;

&lt;p&gt;The cost is in the name: in-process means &lt;strong&gt;not durable&lt;/strong&gt;. The model API key you configure during the demo is gone the moment the container restarts.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Redis is three different questions
&lt;/h3&gt;

&lt;p&gt;Redis is interesting because it is not a binary. The three places that use it disagree about what its absence means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The event bus&lt;/strong&gt; can be switched. &lt;code&gt;memory&lt;/code&gt; is the default; compose is what changes it to &lt;code&gt;redis&lt;/code&gt;:&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="n"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_bus_backend&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;memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&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;backend&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RedisEventBus&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;backend&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&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;_allows_in_memory_adapters&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InMemoryEventBus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;ENVIRONMENT&lt;/code&gt; guard applies — in production that branch raises. The in-memory bus only delivers &lt;strong&gt;within a single process&lt;/strong&gt;, which is exactly why it pairs with folding background work into the API process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The permission cache&lt;/strong&gt; degrades gracefully. In &lt;code&gt;server/app/kernel/identity/permissions.py&lt;/code&gt; the Redis accessor returns &lt;code&gt;None&lt;/code&gt; when it cannot connect, and callers treat &lt;code&gt;None&lt;/code&gt; as a cache miss and re-check against the database. One less cache layer, same answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; is a hard dependency that usually never fires. &lt;code&gt;RateLimiter&lt;/code&gt; (&lt;code&gt;server/app/kernel/ports/common/rate_limiter.py&lt;/code&gt;) is a Redis sliding window with no in-memory equivalent. But the call sites are conditional (&lt;code&gt;server/app/kernel/ports/tools/policy.py&lt;/code&gt;):&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="n"&gt;rate_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&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;rate_limit_per_minute&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate_limit_per_minute&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rate_limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate_limiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_rate_limit&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_quota&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate_limiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_rate_limit&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No configured limit, no Redis call. Dropping Redis from a demo is therefore safe &lt;strong&gt;as long as you do not configure per-tool rate limits or daily quotas&lt;/strong&gt;. That is the one item here that depends on what you do during the demo.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. One service you fold in rather than remove
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;outbox-dispatcher&lt;/code&gt; runs the transactional outbox. Its setting says exactly what the flag means (&lt;code&gt;server/app/settings/settings.py&lt;/code&gt;):&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="n"&gt;outbox_dispatcher_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Enable background outbox dispatcher in the API process.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag does not control &lt;em&gt;whether&lt;/em&gt; dispatching happens — it controls &lt;strong&gt;where&lt;/strong&gt;. Compose sets it to &lt;code&gt;false&lt;/code&gt; and runs the same logic in a separate container. For a demo, invert it: set &lt;code&gt;OUTBOX_DISPATCHER_ENABLED=true&lt;/code&gt; on the api service and skip the container. &lt;code&gt;server/app/main.py&lt;/code&gt; reads the flag at startup and attaches the dispatcher to the API's lifespan.&lt;/p&gt;

&lt;p&gt;Why production does the opposite: &lt;code&gt;validate_runtime_requirements()&lt;/code&gt; contains &lt;code&gt;if self.outbox_dispatcher_enabled: raise ValueError("Production requires the dedicated outbox dispatcher process")&lt;/code&gt;. Dispatching and request handling in one process compete for the same resources, and a restart interrupts both at once. Fine for a demo — and note that &lt;strong&gt;this is enforced by code, not advised by documentation&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Skip the ingestion worker unless you are demoing RAG
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;knowledge-ingest-worker&lt;/code&gt; builds from its own image target (&lt;code&gt;server/Dockerfile&lt;/code&gt;) with one extra dependency group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;knowledge-worker&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.cache/uv &lt;span class="se"&gt;\
&lt;/span&gt;    /bin/uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--frozen&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--extra&lt;/span&gt; knowledge-worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That extra is &lt;code&gt;docling[rapidocr]&lt;/code&gt; — document parsing and OCR. While we are here, a common misconception: &lt;code&gt;torch&lt;/code&gt;, &lt;code&gt;torchvision&lt;/code&gt; and &lt;code&gt;torchaudio&lt;/code&gt; live in the &lt;code&gt;local-embedding&lt;/code&gt; extra in &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;strong&gt;not&lt;/strong&gt; in &lt;code&gt;knowledge-worker&lt;/code&gt;, and not in the API image either. The worker is lighter than people assume — but if your demo never uploads a document, it has no reason to exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. The commands, as actually run
&lt;/h3&gt;

&lt;p&gt;One Compose trap first: &lt;code&gt;api&lt;/code&gt; lists milvus and vault in &lt;code&gt;depends_on&lt;/code&gt;, so &lt;strong&gt;Compose starts them for you even when you leave them off the command line&lt;/strong&gt;. Every step needs an explicit &lt;code&gt;--no-deps&lt;/code&gt;, and you sequence the one-shot jobs yourself.&lt;/p&gt;

&lt;p&gt;The env file (all of these override compose defaults):&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'ENVIRONMENT=development'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'VAULT_URL='&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'VAULT_TOKEN='&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'EVENT_BUS_BACKEND=memory'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'OUTBOX_DISPATCHER_ENABLED=true'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env.minimal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up on the published images — overlay &lt;code&gt;docker-compose.images.yml&lt;/code&gt; and pass &lt;code&gt;--no-build&lt;/code&gt;, or Compose will build from source:&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;COMPOSE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"docker compose --env-file .env.minimal -f docker/docker-compose.yml -f docker/docker-compose.images.yml"&lt;/span&gt;

&lt;span class="nv"&gt;$COMPOSE&lt;/span&gt; up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-build&lt;/span&gt; postgres minio minio-init
&lt;span class="nv"&gt;$COMPOSE&lt;/span&gt; run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; migrate
&lt;span class="nv"&gt;$COMPOSE&lt;/span&gt; run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; bootstrap
&lt;span class="nv"&gt;$COMPOSE&lt;/span&gt; up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--no-build&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; api web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;migrate&lt;/code&gt; prints a run of alembic upgrades; &lt;code&gt;bootstrap&lt;/code&gt; prints &lt;code&gt;Bootstrap completed.&lt;/code&gt; along with the admin ids.&lt;/p&gt;

&lt;p&gt;Then verify. Remember that readiness takes &lt;strong&gt;more than 30 seconds&lt;/strong&gt; (section 4), so give curl a generous timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 60 http://localhost:9200/health/ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ready"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"vector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unavailable"&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;vector: unavailable&lt;/code&gt; while the whole thing still reports &lt;code&gt;ready&lt;/code&gt; is section 1's code path observed from the outside — &lt;strong&gt;the output is its own proof&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Then exercise a real path, not just the health endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:9200/api/v1/login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"admin@example.com","password":"changeme123"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;access_token&lt;/code&gt; in the response means the database and auth path are both working. The UI is on &lt;code&gt;http://localhost:5000&lt;/code&gt; with the same credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker ps&lt;/code&gt; will show the API as &lt;code&gt;unhealthy&lt;/code&gt;, and that is expected&lt;/strong&gt; (section 4). The service is fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this is not
&lt;/h3&gt;

&lt;p&gt;As usual, the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This is not a supported deployment shape.&lt;/strong&gt; It is a demo trim. Set &lt;code&gt;ENVIRONMENT=production&lt;/code&gt; and every shortcut above is closed off one by one: &lt;code&gt;validate_runtime_requirements()&lt;/code&gt; demands the Redis event bus, the dedicated outbox process, Vault, OpenTelemetry, and plugin signature and digest verification. Missing any of them fails startup. That is deliberate fail-closed behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The API stays &lt;code&gt;unhealthy&lt;/code&gt;&lt;/strong&gt;, so do not hand this topology to anything that orchestrates on container health — Kubernetes probes, or start-up ordering that waits on a healthcheck, will both break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-memory means gone on restart&lt;/strong&gt; — secrets, and any event in flight on the in-memory bus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without Milvus, vector features raise rather than return empty.&lt;/strong&gt; Demoing knowledge bases means putting milvus and etcd back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rate-limit caveat is yours to judge&lt;/strong&gt;: dropping Redis assumes no configured limits.&lt;/li&gt;
&lt;li&gt;The default &lt;code&gt;SECRET_KEY&lt;/code&gt; is &lt;code&gt;change-me&lt;/code&gt;, and bootstrap will warn you about it (&lt;code&gt;InsecureKeyLengthWarning&lt;/code&gt;). Harmless for a demo; do not let that value outlive one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Two bugs found along the way
&lt;/h3&gt;

&lt;p&gt;Writing this turned up two problems of our own. Both are filed, and it seems fair to say so here rather than quietly fix them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_normalize_root_path()&lt;/code&gt; calls &lt;code&gt;strip("/")&lt;/code&gt;, which turns absolute paths into relative ones&lt;/strong&gt;, making the local filesystem storage backend unusable inside a container (section 2). That function is presumably meant to normalise object-storage key prefixes; backends like &lt;code&gt;file://&lt;/code&gt;, where an absolute path means something, should not get the same treatment. (&lt;a href="https://github.com/soit-ai/soit/issues/43" rel="noopener noreferrer"&gt;issue #43&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vector readiness probe has no timeout&lt;/strong&gt;, so a missing vector store drags &lt;code&gt;/health/ready&lt;/code&gt; past 30 seconds and makes the Compose health check fail permanently (section 4). Fail-soft was implemented; fail-fast was not. A seconds-level timeout on &lt;code&gt;check_ready()&lt;/code&gt; would give you both. (&lt;a href="https://github.com/soit-ai/soit/issues/44" rel="noopener noreferrer"&gt;issue #44&lt;/a&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worth noting: both are things you only hit by actually running a reduced topology, and our own CI runs the full one. Which is probably an argument for supporting the minimal shape officially.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why write this down at all
&lt;/h3&gt;

&lt;p&gt;If four containers are enough, why does the default ask for twelve?&lt;/p&gt;

&lt;p&gt;Because the default topology targets the &lt;strong&gt;production&lt;/strong&gt; shape, not the demo shape. Every service cut above maps to a requirement that production enforces in code: secrets need a real secret manager, events need to cross process boundaries, dispatching needs to scale independently, vectors need to persist. You get something you can experiment against as if it were production, and the price is a first command that looks frightening.&lt;/p&gt;

&lt;p&gt;The point is that &lt;strong&gt;the distance between those two shapes is measurable in a handful of environment variables&lt;/strong&gt; — and measuring it happens to be the fastest way to understand the architecture: the health check tells you the hard dependencies, the wiring code tells you which ports have fallbacks, and &lt;code&gt;validate_runtime_requirements()&lt;/code&gt; tells you where production draws its line.&lt;/p&gt;

&lt;p&gt;But keep the other lesson too: &lt;strong&gt;reading the code gives you hypotheses; running it gives you conclusions.&lt;/strong&gt; Two of my three were wrong, and the wrong two were exactly the ones that would have stopped you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;p&gt;SOIT is Apache-2.0 and the code is on GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full quickstart (the twelve-service path): &lt;code&gt;docs/quickstart.md&lt;/code&gt; in the repo&lt;/li&gt;
&lt;li&gt;Governance demo: &lt;code&gt;docs/governance-demo.md&lt;/code&gt; — a 20-minute local run that walks through permissions, secrets, call auditing, cost attribution, replay and regression&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you get the minimal topology running, or get stuck on a step, open an issue and say so. Right now this trim only exists as a blog post; if the feedback says it is useful, we will turn it into a Compose profile so &lt;code&gt;--profile minimal&lt;/code&gt; does the whole thing — and fix those two bugs on the way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain SOIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>opensource</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We open-sourced our agent platform's governance layer: hexagonal architecture and governed execution</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:57:32 +0000</pubDate>
      <link>https://dev.to/judezh/we-open-sourced-our-agent-platforms-governance-layer-hexagonal-architecture-and-governed-execution-aam</link>
      <guid>https://dev.to/judezh/we-open-sourced-our-agent-platforms-governance-layer-hexagonal-architecture-and-governed-execution-aam</guid>
      <description>&lt;p&gt;We open-sourced &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;SOIT&lt;/a&gt; a few weeks ago. The one&lt;br&gt;
line version: an agent runtime and governance platform for teams that need AI&lt;br&gt;
agents to touch real enterprise systems without losing control.&lt;/p&gt;

&lt;p&gt;This post is about the part that is actually hard — not building agents, but&lt;br&gt;
being willing to let them run in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem isn't building agents. It's trusting them.
&lt;/h2&gt;

&lt;p&gt;Every team I talk to has the same story. The demo took a week and it was great.&lt;br&gt;
Then security, compliance and ops started asking questions, and the project&lt;br&gt;
parked itself at PoC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is this agent allowed to do, and who decided that?&lt;/li&gt;
&lt;li&gt;Where do its credentials live, and what leaks if a prompt goes wrong?&lt;/li&gt;
&lt;li&gt;Which hosts can it reach when a tool call makes an outbound request?&lt;/li&gt;
&lt;li&gt;What exactly did it do last Tuesday, and can we prove it step by step?&lt;/li&gt;
&lt;li&gt;What did that run cost, and which team pays for it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frameworks answer none of these — they orchestrate calls and leave controls to&lt;br&gt;
you. Hosted platforms answer some, but you inherit their model choices, their&lt;br&gt;
data boundary and their pricing. Cloud-vendor agent services answer more, in&lt;br&gt;
exchange for the deepest lock-in of all.&lt;/p&gt;

&lt;p&gt;Our answer is to make governance a kernel concern rather than a patch applied&lt;br&gt;
afterwards.&lt;/p&gt;
&lt;h2&gt;
  
  
  Governed execution
&lt;/h2&gt;

&lt;p&gt;The idea is simple to state: &lt;strong&gt;every agent run is a governed run.&lt;/strong&gt; Chat turn,&lt;br&gt;
agent loop, or workflow run — everything flows through one runtime ledger&lt;br&gt;
(&lt;code&gt;Run&lt;/code&gt; / &lt;code&gt;RunStep&lt;/code&gt; / &lt;code&gt;Trace&lt;/code&gt;), and the same controls apply on every path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt; — tenant and workspace scoping on every resource, RBAC with
resource-level grants. Every record carries &lt;code&gt;tenant_id&lt;/code&gt; and &lt;code&gt;workspace_id&lt;/code&gt;;
there are no escape hatches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approved capabilities&lt;/strong&gt; — agents bind to models, tools, knowledge bases and
workflows through per-version allowlists. A tool from a plugin, an MCP server,
or a built-in adapter looks identical to the agent and passes the same checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret boundaries&lt;/strong&gt; — credentials live in Vault with workspace-scoped
visibility and are injected at the gateway. Business code never opens a raw
HTTP client or LLM SDK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress policy&lt;/strong&gt; — outbound HTTP from tools is policy-controlled. An agent
cannot quietly call a host you never approved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ledger, cost, audit, replay&lt;/strong&gt; — per-step tokens, latency and cost; a full
audit log of privileged operations; a trace timeline you can replay step by
step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of duties&lt;/strong&gt; — the Dev role that builds and runs agents cannot
change egress policy, secrets, or installed plugins. That takes a workspace
Owner or Admin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a wrapper around someone else's runtime. SOIT is a hexagonal&lt;br&gt;
architecture: a stable kernel, versioned JSON Schema contracts on every&lt;br&gt;
primitive, and replaceable adapters at the edges — so the governance layer holds&lt;br&gt;
no matter which model or vector store you swap in.&lt;/p&gt;

&lt;p&gt;That last property is the whole reason for the shape. Governance implemented at&lt;br&gt;
the integration layer has to be rewritten for every new tool and every new&lt;br&gt;
provider. Governance implemented at the port layer is written once, and every&lt;br&gt;
adapter inherits it whether it wants to or not.&lt;/p&gt;
&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;p&gt;Four pillars, all of them in the open-source edition:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; — visual agent assembly with versioning and release management, a
DAG workflow editor, a knowledge pipeline (PDF/DOCX/Markdown/HTML into
Milvus-backed retrieval), and MCP support: any Model Context Protocol server
resolves into the tool registry without code changes, including OAuth
2.1-protected servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute&lt;/strong&gt; — an outbox-based event-driven runtime with checkpoints, retries
and fallback chains; multi-model routing across OpenAI, Anthropic, DeepSeek,
Qwen and any OpenAI-compatible endpoint — including the one on your own GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observe&lt;/strong&gt; — a workspace console built on the runtime ledger: live run
volume, cost burn, failure rates, drill-down by agent, workflow and tool, plus
OpenTelemetry tracing and Prometheus metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Govern&lt;/strong&gt; — everything in the section above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It self-hosts with one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/soit-ai/soit.git
&lt;span class="nb"&gt;cd &lt;/span&gt;soit
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A supply chain you can verify
&lt;/h2&gt;

&lt;p&gt;If your agents run in production, so does your agent platform — which makes it&lt;br&gt;
part of your attack surface.&lt;/p&gt;

&lt;p&gt;Every SOIT release is built by a tag-triggered pipeline that publishes&lt;br&gt;
digest-addressable images, SPDX SBOMs, Sigstore-backed build provenance and SBOM&lt;br&gt;
attestations, a deterministic source archive, and &lt;code&gt;SHA256SUMS&lt;/code&gt;. v1.0.0 shipped&lt;br&gt;
that way: three images on GHCR that pull anonymously, and artifacts you can&lt;br&gt;
check with &lt;code&gt;gh attestation verify&lt;/code&gt; before they enter your environment — we ran&lt;br&gt;
it ourselves and got exit 0.&lt;/p&gt;

&lt;p&gt;Secret scanning, dependency audit and container scanning run in CI as gates, not&lt;br&gt;
as dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SOIT is not
&lt;/h2&gt;

&lt;p&gt;A post like this should also say what you are not getting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is not a lightweight chatbot builder. If you want a prompt box and a share
link, plenty of tools do that with far less infrastructure.&lt;/li&gt;
&lt;li&gt;Content safety and PII detection are &lt;strong&gt;not implemented&lt;/strong&gt;. SOIT exposes a
content-safety port and an HTTP adapter so you can plug in a classifier you
operate, and inspection outcomes become part of run evidence — but with no
adapter configured, no inspection happens. I would rather say that than ship a
checkbox that does nothing.&lt;/li&gt;
&lt;li&gt;The project is young. v1.0.0 is released and the CI gates are in place, but
there is no large-scale production deployment vouching for it yet. We run it
ourselves. Early adopters welcome, with eyes open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  License: Apache 2.0
&lt;/h2&gt;

&lt;p&gt;Commercial use, self-hosting, internal deployments and building products on top&lt;br&gt;
are all unrestricted. The core platform is and will remain open source; SSO,&lt;br&gt;
advanced audit reports and multi-region deployment live in SOIT Enterprise. The&lt;br&gt;
commercial boundary is drawn in the feature set, not in the license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come break it
&lt;/h2&gt;

&lt;p&gt;The repo is at &lt;strong&gt;&lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;github.com/soit-ai/soit&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
The quickstart takes about ten minutes on a machine with Docker. There are&lt;br&gt;
&lt;code&gt;good first issue&lt;/code&gt;s seeded and the roadmap is pinned.&lt;/p&gt;

&lt;p&gt;If the architecture trade-offs are the interesting part for you — the outbox&lt;br&gt;
runtime, the spec-first contracts, lease-based worker recovery — say so and I&lt;br&gt;
will write those up separately. I also wrote a companion piece on what happens&lt;br&gt;
when an MCP tool call has to obey RBAC, secrets and egress policy:&lt;br&gt;
&lt;a href="https://dev.to/judezh/five-questions-to-answer-before-you-put-mcp-in-production-j58"&gt;Five questions to answer before you put MCP in production&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your agents graduated from notebooks and hit the trust wall, this was built&lt;br&gt;
for you. Tell me where it falls short.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I maintain SOIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>architecture</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Five questions to answer before you put MCP in production</title>
      <dc:creator>Jude</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:46:50 +0000</pubDate>
      <link>https://dev.to/judezh/five-questions-to-answer-before-you-put-mcp-in-production-j58</link>
      <guid>https://dev.to/judezh/five-questions-to-answer-before-you-put-mcp-in-production-j58</guid>
      <description>&lt;p&gt;MCP standardized the boring part of giving an agent tools. One streamable HTTP&lt;br&gt;
endpoint, one &lt;code&gt;list_tools&lt;/code&gt; call, and the tools show up in the model's callable&lt;br&gt;
list. The first time we wired one up internally it took an afternoon.&lt;/p&gt;

&lt;p&gt;Then we tried to put it in production, and someone from security asked five&lt;br&gt;
questions. I could not answer any of them.&lt;/p&gt;

&lt;p&gt;This post is those five questions, and how we ended up answering them in SOIT —&lt;br&gt;
an open-source agent runtime with governance in the middle of it. Everything&lt;br&gt;
below points at a file in the repo, because posts like this are unusually easy&lt;br&gt;
to write as a slide deck instead of as software.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Who is allowed to call this tool?
&lt;/h2&gt;

&lt;p&gt;MCP does not have an opinion here. Whatever &lt;code&gt;list_tools&lt;/code&gt; returns is what the&lt;br&gt;
model can call. Visibility is capability.&lt;/p&gt;

&lt;p&gt;In a multi-tenant, multi-workspace deployment that is not enough. SOIT installs&lt;br&gt;
an MCP server as a &lt;strong&gt;plugin artifact&lt;/strong&gt; rather than as a config entry. Tool&lt;br&gt;
references are namespaced — &lt;code&gt;mcp_tool:{server}:{tool}&lt;/code&gt;, parsed by&lt;br&gt;
&lt;code&gt;parse_mcp_tool_ref&lt;/code&gt; in &lt;code&gt;server/app/adapters/tools/mcp.py&lt;/code&gt; — and every&lt;br&gt;
resolution carries a &lt;code&gt;RequestContext&lt;/code&gt; holding &lt;code&gt;tenant_id&lt;/code&gt;, &lt;code&gt;workspace_id&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;user_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things follow from that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;From the agent's point of view, a tool from a plugin, a tool from an MCP
server, and a built-in adapter all look identical. Bindings are typed and
versioned.&lt;/li&gt;
&lt;li&gt;Permission checks, secret injection, egress limits, audit, cost attribution,
trace and replay apply to MCP tools &lt;strong&gt;automatically&lt;/strong&gt;. Nobody writes the
governance path twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each agent version also carries a capability allowlist covering models,&lt;br&gt;
knowledge bases, workflows, tools, plugins and MCP servers. So "which MCP tools&lt;br&gt;
can v3 of this agent call" is something you can diff and roll back, rather than&lt;br&gt;
a runtime toggle somebody flipped.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Where do the credentials live?
&lt;/h2&gt;

&lt;p&gt;Most MCP integration examples look like this:&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;"auth"&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;"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;"bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-xxxxxxxx"&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;A plaintext token in a config file. It ends up in git. It ends up in logs. It&lt;br&gt;
ends up in the config backup somebody exported to a laptop.&lt;/p&gt;

&lt;p&gt;SOIT rejects this outright. &lt;code&gt;_build_auth_headers&lt;/code&gt; checks for a &lt;code&gt;token&lt;/code&gt; or&lt;br&gt;
&lt;code&gt;value&lt;/code&gt; field in the auth config and raises:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MCP credentials must use secret_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;secret_id&lt;/code&gt; is accepted, resolved through &lt;code&gt;SecretsPort&lt;/code&gt; at call time. Same&lt;br&gt;
for API keys — and they are only supported in headers, never in a query string,&lt;br&gt;
because query strings leak through logs and referrers.&lt;/p&gt;

&lt;p&gt;The real value exists in memory for the duration of the call and nowhere else.&lt;br&gt;
What gets persisted — to the database, to audit records, to traces — is a&lt;br&gt;
&lt;strong&gt;redacted copy&lt;/strong&gt;: &lt;code&gt;ToolPolicyGateway._resolve_secrets&lt;/code&gt; builds it in the same&lt;br&gt;
pass that resolves the secret, keeping only &lt;code&gt;secret_id&lt;/code&gt; and the signing policy&lt;br&gt;
reference (&lt;code&gt;server/app/kernel/ports/tools/policy.py&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Three auth types are supported: &lt;code&gt;bearer&lt;/code&gt;, &lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;oauth2&lt;/code&gt;. OAuth follows&lt;br&gt;
2.1 with authorization-server discovery (RFC 9728, RFC 8414 / OpenID Connect)&lt;br&gt;
and resource-bound tokens (RFC 8707), using the &lt;code&gt;client_credentials&lt;/code&gt; grant.&lt;/p&gt;

&lt;p&gt;One limitation worth stating plainly: &lt;strong&gt;the browser-based authorization_code&lt;br&gt;
flow is not implemented.&lt;/strong&gt; SOIT calls MCP servers on its own behalf, not on&lt;br&gt;
behalf of a user sitting in front of a browser. If you need "call a protected&lt;br&gt;
MCP server as the end user," this does not cover you.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Where can it connect to?
&lt;/h2&gt;

&lt;p&gt;This is the one that should worry you most.&lt;/p&gt;

&lt;p&gt;You deployed the MCP server, but it is a thing that &lt;strong&gt;makes network requests on&lt;br&gt;
your behalf&lt;/strong&gt;. Put a URL in the tool arguments and it will fetch it. The classic&lt;br&gt;
shape of this is asking it for &lt;code&gt;http://169.254.169.254/&lt;/code&gt; — the cloud metadata&lt;br&gt;
service, holding temporary credentials.&lt;/p&gt;

&lt;p&gt;SOIT's egress policy is &lt;strong&gt;deny-by-default&lt;/strong&gt;, in three layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer one: domain policy.&lt;/strong&gt; &lt;code&gt;check_egress_policy&lt;/code&gt; matches the target domain&lt;br&gt;
against tenant-scoped and workspace-scoped allowlists and blocklists, with the&lt;br&gt;
blocklist winning. The defaults are &lt;code&gt;enable_egress_policy: bool = True&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;egress_allowlist: list[str] = []&lt;/code&gt; — an empty allowlist means nothing is&lt;br&gt;
permitted until you say so. And if the policy lookup itself throws, the answer&lt;br&gt;
is deny, not allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&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;ForbiddenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Egress policy lookup failed; request denied&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;resource_ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource_ref&lt;/span&gt;&lt;span class="p"&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;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail-closed is not a slogan. It is whatever you actually wrote in each &lt;code&gt;except&lt;/code&gt;&lt;br&gt;
branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer two: per-address validation after resolution.&lt;/strong&gt; Passing the domain check&lt;br&gt;
is not enough — DNS rebinding lets an allowlisted hostname resolve to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; or &lt;code&gt;10.0.0.x&lt;/code&gt;. So after the domain is allowed, &lt;code&gt;GovernedEgressGuard&lt;/code&gt;&lt;br&gt;
actually resolves the hostname and checks&lt;br&gt;
&lt;code&gt;ipaddress.ip_address(address).is_global&lt;/code&gt; for &lt;strong&gt;every&lt;/strong&gt; address returned. One&lt;br&gt;
non-public address and the whole request is refused&lt;br&gt;
(&lt;code&gt;server/app/kernel/security/egress.py&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Closed off in the same pass: non-http/https schemes are denied by default, URLs&lt;br&gt;
carrying userinfo (&lt;code&gt;https://user:pass@host/&lt;/code&gt;) are denied, and a DNS failure is a&lt;br&gt;
denial rather than a retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer three: authorization per hop.&lt;/strong&gt; A URL that cleared both layers returns a&lt;br&gt;
302 pointing at your internal network. Now what? So the outbound HTTPX client is&lt;br&gt;
built like this (&lt;code&gt;server/app/adapters/http/governed_client.py&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;authorize_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;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;await&lt;/span&gt; &lt;span class="n"&gt;guard&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="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resource_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;event_hooks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;authorize_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;request_hooks&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;follow_redirects&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authorization hangs off the HTTPX request event hook, so &lt;strong&gt;every request that&lt;br&gt;
actually goes out&lt;/strong&gt; is checked, redirect hops included — not just the URL you&lt;br&gt;
handed in at the entry point. And redirects are not followed by default.&lt;/p&gt;

&lt;p&gt;The MCP adapter builds its sessions with that client, so the whole MCP path —&lt;br&gt;
initialization, &lt;code&gt;list_tools&lt;/code&gt;, every &lt;code&gt;call_tool&lt;/code&gt; — sits inside these constraints.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Can you find out what happened afterwards?
&lt;/h2&gt;

&lt;p&gt;Tool calls are the only place an agent produces real side effects. A model&lt;br&gt;
saying something wrong can be asked again. A tool that changed a row in the&lt;br&gt;
production database changed it.&lt;/p&gt;

&lt;p&gt;SOIT persists each tool call as a step of a run, and writes two pieces of&lt;br&gt;
evidence per call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gateway audit.&lt;/strong&gt; &lt;code&gt;log_gateway_request&lt;/code&gt; with &lt;code&gt;gateway_type="tool"&lt;/code&gt;. The
request side records the &lt;code&gt;tool_ref&lt;/code&gt;, redacted parameters, and the egress
decision (allow / deny plus the target URL). The response side records success,
result type, metadata, and error. &lt;strong&gt;The failure path writes one too&lt;/strong&gt; — the
first thing the &lt;code&gt;except&lt;/code&gt; branch does is emit the audit record. That is the one
people forget, and the one you need when something has gone wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step metrics.&lt;/strong&gt; Latency, success flag, summarized arguments and result,
error code and error details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same call writes a cost entry with &lt;code&gt;billing_basis="requests"&lt;/code&gt;, the provider,&lt;br&gt;
and &lt;code&gt;source_port="tools"&lt;/code&gt; — so "what did this agent's MCP tools cost this month"&lt;br&gt;
is a query you can drill into by agent, workflow, tool, and source&lt;br&gt;
(&lt;code&gt;source_kind=plugin | mcp | builtin&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;On top of that, an OpenTelemetry span &lt;code&gt;soit.tool.invoke&lt;/code&gt; carrying tenant,&lt;br&gt;
workspace, run and step ids, for whatever APM you already run.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Can you replay it?
&lt;/h2&gt;

&lt;p&gt;The most frustrating property of agent debugging is that it does not reproduce.&lt;br&gt;
Same input, different reasoning.&lt;/p&gt;

&lt;p&gt;At the tool layer you can at least be deterministic. Every tool call in SOIT&lt;br&gt;
carries an idempotency key, defaulting to &lt;code&gt;tool:{run_id}:{tool_call_id}&lt;/code&gt;, and&lt;br&gt;
claims a leased execution record. If the claim lands on a record that already&lt;br&gt;
completed, the cached response comes straight back and the external tool is&lt;br&gt;
&lt;strong&gt;not called again&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The retry policy changes accordingly. The comment says it better than I can:&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="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kwargs&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;idempotency_key&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Durable Agent calls are at-most-once at this boundary.&lt;br&gt;
Not every downstream adapter can honor an idempotency key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At-most-once at this boundary, because you cannot assume the MCP server on the&lt;br&gt;
other end honors your idempotency key. Better to call once too few than once too&lt;br&gt;
many — for writes, that trade is not really a choice.&lt;/p&gt;

&lt;p&gt;Rate limits and daily quotas come along with it, keyed by &lt;code&gt;tool_ref&lt;/code&gt; plus&lt;br&gt;
tenant, workspace and user, so one runaway agent does not burn a whole tenant's&lt;br&gt;
third-party API budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;The usual honest list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP transport is streamable HTTP only, targeting the MCP SDK v1 line. The
stateless 2026-07-28 protocol revision is &lt;strong&gt;not supported yet&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;OAuth is &lt;code&gt;client_credentials&lt;/code&gt; only, no authorization_code (see question 2).&lt;/li&gt;
&lt;li&gt;A marketplace for one-click MCP tool installation is on the roadmap; today you
install plugin artifacts by hand.&lt;/li&gt;
&lt;li&gt;The default egress allowlist is empty, which means your first MCP server
&lt;strong&gt;will&lt;/strong&gt; be refused until you add its domain explicitly. That is deliberate,
but it does add a step to the quickstart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why none of this belongs in the agent framework
&lt;/h2&gt;

&lt;p&gt;A question that comes up constantly: how does this relate to LangChain and&lt;br&gt;
friends?&lt;/p&gt;

&lt;p&gt;They are not the same layer. A framework answers "how do I orchestrate this&lt;br&gt;
call." A runtime answers "under whose identity did this call run, with whose&lt;br&gt;
credentials, what could it reach, what evidence did it leave, and can I replay&lt;br&gt;
it." The first is a concern while you write the code. The second is a concern&lt;br&gt;
after the code ships and someone else asks.&lt;/p&gt;

&lt;p&gt;You can certainly put permission checks inside a framework, but then every new&lt;br&gt;
tool integration reimplements the governance logic. Push it down into the&lt;br&gt;
runtime's port layer and MCP tools, plugin tools and built-in tools all travel&lt;br&gt;
the same path — which is the reason question 1 could say "nobody writes it&lt;br&gt;
twice."&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;SOIT is Apache-2.0 and the code is all on GitHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/soit-ai/soit" rel="noopener noreferrer"&gt;https://github.com/soit-ai/soit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Quickstart: &lt;code&gt;docs/quickstart.md&lt;/code&gt; in the repo&lt;/li&gt;
&lt;li&gt;Governance demo: &lt;code&gt;docs/governance-demo.md&lt;/code&gt; — a 20-minute local script that
walks through permissions, secrets, call audit, cost attribution, replay and
regression, and writes a machine-readable report at the end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are pushing MCP toward production right now, I would genuinely like to&lt;br&gt;
hear which of the five questions is blocking you. In our experience the hardest&lt;br&gt;
one is not technical — it is "who gets to decide what goes on the allowlist."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I maintain SOIT.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
