<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ichiburn</title>
    <description>The latest articles on DEV Community by Ichiburn (@ichiburn).</description>
    <link>https://dev.to/ichiburn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4048756%2F63712db7-8255-4d67-a891-90e251545f50.png</url>
      <title>DEV Community: Ichiburn</title>
      <link>https://dev.to/ichiburn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ichiburn"/>
    <language>en</language>
    <item>
      <title>I built a CI gate that measures future change friction. It failed its own check.</title>
      <dc:creator>Ichiburn</dc:creator>
      <pubDate>Mon, 27 Jul 2026 07:48:39 +0000</pubDate>
      <link>https://dev.to/ichiburn/i-built-a-ci-gate-that-measures-future-change-friction-it-failed-its-own-check-4gda</link>
      <guid>https://dev.to/ichiburn/i-built-a-ci-gate-that-measures-future-change-friction-it-failed-its-own-check-4gda</guid>
      <description>&lt;p&gt;&lt;em&gt;A Track 01 submission for Agents of SigNoz about measuring what a coding agent had to do, then refusing to trust the result when the control failed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every CI gate you have ever added answers a question in the present tense. Do the unit&lt;br&gt;
tests pass. Do the components fit together. Is a known vulnerability present. All useful,&lt;br&gt;
all about now.&lt;/p&gt;

&lt;p&gt;None of them can tell you that the pull request in front of you just made next month's work&lt;br&gt;
more expensive. That question has no test, so nothing blocks on it, so it accumulates.&lt;/p&gt;

&lt;p&gt;MORROW is an attempt to make it measurable. A future change task is registered in advance.&lt;br&gt;
Its experiment protocol runs the same task against &lt;strong&gt;both&lt;/strong&gt; &lt;code&gt;main&lt;/code&gt; and the candidate —&lt;br&gt;
same model, same prompt, same resource limits, each run in its own container — and the&lt;br&gt;
difference in what the agent had to do is the measurement. The current prototype records&lt;br&gt;
the resulting evidence as cassettes and implements deterministic verification and CI&lt;br&gt;
gating; automatic pull-request orchestration is the next step.&lt;/p&gt;

&lt;p&gt;The agent is the instrument. Three things get counted per run: how many distinct files it&lt;br&gt;
had to read, how many test cycles it burned, how many lines it actually changed.&lt;/p&gt;

&lt;p&gt;And then the honest part, which is most of this post: &lt;strong&gt;my experiment failed its own&lt;br&gt;
validity check, and I shipped that result.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ morrow verify cassettes/treatment-replace-cache
ERROR · INVALID_EXPERIMENT · exit 2
report reproduced byte for byte, and the re-derived verdict is INVALID_EXPERIMENT:
null control FFR_gate 1.7403 exceeds maximum_ffr 1.2000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F012ioh0r9tv3ykn78d4k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F012ioh0r9tv3ykn78d4k.png" alt="MORROW reports INVALID_EXPERIMENT after the null control exceeds its tolerance" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How SigNoz fits in
&lt;/h2&gt;

&lt;p&gt;The agent's trajectory is the raw material, and a trajectory is a trace. That correspondence&lt;br&gt;
is exact enough that the mapping needed no cleverness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;morrow.experiment          ← one registered task, one candidate
 └── morrow.pair           ← one baseline/candidate comparison
      ├── morrow.run       ← one agent execution
      │    ├── file.read
      │    ├── patch.apply
      │    ├── command.run
      │    └── test.run
      └── morrow.run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00k4i24vgc3hais4wq2y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00k4i24vgc3hais4wq2y.png" alt="Baseline and candidate agent runs represented as sibling trace subtrees" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two arms of a pair sit side by side under the same parent, so &lt;strong&gt;the difference between them&lt;br&gt;
is the shape of the trace&lt;/strong&gt;, not a number you have to go look up. In the recorded treatment&lt;br&gt;
trace, the baseline run reads 18 files, burns 1 test cycle and writes 129 lines; the&lt;br&gt;
candidate reads 16, burns 4, and writes 487. You can see that without reading a single&lt;br&gt;
number — one subtree is visibly denser.&lt;/p&gt;

