<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Soit AI Publication]]></title><description><![CDATA[Building SOIT (https://soit.ai) — open-source, self-hosted agent runtime with governance built in (audit, approvals, cost tracking). Apache-2.0, model-neutral.
]]></description><link>https://soit-ai.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Soit AI Publication</title><link>https://soit-ai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 14:12:57 GMT</lastBuildDate><atom:link href="https://soit-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Five tables, four kinds of replay, and a status called in_doubt]]></title><description><![CDATA[The short version
replay means four different things in our codebase. Here they are up front:



#
What it is
Entry point
Re-executes?
Cost



1
Evidence replay
GET /api/v1/observe/runs/{run_id}/repla]]></description><link>https://soit-ai.hashnode.dev/five-tables-four-kinds-of-replay-and-a-status-called-in-doubt</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/five-tables-four-kinds-of-replay-and-a-status-called-in-doubt</guid><category><![CDATA[distributed systems]]></category><category><![CDATA[Databases]]></category><category><![CDATA[debugging]]></category><category><![CDATA[devtools]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Thu, 10 Sep 2026 17:30:28 GMT</pubDate><content:encoded><![CDATA[<h3>The short version</h3>
<p><code>replay</code> means four different things in our codebase. Here they are up front:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>What it is</th>
<th>Entry point</th>
<th>Re-executes?</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Evidence replay</td>
<td><code>GET /api/v1/observe/runs/{run_id}/replay</code></td>
<td>No</td>
<td>One database read</td>
</tr>
<tr>
<td>2</td>
<td>Catch-up after a dropped connection</td>
<td><code>GET /api/v1/runs/{run_id}/stream?last_event_id=...</code></td>
<td>No</td>
<td>One database read, then resubscribe</td>
</tr>
<tr>
<td>3</td>
<td>Idempotent replay of a tool call</td>
<td>Inside the tool gateway, same idempotency key arriving twice</td>
<td>No — returns last time's result</td>
<td>One database read</td>
</tr>
<tr>
<td>4</td>
<td>Actual re-execution</td>
<td><code>POST /api/v1/workflows/{id}/runs/{run_id}/replay</code> and two other paths</td>
<td><strong>Yes</strong></td>
<td>Runs again, spends money again</td>
</tr>
</tbody></table>
<p>All four rest on one thing: <strong>the ledger that hits the database first is the authority.</strong></p>
<p>Not the logs. Not the event stream. Not the SSE feed scrolling in your console.
The rows in the tables. That sounds unremarkable until you notice it is what lets three
of those four survive a process restart.</p>
<h3>1. Why the word needs splitting</h3>
<p>In a demo, these three sentences look like one feature:</p>
<ul>
<li>"Here's every step of that run."</li>
<li>"Lost your connection? Refresh — the missing steps come back."</li>
<li>"Bad answer? Hit replay."</li>
</ul>
<p>Engineering-wise they are nothing alike. The first is a <strong>read</strong>. The second is a
<strong>read plus a subscription</strong>. The third is a <strong>write</strong> — it calls the model again,
sends the HTTP request again, spends the money again.</p>
<p>The cost of collapsing them into one word is a user who assumes "replay" is safe
and sends two emails.</p>
<p>So the order below goes from cheapest to most expensive.</p>
<h3>2. The tables, and one number that shows up three times</h3>
<p>Five tables, all in one file (<code>server/app/kernel/runtime/db/models/runs.py</code>, 396 lines):</p>
<pre><code class="language-plaintext">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
</code></pre>
<p>Three more cover long-running work (<code>models/tasks.py</code>, 98 lines): <code>tasks</code>,
<code>task_checkpoints</code>, <code>task_events</code>. Section 7 uses them.</p>
<p>Now the detail worth stopping on: <strong>8192 appears three times in this ledger,
and it means something different each time.</strong></p>
<p>Twice on the run and the step, where summaries are <strong>truncated</strong>:</p>
<pre><code class="language-python">input_summary=input_summary[:8192] if input_summary else None,
</code></pre>
<p>Once on a tool call result, where anything larger is <strong>offloaded to object storage</strong>
and the ledger keeps a pointer, a byte count and a sha256:</p>
<pre><code class="language-python">if len(encoded_result) &gt; 8192:
    ...
    artifact = self.trace_writer.create_artifact(
        run_id=record.run_id,
        step_id=record.run_step_id,
        artifact_type="json",
        storage_key=storage_key,
        mime="application/json",
        size_bytes=len(encoded_result),
        sha256=hashlib.sha256(encoded_result).hexdigest(),
        meta={"kind": "tool_result", "tool_call_id": record.tool_call_id},
    )
</code></pre>
<p><strong>The asymmetry is deliberate.</strong> Summaries are for humans; losing the tail is fine.
Tool results get reconciled and replayed; losing a byte is not fine.</p>
<p>Section 12 covers a consequence of that asymmetry we have not handled well yet.</p>
<h3>3. Replay #1: reassembling the evidence</h3>
<p>The cheap one. A GET:</p>
<pre><code class="language-plaintext">GET /api/v1/observe/runs/{run_id}/replay
</code></pre>
<p>One sentence of behaviour: <strong>query the five record types by run id, add approvals and
feedback, return the bundle.</strong> The implementation
(<code>server/app/modules/observe/application/service.py:212</code>) returns seven keys:</p>
<pre><code class="language-python">return {
    "run": run,
    "steps": steps,
    "artifacts": artifacts,
    "costs": costs,
    "approvals": approvals,
    "feedback": feedback,
    "trace_spec": to_runtrace_spec(run, steps, artifacts, costs),
}
</code></pre>
<p>Six raw record sets, plus <code>trace_spec</code> — the same data flattened into something you can
hand to a tracing backend (<code>kernel/runtime/runs/exporter.py:88</code>). That spec carries two
rollups alongside the timeline: <code>usage_summary</code> (prompt tokens, completion tokens,
embeddings, reranks, milliseconds, storage bytes, requests, vectors) and <code>charge_summary</code>
(amounts grouped by currency).</p>
<p><strong>Nothing here executes.</strong> No model call, no tool call, no cost. It is a database read,
so you can call it at any point after the run ended, and the ten-thousandth call costs
what the first one did.</p>
<p>Every query carries <code>tenant_id</code> and <code>workspace_id</code> in its <code>where</code> clause — reading another
workspace's ledger is closed off at the SQL level, not at a middleware you can misconfigure.</p>
<h3>4. Replay #2: catching up after the connection drops</h3>
<p>The second-cheapest, for the "tab is open, wifi died" case:</p>
<pre><code class="language-plaintext">GET /api/v1/runs/{run_id}/stream?last_event_id=st_xxxx
</code></pre>
<p>Handled at <code>server/app/api/v1/workflow/streaming.py:401</code>. The part that matters:</p>
<pre><code class="language-python">if last_event_id:
    step_query = select(RunStep).where(
        and_(
            RunStep.id == last_event_id,
            RunStep.run_id == run_id,
            ...
        )
    )
    last_step = db.exec(step_query).first()
    if last_step:
        last_step_time = last_step.created_at
        known_step_ids.add(last_step.id)

steps_query = select(RunStep).where(
    and_(
        RunStep.run_id == run_id,
        ...
        RunStep.created_at &gt; last_step_time if last_step_time else True,
    )
).order_by(RunStep.created_at)
</code></pre>
<p>Look at where it reads from: <code>select(RunStep)</code>. The database. Not an in-memory ring
buffer, not a broker offset.</p>
<p>That choice buys a specific property: <strong>you can reconnect an hour after the run finished,
hand over your <code>last_event_id</code>, and still get the steps you missed.</strong> An in-memory buffer
cannot do that — a restart empties it. A broker can, but then you need a broker.</p>
<p>The SSE <code>id:</code> field is the step's primary key (<code>streaming.py:432</code>), so the <code>Last-Event-ID</code>
that browsers resend automatically is already a row id in the ledger. No second cursor
scheme to keep in sync.</p>
<p>One more detail worth borrowing: that query sets <code>populate_existing=True</code>, with a comment
explaining why — the execution side writes from its own session, so this tailer has to
bypass anything its own session cached earlier. That is the kind of line nobody can
reconstruct three months later without the comment.</p>
<h3>5. Replay #3: the same idempotency key, twice</h3>
<p>This one happens below the surface, inside the tool gateway.</p>
<p>Every tool call gets a <code>run_step_tool_calls</code> row. The table carries three unique
constraints (<code>models/runs.py:182</code>):</p>
<pre><code class="language-python">UniqueConstraint("tenant_id", "workspace_id", "run_step_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "run_id", "tool_call_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "idempotency_key", ...)
</code></pre>
<p>The third is the interesting one. When the same key arrives again and the row is already
terminal:</p>
<pre><code class="language-python">if existing.status in {"succeeded", "failed"}:
    payload = existing.result_json or {}
    ...
    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},
        ),
    )