&lt;p&gt;I self-hosted SigNoz with&lt;br&gt;
&lt;a href="https://signoz.io/docs/install/docker/" rel="noopener noreferrer"&gt;Foundry&lt;/a&gt;. The complete deployment contract is&lt;br&gt;
committed as &lt;code&gt;casting.yaml&lt;/code&gt; and &lt;code&gt;casting.yaml.lock&lt;/code&gt;; the meaningful part is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Installation&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;signoz&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deployment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;flavor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;mcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;foundryctl cast -f casting.yaml&lt;/code&gt; brings up the stack. MORROW uses the Python OpenTelemetry&lt;br&gt;
SDK and exports traces over &lt;a href="https://opentelemetry.io/docs/specs/otlp/" rel="noopener noreferrer"&gt;OTLP gRPC&lt;/a&gt; to&lt;br&gt;
&lt;code&gt;localhost:4317&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three practical things I learned wiring this up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exporting without an error is not evidence that anything landed.&lt;/strong&gt; My first export&lt;br&gt;
"succeeded" and stored nothing I could find. I stopped trusting the exporter's return value&lt;br&gt;
and wrote a readback that queries ClickHouse directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run python scripts/signoz_query.py &lt;span class="nt"&gt;--minutes&lt;/span&gt; 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;file.read        220
patch.apply       88
command.run       78
test.run          27
agent.session     14
agent.complete    14
morrow.run        14
morrow.pair        7
morrow.experiment  3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;It goes to ClickHouse rather than through the SigNoz HTTP API deliberately: no session, no&lt;br&gt;
token, nothing to keep in a file, and the answer is the stored rows rather than a rendering&lt;br&gt;
of them. The exact total is 465 spans: 3 experiment, 7 pair, 14 run and 441 exported event&lt;br&gt;
spans. The cassettes retain 1,553 source events; 1,112 opaque provider events deliberately&lt;br&gt;
do not become spans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The collector cannot register until the first organisation exists.&lt;/strong&gt; Before you create&lt;br&gt;
one, it loops on &lt;code&gt;cannot create agent without orgId&lt;/code&gt; while looking otherwise healthy. I&lt;br&gt;
spent a while debugging my exporter for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every event deserves a span.&lt;/strong&gt; One run produced 108 events that carried no measurement&lt;br&gt;
signal against 22 real actions. Those stay in the published evidence — a reader can count&lt;br&gt;
them — but they do not become spans. A trace that is five-sixths noise is not observability.&lt;/p&gt;

&lt;p&gt;I also left one field out on purpose. Replayed runs carry &lt;strong&gt;no wall-clock duration&lt;/strong&gt;. Wall&lt;br&gt;
time is a property of the machine that did the recording, not of the work the task required,&lt;br&gt;
so it is not in the published evidence. Exporting a zero would have put a number on a&lt;br&gt;
dashboard that nobody measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result, including the part that did not work
&lt;/h2&gt;

&lt;p&gt;Ten container runs. One task, one model (&lt;code&gt;claude-sonnet-5&lt;/code&gt;), identical limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Churn separated descriptively under either arm ordering.&lt;/strong&gt; Every treatment pair landed&lt;br&gt;
between 3.75 and 5.11; the null control — two clones of the same tree — stayed under 2.22.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The aggregate verdict could not be trusted.&lt;/strong&gt; The null is symmetric, but MORROW's aggregation is not:&lt;br&gt;
improvements are floored at 1 so a regression on one axis cannot be cancelled by an&lt;br&gt;
improvement on another. Swapping the arm labels moved the null from &lt;strong&gt;1.0000 to 1.7403&lt;/strong&gt;,&lt;br&gt;
past the published tolerance band of 1.20.&lt;/p&gt;

&lt;p&gt;The design had anticipated this exact failure mode, in writing, before the data existed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The cost of one-sided aggregation is that even symmetric noise biases upward. The null&lt;br&gt;
control is what cancels that bias.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pre-registered rule for a null outside its band is to report the experiment as&lt;br&gt;
&lt;code&gt;INVALID_EXPERIMENT&lt;/code&gt; — not to widen the band. So that is what the published cassette&lt;br&gt;
records, what CI asserts, and what the demo video opens on.&lt;/p&gt;

&lt;p&gt;I committed &lt;strong&gt;both&lt;/strong&gt; arm orderings, so neither can be the one I picked after seeing the&lt;br&gt;
data. &lt;code&gt;files_read_distinct&lt;/code&gt; showed no signal (0.89, 0.81, 1.42); it is reported too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the verdict checkable rather than asserted
&lt;/h2&gt;

&lt;p&gt;A dashboard you have to trust is not much better than a claim you have to trust. So the&lt;br&gt;
evidence for each experiment is committed to the repository as a &lt;strong&gt;cassette&lt;/strong&gt;: normalized&lt;br&gt;
events, churn counts, launcher exit codes, the evaluator policy that was in force, and both&lt;br&gt;
report surfaces.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;morrow verify&lt;/code&gt; re-derives the verdict from those files in a fixed order — digests, closed&lt;br&gt;
schemas, metrics recomputed from the events, the verdict recomputed, then the report&lt;br&gt;
regenerated and compared byte for byte. Nothing in the recorded report's contents is an&lt;br&gt;
input to that decision.&lt;/p&gt;

&lt;p&gt;Six rounds of adversarial review (OpenAI Codex, plus security and quality passes) went&lt;br&gt;
looking for ways a cassette — controlled by the pull request under review — could choose&lt;br&gt;
its own verdict. It could supply its judging thresholds, assert its own success, and reuse&lt;br&gt;
one run as two repetitions. The fixes converged on one rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A manifest carries labels and pointers, not findings.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The manifest may say which arm and pair a run belongs to and where its evidence lives.&lt;br&gt;
Everything that &lt;em&gt;decides&lt;/em&gt; is re-derived, including run success and test cycles.&lt;/p&gt;

&lt;p&gt;Every hole above is now a test that reproduces the attack and asserts the state that stops&lt;br&gt;
it. 212 tests, and CI verifies all three published cassettes on every push with their exit&lt;br&gt;
codes asserted per cassette — &lt;code&gt;0 / 2 / 2&lt;/code&gt;, because two of them are supposed to fail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswaf9sez9x6fxbth7ubm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswaf9sez9x6fxbth7ubm.png" alt="MORROW detects stale evidence after the recorded data is changed" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not claiming
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No statistical significance.&lt;/strong&gt; Three compared pairs put a one-sided sign test's floor at
1/8 = 0.125. The report says so in whichever form is true for its sample size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No safe execution against untrusted repositories.&lt;/strong&gt; The container needs the model API,
so &lt;code&gt;--network none&lt;/code&gt; is not available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tamper resistance.&lt;/strong&gt; The digests detect accidental corruption and recomputation
detects a report that no longer follows from its evidence. Neither is a signature.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ichiburn/morrow &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;morrow
uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--all-groups&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt;

uv run morrow verify cassettes/null-control-as-recorded    &lt;span class="c"&gt;# exit 0: reproduced, in band&lt;/span&gt;
uv run morrow verify cassettes/null-control-arms-swapped   &lt;span class="c"&gt;# exit 2: reproduced, out of band&lt;/span&gt;
uv run morrow verify cassettes/treatment-replace-cache     &lt;span class="c"&gt;# exit 2&lt;/span&gt;

uv run python scripts/export_cassettes.py                  &lt;span class="c"&gt;# → SigNoz on :4317&lt;/span&gt;
uv run python scripts/signoz_query.py &lt;span class="nt"&gt;--minutes&lt;/span&gt; 15         &lt;span class="c"&gt;# read back what stored&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  AI disclosure
&lt;/h2&gt;

&lt;p&gt;Anthropic Claude Code wrote the implementation, the SigNoz and OpenTelemetry integration,&lt;br&gt;
the tests, and the documentation. OpenAI Codex performed adversarial design review (four&lt;br&gt;
rounds before implementation began) and code review (six rounds after). ChatGPT was used for&lt;br&gt;
product architecture and planning.&lt;br&gt;
Every generated change was reviewed by me.&lt;/p&gt;




&lt;p&gt;The thing I would want a reviewer to take from this is not the churn separation. It is that&lt;br&gt;
the instrument was built so it could report a result I did not want, and then it did.&lt;/p&gt;

&lt;p&gt;Project repository: &lt;a href="https://github.com/ichiburn/morrow" rel="noopener noreferrer"&gt;github.com/ichiburn/morrow&lt;/a&gt;&lt;/p&gt;

</description>
      <category>signoz</category>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