</code></pre>
<p><strong>Last time's result comes back; nothing leaves the process.</strong> The metadata carries
<code>idempotent_replay: True</code> so callers can tell this apart from a fresh execution.</p>
<p>If the earlier result was large enough to live in object storage,
<code>load_cached_response</code> (<code>tool_calls.py:636</code>) fetches the artifact — after checking tenant,
workspace, run and step all match, and raising <code>Tool result artifact scope mismatch</code>
if any of them does not.</p>
<p>The point of this layer: <strong>replay #4 is only safe to offer because this one exists.</strong>
When you re-run, the tool calls whose idempotency keys did not change are not actually
executed a second time.</p>
<h3>6. A status that admits we don't know</h3>
<p>This is the design I would point at first if someone asked what is unusual about this
ledger.</p>
<p>Claiming a tool call takes a lease (60 seconds by default, widened by the gateway to the
tool's timeout). An expired lease means the executor may be dead. Retry or not?</p>
<p>The code answers by asking whether the request actually left (<code>tool_calls.py:309</code>):</p>
<pre><code class="language-python">lease_expired = (
    existing.lease_expires_at is not None
    and _aware_utc(existing.lease_expires_at) &lt;= now
)
if lease_expired and existing.outbound_started_at is not None:
    existing.status = "in_doubt"
    ...
    raise ConflictError("Tool call outcome is in doubt")
if lease_expired and existing.outbound_started_at is None:
    existing.status = "claimed"
    existing.attempt_count += 1
    ...
</code></pre>
<p>Two branches, split on one field, <code>outbound_started_at</code>:</p>
<ul>
<li><strong>Died before going out</strong> — safe. Re-claim, bump the attempt count.</li>
<li><strong>Died after going out</strong> — mark it <code>in_doubt</code>, <strong>do not retry</strong>, park the step at
<code>paused</code>, raise a conflict.</li>
</ul>
<p>The second branch is the honest one. On the other end is a real system: an order endpoint,
an email, a transfer. The request left and no response came back.
<strong>We don't know whether it happened, so we don't guess.</strong> The ledger records "in doubt"
and a human decides.</p>
<p>Auto-retrying here is wrong in the specific way that only surfaces when someone gets two
copies of the same email.</p>
<h3>7. Replay #4: actually running it again</h3>
<p>The expensive one. Three separate paths, three different mechanisms.</p>
<p><strong>(a) Workflows: replay and retry</strong></p>
<pre><code class="language-plaintext">POST /api/v1/workflows/{workflow_id}/runs/{run_id}/retry
POST /api/v1/workflows/{workflow_id}/runs/{run_id}/replay
</code></pre>
<p>The two implementations differ by one check
(<code>modules/workflow/application/service.py:804</code> and <code>:821</code>): retry requires the source run
to be <code>failed</code> or <code>canceled</code>; replay does not. Both load the original inputs, execute
again, and put <code>source_run_id</code> and <code>control_action</code> in the response.</p>
<p><strong>(b) Agent tasks: replaying a persisted snapshot</strong></p>
<p>More interesting (<code>server/app/wiring/task_drivers.py:82</code>). Rather than "take the inputs and
run", it loads the previous <code>ResponseInteraction</code> snapshot and <strong>deliberately strips the
identifiers that belonged to the failed attempt</strong> before queueing a new one:</p>
<pre><code class="language-python">execution_json["assistant_message_id"] = generate_thread_message_id()
payload = dict(execution_json.get("payload") or {})
if payload:
    # Drop identifiers that belong to the attempt being replaced so the
    # replay creates its own response, run and task.
    payload.pop("task_id", None)
    payload.pop("run_id", None)
</code></pre>
<p>The old task is then moved to <code>CANCELED</code> with a forward pointer,
<code>retried_as_interaction_id</code>, in its progress payload. The comment is blunt about why:
leaving it queued would report work this task will never perform.</p>
<p>If there is no snapshot, it does not improvise — it fails explicitly with a dedicated
error code, <code>SNAPSHOT_MISSING_ERROR_CODE</code>. <strong>No evidence, no replay.</strong> I like that one.</p>
<p><strong>(c) Knowledge ingestion: lineage that actually lands in the ledger</strong></p>
<p>The only one of the three that writes the lineage into <code>runs</code>
(<code>modules/knowledge/application/runtime_service.py:848</code>):</p>
<pre><code class="language-python">run = self.trace_writer.create_run(
    ...
    source_run_id=previous_run.id,
    attempt_no=max(previous_run.attempt_no + 1, task.retry_count + 1),
    request_id=f"knowledge-ingest:{task.id}:{task.retry_count + 1}",
)
</code></pre>
<p><code>runs</code> has both <code>source_run_id</code> and <code>attempt_no</code>, plus a dedicated index,
<code>ix_runs_scope_source_created</code>. This path uses them.</p>
<p><strong>The other two do not.</strong> That is item ① in section 12.</p>
<h3>8. Why the ledger is trustworthy</h3>
<p>Three reasons, all in the code.</p>
<p><strong>Status changes are conditional UPDATEs, not read-modify-write.</strong>
The <code>where</code> clause at <code>writer.py:375</code> carries the old value:</p>
<pre><code class="language-python">result = self.db.execute(
    update(Run)
    .where(
        Run.id == run_id,
        Run.tenant_id == self.ctx.tenant_id,
        Run.workspace_id == self.ctx.workspace_id,
        Run.status == old_status,
    )
    .values(**values)
    ...
)
if result.rowcount != 1:
    ...
    raise RuntimeTransitionError(f"Concurrent run transition rejected: {old_status} -&gt; {target_status}")
</code></pre>
<p>Two executors racing to change the same run: one wins, the other sees <code>rowcount != 1</code>
and is rejected. Not last-write-wins — <strong>someone jumped the queue, so error out</strong>.</p>
<p><strong>Success is an irreversible terminal state.</strong>
From the transition table in <code>kernel/runtime/status.py</code>:</p>
<pre><code class="language-python">ExecutionStatus.SUCCEEDED: frozenset(),
ExecutionStatus.FAILED: frozenset({ExecutionStatus.RETRYING}),
ExecutionStatus.CANCELED: frozenset({ExecutionStatus.RETRYING}),
ExecutionStatus.EXPIRED: frozenset({ExecutionStatus.RETRYING}),
</code></pre>
<p><code>SUCCEEDED</code> reaches nothing. <strong>A success written into the ledger cannot be walked back</strong>,
not even to failed. Failures can move to <code>retrying</code>; successes go nowhere.</p>
<p><strong>Outbound notification goes through a transactional outbox, not a live broadcast.</strong>
Creating a run and every status change write an outbox row (<code>writer.py:282</code> and four other
sites) inside the same database transaction as the business data.</p>
<p>The live event bus, by contrast, is <strong>best-effort</strong> — the last line of <code>_emit_event</code> is:</p>
<pre><code class="language-python">except Exception:
    return
</code></pre>
<p>Swallowed. That is the right call: <strong>a failed notification must never block the ledger
write.</strong> It also means one thing for anyone verifying behaviour —
<strong>reconcile against the ledger, not against what you saw on the event stream.</strong></p>
<h3>9. The ledger belongs to the ports, not to the loop</h3>
<p>The <a href="https://github.com/soit-ai/soit">previous piece</a> argued that governance is a property
of the port rather than of the agent loop. This one adds a parallel claim.</p>
<p>Count who writes to <code>TraceWriter</code>:</p>
<table>
<thead>
<tr>
<th>File</th>
<th>Mentions of <code>trace_writer</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>kernel/ports/llm/policy.py</code></td>
<td>57</td>
</tr>
<tr>
<td><code>kernel/ports/storage/policy.py</code></td>
<td>47</td>
</tr>
<tr>
<td><code>kernel/ports/vector/policy.py</code></td>
<td>44</td>
</tr>
<tr>
<td><code>kernel/ports/tools/policy.py</code></td>
<td>15</td>
</tr>
<tr>
<td><code>kernel/ports/plugins/policy.py</code></td>
<td>12</td>
</tr>
</tbody></table>
<p>Five kernel ports, five policy gateways, one ledger.</p>
<p>Which means: <strong>you do not instrument the agent loop, and you do not instrument the
workflow engine.</strong> If an operation left through a port, it left a row. The agent loop
calling <code>tool_port.invoke</code> leaves one; a DAG workflow's tool node calling the same
<code>tool_port.invoke</code> leaves one — in the same table, with the same schema.</p>
<p>The converse holds too, and it is <strong>the real boundary of this design</strong>:
<strong>a call that bypasses the ports leaves nothing in the ledger.</strong> That is not a bug, it is
what layering means. The ledger records governed operations, not everything the process
happened to do.</p>
<h3>10. The boolean the platform computes for you</h3>
<p>Mechanism aside, the question a user actually has is simpler: <strong>is there enough evidence
for this run?</strong></p>
<p><code>GET /api/v1/runs/{run_id}</code> returns thirteen governance evidence items
(<code>kernel/runtime/runs/service.py:530</code> onward):</p>
<pre><code class="language-plaintext">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
</code></pre>
<p>The last one is the boolean. Its criteria are at <code>service.py:516</code>:</p>
<pre><code class="language-python">replay_missing: list[str] = []
if not steps:
    replay_missing.append("steps")
if response_timeline_applicable and not response_events:
    replay_missing.append("response_events")
if cost_attribution_applicable and not cost_entries:
    replay_missing.append("costs")
if knowledge_citation_applicable and not citations:
    replay_missing.append("citations")
if tool_governance_applicable and not tool_calls:
    replay_missing.append("tool_calls")
if tool_governance_applicable and not audits:
    replay_missing.append("audits")
</code></pre>
<p>Note the <code>_applicable</code> guards: <strong>it judges against what this run actually did.</strong> A pure
chat run has no tool calls and is not marked deficient for lacking them. A run that did
call a tool, but has no matching audit rows, comes back <code>fail</code> — and names the missing
category in <code>missing</code>.</p>
<p>I find that more useful than a docs page promising "full replay". <strong>It is a field you can
query, not an adjective.</strong> And it can fail — a check that always returns pass is not a
check.</p>
<h3>11. Fingerprints in the ledger, not payloads</h3>
<p>A ledger you keep for a long time is at risk of becoming a disclosure surface.</p>
<p>The handling starts at <code>tool_calls.py:59</code>. Arguments are redacted before they are
persisted; a key matching one of sixteen sensitive names becomes <code>[REDACTED]</code>:</p>
<pre><code class="language-plaintext">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
</code></pre>
<p>Keys are normalized before comparison — camel case split, non-alphanumerics folded to
underscores — so <code>apiKey</code>, <code>API-KEY</code> and <code>api_key</code> are treated alike.</p>
<p>One exception is worth knowing: if the value is a dict carrying a <code>secret_id</code>, it is
<strong>not</strong> redacted. It is already a reference rather than a plaintext, and blanking it would
destroy the one thing you want later: which secret this call used.</p>
<p>Then size. Arguments over 8192 bytes are not stored; three things are kept instead:</p>
<pre><code class="language-python">return {
    "truncated": True,
    "size_bytes": summary["size_bytes"],
    "request_hash": summary["payload_hash"],
    "argument_names": sorted(str(key) for key in value),
}
</code></pre>
<p><strong><code>request_hash</code> is computed over the pre-redaction original</strong> — <code>canonical_request_hash</code>
does a sorted, compact JSON dump and sha256s it.</p>
<p>The effect: the ledger holds no payload, but <strong>idempotency still works</strong>. The same key
arriving twice is compared on <code>request_hash</code>; a mismatch raises
<code>Tool call identity was reused with different input</code>.</p>
<p><strong>Reconcilable, but not leaky.</strong> Second-nicest thing in this ledger, after <code>in_doubt</code>.</p>
<h3>12. Seven things that don't line up yet</h3>
<p>This section is entirely about our own problems, ordered by impact.</p>
<p><strong>① Workflow replay/retry never writes lineage into the ledger.</strong></p>
<p><code>runs</code> has <code>source_run_id</code> and <code>attempt_no</code>, plus an index built for them. Across the
whole repo there are 19 <code>create_run(</code> call sites and <strong>exactly one passes
<code>source_run_id</code></strong> — the knowledge ingestion path from section 7.</p>
<p>Workflow replay goes through <code>execute_workflow</code> into <code>engine.execute</code>, and the engine
creates the run like this (<code>modules/workflow/runtime/engine.py:145</code>):</p>
<pre><code class="language-python">run = self.trace_writer.create_run(
    mode=plan.mode,
    subject_kind=plan.subject_kind,
    subject_id=plan.subject_id,
    subject_version_id=plan.subject_version_id,
    input_summary=input_summary,
    run_id=plan.run_id,
)
</code></pre>
<p>No <code>source_run_id</code>. No <code>attempt_no</code>.</p>
<p><strong>Impact</strong>: <code>source_run_id</code> exists only in the HTTP <strong>response body</strong>. If the caller does
not store it, the "B is a replay of A" relationship is gone — unqueryable in the ledger,
and <code>ix_runs_scope_source_created</code> indexes nothing useful.</p>
<p><strong>Workaround today</strong>: have the caller keep the <code>source_run_id</code> it got back.
<strong>Intended fix</strong>: thread <code>source_run_id</code> and <code>attempt_no</code> through those two paths into
<code>create_run</code>. I intend to open an issue for this; I had not filed it when this was
written, so there is no link here.</p>
<p><strong>② Re-execution reads a truncated copy of the inputs.</strong></p>
<p>Section 2 noted that <code>input_summary</code> is cut at 8192 bytes. Workflow replay loads inputs
like this (<code>modules/workflow/application/service.py:151</code>):</p>
<pre><code class="language-python">def _load_run_inputs(self, run: Run) -&gt; dict[str, Any] | None:
    if not run.input_summary:
        return None
    import json
    try:
        parsed = json.loads(run.input_summary)
        ...
</code></pre>
<p><strong>It <code>json.loads</code> the summary.</strong></p>
<p>So a run whose inputs exceeded 8KB will fail to parse on replay (truncated JSON generally
is not valid) and return <code>Replay requires inputs or a parseable run input_summary</code>.</p>
<p><strong>Workaround today</strong>: pass <code>inputs</code> explicitly instead of letting it read from the ledger —
both endpoints accept an override.
<strong>Intended fix</strong>: send large inputs to an artifact and keep a pointer, exactly like tool
results in section 2. Same status: issue intended, not yet filed.</p>
<p><strong>③ <code>generate_ulid()</code> does not generate a ULID.</strong></p>
<p>The source says so itself (<code>kernel/commons/ids.py:9</code>):</p>
<pre><code class="language-python">def generate_ulid() -&gt; str:
    """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.
    """
    return f"id_{uuid.uuid4().hex}"
</code></pre>
<p>UUID4 is random. <strong>Not sortable at all</strong> — neither the name nor the "sortable" in the
docstring holds.</p>
<p><strong>Bounded but real impact</strong>: everything that needs chronological order has to use
<code>created_at</code> rather than the id. The catch-up in section 4 does exactly that. Arguably
forced into the correct implementation.</p>
<p><strong>④ Catch-up uses a strict greater-than.</strong></p>
<p>Following from ③: <code>created_at</code> comes from Python's <code>datetime.now(UTC)</code>, and the filter is
<code>RunStep.created_at &gt; last_step_time</code>.</p>
<p><strong>Theoretical consequence</strong>: if two steps land on an identical timestamp and the client's
last received event was one of them, the other is skipped by the strict comparison.</p>
<p><strong>I did not reproduce this.</strong> <code>datetime.now()</code> resolves to microseconds on modern Linux,
and two steps in one run colliding on the same microsecond takes unusual conditions.
It is listed as a design fragility, <strong>not an observed bug — please don't repeat it as
one.</strong> The fix is easy once ③ is done: order by <code>(created_at, id)</code>.</p>
<p><strong>⑤ Ids in the ledger come in three shapes.</strong></p>
<p>Because <code>generate_ulid()</code> already returns an <code>id_</code>-prefixed string, anything that adds its
own prefix ends up double-prefixed:</p>
<table>
<thead>
<tr>
<th>Table</th>
<th>How it is generated</th>
<th>What you see</th>
</tr>
</thead>
<tbody><tr>
<td><code>run_step_tool_calls</code></td>
<td><code>f"rstc_{generate_ulid()}"</code></td>
<td><code>rstc_id_xxxx</code></td>
</tr>
<tr>
<td><code>tasks</code></td>
<td><code>f"task_{generate_ulid()}"</code></td>
<td><code>task_id_xxxx</code></td>
</tr>
<tr>
<td><code>run_cost_entries</code></td>
<td><code>default_factory=generate_ulid</code></td>
<td><code>id_xxxx</code></td>
</tr>
</tbody></table>
<p>All three work. The third just gives no hint which table the row belongs to.
<strong>Cosmetic, no functional impact</strong> — but you notice it the moment you start reading rows.</p>
<p><strong>⑥ The <code>Run.status</code> docstring lists 6 statuses; there are 11.</strong></p>
<p>On the model (<code>models/runs.py:79</code>):</p>
<pre><code class="language-python">"""Status: queued, running, paused, succeeded, failed, canceled."""
</code></pre>
<p><code>ExecutionStatus</code> has eleven: those six plus <code>preparing</code>, <code>waiting_input</code>,
<code>waiting_approval</code>, <code>retrying</code>, <code>expired</code>. Steps add <code>skipped</code> on top.</p>
<p><strong>Impact</strong>: anyone writing a client from that comment misses five states. Documentation
drift; a one-line fix.</p>
<p><strong>⑦ The <code>soit runs replay</code> line in the console is display copy — that CLI does not exist.</strong></p>
<p>From the run detail adapter (<code>web/app/console/adapters/run-detail.ts:227</code>):</p>
<pre><code class="language-typescript">ledger_code: {
  command: `soit runs replay ${run.id} --dry-run`,
  output: `replaying ${detail.steps.length} steps · verdict on record: ${run.status}`,
},
</code></pre>
<p>It renders as a code sample explaining what that panel shows.
<strong>But there is no <code>soit</code> CLI in the open-source repo</strong> — <code>server/pyproject.toml</code> has no
<code>[project.scripts]</code>, and <code>server/scripts/</code> has no matching entry point.</p>
<p>The thing that does work is the HTTP endpoint from section 3. There is a replay script in
the repo, but it is for the outbox (<code>server/scripts/replay_outbox_event.py</code>, 41 lines —
it returns one terminally failed domain event to the pending queue), which is a different
thing entirely.</p>
<p><strong>I went back and forth on including this.</strong> Including it says we haven't kept our own
console copy honest. Leaving it out means a reader types the command from a screenshot and
gets nothing. Included, in the end — <strong>the gap between demo copy and real capability is
exactly the kind of thing a reader is entitled to know.</strong></p>
<h3>Coming clean</h3>
<ul>
<li><strong>No fresh live run behind this piece.</strong> Every conclusion comes from reading <code>soit/</code> at
commit <code>fb46f20</code>, 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.</li>
<li><strong>Item ④ in section 12 is an inference, not an observation.</strong> 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.</li>
<li><strong>Replay does not promise identical output.</strong> 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.</li>
<li><strong>This is all the community edition.</strong> Every path above is in
<code>github.com/soit-ai/soit</code> and readable right now.</li>
<li><strong>Disclosure: I maintain SOIT.</strong></li>
</ul>
<h3>One-line version</h3>
<p>"Replayable" here is not an adjective. It is a field the platform computes, that you can
query, and that <strong>can come back fail</strong> — backed by five database tables, four replay paths
with very different costs, and one status willing to admit we don't know whether the other
side did the thing.</p>
<h3>Try it, and come argue</h3>
<p>The repo is <code>github.com/soit-ai/soit</code>. To check the claims above:</p>
<ol>
<li>Start it, send any message, take the <code>run_id</code>.</li>
<li><code>GET /api/v1/runs/{run_id}</code> and look at <code>replay_ready</code> among the thirteen evidence
items — if it is <code>fail</code>, <code>missing</code> names what is absent.</li>
<li><code>GET /api/v1/observe/runs/{run_id}/replay</code> and see what the seven keys hold.</li>
</ol>
<p>If any of the seven items in section 12 is wrong, open an issue and say so. I would rather
learn where this doesn't line up than be told the design is nice.</p>
]]></content:encoded></item><item><title><![CDATA[Agent frameworks and agent runtimes are not the same layer, and the code says where the line is]]></title><description><![CDATA[Why I ran wc -l on my own repo
Someone asked again — roughly the tenth time since we open-sourced — whether SOIT is "just
another LangChain". I have answered that with architecture diagrams before and]]></description><link>https://soit-ai.hashnode.dev/agent-frameworks-and-agent-runtimes-are-not-the-same-layer-and-the-code-says-where-the-line-is</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/agent-frameworks-and-agent-runtimes-are-not-the-same-layer-and-the-code-says-where-the-line-is</guid><category><![CDATA[architecture]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[AI]]></category><category><![CDATA[self-hosted]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Tue, 08 Sep 2026 00:33:46 GMT</pubDate><content:encoded><![CDATA[<h3>Why I ran <code>wc -l</code> on my own repo</h3>
<p>Someone asked again — roughly the tenth time since we open-sourced — whether SOIT is "just
another LangChain". I have answered that with architecture diagrams before and it never lands,
because a diagram is something I drew. So this time I answered it with <code>wc -l</code>.</p>
<p>Our agent loop — plan, act, verify — is <strong>239 lines</strong>. The policy gateway sitting on the single
port that loop reaches out through is <strong>475 lines</strong>. One file, guarding one seam, is roughly
twice the size of the thing everyone assumes is the product. That ratio is the entire argument
of this post, and it is the one claim here I did not have to construct.</p>
<p>What follows is a read-the-code account of where the framework layer ends and the runtime layer
begins, using our own repository as the specimen, with a file and a line number attached to
every claim.</p>
<h3>The short version</h3>
<table>
<thead>
<tr>
<th></th>
<th>Agent framework</th>
<th>Agent runtime</th>
</tr>
</thead>
<tbody><tr>
<td>Operates during</td>
<td>the days you write code</td>
<td>every execution after that</td>
</tr>
<tr>
<td>Core question</td>
<td>how should the agent think, compose, call</td>
<td>is this call allowed, is it written down, who pays for it</td>
</tr>
<tr>
<td>Typical surface</td>
<td>prompt composition, chain/graph authoring, model and tool wrappers, developer experience</td>
<td>permission checks, secret boundaries, egress policy, ledger and replay, cost attribution, approval interrupts</td>
</tr>
<tr>
<td>Judged by</td>
<td>expressiveness, time to first agent, ecosystem breadth</td>
<td>can you reconstruct what happened, can you stop it, can you replay it</td>
</tr>
<tr>
<td>Where it lives in our repo</td>
<td><code>modules/agent/runtime/</code> (planner + executor + verifier)</td>
<td><code>kernel/ports/</code>, <code>kernel/runtime/</code>, <code>kernel/security/</code></td>
</tr>
<tr>
<td>Size</td>
<td><strong>239 lines</strong></td>
<td>the policy gateway on one port alone is <strong>475</strong></td>
</tr>
</tbody></table>
<p>The last two rows are what this post argues, and they are not a diagram I drew. They are <code>wc -l</code>.</p>
<h3>Why this needs a whole post</h3>
<p>"Agent platform" currently swallows at least three separate things: <strong>the library you write
an agent with</strong>, <strong>the engine that runs it</strong>, and <strong>the control plane that governs it</strong>. Once
those three share a word, every discussion becomes two people answering different questions —
one is saying "I built an agent in three lines", the other is saying "I need to know who
approved that call at 3pm yesterday". Both are right. Neither is talking to the other.</p>
<p>I am not going to characterize anyone else's internals. That would require me to have read
their code closely enough to be accountable for the claim, and every claim in this post has to
come with a line number. So the post does two things:</p>
<ol>
<li>describes what the framework layer generally owns — consensus, no assertions about anyone;</li>
<li><strong>uses our own repo as the specimen</strong> to locate what the runtime layer owns.</li>
</ol>
<p>The specimen is <a href="https://github.com/soit-ai/soit">github.com/soit-ai/soit</a>, Apache 2.0. Every
line number below refers to commit <code>3a57ae1</code>.</p>
<h3>1. Counting our own agent loop: 239 lines</h3>
<p>An agent loop is three things: decide the next step, do it, check whether it is done. In our
repo those are three files:</p>
<pre><code class="language-text">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
</code></pre>
<p>The executor is 42 lines and nearly all of it fits here:</p>
<pre><code class="language-python">class AgentExecutor:
    """Execute tool actions for agent."""

    def __init__(self, tool_port: ToolPort):
        self.tool_port = tool_port

    async def execute_tool(
        self, tool_ref, parameters, ctx, run_id, tool_call_id,
        idempotency_key, run_step_id=None, resume_approval=False, lease_owner=None,
    ) -&gt; ToolResponse:
        """Execute tool call."""
        return await self.tool_port.invoke(
            tool_ref=tool_ref, parameters=parameters, run_id=run_id,
            tool_call_id=tool_call_id, idempotency_key=idempotency_key,
            run_step_id=run_step_id, resume_approval=resume_approval,
            lease_owner=lease_owner, ctx=ctx, strict_registry=True,
        )
</code></pre>
<p><strong>This class has no logic.</strong> It hands the call to a port. That is not laziness; it is the
thesis of this post: <strong>the rules of execution are not written in the loop, they are written on
the port.</strong></p>
<p>The planner is equally plain. It hands messages to the model using native function calling and
gets back either tool calls or text — there is no third case:</p>
<pre><code class="language-python">response = await self.llm_port.chat(
    messages=planning_messages, model=model, temperature=temperature,
    tools=tool_definitions if tool_definitions else None,
    tool_choice="auto" if tool_definitions else None,
    run_id=run_id, reasoning_effort=reasoning_effort,
)
if response.tool_calls:
    return PlanResult(action="tool", tool_calls=response.tool_calls, ...)
return PlanResult(action="respond", response=response.text or "", ...)
</code></pre>
<p>No prompt template DSL. No chain or graph authoring language. No regex pulling <code>Action:</code> out of
model output. The verifier is the same shape: one structured-output tool definition
(<code>verify_response</code>, fields <code>ok</code> and <code>reason</code>) asking the model whether the answer is adequate.</p>
<p><strong>This layer is the framework's home turf, and we deliberately built almost nothing here.</strong>
Thin is neither a virtue nor a flaw. It just means we are not competing on expressiveness.</p>
<h3>2. So what are the 1,617 lines wrapped around it doing?</h3>
<p><code>server/app/modules/agent/application/service.py</code> is 1,617 lines. Its import block answers the
question on its own:</p>
<pre><code class="language-python">from app.kernel.identity.guard import workspace_guard
from app.kernel.ports.approvals import ApprovalLedgerPort, ApprovalRecord
from app.kernel.ports.common.rate_limiter import RateLimiter
from app.kernel.runtime.runs.tool_calls import RuntimeToolExecutionService, ToolExecutionCommand
from app.kernel.runtime.runs.writer import TraceWriter
from app.kernel.runtime.tools.approval import tool_approval_rule
from app.kernel.runtime.tools.resolver import ToolResolver
</code></pre>
<p>Workspace guard, approval ledger, rate limiter, tool-execution ledger (with leases and
idempotency), trace writer, approval rules, tool resolver. Plus a dedicated control signal for
human approval:</p>
<pre><code class="language-python">class _AgentApprovalInterrupt(Exception):
    """Internal control signal for a durable human approval checkpoint."""
</code></pre>
<p>The loop itself is one <code>while</code> (<code>service.py:653</code>):</p>
<pre><code class="language-python">while pending_tool_calls or iterations &lt; data.max_iterations:
</code></pre>
<p><strong>One line of loop, sixteen hundred lines around it.</strong> Almost none of those lines are about how
the agent thinks. They are about whether this call is allowed, whether it is written down, who
it is billed to, and how to resume when a human interrupts it.</p>
<p>If nine tenths of a codebase answers the second set of questions, calling it "another agent
framework" describes the least important tenth of it.</p>
<h3>3. Where the line actually is: ten gates on one tool call</h3>
<p><code>server/app/kernel/ports/tools/interface.py</code> is 54 lines: an abstract <code>ToolPort</code> with a single
abstract <code>invoke()</code>. That is the seam.</p>
<p>The thing that does the work is <code>ToolPolicyGateway</code> in
<code>server/app/kernel/ports/tools/policy.py</code>, 475 lines, implementing that same <code>ToolPort</code>. Which
means <strong>calling a tool raw and calling a tool through governance look identical to the caller</strong> —
the only difference is which implementation got injected.</p>
<p><code>ToolPolicyGateway.invoke()</code> starts at <code>policy.py:231</code>. One tool call passes, in order:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Gate</th>
<th>What it does</th>
<th>Where</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Secret resolution and redaction</td>
<td>Secret references in the parameters are resolved through <code>secrets_port</code>, and a <strong>redacted copy</strong> is produced for everything downstream that records</td>
<td><code>policy.py:253–256</code></td>
</tr>
<tr>
<td>2</td>
<td>Ledger claim</td>
<td>The call is claimed in the run ledger; <code>tool_call_id</code> and an idempotency key are minted or reused</td>
<td><code>policy.py:264–290</code></td>
</tr>
<tr>
<td>3</td>
<td>Idempotent replay</td>
<td>If the claim comes back <code>replayed</code>, the cached response is returned and <strong>the call is not made again</strong></td>
<td><code>policy.py:291–296</code></td>
</tr>
<tr>
<td>4</td>
<td>Lease</td>
<td>The execution takes a lease of <code>max(60, ceil(timeout) + 10)</code> seconds, renewed while it runs</td>
<td><code>policy.py:281</code>, <code>policy.py:326</code></td>
</tr>
<tr>
<td>5</td>
<td>Egress policy</td>
<td>Every http URL found anywhere in the parameters goes through <code>check_egress_policy</code></td>
<td><code>policy.py:307–309</code></td>
</tr>
<tr>
<td>6</td>
<td>Rate limit</td>
<td>Per-minute limit keyed on <code>tool_ref</code> + tenant + workspace + user</td>
<td><code>policy.py:311–318</code></td>
</tr>
<tr>
<td>7</td>
<td>Daily quota</td>
<td>86,400-second window keyed on <code>tool_ref</code> + tenant + workspace</td>
<td><code>policy.py:319–324</code></td>
</tr>
<tr>
<td>8</td>
<td>Tracing</td>
<td>A <code>soit.tool.invoke</code> OTel span carrying tenant, workspace, run and step attributes</td>
<td><code>policy.py:336–348</code></td>
</tr>
<tr>
<td>9</td>
<td>Timeout and retry</td>
<td>Shared timeout/retry; <strong>with an idempotency key, <code>max_retries=1</code></strong>, and the comment says why</td>
<td><code>policy.py:349–360</code></td>
</tr>
<tr>
<td>10</td>
<td>Audit and settlement</td>
<td>Audit log (written with the <strong>redacted</strong> parameters), step status and metrics, cost</td>
<td><code>policy.py:367–383</code>, <code>410–425</code></td>
</tr>
</tbody></table>
<p>Watch how gate 1 and gate 10 cooperate: <strong>real values only ever reach the call; the redacted
copy is the only thing that reaches the record.</strong> The code carries <code>resolved_parameters</code> and
<code>redacted_parameters</code> side by side precisely so that audit and metrics can never accidentally
take the wrong one. That is not a discipline you can maintain by being careful in application
code. It only works if it sits on the path everything must take.</p>
<p>Gate 9's comment is the tell:</p>
<pre><code class="language-python"># Durable Agent calls are at-most-once at this boundary.
# Not every downstream adapter can honor an idempotency key.
</code></pre>
<p>That is a runtime-layer concern in one sentence. It does not care whether your agent logic is
elegant. It cares whether a retry files the same ticket twice.</p>
<h3>4. The fact that proves governance does not live in the loop</h3>
<p>At this point you could reasonably say: you just pushed the governance code downstream of your
agent loop, that proves nothing.</p>
<p>So look at a second execution model. Besides the agent loop, the repo has a workflow engine —
<code>modules/workflow/</code>, 6,269 lines, including a 969-line <code>engine.py</code> and an 855-line
<code>executor.py</code>. It is a DAG. It has nothing structurally in common with an agent loop.</p>
<p>How does it call a tool?</p>
<pre><code class="language-python"># server/app/modules/workflow/runtime/executors/tool.py:428
response = await context.tool_port.invoke(...)

# server/app/modules/workflow/runtime/executors/llm.py:105
response: ChatResponse = await context.llm_port.chat(...)
</code></pre>
<p><strong>The same <code>tool_port.invoke</code>. The same <code>llm_port.chat</code>.</strong></p>
<p>Two unrelated execution models, one set of gates. That is the operational definition of a
layer: governance is a property <strong>of the port</strong>, not of any particular loop. Swap the execution
model above and not one of the ten gates below goes away.</p>
<p>Which is also the technical reason the two layers do not conflict: <strong>anything that calls
through these ports gets governed</strong> — our loop, a DAG engine, or something else entirely. The
layer underneath cannot tell the difference and does not need to.</p>
<h3>5. The dependency list is the most honest positioning statement a project has</h3>
<p>What a project says it is, you read in the README. What it actually is, you read in its
dependencies.</p>
<p>In <code>server/pyproject.toml</code>, exactly three core dependencies relate to agents at all:</p>
<pre><code class="language-toml">"mcp&gt;=1.28.1,&lt;2",          # tool protocol
"ag-ui-protocol==0.1.19",  # front-end interaction event protocol
"litellm==1.91.1",         # model invocation
</code></pre>
<p><strong>All three are protocols or call layers. There is no agent framework in the core
dependencies.</strong> That is not a manifesto, it is <code>pyproject.toml</code> lines 68 to 71.</p>
<p>And LangChain? It is there. Here:</p>
<pre><code class="language-toml">[project.optional-dependencies]
local-embedding = [
    "sentence-transformers&gt;=4.1.0",
    "langchain-huggingface&gt;=0.0.6",
    ...
]
</code></pre>
<p><code>pyproject.toml:75–83</code> — an <strong>optional</strong> extra named <code>local-embedding</code>, for running embedding
models locally. Nothing to do with agent orchestration.</p>
<p><strong>While I am here, a correction about us.</strong> Our README's tech-stack table lists the LLM row as
<code>OpenAI · Anthropic · DeepSeek · Qwen · LangChain (adapter layer)</code> (<code>README.md:240</code>). That does
not match the dependencies. LangChain is not an LLM adapter layer here; it is an optional local
embedding dependency. That is our documentation misleading readers, and I intend to open an
issue to fix it — I had not filed it when this was written, so there is no link to give you.</p>
<h3>6. The seams are other people's protocols, not shapes we invented</h3>
<p>A layer boundary is only real if the seam is public. Three seams:</p>
<p><strong>Requests in.</strong> <code>POST /api/v1/responses</code> accepts AG-UI's <code>RunAgentInput</code> directly:</p>
<pre><code class="language-python"># server/app/api/v1/responses/router.py:222
async def create_response(payload: RunAgentInput | ResponseCreateRequest, ...):
</code></pre>
<p>A front end does not have to learn a SOIT-specific message shape.</p>
<p><strong>Tools in.</strong> Tool references are namespaced strings; <code>adapters/tools/router.py</code> shows three
prefixes — <code>tool:http:*</code>, <code>tool:function:*</code>, and <code>mcp_tool:*</code>. Any MCP server resolves into the
tool registry without a code change.</p>
<p><strong>Events out.</strong> The run is persisted and streamed to the front end as AG-UI interaction events
(<code>adapters/agui/agent.py</code> and <code>responses.py</code>, 909 lines together).</p>
<p>Three protocols, no dialect of our own. That is the precondition for two layers being able to
snap together at all.</p>
<h3>7. We did not write a framework at the model layer either</h3>
<p><code>adapters/llm/</code> is 2,619 lines, of which <code>router.py</code> is 534. Those 534 lines do routing,
credential resolution and egress guarding — not prompt composition:</p>
<pre><code class="language-python"># server/app/adapters/llm/router.py:192 (inside _authorize_provider_target)
await self.egress_guard.authorize(ctx, f"model-provider:{provider_slug}", url)
</code></pre>
<p>Both provider-resolution paths (<code>router.py:368</code> and <code>router.py:422</code>) go through it first. In
other words, <strong>calling a model is itself subject to egress policy</strong>. If the target host is not
in policy, the call does not leave the box.</p>
<p>One more that is easy to miss: in production, a provider with no configured credential is
rejected outright (<code>router.py:316–324</code>, <code>MODEL_PROVIDER_CREDENTIAL_REQUIRED</code>). "Production mode
refuses to let you cut corners" is a runtime-layer job description. It does nothing for your
developer experience. It exists to stop development-time convenience from reaching production.</p>
<h3>8. The boundary is welded shut by CI, not asserted in a doc</h3>
<p>Layering usually dies by being true in the documentation and false in the code. So we handed
this one to a tool. The first contract in <code>server/importlinter.ini</code>:</p>
<pre><code class="language-ini">[importlinter:contract:kernel_isolation]
name = Kernel is isolated
type = forbidden
source_modules =
    app.kernel
forbidden_modules =
    app.api
    app.modules
    app.adapters
    app.infra
    app.wiring
</code></pre>
<p><strong>The kernel may not import anything above it.</strong> The moment the governance kernel depends
backwards on a product module, "swap the execution model and every gate survives" stops being
true. That is not something to leave to good intentions; let CI hit the wall instead.</p>
<p><code>server/app/kernel/README.md</code> states the rule in prose, and one line of it is worth quoting:</p>
<blockquote>
<p>Kernel extension points that need product or infrastructure data must use
provider interfaces registered from <code>wiring/</code>.</p>
</blockquote>
<p>When the kernel needs outside data it does not reach for it; it takes a provider interface
registered in <code>wiring/</code>. That is the standing cost of keeping a layer boundary alive.</p>
<h3>9. The honest part: those 6,269 workflow lines do overlap</h3>
<p>So far I have made us sound very tidy: we only do runtime, not framework.</p>
<p>That is not quite true.</p>
<p><code>modules/workflow/</code> is 6,269 lines, with a compiler (<code>compiler.py</code>, 259), variable resolution
(<code>variable_resolver.py</code>, 212), node executors (the tool node alone is 610), resume and reaper
paths. <strong>It is an orchestration engine, and it overlaps in function with orchestration
frameworks people already use.</strong></p>
<p>I am not going to argue that ours is somehow a different species. It overlaps. The only
distinction is the one from section 4: it calls tools through the same <code>tool_port.invoke</code>.</p>
<p>So the accurate statement is not "we don't build framework things". It is: <strong>we built the
minimum of it we needed, and we made it obey the same rules as everybody else.</strong></p>
<h3>10. What "not competitors" concretely means — including what you cannot do today</h3>
<p>Three things you can do, and one you cannot.</p>
<p><strong>You can:</strong></p>
<ol>
<li><strong>Bring framework-side tools in over MCP and have them governed.</strong> Any MCP server resolves
into the tool registry, and from then on every call it makes goes through the ten gates.</li>
<li><strong>Bring framework-side services in as HTTP plugins.</strong> <code>adapters/plugins/http_runtime.py</code> and
<code>skill_runtime.py</code> are the two plugin runtime implementations (129 and 85 lines).</li>
<li><strong>Keep your front end.</strong> Interaction is an AG-UI event stream, and the request shape coming
in is AG-UI's <code>RunAgentInput</code>.</li>
</ol>
<p><strong>You cannot (and this matters more than the three above):</strong></p>
<p><strong>There is no entry point today for handing us a framework-written agent to host wholesale.</strong>
Your loop still runs in your process. What SOIT governs is <strong>the hand it reaches out with</strong> —
tool calls, model calls, egress — not the reasoning inside it.</p>
<p>That is the real boundary right now. Do not size it up as a universal container. If what you
want is "host my existing agent code as-is and get the whole governance stack for free", this
repo does not do that today. What it does is make every hand that code reaches out with sign
its name.</p>
<h3>Confession</h3>
<ul>
<li><strong>This is a read-the-code and read-the-config piece with no new end-to-end run.</strong> Every claim
can be opened and checked at commit <code>3a57ae1</code>; whether it behaves this way at runtime is
outside what this post verified.</li>
<li><strong>All line counts are <code>wc -l</code></strong>, including blanks, comments and docstrings. Fine for orders of
magnitude, wrong for estimating effort.</li>
<li><strong>The README inaccuracy in section 5 is real.</strong> Our own documentation misled readers about
this. I intend to file an issue; it was not filed when this was written.</li>
<li><strong>I made no claims about any specific framework's internals.</strong> "What the framework layer owns"
here is the industry-consensus description. Everything with a line number is our own repo.</li>
<li><strong><code>SandboxToolPort</code> is not a security sandbox.</strong> The 61 lines in
<code>kernel/ports/tools/sandbox.py</code> 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. <strong>It stops side effects, not hostile code.</strong></li>
<li><strong>The section 4 argument has a precondition.</strong> "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.</li>
</ul>
<h3>One sentence</h3>
<p><strong>A framework decides how the agent thinks. A runtime decides whether the hand it reaches out
with counts.</strong></p>
<p>These do not compete, because they do not even operate on the same timescale — one acts on the
few days you spend writing code, the other on every execution afterwards.</p>
<p>If they compete for anything, it is <strong>attention</strong>. Somewhere on the path from demo to
production, a team's attention has to move from the first to the second, and it usually moves
too late — as in, after the first incident.</p>
<h3>Try it, or take it apart</h3>
<p>Code at <a href="https://github.com/soit-ai/soit">github.com/soit-ai/soit</a>, Apache 2.0. Every line
number in this post refers to commit <code>3a57ae1</code>.</p>
<p>If you want the tour of the layer underneath — what the other eleven containers in this stack
actually do — that is <a href="https://soit-ai.hashnode.dev/what-are-the-other-eleven-containers-for-walking-one-agent-run-through-the-whole-stack">the previous post</a>.</p>
<p>Three files are worth opening, because they carry the entire argument:</p>
<ul>
<li><code>server/app/modules/agent/runtime/executor.py</code> — 42 lines, see how empty it is</li>
<li><code>server/app/kernel/ports/tools/policy.py</code> — 475 lines, the ten gates</li>
<li><code>server/importlinter.ini</code> — how the boundary is welded shut</li>
</ul>
<p>If you think the section 4 argument has a hole in it, or you have seen a better way to draw this
line in another project, say so in an issue. The failure mode for a post like this is talking
to myself.</p>
<p><strong>Disclosure: I maintain SOIT.</strong></p>
]]></content:encoded></item><item><title><![CDATA[What are the other eleven containers for? Walking one agent run through the whole stack]]></title><description><![CDATA[You type docker compose up -d on this project's quickstart, and the terminal answers with a wall of container names — a dozen of them, before a single request has been served. That wall is where most ]]></description><link>https://soit-ai.hashnode.dev/what-are-the-other-eleven-containers-for-walking-one-agent-run-through-the-whole-stack</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/what-are-the-other-eleven-containers-for-walking-one-agent-run-through-the-whole-stack</guid><category><![CDATA[Docker]]></category><category><![CDATA[architecture]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[self-hosted]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Sat, 05 Sep 2026 06:25:52 GMT</pubDate><content:encoded><![CDATA[<p>You type <code>docker compose up -d</code> on this project's quickstart, and the terminal answers with a wall of container names — a dozen of them, before a single request has been served. That wall is where most people decide whether a self-hosted project is serious or just bloated. In <a href="https://soit-ai.hashnode.dev/reading-the-code-told-me-which-services-i-could-drop-running-it-told-me-the-truth">the previous post</a> I went the other way and cut the same stack down to four long-running containers, end to end.</p>
<p>The follow-up question was not "how did you cut it." It was the inverse:</p>
<p><strong>So what are the rest of them actually for?</strong></p>
<p>That deserves a straight answer rather than a defence. When a quickstart starts a dozen containers, the default reading is either "the architecture never converged" or "someone is cosplaying enterprise" — and plenty of projects earn both. So this post does exactly one thing: <strong>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.</strong> Here is the least intuitive part up front: <strong>of the twelve containers, exactly one runs a model.</strong> 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."</p>
<h3>The short answer</h3>
<table>
<thead>
<tr>
<th>Service</th>
<th>Where it sits in a run</th>
<th>What it carries</th>
<th>What you lose without it</th>
</tr>
</thead>
<tbody><tr>
<td><code>postgres</code></td>
<td>throughout</td>
<td>the ledger: run / step / tool_call / artifact / cost</td>
<td>the physical basis for observability and replay; hard readiness gate</td>
</tr>
<tr>
<td><code>redis</code></td>
<td>at authz, and on cross-instance broadcast</td>
<td>permission cache (5-minute TTL), rate limiter, cross-instance event bus</td>
<td>replicas stop seeing each other's events; every authz check hits the DB</td>
</tr>
<tr>
<td><code>minio</code></td>
<td>when a run produces something large</td>
<td>artifact bytes; the DB keeps only <code>storage_key</code> + <code>sha256</code></td>
<td>hard readiness gate; nowhere to put outputs</td>
</tr>
<tr>
<td><code>milvus</code></td>
<td>the retrieval step</td>
<td>vector store</td>
<td>retrieval calls fail (but readiness stays green)</td>
</tr>
<tr>
<td><code>etcd</code></td>
<td>never directly</td>
<td><strong>Milvus's own metadata store</strong>, not the platform's dependency</td>
<td>goes wherever Milvus goes</td>
</tr>
<tr>
<td><code>vault</code></td>
<td>when a secret is needed</td>
<td>KV v2 store; credentials stay out of the process and out of <code>.env</code></td>
<td>falls back to an in-process store, lost on restart</td>
</tr>
<tr>
<td><code>migrate</code> / <code>bootstrap</code></td>
<td>before the run</td>
<td>one-shot: schema, first admin, first tenant</td>
<td>— (they exit)</td>
</tr>
<tr>
<td><code>minio-init</code></td>
<td>same</td>
<td>one-shot: create the bucket, disable anonymous access</td>
<td>— (it exits)</td>
</tr>
<tr>
<td><code>api</code></td>
<td>throughout</td>
<td><strong>the one process that actually runs a model</strong></td>
<td>it is the thing being demoed</td>
</tr>
<tr>
<td><code>web</code></td>
<td>throughout</td>
<td>the front end</td>
<td>same</td>
</tr>
<tr>
<td><code>outbox-dispatcher</code></td>
<td><strong>after the response is sent</strong></td>
<td>delivers events committed inside the run's transaction, with per-consumer checkpoints</td>
<td>events sit at <code>pending</code> forever; nothing downstream ever fires</td>
</tr>
<tr>
<td><code>knowledge-ingest-worker</code></td>
<td>unrelated to a run</td>
<td>document parsing and indexing</td>
<td>uploads never reach the knowledge base</td>
</tr>
<tr>
<td><code>scheduler</code></td>
<td>unrelated to a run</td>
<td>fires due schedules</td>
<td>⚠ <strong>the quickstart never starts it</strong> — see below</td>
</tr>
</tbody></table>
<h3>1. First, a correction: it is not 12, it is 14</h3>
<p>The quickstart command names twelve services:</p>
<pre><code class="language-bash">docker compose --env-file .env -f docker/docker-compose.yml up -d \
  postgres redis minio etcd milvus vault migrate bootstrap api web \
  knowledge-ingest-worker outbox-dispatcher
</code></pre>
<p><code>docker/docker-compose.yml</code> defines <strong>fourteen</strong>. The two extras behave very differently:</p>
<ul>
<li><strong><code>minio-init</code></strong> is not in the command, but <code>api</code> declares <code>depends_on: minio-init: service_completed_successfully</code>, so Compose starts it anyway, it creates the bucket and runs <code>mc anonymous set none</code>, and exits. <strong>Thirteen containers actually start.</strong></li>
<li><strong><code>scheduler</code></strong> is not in the command and nothing depends on it — <strong>it never starts in the quickstart topology at all.</strong> That one gets its own section.</li>
</ul>
<p>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.</p>
<h3>2. Before the run: the two containers that exit</h3>
<p><code>migrate</code> and <code>bootstrap</code> are one-shot jobs — <code>restart: "no"</code>, and a healthy result is <code>Exited (0)</code>.</p>
<p><code>migrate</code> runs <code>sh scripts/migrate.sh</code> once <code>postgres</code> is healthy. <code>bootstrap</code> runs <code>scripts/bootstrap_admin.py</code> once <code>migrate</code> has <strong>exited successfully</strong>, creating the first admin and tenant (<code>admin@example.com</code> / <code>changeme123</code> / tenant <code>default</code> by default).</p>
<p>Note the dependency condition: <code>service_completed_successfully</code>, not <code>service_healthy</code>. "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. <code>api</code> waits on both of these completing before it boots.</p>
<p>If <code>docker compose ps</code> shows those two as <code>Exited</code>, that is the correct state, not a failure.</p>
<h3>3. Authz: Postgres is the authority, Redis is a cache</h3>
<p>The first thing a request hits is authorization. Redis appears here, but strictly as a cache — the authority is always Postgres.</p>
<p>From <code>server/app/kernel/identity/permissions.py</code>:</p>
<pre><code class="language-python">class PermissionCache:
    """Permission cache using Redis."""

    def __init__(self, redis_client: redis_async.Redis | None = None):
        self._redis: redis_async.Redis | None = redis_client
        self._redis_pool: redis_async.ConnectionPool | None = None
        self._cache_ttl = 300  # 5 minutes
</code></pre>
<p>Three things worth knowing:</p>
<ol>
<li><strong>The TTL is hard-coded at 300 seconds</strong>, not configurable. A permission change can therefore take up to five minutes to propagate everywhere unless something calls <code>invalidate</code> explicitly (it exists — pattern-matched <code>scan_iter</code> + <code>delete</code>).</li>
<li><strong>Losing Redis degrades rather than fails.</strong> <code>_get_redis()</code> returns <code>None</code> when <code>settings.redis_url</code> is empty or contains <code>"None"</code>; the caller treats that as a cache miss and falls through to the database. No Redis means slower, not broken.</li>
<li>The same Redis backs the rate limiter (<code>server/app/kernel/ports/common/rate_limiter.py</code>), implemented as a Lua script: <code>ZREMRANGEBYSCORE</code> to drop the expired window, <code>ZCARD</code> to count, then <code>ZADD</code> + <code>EXPIRE</code> if under the limit. Sliding-window counting in a single <code>eval</code>, so there is no read-modify-write race.</li>
</ol>
<p>So "can I drop Redis?" resolves to: <strong>in a demo yes, in production no</strong> — and the reason is not the cache, it is the event bus in section 8.</p>
<h3>4. The run gets written down: five ledger tables</h3>
<p>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.</p>
<p>One execution writes to five tables:</p>
<table>
<thead>
<tr>
<th>Table</th>
<th>One row is</th>
<th>Notable columns</th>
</tr>
</thead>
<tbody><tr>
<td><code>runs</code></td>
<td>one execution</td>
<td><code>status</code> / <code>trace_id</code> / <code>request_id</code> / <code>parent_run_id</code> / <code>source_run_id</code> / <code>attempt_no</code> / <code>sandbox</code></td>
</tr>
<tr>
<td><code>run_steps</code></td>
<td>one step inside it</td>
<td><code>step_type</code> (llm / retrieval / rerank / tool / workflow_node / agent_plan / memory_write / io) / <code>metrics_json</code></td>
</tr>
<tr>
<td><code>run_step_tool_calls</code></td>
<td>one tool call</td>
<td><code>idempotency_key</code> / <code>request_hash</code> / <code>lease_owner</code> / <code>attempt_count</code></td>
</tr>
<tr>
<td><code>run_artifacts</code></td>
<td>one produced artifact</td>
<td><code>storage_key</code> / <code>sha256</code> / <code>size_bytes</code> / <code>mime</code></td>
</tr>
<tr>
<td><code>run_cost_entries</code></td>
<td>one metered invocation</td>
<td><code>billed_quantity</code> / <code>amount</code> / <code>currency</code></td>
</tr>
</tbody></table>
<p>A few columns explain why this is not just two lines in a log file:</p>
<ul>
<li><strong><code>parent_run_id</code> / <code>source_run_id</code> / <code>attempt_no</code></strong> (<code>server/app/kernel/runtime/db/models/runs.py</code>). 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 <em>new run</em> pointing back at its source, not a re-read of a log.</li>
<li><strong><code>sandbox</code></strong>. 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.</li>
<li><strong><code>input_summary</code> / <code>output_summary</code> capped at 8KB</strong>, with <code>metrics_json</code> as a JSON column. The ledger stores <em>summaries</em>; the full payload lives in object storage behind <code>run_artifacts</code>. That split is deliberate — the relational store holds queryable structure, object storage holds bulk.</li>
</ul>
<h3>5. Why tool calls get their own table</h3>
<p><code>run_step_tool_calls</code> is the most heavily constrained of the five. It carries <strong>three unique constraints</strong>:</p>
<pre><code class="language-python">UniqueConstraint("tenant_id", "workspace_id", "run_step_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "run_id", "tool_call_id", ...)
UniqueConstraint("tenant_id", "workspace_id", "idempotency_key", ...)
</code></pre>
<p>plus an index on <code>("status", "lease_expires_at")</code>. Together they say one thing: <strong>tool calls have side effects, so they have to be at-most-once.</strong> One step maps to one tool call; a <code>tool_call_id</code> cannot land twice within a run; the idempotency key is globally unique — that key is the <code>tool:{run_id}:{tool_call_id}</code> from the earlier governed-MCP post.</p>
<p>The <code>lease_owner</code> / <code>lease_expires_at</code> 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 (<code>server/app/kernel/runtime/common/lease.py</code>) 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: <code>MIN_LEASE_SECONDS = 30</code> (a smaller configured value is clamped up), <code>LEASE_RENEWALS_PER_LEASE = 3</code> (the heartbeat interval is a third of the lease), and claims use <code>SKIP LOCKED</code> so concurrent workers do not contend — the comment openly notes SQLite ignores the clause, which is fine for single-worker tests.</p>
<p>This section is the whole article in miniature: <strong>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.</strong></p>
<h3>6. Retrieval: what Milvus does, and why etcd tags along</h3>
<p>If the run includes a retrieval step, <code>api</code> queries Milvus. The adapter is <code>server/app/adapters/vector/milvus.py</code>, and the index parameters are fixed:</p>
<pre><code class="language-python">index_params={"index_type": "IVF_FLAT", "metric_type": metric_type, "params": {"nlist": 1024}}
</code></pre>
<p><code>etcd</code> deserves an explicit correction, because it is the container most often misread as padding:</p>
<pre><code class="language-yaml">milvus:
  environment:
    ETCD_ENDPOINTS: etcd:2379
    MINIO_ADDRESS: minio:9000
  depends_on:
    etcd: { condition: service_healthy }
    minio: { condition: service_healthy }
</code></pre>
<p><strong>etcd is not the platform's dependency; it is Milvus's.</strong> 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: <strong>"vector retrieval" is one capability that costs two and a half containers</strong> (etcd + Milvus, sharing MinIO). Whether a demo should pay that is a clear trade-off, not a mystery.</p>
<p>One fact carried over from the previous post, because it matters here: <strong>the vector store does not gate readiness.</strong> In <code>server/app/api/v1/health/router.py</code>, the database and object storage raise 503 when probing fails; the vector store is probed and reported only:</p>
<pre><code class="language-python">try:
    await vector.check_ready()
    vector_status = "connected"
except Exception:
    vector_status = "unavailable"
</code></pre>
<p>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.</p>
<h3>7. Where the big objects go: MinIO</h3>
<p><code>run_artifacts</code> stores <code>storage_key</code>, <code>sha256</code>, <code>size_bytes</code>, <code>mime</code> — <strong>not the content</strong>. The content is in MinIO.</p>
<p>The one-shot <code>minio-init</code> container does two things: <code>mc mb -p local/soit-artifacts</code> to create the bucket, then <code>mc anonymous set none</code> to close anonymous access. The second is a small correct default: <strong>the artifact bucket is not anonymously readable out of the box.</strong></p>
<p>Object storage <em>is</em> 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 <code>strip("/")</code>-ed into a relative path (filed as issue #43). So the practical verdict stands: <strong>MinIO cannot be dropped.</strong></p>
<h3>8. The part that starts after the response is sent</h3>
<p>By now the run is finished and the response has gone back to the caller. One container is only now getting to work.</p>
<p><code>api</code> writes domain events into the <code>event_outbox</code> table <strong>inside the same transaction as the business data</strong> (<code>server/app/kernel/runtime/db/models/events.py</code>). 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.</p>
<p><code>outbox-dispatcher</code> then polls that table as its own process. The row's columns are effectively its state machine: <code>status</code>, <code>available_at</code>, <code>locked_at</code>, <code>lock_owner</code>, <code>lock_expires_at</code>, <code>attempt_count</code>, <code>last_error</code>, <code>processed_at</code>.</p>
<p>The module docstring of <code>server/app/kernel/events/dispatcher.py</code> states the whole flow in one line:</p>
<blockquote>
<p>claim rows, run registered handlers with checkpoint idempotency</p>
</blockquote>
<p>"Checkpoint idempotency" is a second table, <code>event_consumer_checkpoint</code>, unique on <code>(consumer_name, event_id)</code>. Before dispatching, the service asks <code>checkpoints.is_processed(consumer_name, event_id)</code>; on success it calls <code>try_record_success</code>. <strong>So idempotency is per consumer per event, not per event</strong> — if the second of three handlers fails and the row is retried, the first is not re-executed.</p>
<p>That container also exposes its own Prometheus endpoint (<code>expose: 9201</code>, started via <code>start_http_server</code>), and its healthcheck is a scrape of <code>/metrics</code>.</p>
<h3>9. Four of these containers are the same code as <code>api</code></h3>
<p>This is the part most easily mistaken for microservice sprawl, and it is the opposite. <code>migrate</code>, <code>bootstrap</code>, <code>api</code>, <code>outbox-dispatcher</code> and <code>scheduler</code> all share one build context (<code>build: context: ../server</code>). The released-image path makes it starker: <code>docker/docker-compose.images.yml</code> points <code>migrate</code>, <code>bootstrap</code>, <code>api</code> and <code>outbox-dispatcher</code> at the <strong>same image</strong>, <code>ghcr.io/soit-ai/soit/server</code>. Only <code>knowledge-worker</code> and <code>web</code> are separate. <strong>Three images cover twelve containers.</strong></p>
<p>The difference is the entrypoint, plus which background loops are switched on. The lifespan in <code>server/app/main.py</code> has <strong>six flags</strong>, each folding one loop into the API process:</p>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Code default</th>
<th>Loop it folds in</th>
</tr>
</thead>
<tbody><tr>
<td><code>workflow_orphan_reaper_enabled</code></td>
<td><code>False</code> (compose sets <code>true</code> for <code>api</code>)</td>
<td>reaping orphaned workflows</td>
</tr>
<tr>
<td><code>schedule_worker_enabled</code></td>
<td><code>False</code></td>
<td>firing due schedules</td>
</tr>
<tr>
<td><code>account_deletion_sweeper_enabled</code></td>
<td><code>False</code></td>
<td>account deletion sweep</td>
</tr>
<tr>
<td><code>knowledge_ingest_worker_enabled</code></td>
<td><code>False</code></td>
<td>knowledge ingestion</td>
</tr>
<tr>
<td><code>outbox_dispatcher_enabled</code></td>
<td><code>False</code> (compose hard-codes <code>"false"</code>)</td>
<td>outbox dispatch</td>
</tr>
<tr>
<td><code>response_interaction_worker_enabled</code></td>
<td><code>False</code></td>
<td>durable chat interactions</td>
</tr>
</tbody></table>
<p>Which means <strong>"how many containers" is largely a deployment decision, not an architectural one.</strong> The same code can run as one process or five. Compose splits them, and <code>server/scripts/schedule_worker.py</code> states the reason more plainly than I could:</p>
<blockquote>
<p>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.</p>
</blockquote>
<h3>10. Production mode refuses to let you cut corners</h3>
<p>Those flags look like a matter of taste. Half of them are not, once <code>ENVIRONMENT=production</code>. <code>validate_runtime_requirements()</code> in <code>server/app/settings/settings.py</code> fails closed on each of:</p>
<ul>
<li>the database URL must carry host, database name, username and password;</li>
<li>the event bus <strong>must</strong> be <code>redis</code> (the code default is actually <code>memory</code>; compose supplies <code>redis</code>);</li>
<li><code>outbox_dispatcher_enabled</code> being true is an error — <strong>production forbids folding the dispatcher into the API process</strong>, it has to be its own;</li>
<li>inline chat-interaction execution is forbidden, and the durable interaction worker is required;</li>
<li>plugin signature verification is required, <strong>and at least one public key must be configured</strong> — 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.</li>
</ul>
<p>This is the part I'd most want a skeptical reader to notice. <strong>Which services are optional is not an opinion in this repo; it is code that refuses to boot.</strong> 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.</p>
<h3>One gap I found while writing this</h3>
<p>One thing I turned up is not a trade-off, it is a hole:</p>
<ul>
<li><code>docker-compose.yml</code> defines a <code>scheduler</code> service that sets <code>SCHEDULE_WORKER_ENABLED: "true"</code> and runs <code>scripts/schedule_worker.py</code>;</li>
<li>the quickstart command does not include it, and nothing <code>depends_on</code> it;</li>
<li>the <code>api</code> container does not set <code>SCHEDULE_WORKER_ENABLED</code>, and the code default is <code>False</code>;</li>
<li><code>.env.example</code> does not mention the variable;</li>
<li><strong><code>docs/</code> never mentions <code>scheduler</code> at all</strong> — across the whole repo only the two compose files do.</li>
</ul>
<p>Net effect: <strong>in a stack started per the quickstart, schedules never fire on their own.</strong> <code>POST /schedules</code> creates one, the preview endpoint tells you when it would next run, and <code>POST /schedules/{id}/run</code> triggers it by hand — but nothing is polling to claim it when its time comes.</p>
<p><code>docker-compose.production.yml</code> does include a <code>scheduler</code>, 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 <code>scheduler</code> is not one of them, so adding a <code>scheduler</code> to the images-based path silently falls back to a local build.</p>
<p>I plan to file an issue for both halves of this: add <code>scheduler</code> 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.</p>
<h3>What this post is not</h3>
<ul>
<li><strong>There is no new end-to-end run behind it.</strong> 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 <strong>not</strong> 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.</li>
<li><strong>No resource numbers.</strong> This is about responsibilities, not footprint. The image-size figures are in the previous post.</li>
<li><strong>Nothing about wiring it into your existing observability.</strong> <code>OTEL_ENABLED</code> (default <code>false</code>) and an OTLP endpoint are in compose, and the production file ships an otel-collector, but that is its own article.</li>
<li><strong>The Redis conclusion is conditional.</strong> "Fine to drop in a demo" holds because a demo runs a single <code>api</code> replica. Add replicas and the in-process bus stops crossing processes. That is what the production check is protecting.</li>
</ul>
<h3>So where is the weight?</h3>
<p>Regroup the twelve by what they guarantee:</p>
<ul>
<li><strong>2 are the thing being demoed</strong>: <code>api</code>, <code>web</code>.</li>
<li><strong>3 are one-shot jobs</strong>: <code>migrate</code>, <code>bootstrap</code>, <code>minio-init</code> — they exit.</li>
<li><strong>2 are where the ledger and the artifacts physically live</strong>: <code>postgres</code>, <code>minio</code> — also the only two hard readiness gates.</li>
<li><strong>2½ are one capability, vector retrieval</strong>: <code>milvus</code> + <code>etcd</code> (etcd being Milvus's dependency, not ours).</li>
<li><strong>1 is secret isolation</strong>: <code>vault</code>.</li>
<li><strong>1 is cross-replica broadcast and caching</strong>: <code>redis</code>.</li>
<li><strong>2 are background processes split out of the same codebase</strong>: <code>outbox-dispatcher</code>, <code>knowledge-ingest-worker</code>.</li>
</ul>
<p>Exactly one of them runs a model. The rest of the weight buys one thing: <strong>the execution leaves evidence behind, and a crash mid-run does not leave half a state.</strong></p>
<p>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.</p>
<h3>Try it</h3>
<ul>
<li>Repo: <a href="https://github.com/soit-ai/soit">github.com/soit-ai/soit</a></li>
<li>Full topology: <code>docker/docker-compose.yml</code></li>
<li>The four-container version: <a href="https://soit-ai.hashnode.dev/reading-the-code-told-me-which-services-i-could-drop-running-it-told-me-the-truth">Reading the code told me which services I could drop. Running it told me the truth.</a></li>
<li>If you think any one of these trade-offs is wrong, open an issue and say so.</li>
</ul>
<p>Disclosure: I maintain SOIT.</p>
]]></content:encoded></item><item><title><![CDATA[Reading the code told me which services I could drop. Running it told me the truth.]]></title><description><![CDATA[I read our own source code to work out which services a demo could do without, wrote down three conclusions, and then ran the trimmed stack to check. Two of the three were wrong.
That is the real subj]]></description><link>https://soit-ai.hashnode.dev/reading-the-code-told-me-which-services-i-could-drop-running-it-told-me-the-truth</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/reading-the-code-told-me-which-services-i-could-drop-running-it-told-me-the-truth</guid><category><![CDATA[Devops]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Python]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Mon, 31 Aug 2026 16:22:03 GMT</pubDate><content:encoded><![CDATA[<p>I read our own source code to work out which services a demo could do without, wrote down three conclusions, and then ran the trimmed stack to check. Two of the three were wrong.</p>
<p>That is the real subject of this post. The nominal subject is our twelve-service Compose stack, which starts like this:</p>
<pre><code class="language-bash">docker compose --env-file .env -f docker/docker-compose.yml up -d \
  postgres redis minio etcd milvus vault migrate bootstrap api web \
  knowledge-ingest-worker outbox-dispatcher
</code></pre>
<p>Issue #25 asked the obvious question about it: <strong>I just want to look at it — do I really need all of them?</strong></p>
<p>No — four containers are enough to log in and click around. Below is which services go, which leave as a group, which one you fold into the API process instead of deleting, and what stops working with each cut. I left both wrong conclusions in place rather than quietly correcting them, because they are the ones that would have cost you an afternoon: follow the paper version and you get a stack that starts and then refuses to show you a UI.</p>
<h3>The short answer</h3>
<table>
<thead>
<tr>
<th>Service</th>
<th>Can it go?</th>
<th>What it costs you</th>
</tr>
</thead>
<tbody><tr>
<td><code>postgres</code></td>
<td>No</td>
<td>Hard readiness gate — 503 without it</td>
</tr>
<tr>
<td><code>minio</code> + <code>minio-init</code></td>
<td><strong>No</strong> (see section 2)</td>
<td>On paper the local filesystem replaces it. <strong>In the published image that does not work.</strong></td>
</tr>
<tr>
<td><code>migrate</code> / <code>bootstrap</code></td>
<td>No</td>
<td>One-shot jobs: schema and the admin account</td>
</tr>
<tr>
<td><code>api</code> / <code>web</code></td>
<td>No</td>
<td>They are the thing being demoed</td>
</tr>
<tr>
<td><code>milvus</code> + <code>etcd</code></td>
<td>Yes, as a group — <strong>with a side effect</strong></td>
<td>Vector search raises at call time, and readiness gets slow enough to mark the API unhealthy</td>
</tr>
<tr>
<td><code>vault</code></td>
<td>Yes</td>
<td>Secrets move to an in-process store, gone on restart</td>
</tr>
<tr>
<td><code>redis</code></td>
<td>Yes, for a demo</td>
<td>No permission cache, single-process event bus</td>
</tr>
<tr>
<td><code>knowledge-ingest-worker</code></td>
<td>Yes</td>
<td>No document ingestion</td>
</tr>
<tr>
<td><code>outbox-dispatcher</code></td>
<td>Fold it in</td>
<td>One environment variable moves it into the API</td>
</tr>
</tbody></table>
<p>Twelve becomes seven, and since <code>minio-init</code>, <code>migrate</code> and <code>bootstrap</code> exit when they finish, <strong>four containers stay running</strong>: postgres, minio, api, web.</p>
<p>What you save is Milvus (a 2.6GB image), etcd, Vault, Redis, the ingestion worker and the outbox container.</p>
<h3>1. The readiness endpoint tells you which dependencies are real</h3>
<p>The fastest way to find out whether a dependency is hard is not the deployment guide — it is the health check. <strong>A deployment guide documents intent; a health check documents behaviour.</strong></p>
<p>In <code>server/app/api/v1/health/router.py</code>, three backends are treated differently:</p>
<pre><code class="language-python">try:
    db.execute(text("SELECT 1"))
    db_status = "connected"
except Exception:
    raise HTTPException(status_code=503, detail="Database is unavailable")

try:
    await storage.ensure_ready()
    storage_status = "connected"
except Exception:
    raise HTTPException(status_code=503, detail="Object storage is unavailable")

try:
    await vector.check_ready()
    vector_status = "connected"
except Exception:
    vector_status = "unavailable"
</code></pre>
<p>Database or object storage down means 503. Vector store down means the field reads <code>unavailable</code> 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.</p>
<p>That leaves two hard requirements: <strong>a reachable Postgres, and a writable storage root</strong>.</p>
<p>The second one says storage root, not MinIO — and I assumed that distinction meant MinIO could go. That is wrong conclusion number one.</p>
<h3>2. Wrong conclusion #1: local filesystem storage does not work inside the image</h3>
<p>The storage adapter is built on fsspec (<code>server/app/adapters/storage/fsspec.py</code>), and the base URL resolves in this order:</p>
<pre><code class="language-python">self.base_url = base_url or settings.storage_url or self._default_local_base_url()
</code></pre>
<p><code>_default_local_base_url()</code> returns a <code>file://</code> URI under the repository root. So point <code>STORAGE_URL</code> at a local directory (or leave it unset) and storage should land on disk with no object store at all.</p>
<p>I configured exactly that, and the API came up returning 503:</p>
<pre><code class="language-plaintext">{"success":false,"code":"SERVICE_UNAVAILABLE","message":"Object storage is unavailable"}
</code></pre>
<p>Constructing the adapter directly inside the container gave the real error:</p>
<pre><code class="language-plaintext">PermissionError: [Errno 13] Permission denied: '/app/home'
</code></pre>
<p><code>/app/home</code> is a strange path, given that I passed <code>/home/appuser/soit-storage</code>. The cause is this function:</p>
<pre><code class="language-python">@staticmethod
def _normalize_root_path(root_path: str) -&gt; str:
    return root_path.replace("\\", "/").strip("/")
</code></pre>
<p><code>strip("/")</code> removes the <strong>leading</strong> slash too, so the absolute path <code>/home/appuser/soit-storage</code> becomes the relative path <code>home/appuser/soit-storage</code>, which fsspec's LocalFileSystem then resolves against the process working directory. The image sets <code>WORKDIR /app/</code>, so it lands in <code>/app/home/...</code>.</p>
<p>And <code>/app</code> is not writable: <code>server/Dockerfile</code> does <code>COPY ./ /app/</code> <strong>without <code>--chown</code></strong>, leaving it owned by root, while the final instruction is <code>USER appuser</code> (uid 10001).</p>
<p>So inside the published image the local-filesystem path is effectively dead: whatever you pass ends up under <code>/app</code>, 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.</p>
<p><strong>So MinIO stays.</strong> It is cheap, at least: one long-running container plus a <code>minio-init</code> that exits, on a couple hundred megabytes — an order of magnitude smaller than the Milvus group.</p>
<p>(For the record, MinIO wears <strong>two hats</strong> in the full topology: the platform's artifact store, and Milvus's object backend. It was never separable from Milvus anyway.)</p>
<h3>3. The vector group leaves as a unit — and it is not a graceful degradation</h3>
<p><code>milvus</code> depends on <code>etcd</code> (metadata) and <code>minio</code> (data). Does the API still start without milvus and etcd? Yes, and the reason is in the adapter's constructor docstring (<code>server/app/adapters/vector/milvus.py</code>): the connection is established <strong>lazily on first use</strong>, so building the port during dependency injection does not fail when the vector store is unavailable.</p>
<p>But do not expect it to degrade into empty results, because <strong>the vector port has no environment-level fallback</strong>. From <code>server/app/wiring/container.py</code>:</p>
<pre><code class="language-python">def _create_vector_port(self) -&gt; VectorPort:
    import os
    if os.getenv("PYTEST_CURRENT_TEST") or os.getenv("SOIT_TESTING") == "1":
        from app.adapters.vector.memory import InMemoryVectorPort
        return InMemoryVectorPort()
    from app.adapters.vector.milvus import MilvusVectorPort
    return MilvusVectorPort()
</code></pre>
<p>The in-memory implementation is reserved for test runs; unlike the secrets port, it never consults <code>ENVIRONMENT</code>. The real effect: the platform boots, non-vector features work, readiness honestly reports <code>vector: "unavailable"</code>, and knowledge retrieval raises the moment you use it.</p>
<p>The readiness response from the actual run says exactly that:</p>
<pre><code class="language-json">{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
</code></pre>
<p>That conclusion held. But the same run surfaced something the code does not show you, which is the next section.</p>
<h3>4. Wrong conclusion #2: without Milvus, the web container never starts</h3>
<p>That readiness response took <strong>34 seconds</strong> to come back.</p>
<p>The reason is not hard to guess: <code>vector.check_ready()</code> has to resolve the <code>milvus</code> 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 <strong>it is not fail-fast</strong> — nothing bounds how long it may take.</p>
<p>Which runs straight into Compose's own health check:</p>
<pre><code class="language-json">{"Test": ["CMD-SHELL", "python -c \"...urlopen('http://localhost:9200/health/ready', timeout=3)\""],
 "Interval": "10s", "Timeout": "5s", "Retries": 5}
</code></pre>
<p>The probe times out after 3 seconds, Compose gives it 5, and the endpoint needs 34. It <strong>cannot</strong> pass. In the run, the API container sat permanently at:</p>
<pre><code class="language-plaintext">soit-api-1   Up 3 minutes (unhealthy)
</code></pre>
<p>The service itself is fine — I logged into it. Only the health check fails. But <code>web</code> declares <code>depends_on: api: condition: service_healthy</code>, so a normal <code>up -d web</code> means <strong>web never starts at all</strong>. You get a stack with a perfectly working API and no UI, and very little to tell you why.</p>
<p>The fix is small: pass <code>--no-deps</code> for <code>web</code> 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.</p>
<pre><code class="language-bash">docker compose ... up -d --no-deps web
</code></pre>
<p>Started that way, web comes up <code>healthy</code> and serves HTTP 200.</p>
<p><strong>This is the one finding in this post that reading the code could never produce.</strong> On paper you get a guide that looks right and leaves you staring at a dead URL.</p>
<h3>5. Vault genuinely does degrade</h3>
<p>The secrets port is wired differently (same <code>container.py</code>):</p>
<pre><code class="language-python">if not settings.vault_url or not settings.vault_token:
    if self._allows_in_memory_adapters():
        from app.adapters.secrets.memory import InMemorySecretValueStore
        return InMemorySecretValueStore()
    raise RuntimeError("Production requires Vault URL and token for the secrets adapter")
</code></pre>
<p><code>_allows_in_memory_adapters()</code> accepts <code>ENVIRONMENT</code> values <code>dev / development / local / test / testing</code>, and compose defaults to <code>development</code>. So <strong>leaving <code>VAULT_URL</code> and <code>VAULT_TOKEN</code> empty swaps in the in-process secret store</strong> and the Vault container can stay down. Verified in the run: migrate, bootstrap and api all worked with no Vault anywhere.</p>
<p>The cost is in the name: in-process means <strong>not durable</strong>. The model API key you configure during the demo is gone the moment the container restarts.</p>
<h3>6. Redis is three different questions</h3>
<p>Redis is interesting because it is not a binary. The three places that use it disagree about what its absence means.</p>
<p><strong>The event bus</strong> can be switched. <code>memory</code> is the default; compose is what changes it to <code>redis</code>:</p>
<pre><code class="language-python">backend = (settings.event_bus_backend or "memory").lower()
if backend == "redis":
    return RedisEventBus(...)
if backend == "memory" and self._allows_in_memory_adapters():
    return InMemoryEventBus()
</code></pre>
<p>The same <code>ENVIRONMENT</code> guard applies — in production that branch raises. The in-memory bus only delivers <strong>within a single process</strong>, which is exactly why it pairs with folding background work into the API process.</p>
<p><strong>The permission cache</strong> degrades gracefully. In <code>server/app/kernel/identity/permissions.py</code> the Redis accessor returns <code>None</code> when it cannot connect, and callers treat <code>None</code> as a cache miss and re-check against the database. One less cache layer, same answers.</p>
<p><strong>Rate limiting</strong> is a hard dependency that usually never fires. <code>RateLimiter</code> (<code>server/app/kernel/ports/common/rate_limiter.py</code>) is a Redis sliding window with no in-memory equivalent. But the call sites are conditional (<code>server/app/kernel/ports/tools/policy.py</code>):</p>
<pre><code class="language-python">rate_limit = kwargs.get("rate_limit_per_minute") or self.rate_limit_per_minute
if rate_limit:
    await self.rate_limiter.check_rate_limit(...)
if self.daily_quota:
    await self.rate_limiter.check_rate_limit(...)
</code></pre>
<p>No configured limit, no Redis call. Dropping Redis from a demo is therefore safe <strong>as long as you do not configure per-tool rate limits or daily quotas</strong>. That is the one item here that depends on what you do during the demo.</p>
<h3>7. One service you fold in rather than remove</h3>
<p><code>outbox-dispatcher</code> runs the transactional outbox. Its setting says exactly what the flag means (<code>server/app/settings/settings.py</code>):</p>
<pre><code class="language-python">outbox_dispatcher_enabled: bool = False
"""Enable background outbox dispatcher in the API process."""
</code></pre>
<p>The flag does not control <em>whether</em> dispatching happens — it controls <strong>where</strong>. Compose sets it to <code>false</code> and runs the same logic in a separate container. For a demo, invert it: set <code>OUTBOX_DISPATCHER_ENABLED=true</code> on the api service and skip the container. <code>server/app/main.py</code> reads the flag at startup and attaches the dispatcher to the API's lifespan.</p>
<p>Why production does the opposite: <code>validate_runtime_requirements()</code> contains <code>if self.outbox_dispatcher_enabled: raise ValueError("Production requires the dedicated outbox dispatcher process")</code>. 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 <strong>this is enforced by code, not advised by documentation</strong>.</p>
<h3>8. Skip the ingestion worker unless you are demoing RAG</h3>
<p><code>knowledge-ingest-worker</code> builds from its own image target (<code>server/Dockerfile</code>) with one extra dependency group:</p>
<pre><code class="language-dockerfile">FROM base AS knowledge-worker
RUN --mount=type=cache,target=/root/.cache/uv \
    /bin/uv sync --frozen --no-dev --extra knowledge-worker
</code></pre>
<p>That extra is <code>docling[rapidocr]</code> — document parsing and OCR. While we are here, a common misconception: <code>torch</code>, <code>torchvision</code> and <code>torchaudio</code> live in the <code>local-embedding</code> extra in <code>pyproject.toml</code>, <strong>not</strong> in <code>knowledge-worker</code>, 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.</p>
<h3>9. The commands, as actually run</h3>
<p>One Compose trap first: <code>api</code> lists milvus and vault in <code>depends_on</code>, so <strong>Compose starts them for you even when you leave them off the command line</strong>. Every step needs an explicit <code>--no-deps</code>, and you sequence the one-shot jobs yourself.</p>
<p>The env file (all of these override compose defaults):</p>
<pre><code class="language-bash">printf '%s\n' \
  'ENVIRONMENT=development' \
  'VAULT_URL=' \
  'VAULT_TOKEN=' \
  'EVENT_BUS_BACKEND=memory' \
  'OUTBOX_DISPATCHER_ENABLED=true' \
  &gt; .env.minimal
</code></pre>
<p>Bring it up on the published images — overlay <code>docker-compose.images.yml</code> and pass <code>--no-build</code>, or Compose will build from source:</p>
<pre><code class="language-bash">COMPOSE="docker compose --env-file .env.minimal -f docker/docker-compose.yml -f docker/docker-compose.images.yml"

$COMPOSE up -d --no-build postgres minio minio-init
$COMPOSE run --rm --no-deps migrate
$COMPOSE run --rm --no-deps bootstrap
$COMPOSE up -d --no-build --no-deps api web
</code></pre>
<p><code>migrate</code> prints a run of alembic upgrades; <code>bootstrap</code> prints <code>Bootstrap completed.</code> along with the admin ids.</p>
<p>Then verify. Remember that readiness takes <strong>more than 30 seconds</strong> (section 4), so give curl a generous timeout:</p>
<pre><code class="language-bash">curl -s -m 60 http://localhost:9200/health/ready
</code></pre>
<p>From the run:</p>
<pre><code class="language-json">{"status":"ready","database":"connected","storage":"connected","vector":"unavailable"}
</code></pre>
<p><code>vector: unavailable</code> while the whole thing still reports <code>ready</code> is section 1's code path observed from the outside — <strong>the output is its own proof</strong>.</p>
<p>Then exercise a real path, not just the health endpoint:</p>
<pre><code class="language-bash">curl -s -X POST http://localhost:9200/api/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"changeme123"}'
</code></pre>
<p>An <code>access_token</code> in the response means the database and auth path are both working. The UI is on <code>http://localhost:5000</code> with the same credentials.</p>
<p><strong><code>docker ps</code> will show the API as <code>unhealthy</code>, and that is expected</strong> (section 4). The service is fine.</p>
<h3>What this is not</h3>
<p>As usual, the limits:</p>
<ul>
<li><strong>This is not a supported deployment shape.</strong> It is a demo trim. Set <code>ENVIRONMENT=production</code> and every shortcut above is closed off one by one: <code>validate_runtime_requirements()</code> 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.</li>
<li><strong>The API stays <code>unhealthy</code></strong>, 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.</li>
<li><strong>In-memory means gone on restart</strong> — secrets, and any event in flight on the in-memory bus.</li>
<li><strong>Without Milvus, vector features raise rather than return empty.</strong> Demoing knowledge bases means putting milvus and etcd back.</li>
<li><strong>The rate-limit caveat is yours to judge</strong>: dropping Redis assumes no configured limits.</li>
<li>The default <code>SECRET_KEY</code> is <code>change-me</code>, and bootstrap will warn you about it (<code>InsecureKeyLengthWarning</code>). Harmless for a demo; do not let that value outlive one.</li>
</ul>
<h3>Two bugs found along the way</h3>
<p>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:</p>
<ol>
<li><strong><code>_normalize_root_path()</code> calls <code>strip("/")</code>, which turns absolute paths into relative ones</strong>, 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 <code>file://</code>, where an absolute path means something, should not get the same treatment. (<a href="https://github.com/soit-ai/soit/issues/43">issue #43</a>)</li>
<li><strong>The vector readiness probe has no timeout</strong>, so a missing vector store drags <code>/health/ready</code> 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 <code>check_ready()</code> would give you both. (<a href="https://github.com/soit-ai/soit/issues/44">issue #44</a>)</li>
</ol>
<p>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.</p>
<h3>Why write this down at all</h3>
<p>If four containers are enough, why does the default ask for twelve?</p>
<p>Because the default topology targets the <strong>production</strong> 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.</p>
<p>The point is that <strong>the distance between those two shapes is measurable in a handful of environment variables</strong> — 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 <code>validate_runtime_requirements()</code> tells you where production draws its line.</p>
<p>But keep the other lesson too: <strong>reading the code gives you hypotheses; running it gives you conclusions.</strong> Two of my three were wrong, and the wrong two were exactly the ones that would have stopped you.</p>
<h3>Try it</h3>
<p>SOIT is Apache-2.0 and the code is on GitHub:</p>
<ul>
<li>Repository: <a href="https://github.com/soit-ai/soit">github.com/soit-ai/soit</a></li>
<li>Full quickstart (the twelve-service path): <code>docs/quickstart.md</code> in the repo</li>
<li>Governance demo: <code>docs/governance-demo.md</code> — a 20-minute local run that walks through permissions, secrets, call auditing, cost attribution, replay and regression</li>
</ul>
<p>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 <code>--profile minimal</code> does the whole thing — and fix those two bugs on the way.</p>
<hr />
<p><em>Disclosure: I maintain SOIT.</em></p>
]]></content:encoded></item><item><title><![CDATA[Governing MCP tool calls: permissions, secrets, and egress]]></title><description><![CDATA[Wiring up an MCP server is an afternoon of work. One streamable HTTP endpoint, one list_tools call, and the tools land in the model's callable list. That part is genuinely solved.
Everything after it ]]></description><link>https://soit-ai.hashnode.dev/governing-mcp-tool-calls-permissions-secrets-and-egress</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/governing-mcp-tool-calls-permissions-secrets-and-egress</guid><category><![CDATA[mcp]]></category><category><![CDATA[#ai-tools]]></category><category><![CDATA[Security]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Sat, 29 Aug 2026 01:58:36 GMT</pubDate><content:encoded><![CDATA[<p>Wiring up an MCP server is an afternoon of work. One streamable HTTP endpoint, one <code>list_tools</code> call, and the tools land in the model's callable list. That part is genuinely solved.</p>
<p>Everything after it is not. The moment you propose pointing that server at real systems, somebody from security starts asking questions, and "the SDK handles it" stops being an answer. There turned out to be five of those questions. When they were first put to me I could not answer a single one.</p>
<p>Below is each question, and how we ended up answering it in SOIT — an open-source agent runtime with governance sitting in the middle of the call path. Every claim points at a file in the repo, because posts like this are unusually easy to write as a slide deck instead of as software.</p>
<h2>1. Who is allowed to call this tool?</h2>
<p>MCP does not have an opinion here. Whatever <code>list_tools</code> returns is what the model can call. Visibility is capability.</p>
<p>In a multi-tenant, multi-workspace deployment that is not enough. SOIT installs an MCP server as a <strong>plugin artifact</strong> rather than as a config entry. Tool references are namespaced — <code>mcp_tool:{server}:{tool}</code>, parsed by <code>parse_mcp_tool_ref</code> in <code>server/app/adapters/tools/mcp.py</code> — and every resolution carries a <code>RequestContext</code> holding <code>tenant_id</code>, <code>workspace_id</code> and <code>user_id</code>.</p>
<p>Two things follow from that:</p>
<ol>
<li><p>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.</p>
</li>
<li><p>Permission checks, secret injection, egress limits, audit, cost attribution, trace and replay apply to MCP tools <strong>automatically</strong>. Nobody writes the governance path twice.</p>
</li>
</ol>
<p>Each agent version also carries a capability allowlist covering models, knowledge bases, workflows, tools, plugins and MCP servers. So "which MCP tools can v3 of this agent call" is something you can diff and roll back, rather than a runtime toggle somebody flipped.</p>
<h2>2. Where do the credentials live?</h2>
<p>Most MCP integration examples look like this:</p>
<pre><code class="language-json">{
  "auth": { "type": "bearer", "token": "sk-xxxxxxxx" }
}
</code></pre>
<p>A plaintext token in a config file. It ends up in git. It ends up in logs. It ends up in the config backup somebody exported to a laptop.</p>
<p>SOIT rejects this outright. <code>_build_auth_headers</code> checks for a <code>token</code> or <code>value</code> field in the auth config and raises:</p>
<pre><code class="language-plaintext">MCP credentials must use secret_id
</code></pre>
<p>Only <code>secret_id</code> is accepted, resolved through <code>SecretsPort</code> at call time. Same for API keys — and they are only supported in headers, never in a query string, because query strings leak through logs and referrers.</p>
<p>The real value exists in memory for the duration of the call and nowhere else. What gets persisted — to the database, to audit records, to traces — is a <strong>redacted copy</strong>: <code>ToolPolicyGateway._resolve_secrets</code> builds it in the same pass that resolves the secret, keeping only <code>secret_id</code> and the signing policy reference (<code>server/app/kernel/ports/tools/policy.py</code>).</p>
<p>Three auth types are supported: <code>bearer</code>, <code>api_key</code>, <code>oauth2</code>. OAuth follows 2.1 with authorization-server discovery (RFC 9728, RFC 8414 / OpenID Connect) and resource-bound tokens (RFC 8707), using the <code>client_credentials</code> grant.</p>
<p>One limitation worth stating plainly: <strong>the browser-based authorization_code flow is not implemented.</strong> SOIT calls MCP servers on its own behalf, not on behalf of a user sitting in front of a browser. If you need "call a protected MCP server as the end user," this does not cover you.</p>
<h2>3. Where can it connect to?</h2>
<p>This is the one that should worry you most.</p>
<p>You deployed the MCP server, but it is a thing that <strong>makes network requests on your behalf</strong>. Put a URL in the tool arguments and it will fetch it. The classic shape of this is asking it for <code>http://169.254.169.254/</code> — the cloud metadata service, holding temporary credentials.</p>
<p>SOIT's egress policy is <strong>deny-by-default</strong>, in three layers.</p>
<p><strong>Layer one: domain policy.</strong> <code>check_egress_policy</code> matches the target domain against tenant-scoped and workspace-scoped allowlists and blocklists, with the blocklist winning. The defaults are <code>enable_egress_policy: bool = True</code> and <code>egress_allowlist: list[str] = []</code> — an empty allowlist means nothing is permitted until you say so. And if the policy lookup itself throws, the answer is deny, not allow:</p>
<pre><code class="language-python">except Exception as exc:
    raise ForbiddenError(
        "Egress policy lookup failed; request denied",
        {"resource_ref": resource_ref},
    ) from exc
</code></pre>
<p>Fail-closed is not a slogan. It is whatever you actually wrote in each <code>except</code> branch.</p>
<p><strong>Layer two: per-address validation after resolution.</strong> Passing the domain check is not enough — DNS rebinding lets an allowlisted hostname resolve to <code>127.0.0.1</code> or <code>10.0.0.x</code>. So after the domain is allowed, <code>GovernedEgressGuard</code> actually resolves the hostname and checks <code>ipaddress.ip_address(address).is_global</code> for <strong>every</strong> address returned. One non-public address and the whole request is refused (<code>server/app/kernel/security/egress.py</code>).</p>
<p>Closed off in the same pass: non-http/https schemes are denied by default, URLs carrying userinfo (<code>https://user:pass@host/</code>) are denied, and a DNS failure is a denial rather than a retry.</p>
<p><strong>Layer three: authorization per hop.</strong> A URL that cleared both layers returns a 302 pointing at your internal network. Now what? So the outbound HTTPX client is built like this (<code>server/app/adapters/http/governed_client.py</code>):</p>
<pre><code class="language-python">async def authorize_request(request: httpx.Request) -&gt; None:
    await guard.authorize(ctx, resource_ref, str(request.url))

event_hooks["request"] = [authorize_request, *request_hooks]
kwargs.setdefault("follow_redirects", False)
</code></pre>
<p>Authorization hangs off the HTTPX request event hook, so <strong>every request that actually goes out</strong> is checked, redirect hops included — not just the URL you handed in at the entry point. And redirects are not followed by default.</p>
<p>The MCP adapter builds its sessions with that client, so the whole MCP path — initialization, <code>list_tools</code>, every <code>call_tool</code> — sits inside these constraints.</p>
<h2>4. Can you find out what happened afterwards?</h2>
<p>Tool calls are the only place an agent produces real side effects. A model saying something wrong can be asked again. A tool that changed a row in the production database changed it.</p>
<p>SOIT persists each tool call as a step of a run, and writes two pieces of evidence per call:</p>
<ul>
<li><p><strong>Gateway audit.</strong> <code>log_gateway_request</code> with <code>gateway_type="tool"</code>. The request side records the <code>tool_ref</code>, redacted parameters, and the egress decision (allow / deny plus the target URL). The response side records success, result type, metadata, and error. <strong>The failure path writes one too</strong> — the first thing the <code>except</code> branch does is emit the audit record. That is the one people forget, and the one you need when something has gone wrong.</p>
</li>
<li><p><strong>Step metrics.</strong> Latency, success flag, summarized arguments and result, error code and error details.</p>
</li>
</ul>
<p>The same call writes a cost entry with <code>billing_basis="requests"</code>, the provider, and <code>source_port="tools"</code> — so "what did this agent's MCP tools cost this month" is a query you can drill into by agent, workflow, tool, and source (<code>source_kind=plugin | mcp | builtin</code>).</p>
<p>On top of that, an OpenTelemetry span <code>soit.tool.invoke</code> carrying tenant, workspace, run and step ids, for whatever APM you already run.</p>
<h2>5. Can you replay it?</h2>
<p>The most frustrating property of agent debugging is that it does not reproduce. Same input, different reasoning.</p>
<p>At the tool layer you can at least be deterministic. Every tool call in SOIT carries an idempotency key, defaulting to <code>tool:{run_id}:{tool_call_id}</code>, and claims a leased execution record. If the claim lands on a record that already completed, the cached response comes straight back and the external tool is <strong>not called again</strong>.</p>
<p>The retry policy changes accordingly. The comment says it better than I can:</p>
<pre><code class="language-python">max_retries=1 if kwargs.get("idempotency_key") else self.max_retries,
</code></pre>
<blockquote>
<p>Durable Agent calls are at-most-once at this boundary. Not every downstream adapter can honor an idempotency key.</p>
</blockquote>
<p>At-most-once at this boundary, because you cannot assume the MCP server on the other end honors your idempotency key. Better to call once too few than once too many — for writes, that trade is not really a choice.</p>
<p>Rate limits and daily quotas come along with it, keyed by <code>tool_ref</code> plus tenant, workspace and user, so one runaway agent does not burn a whole tenant's third-party API budget.</p>
<h2>What this does not do</h2>
<p>The usual honest list:</p>
<ul>
<li><p>MCP transport is streamable HTTP only, targeting the MCP SDK v1 line. The stateless 2026-07-28 protocol revision is <strong>not supported yet</strong>.</p>
</li>
<li><p>OAuth is <code>client_credentials</code> only, no authorization_code (see question 2).</p>
</li>
<li><p>A marketplace for one-click MCP tool installation is on the roadmap; today you install plugin artifacts by hand.</p>
</li>
<li><p>The default egress allowlist is empty, which means your first MCP server <strong>will</strong> be refused until you add its domain explicitly. That is deliberate, but it does add a step to the quickstart.</p>
</li>
</ul>
<h2>Why none of this belongs in the agent framework</h2>
<p>A question that comes up constantly: how does this relate to LangChain and friends?</p>
<p>They are not the same layer. A framework answers "how do I orchestrate this call." A runtime answers "under whose identity did this call run, with whose credentials, what could it reach, what evidence did it leave, and can I replay it." The first is a concern while you write the code. The second is a concern after the code ships and someone else asks.</p>
<p>You can certainly put permission checks inside a framework, but then every new tool integration reimplements the governance logic. Push it down into the runtime's port layer and MCP tools, plugin tools and built-in tools all travel the same path — which is the reason question 1 could say "nobody writes it twice."</p>
<h2>Try it</h2>
<p>SOIT is Apache-2.0 and the code is all on GitHub:</p>
<ul>
<li><p>Repository: <a href="https://github.com/soit-ai/soit">https://github.com/soit-ai/soit</a></p>
</li>
<li><p>Quickstart: <code>docs/quickstart.md</code> in the repo</p>
</li>
<li><p>Governance demo: <code>docs/governance-demo.md</code> — 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</p>
</li>
</ul>
<p>If you are pushing MCP toward production right now, I would genuinely like to hear which of the five questions is blocking you. In our experience the hardest one is not technical — it is "who gets to decide what goes on the allowlist."</p>
<p><em>Disclosure: I maintain SOIT.</em></p>
]]></content:encoded></item><item><title><![CDATA[Every agent run is a governed run: the architecture behind our open-source agent runtime]]></title><description><![CDATA[We open-sourced SOIT a few weeks ago. The one line version: an agent runtime and governance platform for teams that need AI agents to touch real enterprise systems without losing control.
This post is]]></description><link>https://soit-ai.hashnode.dev/every-agent-run-is-a-governed-run-the-architecture-behind-our-open-source-agent-runtime</link><guid isPermaLink="true">https://soit-ai.hashnode.dev/every-agent-run-is-a-governed-run-the-architecture-behind-our-open-source-agent-runtime</guid><category><![CDATA[software architecture]]></category><category><![CDATA[self-hosted]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[jude]]></dc:creator><pubDate>Thu, 27 Aug 2026 16:29:33 GMT</pubDate><content:encoded><![CDATA[<p>We open-sourced <a href="https://github.com/soit-ai/soit">SOIT</a> a few weeks ago. The one line version: an agent runtime and governance platform for teams that need AI agents to touch real enterprise systems without losing control.</p>
<p>This post is about the part that is actually hard — not building agents, but being willing to let them run in production.</p>
<h2>The problem isn't building agents. It's trusting them.</h2>
<p>Every team I talk to has the same story. The demo took a week and it was great. Then security, compliance and ops started asking questions, and the project parked itself at PoC:</p>
<ul>
<li><p>What is this agent allowed to do, and who decided that?</p>
</li>
<li><p>Where do its credentials live, and what leaks if a prompt goes wrong?</p>
</li>
<li><p>Which hosts can it reach when a tool call makes an outbound request?</p>
</li>
<li><p>What exactly did it do last Tuesday, and can we prove it step by step?</p>
</li>
<li><p>What did that run cost, and which team pays for it?</p>
</li>
</ul>
<p>Frameworks answer none of these — they orchestrate calls and leave controls to you. Hosted platforms answer some, but you inherit their model choices, their data boundary and their pricing. Cloud-vendor agent services answer more, in exchange for the deepest lock-in of all.</p>
<p>Our answer is to make governance a kernel concern rather than a patch applied afterwards.</p>
<h2>Governed execution</h2>
<p>The idea is simple to state: <strong>every agent run is a governed run.</strong> Chat turn, agent loop, or workflow run — everything flows through one runtime ledger (<code>Run</code> / <code>RunStep</code> / <code>Trace</code>), and the same controls apply on every path:</p>
<ul>
<li><p><strong>Permissions</strong> — tenant and workspace scoping on every resource, RBAC with resource-level grants. Every record carries <code>tenant_id</code> and <code>workspace_id</code>; there are no escape hatches.</p>
</li>
<li><p><strong>Approved capabilities</strong> — 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.</p>
</li>
<li><p><strong>Secret boundaries</strong> — 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.</p>
</li>
<li><p><strong>Egress policy</strong> — outbound HTTP from tools is policy-controlled. An agent cannot quietly call a host you never approved.</p>
</li>
<li><p><strong>Ledger, cost, audit, replay</strong> — per-step tokens, latency and cost; a full audit log of privileged operations; a trace timeline you can replay step by step.</p>
</li>
<li><p><strong>Separation of duties</strong> — the Dev role that builds and runs agents cannot change egress policy, secrets, or installed plugins. That takes a workspace Owner or Admin.</p>
</li>
</ul>
<p>None of this is a wrapper around someone else's runtime. SOIT is a hexagonal architecture: a stable kernel, versioned JSON Schema contracts on every primitive, and replaceable adapters at the edges — so the governance layer holds no matter which model or vector store you swap in.</p>
<p>That last property is the whole reason for the shape. Governance implemented at the integration layer has to be rewritten for every new tool and every new provider. Governance implemented at the port layer is written once, and every adapter inherits it whether it wants to or not.</p>
<h2>What's in the box</h2>
<p>Four pillars, all of them in the open-source edition:</p>
<ul>
<li><p><strong>Build</strong> — 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.</p>
</li>
<li><p><strong>Execute</strong> — 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.</p>
</li>
<li><p><strong>Observe</strong> — 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.</p>
</li>
<li><p><strong>Govern</strong> — everything in the section above.</p>
</li>
</ul>
<p>It self-hosts with one command:</p>
<pre><code class="language-bash">git clone https://github.com/soit-ai/soit.git
cd soit
cp .env.example .env
docker compose --env-file .env -f docker/docker-compose.yml up -d
</code></pre>
<h2>A supply chain you can verify</h2>
<p>If your agents run in production, so does your agent platform — which makes it part of your attack surface.</p>
<p>Every SOIT release is built by a tag-triggered pipeline that publishes digest-addressable images, SPDX SBOMs, Sigstore-backed build provenance and SBOM attestations, a deterministic source archive, and <code>SHA256SUMS</code>. v1.0.0 shipped that way: three images on GHCR that pull anonymously, and artifacts you can check with <code>gh attestation verify</code> before they enter your environment — we ran it ourselves and got exit 0.</p>
<p>Secret scanning, dependency audit and container scanning run in CI as gates, not as dashboards.</p>
<h2>What SOIT is not</h2>
<p>A post like this should also say what you are not getting:</p>
<ul>
<li><p>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.</p>
</li>
<li><p>Content safety and PII detection are <strong>not implemented</strong>. 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.</p>
</li>
<li><p>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.</p>
</li>
</ul>
<h2>License: Apache 2.0</h2>
<p>Commercial use, self-hosting, internal deployments and building products on top are all unrestricted. The core platform is and will remain open source; SSO, advanced audit reports and multi-region deployment live in SOIT Enterprise. The commercial boundary is drawn in the feature set, not in the license.</p>
<h2>Come break it</h2>
<p>The repo is at <a href="https://github.com/soit-ai/soit"><strong>github.com/soit-ai/soit</strong></a>. The quickstart takes about ten minutes on a machine with Docker. There are <code>good first issue</code>s seeded and the roadmap is pinned.</p>
<p>If the architecture trade-offs are the interesting part for you — the outbox runtime, the spec-first contracts, lease-based worker recovery — say so and I will write those up separately. I also wrote a companion piece on what happens when an MCP tool call has to obey RBAC, secrets and egress policy: <a href="https://dev.to/judezh/five-questions-to-answer-before-you-put-mcp-in-production-j58">Five questions to answer before you put MCP in production</a>.</p>
<p>If your agents graduated from notebooks and hit the trust wall, this was built for you. Tell me where it falls short.</p>
<p><em>Disclosure: I maintain SOIT.</em></p>
<p><em>Originally published at</em> <a href="https://soit.ai/en/blog/soit-open-source-launch"><em>https://soit.ai/en/blog/soit-open-source-launch</em></a></p>
]]></content:encoded></item></channel></rss>