<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://germanvalencia.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://germanvalencia.dev/" rel="alternate" type="text/html" /><updated>2026-08-16T13:52:06+00:00</updated><id>https://germanvalencia.dev/feed.xml</id><title type="html">German Valencia</title><subtitle>Notes on software engineering</subtitle><entry><title type="html">Anything the Tool Needs to Answer Belongs in the Schema</title><link href="https://germanvalencia.dev/2026/08/16/anything-the-tool-needs-to-answer-belongs-in-the-schema.html" rel="alternate" type="text/html" title="Anything the Tool Needs to Answer Belongs in the Schema" /><published>2026-08-16T00:00:00+00:00</published><updated>2026-08-16T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/08/16/anything-the-tool-needs-to-answer-belongs-in-the-schema</id><content type="html" xml:base="https://germanvalencia.dev/2026/08/16/anything-the-tool-needs-to-answer-belongs-in-the-schema.html"><![CDATA[<p>My harness had three records claiming to know where a feature stood: the root ticket’s Linear status, four Linear child tickets — one per phase — and the repository handoff doc with its prose Dev Log. When they disagreed, and they did, nothing said which one won. The resume point was whichever one the agent happened to read first.</p>

<p>The failure that finally forced the rewrite is dull enough to be convincing. A ticket merged on the 14th. Its documentation phase completed correctly — the handoff was folded into the domain specs and deleted, source-ticket lineage intact. Its four phase children stayed open anyway: one sitting at <code class="language-plaintext highlighter-rouge">In Review</code>, one at <code class="language-plaintext highlighter-rouge">In Progress</code>, until I closed them by hand a day after the work merged and nine days after the phase they represented had actually finished.</p>

<p>The cause was a sentence in the skill’s own text:</p>

<blockquote>
  <p>Moving a child ticket (or the root) to “Done” once its PR is actually merged is a manual action the human does.</p>
</blockquote>

<p>Four tickets per feature, each requiring a person to remember, none of which any automation read. Stale by construction. That’s the same shape as the <a href="/2026/08/15/too-much-ceremony-buys-you-none.html#the-ceremony-that-survived">ceremony nobody performs</a> — except here the un-performed step wasn’t optional politeness, it was the state the next run would route from.</p>

<h2 id="the-process-couldnt-run-the-ticket">The process couldn’t run the ticket</h2>

<p>Both versions of the harness were unavailable at once, and the bind is worth naming because it recurs.</p>

<p>The old one couldn’t run it, because the work <em>was</em> editing it. Following <code class="language-plaintext highlighter-rouge">/corpus-work</code> would have created the four phase child tickets the ticket explicitly forbids and written a handoff doc in the exact template the ticket replaces — the process would have violated the deliverable. There was nothing for the phases to bite on either: no domain spec for the harness skills, no test layer for prose instructions, and an acceptance criterion demanding all eight reference files be rewritten in one atomic change. Every phase boundary the pipeline exists to enforce was degenerate.</p>

<p>The new one couldn’t run it for the duller reason that it didn’t exist yet. It was the deliverable.</p>

<p>So the ticket ran with no harness at all: read everything, put a plan on a review surface, one branch, one PR, and me standing in for every gate that would otherwise have fired on its own. That isn’t the escape hatch — the escape hatch is a sanctioned cheap path for small work, and it still runs <em>through</em> the machinery. This is the condition a harness hits precisely when it’s being changed, and it’s the one moment none of what you’ve built is available to help.</p>

<h2 id="one-record-and-only-one-thing-allowed-to-write-it">One record, and only one thing allowed to write it</h2>

<p>The fix is a YAML frontmatter block on <code class="language-plaintext highlighter-rouge">docs/handoffs/&lt;ROOT-ID&gt;.md</code>, and it is the only machine-readable record of where implementation stands: schema version, Linear id, domain, surface, feature branch, phase, round, phase status, active branch, active PR, test baseline commit, final PR.</p>

<p>Routing reads that block and nothing else. Not the Dev Log. Not a branch-name convention. Not a Linear comment, a prose heading, or the Linear status. Linear keeps <code class="language-plaintext highlighter-rouge">Backlog/Todo → In Progress → In Review → Done</code>, and none of those map to a phase number — the phases live entirely in the repository now, and there are seven of them instead of four, because the old single “implementation” phase split into unit, integration and E2E.</p>

<p>That leaves three authorities with clean edges:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Ask</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Why does this exist, and what is “done”?</td>
      <td>The Linear root ticket</td>
    </tr>
    <tr>
      <td>Where does implementation stand?</td>
      <td>The handoff frontmatter</td>
    </tr>
    <tr>
      <td>Did a branch, PR, or merge actually happen?</td>
      <td>Git and GitHub</td>
    </tr>
  </tbody>
</table>

<p>And one rule the whole design rests on: <strong>the checkpoint records intent and the last known position; GitHub decides whether that position is still true.</strong> A recorded PR is never assumed merged.</p>

<h2 id="wrap-the-mechanism-in-a-skill">Wrap the mechanism in a skill</h2>

<p>The plan went out as a review artifact with three open decisions on it. Two came back as I’d recommended. The one that mattered arrived as a freeform annotation:</p>

<blockquote>
  <p>Can we ensure that the harness interacts with the handoff with a dedicated skill, and that skill in turn wraps over that mechanical tool?</p>
</blockquote>

<p>That turned what had been section 4 of a plan — an “evidence rules” table the agent was supposed to read and apply — into an executable command with a skill in front of it. Three layers, each doing one job:</p>

<ul>
  <li><strong>The data.</strong> Typed frontmatter, one file per feature. No second state file beside it.</li>
  <li><strong>The tool.</strong> <code class="language-plaintext highlighter-rouge">pnpm handoff</code> — arithmetic over that data plus a GitHub check. <code class="language-plaintext highlighter-rouge">show</code> returns a <code class="language-plaintext highlighter-rouge">resolution.action</code> from a closed set: <code class="language-plaintext highlighter-rouge">plan</code>, <code class="language-plaintext highlighter-rouge">start</code>, <code class="language-plaintext highlighter-rouge">resume</code>, <code class="language-plaintext highlighter-rouge">report-and-stop</code>, <code class="language-plaintext highlighter-rouge">advance</code>, <code class="language-plaintext highlighter-rouge">blocked</code>, <code class="language-plaintext highlighter-rouge">stop-ask</code>. It also returns the exact reference file to open and the exact <code class="language-plaintext highlighter-rouge">set</code> command to run next.</li>
  <li><strong>The skill.</strong> <code class="language-plaintext highlighter-rouge">corpus-handoff</code>, the only agent-facing interface. <code class="language-plaintext highlighter-rouge">corpus-work</code> never touches the file, and never touches the tool’s flags.</li>
</ul>

<p>The skill is the interesting layer, and it’s the one I wouldn’t have built unprompted. Its content is almost entirely about <em>obligation</em> — the bridge between a typed answer and what a model is allowed to conclude from it. The action table is that bridge in miniature: a mechanical enum on the left, a sentence about judgment on the right.</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">action</code></th>
      <th>What you must do</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">resume</code></td>
      <td>Check out that branch and continue. <strong>Never cut a second branch for the same phase.</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">report-and-stop</code></td>
      <td>Report the URL and stop the run. Do not start the next phase, however mergeable the diff looks.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">advance</code></td>
      <td>Run the <code class="language-plaintext highlighter-rouge">set</code> in <code class="language-plaintext highlighter-rouge">help</code>, delete the merged branch, go on.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">blocked</code></td>
      <td>Stop and ask. An abandoned PR is always a human decision.</td>
    </tr>
  </tbody>
</table>

<p>Plus the line that keeps the layer from decaying into advice: <strong><code class="language-plaintext highlighter-rouge">show</code> is not optional and its result is not advisory.</strong> Skipping it and reading the frontmatter yourself reintroduces exactly the assumed-it-merged bug the whole thing replaced.</p>

<p>This is the same ladder as <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html#rank-the-layers-by-what-they-dont-trust">the enforcement layers</a>, read from the other end. There, the question was which layer can apply a rule without anyone choosing to. Here it’s which layer can <em>answer a question</em> without anyone choosing to — and the skill exists precisely because the answer still has to be handed to something that reasons.</p>

<h2 id="the-challenge-that-found-the-defect">The challenge that found the defect</h2>

<p>Then the design got pushed on: is the split really “what’s next” mechanical, “commit to it” judgment?</p>

<p>Where it held: <code class="language-plaintext highlighter-rouge">show</code> is pure computation with no side effects and can run any number of times; <code class="language-plaintext highlighter-rouge">set</code> is the only mutation; the tool never calls <code class="language-plaintext highlighter-rouge">set</code> itself. A recommendation can sit in front of you indefinitely without the checkpoint moving. The code already enforced that — I just hadn’t stated it.</p>

<p>Where my implementation failed its own claim: <code class="language-plaintext highlighter-rouge">next_phase</code> was <strong>not</strong> mechanical. Phases 2–4 each implement one test layer and are conditional, and which layers a feature runs was decided in phase 0 — as prose, in the Plan section, which the tool cannot read. So for a feature with no integration layer, <code class="language-plaintext highlighter-rouge">show</code> would have confidently returned <code class="language-plaintext highlighter-rouge">phase: 3</code> and the skill would have had to overrule it.</p>

<p>Worth being precise about what kind of defect that is, because my first description of it was too strong. <strong>Nothing was failing.</strong> The model has always got this state machine right — reading the plan, noticing a layer wasn’t in scope, routing past it. What I found wasn’t a bug caught in the act. It was that the correctness was resting on the model reading prose carefully, every run, indefinitely — and I was in the middle of building a tool whose entire premise is that nothing load-bearing should rest on that.</p>

<p>Which is the more useful version of the point anyway. A mechanical answer derived from incomplete data isn’t a neutral non-answer — it’s a confident wrong one that a human record then has to contradict, which is exactly the two-records-disagree shape I was removing. The door was open. Nobody had walked through it yet, and “not yet” is not a property you can schedule around.</p>

<p>Worse, it doesn’t get to pick its moment. A latent gap like that fires in the middle of some larger piece of work — an interruption to something already half-built, which is the most expensive order in which to discover anything. The obvious objection is that closing a door nobody has walked through is premature optimisation. But that objection was always a claim about <em>price</em>, not about correctness — and AI has brought that price down massively.</p>

<p>The guard here was one schema field in the handoff’s YAML frontmatter — <code class="language-plaintext highlighter-rouge">layers</code>, the list of test layers this feature actually runs, defaulted at <code class="language-plaintext highlighter-rouge">init</code>, revisable mid-flight when a layer turns out to be unnecessary — plus the branch in the resolution function that routes past the phases those layers don’t cover, a validator rule rejecting any checkpoint parked in a phase its feature never runs, and the tests around all three. Small, but exactly the kind of small that got deferred anyway when it had to compete for the same hands. When building the guard gets cheap enough, “we’ll deal with it if it happens” stops being prudence about effort and becomes a bet that the interruption will land somewhere convenient. It’s the same shift that <a href="/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html#what-cheap-code-generation-actually-bought">made a 100% coverage gate affordable</a>: the standard didn’t get better, the tax on holding it collapsed.</p>

<p>The fix was to make the layer decision data. <code class="language-plaintext highlighter-rouge">layers</code> is now a checkpoint field. Routing skips absent layers and says so — <code class="language-plaintext highlighter-rouge">skipping 4 (e2e) — not in this feature's layers</code> — rather than looking like a miscount, and the validator rejects a checkpoint parked in a phase its feature never runs.</p>

<p>The rule that came out of it went into the skill verbatim:</p>

<blockquote>
  <p>Anything the tool needs in order to answer correctly belongs in the schema; anything requiring judgment stays with you.</p>
</blockquote>

<p>With the second half enumerated, so it can’t quietly migrate: whether to start the phase at all, whether a failure is a wrong test or a wrong contract, whether a layer turned out to be unnecessary, and every human gate.</p>

<h2 id="what-the-tool-refuses">What the tool refuses</h2>

<p>The schema isn’t held up by good intentions. <code class="language-plaintext highlighter-rouge">pnpm handoff validate</code> runs on every commit through the pre-commit hook, so a malformed checkpoint fails at the commit that introduces it rather than several phases later, when it would be the last thing standing between the branch and the final PR.</p>

<p>The same rules run inside <code class="language-plaintext highlighter-rouge">set</code>. The tool validates the transition before performing it, so a rejected change leaves the file exactly as it was rather than half-applied:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>error: that change would leave the checkpoint invalid — nothing was written
issues[1]:
  `phase: 2` with no `test_baseline_commit` — implementation is measured
  against the merged phase-1 specification, so the baseline is pinned
  before phase 2 starts
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  `active_branch` is still set while `phase_status: complete` — clear
  evidence when a phase ends, or a later run resumes a branch that
  already merged
</code></pre></div></div>

<p>Both errors name the rule rather than the field, which is what makes them usable by the thing reading them. And the skill closes the obvious escape route: hand-editing costs you the validator and gains nothing — if a transition seems impossible through the tool, that’s a signal the transition is incoherent, not that the tool is in the way.</p>

<h2 id="write-the-tool-for-the-agent-the-human-reads-it-anyway">Write the tool for the agent; the human reads it anyway</h2>

<p>The steer I had to keep repeating while the tool was being built: <strong>its audience is a peer, not a person.</strong> Left alone, an agent writing a CLI writes it for a human — progress lines in stdout, help text where content should be, a nicely aligned table, an error phrased as an apology. Those are the conventions in every CLI it has ever read, so they’re the default, and saying “this is agent-facing” once doesn’t hold it. It has to be said again at the next design decision.</p>

<p>What changes when the reader is an agent is specific enough to have a standard, and the tool follows it: <a href="https://axi.md/">AXI</a>, the Agent eXperience Interface. Output is TOON rather than JSON — the standard puts that at roughly 40% fewer tokens for the same content. Errors go to <em>stdout</em>, in the same shape as success, because an agent doesn’t read stderr and an error it can’t see is an error it retries blindly. Exit codes mean something: 0 including no-ops, 1 error, 2 usage. Nothing prompts interactively. And every response carries its own next step:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>resolution:
  action: advance
  phase: 1
  reference: .agents/skills/corpus-work/references/phase-1-tests.md
  reason: phase 0 is complete — route to phase 1
help[1]:
  Run `pnpm handoff set COR-999 --phase 1 --status not_started`
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">help</code> line is the entire ergonomic argument in one row. The expensive token cost is rarely a longer response — it’s the follow-up call. So the command the agent is about to have to assemble gets handed over already assembled.</p>

<p>Then the asymmetry that makes this a free choice rather than a trade: <strong>I can read that too.</strong> It’s English tokens in a shape a person parses fine on sight, arguably better than the aligned table I’d have asked for, since nothing is hidden behind a column width. The reverse does not hold. An agent handed human-shaped output reads <code class="language-plaintext highlighter-rouge">Fetching data…</code> as data, can’t distinguish a truncated list from a complete one, and never sees the error on stderr at all.</p>

<p>So the two audiences aren’t symmetric, and the tie goes to the agent every time. Optimising for the human costs the agent real capability; optimising for the agent costs the human some polish. That isn’t a close call — it’s just a call the default pulls against, which is why it has to be made out loud and then made again.</p>

<h2 id="prose-was-already-working-thats-the-argument-not-the-objection">Prose was already working. That’s the argument, not the objection</h2>

<p>The thing I want to be careful about claiming: the harness was already behaving well. Tickets in Linear with real acceptance criteria, phase instructions written as prose, <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html#the-gate-you-cant-skip">a gate that costs you a review round rather than a merge</a> — that combination has been producing work I’d defend for weeks. This isn’t a rescue.</p>

<p>It’s the same designs, moved down a layer. Every rule in that frontmatter schema was already written somewhere in the skill’s prose: don’t start phase 2 without a merged phase-1 baseline, don’t leave a branch recorded once a phase completes, don’t cut a second branch for a phase that already has one, don’t assume a recorded PR merged. Prose stated them. A model followed them, mostly, and I couldn’t tell the difference between “followed” and “happened not to be tested” from the outside. Now the transition that would violate one of them exits non-zero and writes nothing.</p>

<p>Two properties change when a rule moves from a paragraph to a validator. It stops being negotiable — there’s no reading of <code class="language-plaintext highlighter-rouge">phase: 2</code> with a null baseline that gets through, and no deadline under which it becomes reasonable. And it stops being <em>distributed</em>: eight reference files each restating a fragment of the state machine becomes one schema, one resolution function, and one skill that says what each answer obliges. The failure I opened this post with was a coordination failure between three records. Centralizing was never about tidiness — a single record is the only kind that can’t disagree with itself.</p>

<p>There’s a third property, and it’s the one that decides whether any of this was worth building. To <em>trust</em> a guarantee written in prose, I’d have to evaluate whether the model follows it — across phrasings, across context lengths, across the runs where it’s mid-task and reasoning about something else. That’s an open-ended behavioural eval. It’s expensive to build, expensive to run, and it goes stale the moment either the prose or the model changes.</p>

<p>Putting the guarantee in a validator collapses that surface to a single question: <strong>does the agent actually call the skill and the tool?</strong> Everything downstream of the invocation is arithmetic, and arithmetic is covered by 36 unit tests with no model in them at all. The remaining eval is binary, cheap, and observable in a transcript rather than inferred from the quality of the output.</p>

<p>That’s also the honest statement of what’s still unguarded, and it lands exactly where <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html#rank-the-layers-by-what-they-dont-trust">the enforcement ladder</a> already put skills: triggering on model-judged intent, the probabilistic layer. I haven’t removed the probability. I’ve concentrated all of it into one event I can watch, which is a strictly better place for it than distributed across every rule in eight reference files.</p>

<p>What I’d resist calling it is a railroad, at least not yet. The tool can refuse an incoherent transition; it can’t make anyone run <code class="language-plaintext highlighter-rouge">show</code> in the first place, and it has no view on whether the work in the branch is any good. It narrows the space of states the agent can leave behind. Everything the model does <em>inside</em> a phase is still governed by prose, review, and the same <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">semantic residue</a> a human has to read. The gate got harder; the judgment didn’t move.</p>

<h2 id="where-id-temper-this">Where I’d temper this</h2>

<p>Nothing here has been exercised. The change can’t dogfood itself — running the pipeline on it would have created the tickets it forbids — so the substitute was walking the finished instructions against ten forward-test scenarios, six of them driven through the real tool and four confirmed only as instructions. That’s a walkthrough, not a run. The first real exercise is the next ticket, and the <a href="/2026/07/30/the-tests-written-without-the-rubric-are-its-test-set.html#rule-9-aimed-one-level-up">rubric argument</a> applies unchanged: a mechanism nobody has seen fire is not known to work.</p>

<p>The one real gap in this whole session wasn’t found by the validator, the 36 tests, or the four green gates — none of which would have gone red, since nothing was actually failing. It was found by someone reading a plan and asking whether the boundary I’d drawn was the one I’d actually built. A gate can catch a rule being broken. It cannot tell you a rule was never mechanised in the first place.</p>

<p>The tool tells you what’s next. It still can’t tell you that what’s next was computed from a field you forgot to give it.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[My harness had three records claiming to know where a feature stood: the root ticket’s Linear status, four Linear child tickets — one per phase — and the repository handoff doc with its prose Dev Log. When they disagreed, and they did, nothing said which one won. The resume point was whichever one the agent happened to read first.]]></summary></entry><entry><title type="html">Too Much Ceremony Buys You None</title><link href="https://germanvalencia.dev/2026/08/15/too-much-ceremony-buys-you-none.html" rel="alternate" type="text/html" title="Too Much Ceremony Buys You None" /><published>2026-08-15T00:00:00+00:00</published><updated>2026-08-15T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/08/15/too-much-ceremony-buys-you-none</id><content type="html" xml:base="https://germanvalencia.dev/2026/08/15/too-much-ceremony-buys-you-none.html"><![CDATA[<p>Everything I’ve written here for the last three weeks argues one direction: gate it, write the rule down, make the mechanism apply itself. This is the counterweight, and it’s the failure I actually hit rather than one I’m warning about hypothetically.</p>

<p>There are changes in my repos with no ticket, no scoping artifact, and no phase PR. Not because the harness broke — it works fine. Because I didn’t invoke it.</p>

<p>The part worth writing about isn’t that I skipped it. It’s that the skipping is <em>binary</em>. I don’t run an abbreviated version — a thin ticket, three of the eight sections, a scoping phase whose artifact I drop. I run none of it, and then I run all of it again on the next thing that clears the bar. The usual diagnosis for that pattern is discipline, and I think the usual diagnosis is wrong.</p>

<p>I also thought I knew which work was falling through, and I was wrong about that too. I counted, about two thirds of the way through writing this, and the count is in the middle of the post rather than at the end because it changed what the rest of it says.</p>

<h2 id="a-process-with-one-mode-is-a-step-function">A process with one mode is a step function</h2>

<p>Ceremony cost is roughly fixed. Task value varies over three or four orders of magnitude. Put a fixed cost against a variable payoff and you get a crossover point, and below the crossover the rational move is not to pay less — there is no <em>less</em> — it’s to pay nothing.</p>

<p>That alone explains a gap. It doesn’t explain the shape of the gap, and the shape is the interesting part.</p>

<p>A pure cost calculation predicts a gradient, and my harness is built for one. It has <a href="/2026/07/24/corpus-ticket-and-corpus-work-under-the-hood.html">a named escape hatch</a>: a bug or a DX-only improvement with no new user-observable surface skips the four phases and goes straight to a single PR, after an explicit propose-and-confirm. There was a two-tier inspection dial next to it, until I deleted it — which turns out to be the best single piece of evidence in this post, so I’ll come back to it.</p>

<p>The hatch is real and it gets used. And most of what I commit still goes around it entirely. That’s the more useful version of the observation, because it rules out the obvious fix: <strong>the tier existed and I bypassed it anyway.</strong> Three things explain that:</p>

<p><strong>The discount is on the wrong axis.</strong> The escape hatch cuts four phases to one, and phases are mostly the agent’s work. What it doesn’t cut is the cost of <em>entering</em>: a ticket has to exist, the scope has to be written, the propose-and-confirm is an exchange I have to have, and there’s still a handoff doc and a PR at the end. That’s a large discount on the cheap half and almost none on the expensive half. The crossover is set by entry cost, and entry cost barely moved.</p>

<p><strong>Nothing marks which parts are load-bearing.</strong> Every section of a ticket template looks deliberate, because it is — each one got added by someone solving a real problem. Which of them earn their place on <em>this</em> ticket is a different question, the answer changes from ticket to ticket, and nobody could have marked it in advance. So the trimming has to happen at write time, by the person least inclined to do it. “Just fill in the useful parts” isn’t an option you can execute; it’s an option you’d have to design.</p>

<p><strong>Partial compliance feels like a lie. Total avoidance feels like a decision.</strong> This is the asymmetry that makes the edge sharp. A ticket with half its sections empty is a visible failure to do the thing properly, sitting in a tracker with my name on it. No ticket at all is just a small change that happened. One of those is embarrassing and the other is invisible, and they’re both the same underlying choice. Nothing about the system makes it easier to be honest at low effort than to be absent at zero.</p>

<p>So the sanctioned cheap path is cheaper than the full process and still dearer than nothing, the operator opts out whole, and the process reports full compliance on everything that reached it.</p>

<h2 id="cheap-generation-moved-the-crossover-the-wrong-way">Cheap generation moved the crossover the wrong way</h2>

<p>Here’s the part that’s specific to now, and it’s why my own harness got harder to justify without changing a line.</p>

<p>Ceremony is a <em>human</em>-executed cost. Deciding what the thing is, writing the scope, reviewing what came back. It sits entirely in the bucket that <a href="/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html">didn’t get cheaper</a> — the reading-and-deciding bucket, the one that got <em>worse</em> because there’s more output competing for the same attention. Implementation sits in the bucket that collapsed.</p>

<p>Hold ceremony cost fixed at <em>C</em> and let build time <em>B</em> fall. Ceremony’s share of the task is <em>C / (C + B)</em>, which does this:</p>

<table>
  <thead>
    <tr>
      <th>Build time, as a multiple of ceremony</th>
      <th>Ceremony share</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10×</td>
      <td>9%</td>
    </tr>
    <tr>
      <td>1×</td>
      <td>50%</td>
    </tr>
    <tr>
      <td>¼×</td>
      <td>80%</td>
    </tr>
  </tbody>
</table>

<p>That table is arithmetic, not a measurement. I haven’t timed my own tickets, and I’d rather say so than dress an identity up as data.</p>

<p>The direction doesn’t need the data, though. Nothing in the last two years reduced the cost of deciding what a thing is and reviewing what came back; a great deal reduced the cost of building it. So every harness that was proportionate at the top row has been sliding down the table ever since.</p>

<p>Every row is the same harness. <strong>My process didn’t get heavier; the work got lighter, which is arithmetically the same thing.</strong> And there’s no event for it. No commit moves a design across a row, so nothing ever prompts the re-read.</p>

<p>And the collapse isn’t uniform across kinds of work. A feature still has a build phase long enough to keep ceremony’s share respectable. Editing a Markdown rule doesn’t — the build time for “add a rule to the rubric” is close to zero, so ceremony is essentially the entire cost of doing it properly. Anything whose deliverable is a paragraph sits at the bottom row of that table permanently.</p>

<p>I wrote that as a prediction and then went and checked it, which is the next section, and it’s the point where the post stopped being the one I set out to write.</p>

<h2 id="a-process-can-only-measure-the-work-that-agreed-to-be-measured">A process can only measure the work that agreed to be measured</h2>

<p>This is the line I’d keep from the whole post.</p>

<p>I review my tickets and my scoping artifacts and I conclude the harness is working. It is — on its sample. The sample is “tasks that cleared the crossover,” which is the definition of the tasks the ceremony was cheap relative to. Every metric drawn from a process’s own artifacts is computed over the subset that opted in.</p>

<p>That makes the record worse than no record, for the same reason <a href="/2026/07/29/when-a-view-becomes-a-record.html">a rendered view becomes dangerous once it’s committed</a>: people trust it. A tracker with nothing in it is obviously incomplete. A tracker with forty well-scoped tickets reads as the history of the project, and there’s no field in it for the eighty changes that didn’t come through.</p>

<p>You can’t detect this by asking, including asking yourself — the whole point is that the skipped work didn’t feel like skipping. You have to count from the side that can’t be avoided, which is the repository. Two queries, and I was confident the first one would be the informative one:</p>

<ol>
  <li><strong>Merged PRs with no ticket reference.</strong> The classic hole.</li>
  <li><strong>Mainline commits that never came from a PR at all.</strong></li>
</ol>

<h2 id="so-i-ran-them">So I ran them</h2>

<p>The window is 2026-07-11 to 2026-08-15 — the day after the harness skills first landed, to the day I’m writing this. 39 first-parent commits on <code class="language-plaintext highlighter-rouge">main</code>.</p>

<p><strong>The first query came back empty.</strong> 37 merged PRs, 36 of them naming a <code class="language-plaintext highlighter-rouge">COR-</code> ticket. The one exception is a turbo version pin. The metric I’d have led with detects nothing here, and that’s worth recording rather than quietly dropping, because <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">a null result is ambiguous</a> until you know <em>why</em> it’s null. It’s null because PR discipline isn’t where this process leaks.</p>

<p>The second query is where everything was.</p>

<table>
  <thead>
    <tr>
      <th>Mainline commits, 2026-07-11 → 2026-08-15</th>
      <th>Count</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Landed through a pull request</td>
      <td>14</td>
    </tr>
    <tr>
      <td>Landed straight on <code class="language-plaintext highlighter-rouge">main</code> — no PR, no ticket, no review</td>
      <td>25</td>
    </tr>
  </tbody>
</table>

<p>Then the split that actually matters, which is <em>what those commits changed</em>:</p>

<table>
  <thead>
    <tr>
      <th>Area</th>
      <th>Via PR</th>
      <th>Direct to <code class="language-plaintext highlighter-rouge">main</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Product code</td>
      <td>4</td>
      <td>0</td>
    </tr>
    <tr>
      <td>The harness itself — skills, rubrics, specs, ticket drafts</td>
      <td>5</td>
      <td>25</td>
    </tr>
  </tbody>
</table>

<p>Product code has a spotless record. Every feature went through the four phases or the hatch, every PR named its ticket, and not one line of application code has reached <code class="language-plaintext highlighter-rouge">main</code> unreviewed since the harness existed. The single product file in the direct column is a test file that rode along inside a harness commit. The gate works exactly as advertised.</p>

<p>Everything that went around it is the harness. Ten of those commits edit <code class="language-plaintext highlighter-rouge">.claude/skills/</code> — the files that <em>define what the phases are</em>. Three edit <code class="language-plaintext highlighter-rouge">docs/rubrics/</code>, including the commit that first added the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">testing rubrics</a> I’d just spent three posts on.</p>

<p><strong>The process governs the product completely, and does not govern itself.</strong></p>

<p>I had this backwards for the entire first draft. I assumed the leak was small work, and that the fix was a cheaper tier. Small work isn’t the category. <em>Rules</em> are the category.</p>

<h3 id="the-receipt">The receipt</h3>

<p>The sharpest one isn’t an addition. On 2026-08-09, direct to <code class="language-plaintext highlighter-rouge">main</code>:</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">chore(skills): drop inspection mode from corpus-work and corpus-ticket</code></p>

  <p>Standard/Exhaustive added a why-trail requirement at every hard gate, all of which fire before the PR is reviewable — ceremony that cost context without changing what got reviewed.</p>
</blockquote>

<p>That commit deletes a governance tier out of both skill files. It’s why the inspection dial I described in the harness posts isn’t in the list of tiers above: it’s gone. I still think the reasoning is correct — it’s the argument this whole post is making, in a commit body. But a change that <em>removes a review requirement</em> landed with no ticket, no PR, and no reviewer, and the justification I just quoted approvingly is one I wrote and nobody else has read until now.</p>

<p>Which is precisely the failure the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">hard-gate post</a> named and that I was most confident I’d avoid: <strong>a wrong rule amortizes a wrong standard.</strong> That post’s own closing move was that rules need the same gate the tests got. Five weeks of commits say they never got it — and not because I changed my mind. Because the only gate on offer was priced for a feature.</p>

<h2 id="the-fix-that-makes-it-worse">The fix that makes it worse</h2>

<p>The instinct on seeing that ratio is to close the hole. A commit hook that rejects a message with no ticket ID. Branch protection requiring a linked issue. Required fields that won’t let you save.</p>

<p>That converts avoidance into fake compliance, and fake compliance is strictly worse.</p>

<p>Pre-2023 it was at least <em>weakly</em> self-limiting: filling in eight sections of boilerplate to land a typo fix cost enough that most people would either do it properly or give up loudly. The cost was a bad proxy for care, but it was a proxy. That proxy is now gone. A model will produce a fluent, well-structured, entirely plausible eight-section ticket for a one-line change in about four seconds, and it will do it every time without ever getting annoyed enough to tell you the format is wrong.</p>

<p>Same shape as <a href="/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html">the beautiful ignore-comment reason</a>, one level up. So:</p>

<blockquote>
  <p><strong>Anything you mandate that a model can produce, you have mandated nothing.</strong> You’ve mandated the string, and the string was never the point.</p>
</blockquote>

<p>And you’ve paid for it by destroying the one signal you had. Bypass leaves a hole you can see. A mandate leaves a filled field you’ll believe — present, readable, well-formatted, and doing nothing, which is the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">inert-is-worse-than-broken</a> failure with a compliance rate attached. The metric goes to 100% and the information content goes to zero, and those two things happen in the same commit.</p>

<h2 id="who-executes-the-step-and-which-budget-does-it-draw-on">Who executes the step, and which budget does it draw on</h2>

<p>The useful audit question isn’t “is this ceremony valuable.” Everything in a template is valuable to someone. It’s:</p>

<table>
  <thead>
    <tr>
      <th>Executed by</th>
      <th>Marginal cost</th>
      <th>Real constraint</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A machine, on an event</td>
      <td>~zero</td>
      <td>correctness of the rule</td>
    </tr>
    <tr>
      <td>An agent, in-loop</td>
      <td>near zero</td>
      <td>the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">context tax</a> — re-read every run, forever</td>
    </tr>
    <tr>
      <td>A human, on their own initiative</td>
      <td>high</td>
      <td>attention, which is the scarce one</td>
    </tr>
  </tbody>
</table>

<p>Harnesses grow downward through that table. Adding a field to a human-facing template is the single easiest change to make and the only one that requires no implementation, so it’s where accumulation lands by default. Each addition is individually defensible and individually small. The sum of them is the crossover point, and no single commit ever moved it.</p>

<p>The corollary is more optimistic than it sounds: a lot of what’s in my human-facing ceremony is there because, when I wrote it, a human was the only thing that could execute it. That’s a stale assumption in most cases now. Moving a step from row three to row two isn’t a reduction in rigour, it’s a change of payer — and it’s the only kind of ceremony reduction that doesn’t cost you the ceremony’s function.</p>

<h2 id="make-the-tier-the-artifact">Make the tier the artifact</h2>

<p>The wrong response to “the step is too high” is to lower it uniformly. That just relocates the crossover and loses the rigour on the work that warranted it.</p>

<p>More steps is the right shape, and I want to be careful here, because I already had a second step and it didn’t work. So the shape isn’t the hard part. Tiers only work under one constraint, and it’s the one my own escape hatch misses:</p>

<p><strong>The cheapest sanctioned path has to be cheaper than bypassing it.</strong> If bypass is <code class="language-plaintext highlighter-rouge">git commit -m</code>, the small tier is one line in the commit message — not a short form, not a “lightweight” template with four fields. Anything heavier than the bypass is a tier that will never be used, and you’ve added documentation instead of a path.</p>

<p>Measured that way, my escape hatch isn’t a small tier at all. It’s the full entry cost with the phases removed, which prices it against the four-phase path rather than against <code class="language-plaintext highlighter-rouge">git commit</code> — <strong>a discount computed relative to the wrong alternative.</strong> That’s the mistake I’d expect to be common, because from inside a process the interesting variation is all at the top end. The tier a process actually needs is the one immediately above doing nothing, and it’s the one nobody designs.</p>

<p>Then borrow the move that makes the coverage gate honest — <a href="/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html">three exits, and the diff won’t merge until you’ve taken one</a>. The tier choice itself becomes the recorded decision. Not “did you follow the process,” which is unanswerable and, once mandated, dishonest — but “which mode did you pick,” which is one greppable word that someone can disagree with six weeks later. A wrong tier is reviewable. An absent process isn’t there to review.</p>

<p>The honest catch: tier boundaries drift downward. Everything becomes small, and you’ve built bypass with a permission slip. The countermeasure is that <strong>the distribution is the metric, not the compliance rate.</strong> My own numbers make the case: 36 of 37 PRs cited a ticket, which is a compliance rate of 97% and tells you nothing, while the distribution says two thirds of my commits never entered the system at all.</p>

<p>And the count says something about <em>which</em> tier to build, which I would have got wrong from the armchair. I’d have designed a tier for small changes, sorted by size. The work falling through isn’t sorted by size — it’s sorted by kind, and the kind is rule changes. A rubric line and a feature are not the same object and shouldn’t share a pipeline: one needs acceptance tests and a running-feature review, the other needs someone to argue with the rule before it starts applying itself to everything downstream. What a rule change actually wants is <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">the thing that made the rules trustworthy in the first place</a> — planting the violation and watching it fire — which is cheap, mechanical, and has nothing to do with four phases.</p>

<h2 id="the-ceremony-that-survived">The ceremony that survived</h2>

<p>Sorting my own harness by what I still actually do, the split isn’t by value. Some of what I skip is more valuable than some of what I keep. It’s by <em>who initiates</em>.</p>

<p>The things that survived — dependency-cruiser, the architecture test, types, lint, the coverage threshold, the phase PR that won’t let phase 2 start — all fire on their own, at an event, whether or not I remembered them. The things that eroded are the ones where the first step is me deciding to begin. That’s the same three-tier table from <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">the hard-gate post</a>, read from the failure side: prose written for humans “needs discipline,” and discipline turns out to be a budget I’ve spent by lunchtime.</p>

<p>So the rule I’d put next to the other ones:</p>

<blockquote>
  <p><strong>Ceremony you have to remember to perform will be performed exactly as long as the work is expensive enough to justify it, and not one task longer.</strong> The only durable ceremony is the kind that happens to you.</p>
</blockquote>

<p>Which reframes what <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">configuring a quality gate</a> is even for. I’d been treating “find a mechanism that applies this without anyone choosing to” as an anti-forgetting measure. It isn’t. It’s an anti-<em>economics</em> measure. Forgetting is random; opting out is systematic, it concentrates on one category, and it leaves a record that looks complete.</p>

<p>There’s a structural reason the harness ended up as the uncovered category, and it isn’t only price. Every gate I have is bound to an event in the product’s lifecycle — a commit, a push, a PR, a merge. Changing a rule isn’t an event in that lifecycle. It’s an event in the lifecycle of the thing that watches the lifecycle, and I never built a clock for that one. <strong>The layer that governs is the layer with no events in it</strong>, which is why it ends up ungoverned by default rather than by decision.</p>

<h2 id="limits">Limits</h2>

<p>I’m one person, and ceremony has a function for teams that it doesn’t have for me — consent, coordination, and giving people who weren’t in the room a way to object. Bypassing in a team also has a different cost: someone else finds out from the diff. Read the tiering advice above with that in mind; the crossover point moves when the record’s audience isn’t just you.</p>

<p>There are also contexts where the ceremony <em>is</em> the deliverable — regulated work, anything with an audit obligation — and “nobody reads it” isn’t a defect in the process, it’s the process functioning. None of this applies there.</p>

<p>The count is one repo, one person, five weeks, and 39 commits. It’s enough to falsify my guess about <em>which</em> work leaks — that part is unambiguous — and nowhere near enough to establish that rule changes are the leaking category anywhere but here.</p>

<p>And the obvious one: this is a post in which I describe my own avoidance and then explain why it was rational, which is precisely what someone being lazy would write. The check that would settle it is whether the un-ceremonied work caused problems the process would have caught. The one piece of evidence I have points the wrong way for me — the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">three consecutive commits that each reversed the rule in the commit before</a> were exactly this category, landing exactly this way. Three reversals is a small sample and I found them by reading, not by measuring, so I’d rather call it a warning than a result. What I can’t do is claim nothing went wrong, because I never had anything watching.</p>

<p>So the fix I’d defend is narrower than the essay and it’s specific: whatever governs your work needs a gate of its own, priced for a paragraph rather than a feature, fired by an event that exists in the rules’ lifecycle rather than the product’s. Everything above is the argument for why nobody builds that one — a process you can’t afford isn’t a lighter process, it’s a process you don’t have plus the belief that you do, and the belief is the expensive half. Mine cost me five weeks of unreviewed rules while I wrote three posts about how well the gates were working.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Everything I’ve written here for the last three weeks argues one direction: gate it, write the rule down, make the mechanism apply itself. This is the counterweight, and it’s the failure I actually hit rather than one I’m warning about hypothetically.]]></summary></entry><entry><title type="html">100% Coverage Was Always Right, Just Too Expensive</title><link href="https://germanvalencia.dev/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html" rel="alternate" type="text/html" title="100% Coverage Was Always Right, Just Too Expensive" /><published>2026-08-05T00:00:00+00:00</published><updated>2026-08-05T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive</id><content type="html" xml:base="https://germanvalencia.dev/2026/08/05/100-percent-coverage-was-always-right-just-too-expensive.html"><![CDATA[<p>Years before any of this, I worked on a project with a hard 100% coverage gate. Istanbul, thresholds at 100 across all four metrics, CI red at 99.4%. Exceptions were allowed, but only one way: an explicit ignore comment with a written reason next to it.</p>

<p>It worked. It found blind spots in our tests passively, week after week, in a way no review round ever did — not because we were disciplined, but because the tool didn’t need us to be. And it was tedious enough that I would not have recommended it to anyone.</p>

<p>The tedium had a specific shape, and naming it matters, because it’s the part that changed. The gate never left you wondering <em>what</em> to test — it printed the file and the line. The cost was that writing the fifteenth test for a <code class="language-plaintext highlighter-rouge">default:</code> branch nobody will ever hit costs exactly as much as writing an interesting one. Sustained tedium is how a standard dies: not repealed, just quietly lowered to 85 during a bad sprint.</p>

<p>Test code is now the cheapest thing in my pipeline. So the standard is worth another look, and I think it’s better now than it was then — for a reason that has nothing to do with generating tests faster.</p>

<h2 id="the-gate-the-model-cant-argue-with">The gate the model can’t argue with</h2>

<p>I keep coming back to the same want: assertions that hold without the AI’s cooperation. Coverage instrumentation is nearly the ideal shape of one. It’s a counter, not an opinion. It doesn’t read the diff, has no taste, cannot be persuaded, and has no view on whether this particular exception is reasonable given the deadline. It reports which lines executed. The line ran or it didn’t.</p>

<p>Compare that to the layers I actually rely on for semantics: a reviewer agent’s findings, a rubric applied by a model, a doc that gets followed. All useful; all model-judged. Coverage is arithmetic. That makes it a poor quality bar and an excellent blind-spot detector — a distinction I’ll come back to, because conflating the two is how 100% coverage earned its bad reputation.</p>

<h2 id="why-100-and-not-90">Why 100, and not 90</h2>

<p>The number isn’t ambition. 100 is the only threshold with no argument left inside it.</p>

<p>Any number below it has to be defended, and re-defended every time someone wants to land something. Nobody has ever won 85-versus-90 on merit; the winner is whoever is more tired. At 100 there’s no debate to have, and — more to the point — nothing for an agent to negotiate with either.</p>

<p>Below 100, global and per-file thresholds are two different tools with two different failure modes, because a well-tested core carries an untested module. At 100 that distinction collapses: 100% global <em>is</em> 100% per file. Vitest even has a shorthand for the case where it stops mattering.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">coverage</span><span class="p">:</span> <span class="p">{</span>
  <span class="nl">thresholds</span><span class="p">:</span> <span class="p">{</span> <span class="na">perFile</span><span class="p">:</span> <span class="p">{</span> <span class="mi">100</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And only at 100 is the check monotone in the direction you need. Any newly uncovered line fails, so the report is a diff. At 92, you can add a pile of uncovered code, stay at 92, and learn nothing.</p>

<p>Which is also why I’d skip the ratchet. Tools will offer to raise the threshold for you as coverage improves — <code class="language-plaintext highlighter-rouge">thresholds.autoUpdate</code> in vitest, the same idea by other names elsewhere. A ratchet beats a static 80. It’s also a gate whose position is set by whatever last landed, which means the bar is an artifact of your history rather than a decision. 100 is the only value a ratchet converges to that nobody had to pick.</p>

<h2 id="three-exits-and-you-choose-one-in-the-diff">Three exits, and you choose one in the diff</h2>

<p>The gate’s real effect isn’t that it forces tests. It’s that an uncovered line has exactly three exits, and the diff won’t merge until you’ve taken one.</p>

<table>
  <thead>
    <tr>
      <th>Exit</th>
      <th>What it means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Write the test</td>
      <td>the branch is real and nobody was looking at it</td>
    </tr>
    <tr>
      <td>Delete the code</td>
      <td>the branch is unreachable, so it was decoration</td>
    </tr>
    <tr>
      <td>Write the reason</td>
      <td>it’s real, not testable here, and now says so in the file</td>
    </tr>
  </tbody>
</table>

<p>The middle row is the one I underrated at the time. A hard gate <em>prices</em> defensive code, and a surprising amount of defensive code turns out not to be worth its price. Discovering that an <code class="language-plaintext highlighter-rouge">if (!x) throw</code> cannot be reached from any caller is a design finding, and it arrives as a coverage failure rather than as someone’s opinion in review.</p>

<p>The third row is the whole design. Without it, a 100% gate is a lie factory — people write tests that execute lines for the sake of the number, and the gate reports green over a suite that asserts nothing. With it, the number stays honest and the exceptions become a short, greppable list you can actually read.</p>

<h2 id="the-escape-hatch-is-where-all-the-judgment-went">The escape hatch is where all the judgment went</h2>

<p>The hatch is a comment: <code class="language-plaintext highlighter-rouge">/* istanbul ignore next */</code>, and its siblings for <code class="language-plaintext highlighter-rouge">if</code>, <code class="language-plaintext highlighter-rouge">else</code>, and <code class="language-plaintext highlighter-rouge">file</code>. Istanbul tolerates trailing text, so the convention is to attach the reason to the hint itself:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* istanbul ignore next -- process.exit(); the test runner would die with it */</span>
</code></pre></div></div>

<p>Istanbul does not care whether you wrote one. So the policy that makes this design work is not a coverage setting at all: the coverage tool enforces the number, and something else has to enforce that the reason exists. That split is the same shape as <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">the config block that did nothing</a>: a bare ignore and a justified ignore look equally deliberate in a diff, and only a second mechanism tells them apart.</p>

<p>The first rule I’d carry over unchanged: <strong>a bare ignore fails the build</strong> — it’s an unexplained hole in the number, with no record of who decided it was fine.</p>

<p>The second one I had as <em><code class="language-plaintext highlighter-rouge">istanbul ignore file</code> is banned outright</em>, and that’s too strong. Debug harnesses, CI fixtures, and modules whose only job is to be driven by an integration test are all real, and demanding unit coverage of them manufactures exactly the assertion-free tests this gate is supposed to catch. A file that is covered somewhere else is not the same object as a file nobody tested.</p>

<p>What I’d defend instead is narrower, and it’s about <em>where the decision lives</em>: <strong>a whole-file exemption is the only exception whose scope grows by itself.</strong> Every other ignore covers a fixed branch. This one covers code nobody has written yet — including the module that quietly grows inside the fixture six months from now. So the rule isn’t “never”; it’s that the exemption has to be visible from outside the file it exempts, and it has to say where the coverage actually comes from. Three moves, in the order I’d try them:</p>

<ol>
  <li><strong>Merge the coverage.</strong> If an integration suite genuinely exercises the file, the honest fix is to make the number say so rather than to carve out an exception — blob reports plus <code class="language-plaintext highlighter-rouge">vitest run --merge-reports</code>, or <code class="language-plaintext highlighter-rouge">nyc merge</code> on the istanbul CLI. No exemption at all, and the day the integration test stops touching that file, the gate tells you. That’s the one option where the claim “it’s covered by the integration test” stays <em>true</em> rather than becoming folklore.</li>
  <li><strong>Exempt in config, not in the file.</strong> <code class="language-plaintext highlighter-rouge">coverage.exclude</code> for a fixture, or a per-glob threshold when you want a lower bar rather than none: <code class="language-plaintext highlighter-rouge">'src/fixtures/**': { lines: 0 }</code>. One list, in one place, in the diff — the same enumerate-don’t-pattern-match move that keeps <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">structural rules from dying</a>. Keep the glob tight enough that a new module can’t drift into it by accident, because a broad exclude is the failure mode wearing a policy’s clothes.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ignore file</code> with a reason naming the covering test</strong>, when neither of the above is worth the work. The check below treats it like any other hint — a reason is required, nothing more — which is the permissive reading. Rejecting it outright is defensible too. The part that isn’t optional is that <em>something</em> records why.</li>
</ol>

<p>The distinction that survives all three: an exemption is fine, an <em>invisible</em> exemption isn’t. A comment at the top of a file is the least visible place to put a policy, which is the whole objection — not the exemption itself.</p>

<h3 id="what-actually-enforces-it">What actually enforces it</h3>

<p>A grep, in CI. That’s the whole mechanism:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># fail on any coverage hint that doesn't carry a reason</span>
<span class="k">if </span><span class="nb">grep</span> <span class="nt">-rPno</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.ts'</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.tsx'</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.js'</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.jsx'</span> <span class="se">\</span>
     <span class="s1">'(?:istanbul|c8)\s+ignore\s+(?:next|if|else|file)\b(?!\s+--\s+\w)'</span> src<span class="p">;</span> <span class="k">then
  </span><span class="nb">exit </span>1
<span class="k">fi</span>
</code></pre></div></div>

<p>Two things about it are policy rather than plumbing. <strong>The reason has to sit on the same line as the hint</strong> — partly because a check that looks for a hint and for dashes as two separate facts can be satisfied by unrelated code on the same line, and partly because a reason short enough to fit on the line is a reason that stays scannable. And it’s restricted to source extensions, or else the document describing the policy fails the policy, which is how a check gets deleted.</p>

<p><strong>Wrap it in <code class="language-plaintext highlighter-rouge">if ...; then exit 1; fi</code>, never <code class="language-plaintext highlighter-rouge">grep | grep -v</code>.</strong> An inverted pipeline exits 1 when nothing matches, which is the <em>success</em> case. Get the polarity backwards and you ship a check that’s either permanently red or permanently green, and permanently green looks exactly like working.</p>

<p>That’s run, not sketched — a fixture of eleven awkward comments, including hints sharing a line with code, dashes with nothing after them, and reasons split across lines. It fails the seven that should fail.</p>

<p>One thing to get right rather than infer: <strong>the pattern has to recognise everything istanbul recognises.</strong> Istanbul anchors the directive at the start of the comment and does nothing with whatever follows, which is exactly why <code class="language-plaintext highlighter-rouge">-- reason</code> is a convention and not a feature. Anything narrower than istanbul leaves a comment that suppresses coverage without tripping the check; anything broader is merely noisy. Reading the two patterns won’t tell you which you’ve built — plant a bare ignore and watch CI go red, then plant a justified one and confirm coverage really does stop counting that line.</p>

<p>ESLint can carry the same rule if you want it at edit time rather than at push time, but not as a config line: comments aren’t AST nodes, so no core rule reaches them and you end up writing the rule yourself. I’d still start with the grep. One line, one place, no parser, no config resolution, and it sees every file type rather than only the ones your lint config parses. The editor loop is the only thing the other version buys.</p>

<p>Worth naming the layer above both, since neither can do it: checking that a reason <em>exists</em> is deterministic, but judging whether the reason is any good is a model-judged CI step. Different tier, and a different post.</p>

<p>Then there’s the part that’s genuinely new since the old project: a model will write you a <em>beautiful</em> reason. Fluent, plausible, well-formatted, and produced in the same second as the ignore hint it justifies. The gate is deterministic; the hatch is prose, and prose on demand is precisely the thing on the other side of the table.</p>

<p>So the sentence I’d put on the wall: <strong>a deterministic gate with a prose escape hatch relocates all of the judgment into the escape hatch.</strong> Which is fine — as long as you know that’s where it went, and you read it. The consolation is that it’s a bounded amount of reading, in one grep, and the count going up is itself the signal. Vague coverage anxiety becomes a specific list of lines someone argued their way out of.</p>

<h2 id="what-cheap-code-generation-actually-bought">What cheap code generation actually bought</h2>

<p>Not what I expected. The tedium was in writing the boring tests, and yes, that’s now nearly free — the same inversion as <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">noticing being the job while fixing is free</a>. The gate stops trading iteration speed for blind-spot detection, which was the trade that made it unrecommendable.</p>

<p>But the cost didn’t vanish; it moved, and it moved toward the scarcer resource. A 100% gate generates a <em>lot</em> of test code, and every line of it lands in a diff I’m supposed to review. Volume is the raw material of a <a href="/2026/07/26/steering-the-ai-without-steering-yourself-into-a-rubber-stamp.html">rubber stamp</a>. Iteration speed I had to spare; review attention I don’t.</p>

<p>That’s the honest version of “AI compensates for the tedium.” It converts a throughput cost into an attention cost. Worth it — but only if something other than my attention is checking whether those tests mean anything. Which is the ceiling.</p>

<h2 id="coverage-measures-execution-not-assertion">Coverage measures execution, not assertion</h2>

<p>Istanbul counts which lines ran. It has nothing to say about whether anything was checked.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">it</span><span class="p">(</span><span class="dl">"</span><span class="s2">formats the record</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">format</span><span class="p">(</span><span class="nx">record</span><span class="p">);</span> <span class="c1">// 100% of format(), zero claims about it</span>
<span class="p">});</span>
</code></pre></div></div>

<p>That test takes a function to full coverage and asserts nothing at all. It’s the exact failure that <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">rubric question</a> exists for — <em>would this test fail if the behaviour it names were wrong?</em> — and coverage is structurally incapable of asking it. That question also has a name and a tool, which I’ll come back to in a moment.</p>

<p>Which sharpens the claim I started with. The gate an agent can’t weasel out of is one-dimensional: it must make the line execute, and no amount of arguing changes the counter. It can weasel completely on whether executing proved anything, and a green coverage threshold is a direct incentive to do exactly that. <strong>100% coverage without an assertion standard is a machine for manufacturing tests that run code.</strong></p>

<p>Paired, though, they cover each other’s blind spot precisely: coverage is deterministic about <em>where nobody looked</em>, the rubric is judgmental about <em>whether looking meant anything</em>. Neither substitutes for the other, and I’d be nervous shipping the first without the second now in a way I wasn’t years ago, because back then nothing was generating tests at volume.</p>

<p>The name is <strong>mutation testing</strong>, and it’s the honest next rung: Stryker and friends perturb the code and check that the suite notices, which is <em>would this test fail if the behaviour were wrong</em> asked mechanically rather than by a reviewer. So the rubric is the hand-rolled approximation of a check that already exists as a tool — which is a slightly deflating thing to discover, and also the best argument for eventually running the tool.</p>

<p>I haven’t. It costs a multiple of your suite runtime on every run, and cheap code generation does nothing about that: it’s compute, not typing. Which makes it nightly rather than per-PR, and a thing I want to try properly rather than assert about here. I name it because the temptation, having found one deterministic gate, is to assume it covers more than it does.</p>

<h2 id="in-a-tests-first-cycle-the-gate-grades-the-plan">In a tests-first cycle, the gate grades the plan</h2>

<p>There’s a second reading of the same number, and it only exists if the ordering is enforced rather than intended. My harness <a href="/2026/07/24/agentic-harness-styles.html">runs one phase per pull request</a>: Phase 1 writes tests, red by design; Phase 2 makes them green. So picture Phase 2 landing with every test passing and coverage at 96%. Nothing is broken. Every assertion anyone wrote is satisfied. And yet there are lines in the implementation that no test written the day before anticipated.</p>

<p>That isn’t a missing test. It’s a measurement of the distance between the plan and the build, and it has exactly two explanations:</p>

<ul>
  <li><strong>The implementation built something nobody asked for.</strong> An extra branch, a defensive path, a case the ticket never mentioned. The plan was fine; the code is over-specified, and the fix is a deletion.</li>
  <li><strong>The plan asked for it and the tests-phase rubric didn’t produce a test for it.</strong> The code is right; the rubric has a hole, and that hole recurs on every ticket until someone writes it down.</li>
</ul>

<p>Both are findings about <em>upstream</em> artifacts, which is what makes this worth more than the raw percentage. Every other check I run grades the diff. This one grades the ticket — and it grades it automatically, on every cycle, without anyone deciding to ask whether the planning was thorough. That question is normally unanswerable except retroactively, after the bug.</p>

<p>Which is why the <em>direction</em> of the repair matters more than the number does. The tempting fix — write a test for the uncovered line — is the one move that’s almost always wrong here, because a test authored after the implementation in order to satisfy a threshold is derived from the code rather than from the requirement. That’s the <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">fabricated premise</a> in its purest form: it will pass forever, and what it asserts is that the code does what the code does. In a tests-first cycle the legitimate paths back to 100% are deleting the code or amending the plan and re-running the tests phase. Topping up coverage at the end quietly converts an implementation detail into a requirement, and the number goes green while the signal is destroyed.</p>

<p>So the three exits from earlier acquire an order in this setting. Delete first. Amend the plan second. The written reason last, and only when the first two genuinely don’t apply.</p>

<p>And it closes the loop on what makes these gates worth paying for at all: the deliverable of an expensive gate is a rule that makes the next one cheap. A coverage failure diagnosed as “the rubric never asks for an error-path test on external calls” produces a rubric line, and the next ticket’s tests phase writes that test before any implementation exists. The number stays at 100 because the plan got better, not because someone chased it.</p>

<p>The honest limit is the same one that makes red-by-design work: this reading requires that the tests came first <em>and</em> that you can prove it. Where the ordering is a convention rather than a gate, “the test anticipated the code” and “the test was written to cover the code” are indistinguishable after the fact, and a coverage gap stops being evidence about the plan.</p>

<p>Once you’re in that position, fabricating the premise is close to unavoidable — the only test available to you is one derived from code that already exists. Which is why the durable response to an uncovered line is not the test. It’s <strong>the rubric</strong>: write down the rule that would have made the tests phase anticipate that code, and the next cycle starts from a stronger spec regardless of how honest this one’s ordering was. A test written after the fact fixes one line and teaches nothing. A rubric line changes what gets written <em>before</em> the implementation exists, which is the only place the fix can actually land.</p>

<h2 id="the-backlog-of-things-we-knew-were-right">The backlog of things we knew were right</h2>

<p>The coverage gate is one instance of a larger category, and the category is the interesting part: <strong>practices that were abandoned on labour cost, not on merit.</strong> Not the ideas we tried and disliked — the ones we agreed were correct, scheduled, and then never got to, because there were always more tasks than people. Every team I’ve been on has that list, and nobody writes it down, because a backlog item whose only blocker is “this is a lot of typing nobody will fund” doesn’t feel like a decision. It feels like weather.</p>

<p>Cheap code generation is a re-litigation of that entire list. Which is more interesting than it being a faster way to do the things we already do, and it deserves a filter rather than enthusiasm, because only part of the list actually comes back.</p>

<p>The filter is the same one that just showed up twice above: <strong>ask what the practice was actually paying for.</strong></p>

<ul>
  <li>Cost was <em>writing code</em> — the fifteenth <code class="language-plaintext highlighter-rouge">default:</code> branch test, the fixture that takes an afternoon, the error path nobody wanted to simulate, the property-based generators, the doc for the second consumer of an interface. These are now nearly free, and every one of them is a candidate to reopen.</li>
  <li>Cost was <em>reading code</em> — a second reviewer on every PR, a design doc per ticket, manual QA passes. These got <em>worse</em>, not better, because there’s now more output competing for the same attention. Volume is the constraint, and generation adds to it.</li>
  <li>Cost was <em>compute or wall-clock</em> — mutation testing, exhaustive fuzzing, full-matrix CI. Unchanged. Nothing about cheap generation makes your test suite finish faster.</li>
</ul>

<p>Three buckets, and the whole win lives in the first one. So the question worth asking of an old abandoned standard isn’t “could an agent do this now” — it’s “was the thing that killed it typing?” If yes, it’s probably back on the table at close to zero cost. If it died because someone had to <em>read</em> the output, cheap generation has moved it further out of reach, not closer.</p>

<p>100% coverage happens to sit almost perfectly in the first bucket, which is why it’s the one I reached for first. That’s also the reason it isn’t a general endorsement: the same reasoning that revives it rules out most of what’s next to it on the list.</p>

<h2 id="the-setup">The setup</h2>

<ul>
  <li>Thresholds at 100 on statements, branches, functions, and lines. At 100 the per-file question answers itself.</li>
  <li><code class="language-plaintext highlighter-rouge">/* istanbul ignore &lt;hint&gt; -- reason */</code> as the only exception, plus a separate check that rejects a hint with no reason.</li>
  <li>Whole-file exemptions in the coverage config where they’re visible, not as a comment inside the exempted file — and merged coverage instead, wherever an integration suite already covers it.</li>
  <li>Then plant an uncovered line, watch CI go red, and remove it — because a threshold nobody has <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">seen fire</a> is indistinguishable from a threshold set to zero, and the coverage config that silently excludes half the source tree is a very common object.</li>
</ul>

<p>The gate’s output was never really a number. It’s a list of places nobody looked, regenerated on every push by something with no opinions and no stake in the deadline. That part was always worth having. What was missing was any way to act on the list cheaply, and that’s the part that arrived.</p>

<p>Which is why I’d rather people take the filter than the gate. The gate is one revived standard and it might not be yours. The filter is the reusable move: go find the practice your team agreed was correct and dropped anyway, and check whether what killed it was typing.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Years before any of this, I worked on a project with a hard 100% coverage gate. Istanbul, thresholds at 100 across all four metrics, CI red at 99.4%. Exceptions were allowed, but only one way: an explicit ignore comment with a written reason next to it.]]></summary></entry><entry><title type="html">The Tests Written Without the Rubric Are Its Test Set</title><link href="https://germanvalencia.dev/2026/07/30/the-tests-written-without-the-rubric-are-its-test-set.html" rel="alternate" type="text/html" title="The Tests Written Without the Rubric Are Its Test Set" /><published>2026-07-30T00:00:00+00:00</published><updated>2026-07-30T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/30/the-tests-written-without-the-rubric-are-its-test-set</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/30/the-tests-written-without-the-rubric-are-its-test-set.html"><![CDATA[<p>This is the third thing I’ve written about the same two Markdown files. The first was about earning them: seven review rounds that produced <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">nine rules for what makes a test any good</a>, none of which I could have authored up front. The second was about discovering that <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">nothing in the repo would ever apply them</a>, and the four plausible mechanisms I tried before finding one that worked on a fresh clone.</p>

<p>This one is about the question I hadn’t asked either time: <strong>how would I know the rules are any good?</strong></p>

<p>It came up sideways. I was mid-review on an unrelated PR, found a mock I didn’t like, and went looking for whether the rubrics already covered it. They didn’t. So I drafted new rules, checked whether a ticket already owned that work, and found the one that owns the delivery mechanism — with a priority rationale I’d written myself a day earlier:</p>

<blockquote>
  <p>the payoff window is the next test-writing pass; a rubric that lands a pass late gets judged by the tests written without it.</p>
</blockquote>

<p>I quoted that back as an argument for urgency. The pass in question was the one I was standing in. The rubric was about to miss it. Sunk cost, get moving.</p>

<p>The reply I got was one sentence: <em>if we judge the rubric on known-bad items, that’s a good regression test.</em></p>

<p>That inverts the whole thing. The tests written without the rubric aren’t evidence the rubric arrived late. They’re <strong>labelled data</strong>. Lateness isn’t the cost — it’s the only reason there’s anything to score the rubric against.</p>

<h2 id="rule-9-aimed-one-level-up">Rule 9, aimed one level up</h2>

<p>The rubric already contains the discipline that validates it. Rule 9, verbatim:</p>

<blockquote>
  <p><strong>Prove the test can fail before trusting it.</strong> Plant the violation, confirm the failure names it, remove it, confirm green. A check nobody has seen fail is not known to work.</p>
</blockquote>

<p>Its stated trigger is “always for a new lint or dependency-cruiser rule; whenever an assertion has only ever been observed green.” It points outward, at machinery. Turn it around and it reads: <strong>a rubric nobody has seen catch anything is not known to work.</strong></p>

<p>That’s not a cute symmetry. It’s the same failure mode I’d just spent a session on in a different costume. A dead config block and a live one look identical in the diff; a rule that catches real defects and a rule that catches nothing look identical in the Markdown. Both are indistinguishable by reading, and both stay indistinguishable until something travels end to end.</p>

<p>The difference is that a config block needs a sentinel I have to invent. A rubric’s sentinels already exist — I’ve been generating them for weeks and throwing them away.</p>

<h2 id="the-corpus-is-mostly-transcription--this-time">The corpus is mostly transcription — this time</h2>

<p>Here’s what made me take this seriously rather than file it as a nice-to-have.</p>

<p>The test file with the mock I objected to carries a header documenting two <em>rejected earlier revisions of itself</em>: first a hand-rolled <code class="language-plaintext highlighter-rouge">as unknown as Context</code> fake, then an app whose routes were <code class="language-plaintext highlighter-rouge">() =&gt; { throw x }</code>. Both were reviewed, argued about, and replaced. And <code class="language-plaintext highlighter-rouge">IntegrationTesting.md</code> Rule 3 — “use a real route table, not an inline throwing app” — was plainly induced from the second one. Same story for the unit rubric’s Rule 1, whose bad example is a hand-constructed <code class="language-plaintext highlighter-rouge">PrismaClientKnownRequestError</code> with an assumed <code class="language-plaintext highlighter-rouge">P2002</code> code, which is a real thing someone tried.</p>

<p>So the specimens are sitting in the tree, with recorded verdicts, next to the rules they produced. Building the corpus is largely harvesting.</p>

<p>That’s worth stating as a general property: <strong>a review process that writes down <em>why</em> it rejected something is already producing labelled data, and most of us discard it.</strong> The rejection lives in a PR comment thread, or a commit that got amended away, or — if you’re lucky and slightly obsessive — a comment block at the top of the file. A rubric extracted from those incidents is a compression of the corpus. Keeping the corpus means you can check the compression.</p>

<p>And then the counter-point that undercuts my own optimism, which I’d rather state than dodge: <strong>none of that debris is guaranteed to exist any more, and agents make it scarcer, not more plentiful.</strong> Nothing about that file header was inevitable. The default shape of an agentic correction is silent repair — I say <em>fix the mock</em>, it gets fixed inside the turn, and the rejected revision doesn’t reach a comment thread or even an amended commit, because it was never a commit. Human review is sloppy and leaves debris everywhere. An agent handed a correction leaves none unless something obliges it to.</p>

<p>So the velocity that produces specimens faster also destroys their labels faster, and the two effects don’t cancel — you end up with more defects fixed and less evidence about which rule would have caught them. The specimens I just called free exist only because a habit older than the agent happened to catch them on the way past.</p>

<p>Which flips the conclusion rather than weakening it. If the corpus is what makes a rubric checkable, recording the verdict cannot be a byproduct I hope to harvest later; it has to be something the review step is obliged to emit, for the same reason the rubric needed a delivery mechanism instead of my memory. Otherwise the eval set is whatever survived by accident — and “whatever survived by accident” is a description of a biased sample, not a test set. The specimens that get documented are the ones someone found interesting enough to write up, which is exactly the correlation you don’t want between your corpus and your existing rules.</p>

<h2 id="two-failures-that-look-identical-from-outside">Two failures that look identical from outside</h2>

<p>The immediate payoff isn’t scoring. It’s a distinction I’d been collapsing.</p>

<p>When a defect gets past a rubric, exactly one of two things happened:</p>

<p><strong>Coverage gap</strong> — no rule covers it. The rubric had nothing to say. My <code class="language-plaintext highlighter-rouge">jest.mock("@corpus/database")</code> is this one, and I checked carefully: nothing in either document addresses stubbing a <em>first-party</em> package, and Rule 1 forbids <em>constructing</em> a dependency’s error class while saying nothing about <em>substituting</em> the class object. Worse, the unit rubric’s <code class="language-plaintext highlighter-rouge">infrastructure</code> section says “Prisma’s own class/code branches are <strong>not</strong> hermetic — Rule 1,” which is correct and which is probably what made the mock feel sanctioned. The rubric told the author not to test those branches hermetically; nothing told them that stubbing the classes wholesale to reach the <em>other</em> branches drains every <code class="language-plaintext highlighter-rouge">instanceof</code> of meaning. The gap isn’t an absent tenth rule, it’s a sentence that opens a door and doesn’t close it.</p>

<p><strong>Compliance gap</strong> — a rule existed and wasn’t applied. This is the category the delivery mechanism addresses, and the one I’d already measured: a repo-wide grep for <code class="language-plaintext highlighter-rouge">rubrics</code> returned zero hits outside the rubrics’ own directory. Nothing referenced them. Any rule in there could only fire if someone happened to name the path.</p>

<p>These want opposite fixes. A coverage gap needs <em>content</em> — a new rule, or an edit to the sentence that misled. A compliance gap needs <em>delivery</em> — a hook, an auto-loaded file, something that puts the existing rule in front of the writer. And the failure is symmetric: <strong>conflating them is how you ship a hook when you needed a rule, or a rule when you needed a hook.</strong> I have now done both. The hook I built delivers a rubric that would have said nothing about the specific defect that motivated me to build it; the rules I drafted would have sat in a document that nothing loaded.</p>

<p>You cannot tell the two apart by looking at the defect. You can only tell by asking, of a specimen with a known verdict: was there a rule, and did the mechanism deliver it? That’s a two-cell answer per specimen, and it’s the whole diagnostic.</p>

<h2 id="its-scorable-which-surprised-me">It’s scorable, which surprised me</h2>

<p>I expected “evaluate the rubric” to bottom out in taste. It doesn’t, and the reason is that the output format already exists.</p>

<p>The one mechanism that worked out of the box was the external review step finding the rubric unaided. What it produced, when I probed it on a stripped-down repo:</p>

<blockquote>
  <p>findings citing Rules 3 and 4 <strong>by name</strong>, naming the exact <code class="language-plaintext highlighter-rouge">toBeGreaterThan(0)</code> trigger in the diff, and quoting the rubric’s own headline test</p>
</blockquote>

<p>That is eval output. For each specimen, the question is mechanical: <strong>did the reviewer cite the rule this specimen is labelled with?</strong> Not “did it seem to review thoughtfully.” Precision and recall over a labelled corpus.</p>

<p>What makes that work is a property of the rubric I’d filed under style. Every rule carries a mechanical trigger — <em>“always for a new lint or dependency-cruiser rule; whenever an assertion has only ever been observed green”</em> — and the document says so in its own header: check the rule against the code you are about to write, not against your intent. I’d read that as good technical writing. It’s actually the precondition for labelling anything. You cannot mark a specimen as violating <em>write clear tests</em>, so you cannot score that rule, so you can never discover it isn’t working. <strong>A rule without a trigger isn’t merely weak, it’s unfalsifiable</strong> — and a rubric made of them stays unmeasurable no matter how good the corpus is.</p>

<p>Which forces one addition the session didn’t get to. A corpus of only known-bad items measures recall — how many real defects the rubric catches. It says nothing about precision, and a rule that fires on everything scores a perfect recall. <strong>You need clean specimens too</strong>: tests that were reviewed and accepted, labelled “no finding.” Without them there’s no penalty for a rule that flags every mock in the repo, and vague rules are exactly the ones that drift that way. The accepted revisions are as much data as the rejected ones, and they’re in the same file headers.</p>

<p>Once it’s precision and recall over a labelled set, the obvious question is whether the existing LLM-eval harnesses just do this — a diff is text, a finding is text, and scoring predictions against labels is what those tools are for. I suspect the friction is that the unit under evaluation is a repository state rather than a prompt, which may want a thin adapter or may want something purpose-built. I haven’t tried it, so that’s a separate post rather than a claim here.</p>

<h2 id="the-trap-stated-plainly">The trap, stated plainly</h2>

<p>The specimens a rule was induced from are its training set.</p>

<p>A corpus built only from them tells you the rules encode their own originating cases, which you already knew, because you wrote them from those cases. It measures nothing about generalisation. And I’d flagged the overfitting risk in the first post — “mock returns must be distinguishable from their inputs” generalises, “prefix staged keys with <code class="language-plaintext highlighter-rouge">uploaded-</code>” does not — without noticing that the risk was measurable rather than merely acknowledgeable.</p>

<p>So the corpus needs held-out items: defects found <em>after</em> the rubric was written.</p>

<p>I have exactly one. The mock survived review, shipped into an open PR, and the rubric was sitting in the tree the entire time. Nobody was prevented from applying it — it just wasn’t. And the verdict on it came from a human read during review, which is what makes it a label rather than just a diff.</p>

<p>One held-out specimen, and it’s a coverage gap. Data point one, in the actionable category.</p>

<h2 id="which-reorders-the-work">Which reorders the work</h2>

<p>The practical consequence is that the corpus is a better first ticket than the new rules it was supposed to justify.</p>

<p>I had drafted two content rules — <em>never stub a value whose identity is the contract</em>, and <em>identity that crosses a module boundary gets a pinning test in the tier CI actually runs</em>. Both feel right. Neither is demonstrably necessary. With a corpus, the argument changes shape entirely: instead of asserting the new rule would have caught this, I can show that the current rubric provably misses a specimen and the new rule catches it. That’s the difference between a rule I’m confident about and a rule with a receipt.</p>

<p>There’s a second thing the scores would settle that I’d been deciding by instinct. The rules are not equally mechanizable. <em>A mock that fixes a load failure is an import-graph bug</em> has a tell precise enough to lint for — the suite failed to <strong>load</strong>, not to assert — whereas <em>never stub a value whose identity is the contract</em> cannot be machine-checked in the general case. The best that one reduces to is a question a reviewer has to actually ask: <em>if this stub were the wrong object, would any assertion in this file fail?</em> If the answer is no, the test agrees with itself. I’d been sorting rules onto <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">the enforcement ladder</a> by feel. A score sorts them by evidence: a rule the reviewer reliably cites is fine left as prose, and a rule it keeps missing is either badly written or needs to stop being prose.</p>

<p>It also, usefully, stays out of the way. The delivery ticket’s scope explicitly excludes rubric <em>content</em> and machine-enforcement — it changes only what loads the rubrics. Validating a rubric is neither of those. Four ticket-sized pieces that don’t overlap: does the standard exist, does anything apply it, does anything record the verdict when it’s applied, and does applying it catch anything. I had been treating the third as free.</p>

<h2 id="so-when-should-you-introduce-a-rubric">So when should you introduce a rubric?</h2>

<p>The answer I started the session with was “as early as possible, and the window is closing.” That’s not wrong, but it’s answering a different question than the one that matters.</p>

<p>You write the rules when you have incidents, because — as far as I can tell — you cannot author them up front; every line I have was earned by watching a specific assertion fail to distinguish a specific pair of behaviours. You deliver them when a mechanism exists that doesn’t depend on anyone remembering. But you can only <strong>know</strong> they work after a pass has run without them, because that pass is what produces the labelled specimens.</p>

<p>Which means the uncomfortable framing is the correct one: a rubric introduced before there’s work to judge it against isn’t early, it’s <em>unmeasured</em>. Lateness isn’t the failure mode. Lateness is the eval set arriving.</p>

<p>Honest about where this stands: none of it is built. The corpus is a design and a ticket I hadn’t filed when the session ended, with one held-out specimen in it, and the reviewer behaviour I want to score was observed three times out of three, which is a signal and not a guarantee. The interesting part isn’t the artifact. It’s that “is this rubric good?” turned out not to be a matter of taste, and that the thing making it answerable was the pass I’d been treating as a missed opportunity.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This is the third thing I’ve written about the same two Markdown files. The first was about earning them: seven review rounds that produced nine rules for what makes a test any good, none of which I could have authored up front. The second was about discovering that nothing in the repo would ever apply them, and the four plausible mechanisms I tried before finding one that worked on a fresh clone.]]></summary></entry><entry><title type="html">A Hard Gate Is Expensive Exactly Once</title><link href="https://germanvalencia.dev/2026/07/29/a-hard-gate-is-expensive-exactly-once.html" rel="alternate" type="text/html" title="A Hard Gate Is Expensive Exactly Once" /><published>2026-07-29T00:00:00+00:00</published><updated>2026-07-29T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/29/a-hard-gate-is-expensive-exactly-once</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/29/a-hard-gate-is-expensive-exactly-once.html"><![CDATA[<p>I built a feature this week without opening an editor. A terminal and GitHub’s PR review UI, nothing else. It was liberating, and I want to be careful about how I say that, because “I don’t read the code in an editor” sounds like negligence.</p>

<p>It isn’t, and the reason is a division of labour that took a whole session to see clearly. Structure is checked by dependency-cruiser and an architecture test. Types and lint are checked by their own tools. What’s left for a human is <em>semantics</em>: does this assertion distinguish the two behaviours it needs to, is this the copy a user should actually see, is a bare <code class="language-plaintext highlighter-rouge">ZodError</code> a 400 or a 500. That residue is small enough to read in a PR diff — and it’s the only part where my judgment was ever the bottleneck anyway.</p>

<p>But the residue is only small because a lot of it got moved. This is the story of moving it, which cost seven review rounds, and of why I think that’s the correct price to have paid exactly once.</p>

<h2 id="the-gate-you-cant-skip">The gate you can’t skip</h2>

<p>My build harness runs one phase per invocation, each ending in a pull request I have to merge before the next phase can start — the gated style I described in <a href="/2026/07/24/agentic-harness-styles.html">Two Styles of Agentic SDLC Harness</a>. Phase 1 writes tests. Phase 2 implements them. That ordering isn’t a convention I’m trying to live up to; it’s the shape of the tool.</p>

<p>Which quietly solves the thing TDD always lost on. TDD never failed on merit — it failed because nothing <em>stops</em> you from writing the tests afterward, so under deadline you don’t. Here there’s nothing to resist. The agent has no ego about test-first, and it cannot peek at an implementation that doesn’t exist yet.</p>

<p>Better, the Phase 1 PR is <em>supposed</em> to be red. Mine opened at 25 failing and 44 green, with a single cause: <code class="language-plaintext highlighter-rouge">not implemented yet</code>. A green tests-phase PR would have meant the new assertions asserted nothing new. Red-by-design is the receipt — you can’t fake the ordering.</p>

<p>The gate also gives me somewhere to stand. Over seven rounds I reversed five of my own earlier decisions and the agent retracted four of its claims. None of that survives a fluid harness, where there’s no natural moment to say “not yet” without feeling like you’re obstructing your own tooling.</p>

<h2 id="the-ladder">The ladder</h2>

<p>Everything below came out of reading assertions in a diff. No tool found any of it.</p>

<p><strong>A mock that echoed its input.</strong> Cleanup was asserted with <code class="language-plaintext highlighter-rouge">toHaveBeenCalledTimes(2)</code>. The storage mock echoed back the key it was handed — exactly like the real gateway does — so two genuinely different implementations produced identical calls: using the key that staging <em>returned</em>, versus re-deriving one locally from <code class="language-plaintext highlighter-rouge">randomUUID()</code>. The second leaves real orphaned files in storage forever. The count passed either way. Making the mock return <code class="language-plaintext highlighter-rouge">uploaded-${key}</code> and asserting that prefix separated them.</p>

<p><strong>The same test again, harder.</strong> A prefix only proves the deleted keys were staged keys, not <em>which</em> ones. So: mock <code class="language-plaintext highlighter-rouge">randomUUID</code> to a counter, make every key predictable, assert literally.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">expect</span><span class="p">(</span><span class="nx">deletedKeys</span><span class="p">()).</span><span class="nx">toEqual</span><span class="p">([</span><span class="nx">stagedKeyFor</span><span class="p">(</span><span class="mi">1</span><span class="p">),</span> <span class="nx">stagedKeyFor</span><span class="p">(</span><span class="mi">3</span><span class="p">)]);</span>
</code></pre></div></div>

<p>That immediately exposed a wrong assertion. In the staging-failure case <code class="language-plaintext highlighter-rouge">a.txt</code> and <code class="language-plaintext highlighter-rouge">c.txt</code> succeed while <code class="language-plaintext highlighter-rouge">b.txt</code> fails, so <strong>two</strong> keys need cleaning up — and the count-based assertion had expected one. It had sailed through because the suite was already red for an unrelated reason.</p>

<p>Sit with that one, because it’s the sharpest thing in the session. <strong>A red-by-design phase hides wrong assertions inside expected failure.</strong> The property that makes the gate honest is the same property that makes a bad assertion invisible to everything except a human reading it.</p>

<p>Worth noticing too that the expected keys are 1 and 3, not 1 and 2 — the failing file still consumes a uuid, because the key is generated before storage is ever touched. That asymmetry now lives in the test instead of being buried in a number.</p>

<p><strong>Assertions no string could fail.</strong> A helper called <code class="language-plaintext highlighter-rouge">expectSafeFailureBody</code> checked five regexes for what an error message <em>wasn’t</em> — no model name, no storage key, no Prisma error code, no env var name — plus a non-empty check. Any string satisfied all six. It never said what a user should see. Replaced with the literal copy per status:</p>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Copy</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>409</td>
      <td><code class="language-plaintext highlighter-rouge">That record already exists.</code></td>
    </tr>
    <tr>
      <td>503</td>
      <td><code class="language-plaintext highlighter-rouge">The service is temporarily unavailable. Please try again.</code></td>
    </tr>
    <tr>
      <td>500</td>
      <td><code class="language-plaintext highlighter-rouge">Something went wrong. Please try again.</code></td>
    </tr>
  </tbody>
</table>

<p>An exact match subsumes every leak regex — a body equal to one of those three strings cannot contain a Prisma code. Two things fell out of this that I’d keep as rules anywhere.</p>

<p>First: the literals are written into each test file rather than imported from the middleware, because importing them makes every assertion agree with whatever the code says, including copy someone changed by accident. <strong>A test that can’t disagree with the implementation isn’t testing it.</strong></p>

<p>Second: strengthening these took the suite from 26 red to 24. The leak-pattern assertions and a separate response-shape test all collapsed into one <code class="language-plaintext highlighter-rouge">toEqual</code> on the whole body. Raising the bar <em>deleted</em> test code. That surprised me, and it’s a useful counter to the assumption that a stricter standard means more to maintain.</p>

<p><strong>Fabricated premises.</strong> This one took me two attempts to articulate, and the sentence I landed on is the one I’d keep:</p>

<blockquote>
  <p>Even though the error does have a public constructor, building it manually bakes in a presumption that X failure from PG becomes Y code from Prisma. That’s what fabricated means.</p>
</blockquote>

<p>A hand-constructed <code class="language-plaintext highlighter-rouge">PrismaClientKnownRequestError</code> doesn’t test that we map Prisma’s behaviour. It tests that we map <em>my belief about</em> Prisma’s behaviour, and it will keep passing after that belief goes stale. Same flaw in different clothing: <code class="language-plaintext highlighter-rouge">as unknown as Context</code> in the middleware tests — a fake that silently encodes which <code class="language-plaintext highlighter-rouge">Context</code> members the handler happens to touch today.</p>

<p>The fix for that one is instructive because the obvious destination was wrong. Moving those cases to the HTTP-level test file would have made them real, but that tier is excluded from <code class="language-plaintext highlighter-rouge">pnpm test</code> and needs a listening service, so it <strong>never runs in CI</strong>. Real-but-never-executed is the worse trade. Hono’s <code class="language-plaintext highlighter-rouge">app.request()</code> was the third option: real dispatch, real <code class="language-plaintext highlighter-rouge">Context</code>, real <code class="language-plaintext highlighter-rouge">onError</code>, real <code class="language-plaintext highlighter-rouge">Response</code>, in-process, no port, still hermetic.</p>

<p>The confirmation was accidental and pleasing. After the switch the failure message reads <code class="language-plaintext highlighter-rouge">(reached from GET /api/health)</code> — values that now come from Hono’s own dispatch rather than a literal I typed into a fake.</p>

<p>Same principle produced the CI fixture. Instead of an inline <code class="language-plaintext highlighter-rouge">throw</code> — a throw wearing a route’s clothes, where the error never travels the path a real one would — there’s a real route table calling the real env loader and a real Zod <code class="language-plaintext highlighter-rouge">.parse()</code>, never mounted anywhere. Nothing in a running service can reach it, which makes it strictly safer than a flag-guarded debug endpoint somebody can misconfigure.</p>

<p>And the <code class="language-plaintext highlighter-rouge">ZodError</code> case earned its own place, because <code class="language-plaintext highlighter-rouge">.message</code> is a JSON dump carrying field paths and received values — <code class="language-plaintext highlighter-rouge">["collections", 0, "name"]</code>, <code class="language-plaintext highlighter-rouge">expected string, received number</code>. A handler passing that through leaks the shape of internal data structures <em>and</em> returns a blob where a sentence belongs. Two more rules came out of building it: use a real failed parse rather than a constructed error, and make the fixture guard itself — it asserts that the <code class="language-plaintext highlighter-rouge">ZodError</code>’s own message still contains the field path, so if zod stops putting paths there the test fails loudly instead of passing while proving nothing.</p>

<p>The interesting assertion there is 500, not 400. Request validation (the client’s mistake) and parsing an upstream’s response (not the client’s mistake) produce the identical error class, so the boundary must not guess between them: a bare one falling through to a generic 500 is the correct conservative default, and a typed wrapper is what earns a 502.</p>

<h2 id="noticing-is-the-job-fixing-is-free">Noticing is the job; fixing is free</h2>

<p>Here is the part that changed how I work. <strong>I didn’t write a single one of those assertions.</strong> My inputs were one-sentence discomforts:</p>

<ul>
  <li>“The mock should return <code class="language-plaintext highlighter-rouge">uploaded-${key}</code> so we can differentiate errors and logs in tests.”</li>
  <li>“I’d rather have hard expectations of keys, not simply a count of regex matches.”</li>
  <li>“We should assert hard user-friendly strings, as that is what the middleware is supposed to return.”</li>
</ul>

<p>Each came back implemented in minutes. And note that <strong>not one of them named a bug.</strong> The first was about log legibility. The second was aesthetic. The third was about intent. All three turned out to be covering real defects.</p>

<p>That inverts the economics of diligence. The expensive part of test review used to be <em>fixing</em> what you found, which is exactly why you learn to swallow the vague objections — not worth the detour. When noticing is the entire cost, following unarticulated taste is free, so I raised the complaints I’d previously have let go. They paid, every time.</p>

<p>It’s also what <a href="/2026/07/26/steering-the-ai-without-steering-yourself-into-a-rubber-stamp.html">steering by asking rather than asserting</a> turns into once the fixing is free. A one-sentence discomfort is the smallest possible version of that move: it points at something without prescribing the answer, so what comes back is an implementation I still have to judge rather than an agreement I asked for.</p>

<p>The skill this rewards is not “navigate the codebase fast.” It’s “notice precisely, and say it in one sentence.” What I’ve replaced the editor with is prose.</p>

<h2 id="the-bill">The bill</h2>

<p>Seven rounds. That’s not slow because gating is slow — it’s slow because every catch above came out of my head at review time, which means the standard for “a good test in this repo” was unwritten and got re-derived by hand at each gate. It doesn’t amortize. The next tests phase would start from zero and pay the same seven rounds.</p>

<p>The gate was doing double duty: the place the standard gets <em>discovered</em> rather than the place it gets <em>applied</em>.</p>

<p>But it wasn’t only producing an approved PR. It was producing contract. The three copy strings, the 500-versus-502 boundary, the requirement that staging generate each key before its first await — all of it went into the handoff document, so Phase 2 has to match the tests rather than the tests being bent to fit the implementation. Those are constraints the next phase inherits instead of rediscovering.</p>

<p>That’s the mechanism worth naming: careful test review doesn’t just filter the next phase, it <strong>specifies</strong> it. The gate’s real output is trust in work that hasn’t happened yet.</p>

<p>Which suggested the fix for the seven rounds. If the gate already produces contract at ticket scope, do the same move at repo scope — write down what makes a test good here, once.</p>

<h2 id="three-tiers-not-two">Three tiers, not two</h2>

<p>My first attempt at that conclusion was half right, and the half that was wrong is the interesting part.</p>

<p>The right half: structure belongs to tooling, and tooling is better at it than I am because it never gets tired at round six. dependency-cruiser and the repo’s architecture test earned their keep twice this week. When I asked for a fixture file named <code class="language-plaintext highlighter-rouge">routes.ci.ts</code>, the architecture test rejected it and told me the <em>process</em>, not just the verdict:</p>

<blockquote>
  <p>“The concern implies the file” — if this is a new concern, add it to the doc first; if it fits an existing one, rename it.</p>
</blockquote>

<p>So the convention got documented before the allowlist widened. Without the check, the allowlist gets quietly widened and the convention is never written down at all. And it widened precisely rather than bluntly: a plain <code class="language-plaintext highlighter-rouge">helpers.ts</code> in that directory is still rejected. Worth saying, because the lazy response to a failing structural check is to loosen it until it passes, and that’s how these rules die.</p>

<p>The wrong half was concluding that everything tooling <em>can’t</em> express has to stay in my head, applied by hand, forever. dependency-cruiser cannot express <em>a mock’s output must differ from its input</em>. So I assumed semantic rules were mine to remember.</p>

<p>What showed me otherwise was one sentence I typed while the rubrics were being drafted: <strong>the intended audience isn’t a human, it’s the agent that writes the next tests.</strong></p>

<p>That changes the genre completely. Each rule now leads with a mechanical trigger — “you are about to write <code class="language-plaintext highlighter-rouge">toHaveLength</code>”, “your mock returns its input unchanged” — followed by a bad/good code pair and one line of reason. The persuasion is gone. The narrative of how we found it is gone. An agent needs a decidable condition and just enough rationale to generalise, not convincing.</p>

<p>So there are three tiers, and the middle one is the one I didn’t know existed when the session started:</p>

<table>
  <thead>
    <tr>
      <th>Tier</th>
      <th>Applies itself?</th>
      <th>Covers</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Prose written for humans</td>
      <td>no — needs discipline</td>
      <td>anything</td>
    </tr>
    <tr>
      <td>Agent-facing rules with decidable triggers</td>
      <td>yes</td>
      <td>semantics</td>
    </tr>
    <tr>
      <td>Structural tooling — dep-cruiser, arch tests, types, lint</td>
      <td>yes</td>
      <td>structure</td>
    </tr>
  </tbody>
</table>

<p>Two rubrics exist now, one per testing domain, each organised around a single question: <em>would this test fail if the behaviour it names were wrong?</em> The nine unit rules are nine distinct ways of answering “no” while still looking thorough. Rule 9 is literally “prove the test can fail,” so the verified-red standard is codified rather than remembered.</p>

<h2 id="rules-need-the-same-gate-the-tests-got">Rules need the same gate the tests got</h2>

<p>The necessary counterweight: <strong>a wrong rule amortizes a wrong standard.</strong> Tooling outliving my attention is the entire benefit and the entire risk, and in the last stretch of this session three consecutive commits each reversed something from the commit before — all of them rules.</p>

<p>Three lessons, and they’re the same lessons one level up.</p>

<p><strong>An unfalsified rule proves nothing.</strong> Every new dep-cruiser rule got verified by planting the violation, watching it fire, removing it, watching it pass. A rule never observed failing is indistinguishable from a rule that doesn’t work. That’s how I found the bug in my own: <code class="language-plaintext highlighter-rouge">ci-fixtures-are-test-only</code> rejected <code class="language-plaintext highlighter-rouge">.ci.ts</code> importing <code class="language-plaintext highlighter-rouge">.ci.ts</code>, so a fixture could only ever be a single file — directly contradicting the convention’s premise, since a route table held to our standards delegates to a service rather than inlining logic. Reading the pattern didn’t reveal that. Building the two-file case did.</p>

<p><strong>Enumerate, don’t pattern-match.</strong> My first rule exempted <code class="language-plaintext highlighter-rouge">*.ci.ts</code> from canonical naming, which meant <code class="language-plaintext highlighter-rouge">helpers.ci.ts</code> passed where <code class="language-plaintext highlighter-rouge">helpers.ts</code> is rejected — any file could opt out of the convention by renaming itself. A special-case predicate in a lint rule is one rename away from being a universal escape hatch. The fix was to stop having a predicate: <code class="language-plaintext highlighter-rouge">routes.ci</code> became an ordinary entry in the canonical list, so a misnamed fixture fails like any other misnamed file. The correct rule ended up tighter than both attempts before it, and review pressure moving in the restrictive direction is not the direction unreviewed tooling drifts.</p>

<p><strong>A null result is ambiguous.</strong> Twice a check “looked clean” only because the check itself was broken — a grep tripping on escaped quotes, and a link-verification script that silently matched nothing. Absence of output means either no violation or your verification didn’t execute, and you have to know which. The strongest version of this bit me hardest elsewhere in the same session: I argued a failure mode was <em>impossible</em> on the strength of a string in a binary, wrote that into acceptance criteria as self-verifying, and was exactly wrong. So the rule isn’t only <em>prove the check can fail</em> — it’s <em>prove the thing you’re relying on to tell you it failed.</em></p>

<p>One shape worth preferring, since two of the session’s failures had it: <strong>inert is worse than broken.</strong> A thing that is present, readable, and does nothing looks configured in the diff, in review, and in the file forever after. Prefer the mechanism whose failure is loud; when you can’t, write the quiet failure down before it happens rather than after.</p>

<p>And a small loop-closing detail I like. The agent had <em>documented</em> the naming loophole as an acceptable trade-off, with reasoning — and writing it down is precisely what made it reviewable, and what got it killed a turn later. The payoff of recording a decision isn’t only future legibility. A written-down bad decision is far easier to spot than an unwritten one.</p>

<h2 id="the-context-tax">The context tax</h2>

<p>One more property of agent-facing docs, because it has no human analogue and it changed how I wrote them.</p>

<p>The ordinary reason not to restate another document is DRY: two copies is how the unread copy goes stale. Two of my integration rules were restating the existing testing-setup doc, which is both derivative <em>and</em> precisely the failure the same file warned about three paragraphs earlier. They got demoted to an “inherited policy, not restated here” pointer.</p>

<p>The second reason is specific to this audience. The phase reference files are re-read <em>in full</em> on every single run, so anything restated in them is paid for on every invocation, forever, in exchange for a sentence the agent could have followed a path to. A human skims past a redundant paragraph once. An agent buys it again every time.</p>

<p>That rule also caught a misapplication of itself, which is the most reassuring thing a rule can do. I’d rejected a proposed test-writing skill partly on the grounds that it would be a third copy of the same pointer — except a pointer isn’t a copy. The objection was wrong on its own terms, and the rule I was citing is what made that visible.</p>

<h2 id="what-the-gate-is-for">What the gate is for</h2>

<p>I started out thinking the value of a hard gate is that it stops bad code. It isn’t — bad code is what the <em>next</em> gate stops. The value is that the gate creates a moment where reviewing tests is the only thing on my desk, so I read the mock instead of the pass count.</p>

<p>Which gives me the rule I’d actually defend: <strong>a hard gate is expensive exactly once per class of mistake, and the deliverable of each expensive gate is a rule that makes the next one cheap.</strong> A gate that produces no rule is pure cost. The rubric accreting is what turns “was that slow phase waste?” from a hunch into evidence — six or seven lines earned so far, none of them authorable up front, every one of them from watching a specific assertion fail to distinguish a specific pair of behaviours.</p>

<p>Some honest limits. This was a tests phase, which is unusually readable by design; whether an implementation diff of the same size survives PR-UI-only review is untested, and I shouldn’t pretend otherwise. A rubric extracted from a handful of incidents will overfit in places — “mock returns must be distinguishable from their inputs” generalises, “prefix staged keys with <code class="language-plaintext highlighter-rouge">uploaded-</code>” does not. The no-editor feeling is downstream of the checks being <em>right</em>, and the moment a rule is wrong I’m reading prose about code I can’t see, which is exactly what those three reversed commits were. And this all runs on <a href="/2026/07/24/corpus-ticket-and-corpus-work-under-the-hood.html">the gap I’ve written about before</a> — no CI, no branch protection, and this week the automated reviewer hit its quota two PRs running, so my read was the entire review process. A gate is not a second pair of eyes, and I shouldn’t confuse the two.</p>

<p>There’s also a gap I didn’t see until the rubrics were written and committed: a repo-wide grep for them returned zero hits outside their own directory. Nothing in the system referenced them. I had a standard and no mechanism that would ever apply it — which turns out to be a completely separate problem, and a harder one: <a href="/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html">Your Quality Gate Isn’t Configured Until You’ve Watched It Fire</a>.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I built a feature this week without opening an editor. A terminal and GitHub’s PR review UI, nothing else. It was liberating, and I want to be careful about how I say that, because “I don’t read the code in an editor” sounds like negligence.]]></summary></entry><entry><title type="html">When a View Becomes a Record</title><link href="https://germanvalencia.dev/2026/07/29/when-a-view-becomes-a-record.html" rel="alternate" type="text/html" title="When a View Becomes a Record" /><published>2026-07-29T00:00:00+00:00</published><updated>2026-07-29T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/29/when-a-view-becomes-a-record</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/29/when-a-view-becomes-a-record.html"><![CDATA[<p>A skill in my harness ends by rendering an HTML artifact — a human-readable representation of what it just scoped — and then committing it into the repo. That commit is where the trouble started. The skill’s instructions said to commit “on whatever branch is currently checked out,” and a ticket-filing session has no idea what the working tree is doing. So a ticket’s scoping artifact landed on an unrelated in-flight feature branch, and I had to rebase it back out.</p>

<p>The obvious fix is to pick a better branch. That’s the fix I spent an hour not making, and I’m glad I didn’t, because the branch was never the question.</p>

<h2 id="three-homes-none-of-them-right">Three homes, none of them right</h2>

<p>Once you accept the artifact must be committed, there are only three places it can go: <code class="language-plaintext highlighter-rouge">main</code> directly, a dedicated <code class="language-plaintext highlighter-rouge">docs/&lt;id&gt;-ticket-draft</code> branch, or the ticket’s eventual feature branch. I rejected <code class="language-plaintext highlighter-rouge">main</code> on instinct. The AI built the docs-branch version. Then I rejected the premise underneath both remaining options: a skill whose entire stated job ends at <em>“the ticket is filed”</em> should not be creating branches at all. Cutting a feature branch is the first step of building a feature, not of scoping one.</p>

<p>Which surfaced the real tension. If the skill doesn’t commit, the artifact sits untracked from the moment the ticket is filed until someone actually starts the work — possibly weeks, possibly never, one <code class="language-plaintext highlighter-rouge">git clean</code> from gone. The skill’s own instructions defended committing for exactly that reason:</p>

<blockquote>
  <p>It’s a real record of what was scoped and approved, not scratch to end the session with — this skill has no Phase 4 of its own to fold it into later, so it just stays.</p>
</blockquote>

<p>So: is an untracked artifact acceptable in exchange for the skill never touching git? Yes or no, and everything else follows.</p>

<h2 id="the-repo-had-already-answered">The repo had already answered</h2>

<p>The answer was in the commit log. A commit on <code class="language-plaintext highlighter-rouge">main</code> from the day before, <code class="language-plaintext highlighter-rouge">chore: remove spent Lavish scoping artifacts</code>, had deleted two of these files with this reasoning:</p>

<blockquote>
  <p>Per-ticket Lavish artifacts are <strong>scoping surfaces, not durable docs</strong> … Both of these have outlived their work and their content lives in <code class="language-plaintext highlighter-rouge">docs/</code>.</p>
</blockquote>

<p>The repo had already ruled these things ephemeral. The skill’s <em>“it just stays”</em> sentence had been contradicting a decision that was already made, and the draft commit I’d been reviewing argued the opposite of it — citing one of the very artifacts that had been deleted as precedent for artifacts living on <code class="language-plaintext highlighter-rouge">main</code>. It wasn’t wrong about the branch. It was wrong on the merits.</p>

<p>That’s the part worth generalizing. Two instructions in the same repo, both plausible in isolation, silently contradicting each other. Nothing catches that except reading the log before writing the fix.</p>

<h2 id="naming-the-tiers">Naming the tiers</h2>

<p>The rule I landed on isn’t about artifacts at all:</p>

<ul>
  <li><strong>Linear is the source of truth for planning.</strong></li>
  <li><strong>OpenSpec deltas are the archive of what actually got implemented.</strong></li>
  <li><strong>Lavish artifacts are human-friendly representations of those two, and own nothing.</strong></li>
</ul>

<p>From which one line does all the work:</p>

<blockquote>
  <p>A Lavish artifact persists exactly as long as the skill lifecycle that needs it as a review surface, and no longer.</p>
</blockquote>

<p>Note what that buys over “don’t commit ticket artifacts.” My two skills behave <em>differently</em> — <code class="language-plaintext highlighter-rouge">corpus-ticket</code> never commits its artifact; <code class="language-plaintext highlighter-rouge">corpus-work</code> commits its Phase 0 plan artifact on the feature branch and deletes it in Phase 4. Stated as two rules, that reads as an inconsistency, and the next person to tidy up will fix the wrong one. Stated as one principle, both are derivable: the filing lifecycle ends at filing, so the artifact dies there. The build lifecycle spans five phases across multiple sessions, so committing is the only way the artifact survives <em>within</em> one lifecycle — that’s continuity, not durability, and Phase 4’s deletion is the lifecycle ending on schedule.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>ticket artifact</th>
      <th>Phase 0 plan artifact</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Committed?</td>
      <td>no</td>
      <td>yes, on the feature branch</td>
    </tr>
    <tr>
      <td>Lifecycle</td>
      <td>ends at filing</td>
      <td>folded and deleted in Phase 4</td>
    </tr>
    <tr>
      <td>Touches git?</td>
      <td>never</td>
      <td>yes</td>
    </tr>
  </tbody>
</table>

<p>The principle also overrules something I’d have kept. The earlier cleanup commit had spared one artifact because its tickets were still in Backlog — “delete when spent” rather than “never durable.” Under the stated rule that exception doesn’t hold: the <em>filing</em> lifecycle ended regardless of what the tickets are doing. Worth writing the overrule down explicitly, so it doesn’t read later as an oversight.</p>

<h2 id="the-ignore-pattern-is-load-bearing">The ignore pattern is load-bearing</h2>

<p>One consequence I initially under-called. With the skill no longer committing, a leftover artifact shows up as untracked in <code class="language-plaintext highlighter-rouge">git status</code> — and I described that as “noise.” It isn’t noise. My build skill’s Step 0 says:</p>

<blockquote>
  <p>Run <code class="language-plaintext highlighter-rouge">git status --porcelain</code>; if it isn’t empty, stop and ask.</p>
</blockquote>

<p>An untracked artifact therefore <strong>blocks the next run of that skill, for any ticket</strong>, until someone deletes it by hand. The <code class="language-plaintext highlighter-rouge">.gitignore</code> entry isn’t tidiness, it’s what keeps the harness startable. And the pattern has to be surgical: it must not swallow the Phase 0 plan artifact, which has to stay committable. A subdirectory for ephemeral drafts beats a negation pattern with a growing list of exceptions — the negation pattern is always one edit away from ignoring something load-bearing.</p>

<p>There’s a sharper version of the classification test, too. Not “is this a reference doc?” but <em>“was this rendered as the review surface for a skill invocation?”</em> My two skill explainers live in the same directory and look superficially identical to the ticket drafts. Under the weaker test they’re an exception to the rule. Under the sharper one they simply fall outside it, because nobody invoked a skill to produce them. Rules with exceptions rot. Tests that partition cleanly don’t.</p>

<h2 id="what-this-is-really-about">What this is really about</h2>

<p>Every one of these artifacts is a <em>view</em>. The failure mode — the branch collision, the duplicated content, the contradicting instructions — came from letting a view quietly become a record. The commit I ultimately threw away was doing exactly that: arguing that what mattered was that the artifact “exists somewhere durable,” when the thing it represented was already durable somewhere better.</p>

<p>The artifact was, by the skill’s own instructions, required to match the Linear description <strong>verbatim</strong>. So the committed file was by construction a second copy of something Linear already held. That’s not a principle, that’s just a fact — and it’s the cheapest possible argument for the rule. I’d spent an hour on the principled version first.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A skill in my harness ends by rendering an HTML artifact — a human-readable representation of what it just scoped — and then committing it into the repo. That commit is where the trouble started. The skill’s instructions said to commit “on whatever branch is currently checked out,” and a ticket-filing session has no idea what the working tree is doing. So a ticket’s scoping artifact landed on an unrelated in-flight feature branch, and I had to rebase it back out.]]></summary></entry><entry><title type="html">Your Quality Gate Isn’t Configured Until You’ve Watched It Fire</title><link href="https://germanvalencia.dev/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html" rel="alternate" type="text/html" title="Your Quality Gate Isn’t Configured Until You’ve Watched It Fire" /><published>2026-07-29T00:00:00+00:00</published><updated>2026-07-29T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/29/your-quality-gate-isnt-configured-until-youve-watched-it-fire.html"><![CDATA[<p>A long review session left me with a written standard: two Markdown files spelling out what makes a good test in one particular repo, each rule earned from watching a specific assertion fail to distinguish a specific pair of behaviours. That session is <a href="/2026/07/29/a-hard-gate-is-expensive-exactly-once.html">A Hard Gate Is Expensive Exactly Once</a>; this post picks up at the sentence that one ends on.</p>

<p>Writing them down was the cheap part. Then a repo-wide grep for <code class="language-plaintext highlighter-rouge">rubrics</code> returned zero hits outside <code class="language-plaintext highlighter-rouge">docs/rubrics/</code> itself. No doc, no skill, no config, no code mentioned them. I had a standard that nothing in the system would ever apply.</p>

<p>So the configuration problem isn’t “which linter.” It’s: <strong>for every rule you want enforced, what mechanism applies it without anyone choosing to?</strong> And then, immediately after: how do you know that mechanism is actually running? Because I got that second question wrong four separate times in one session, and every wrong answer looked like a working one.</p>

<h2 id="rank-the-layers-by-what-they-dont-trust">Rank the layers by what they don’t trust</h2>

<p>The available places to put a rule sort into a ladder — ordered not by how much they can express, but by how little they ask of anybody:</p>

<ol>
  <li><strong>Structural checks</strong> — dependency-cruiser rules, an architecture test in the suite. They run in <code class="language-plaintext highlighter-rouge">pnpm test</code>. Nothing to remember, nothing to trigger.</li>
  <li><strong>Hooks on the write itself</strong> — <code class="language-plaintext highlighter-rouge">PreToolUse</code> on <code class="language-plaintext highlighter-rouge">Write|Edit</code>. Fires whether or not anyone wanted it to.</li>
  <li><strong>Prompt files that are loaded in full every run</strong> — my harness’s phase references. A pointer sitting there is in the context window automatically; nobody has to fetch it.</li>
  <li><strong>The agent-facing index</strong> — <code class="language-plaintext highlighter-rouge">AGENTS.md</code>. Depends on the index being loaded, and then on the link being followed. More on this below, because I was wrong about the first half.</li>
  <li><strong>A skill</strong> — triggers on model-judged intent. Probabilistic, and the only layer that can load a whole document before the write.</li>
  <li><strong>A flag you pass at invocation time</strong> — technically the most reliable transport on the list, and last anyway. It attaches a standing responsibility to whoever is driving.</li>
</ol>

<p>That last entry is what the whole ordering is really measuring. The question isn’t how reliably a layer delivers its payload; it’s <strong>how much operator responsibility it attaches</strong> to deliver it at all. A structural check attaches none — clone the repo, run the suite, it’s there. A flag attaches a permanent one, on every invocation, forever, to every person and every automation that ever pushes.</p>

<p>The temptation is to place each rule at whichever layer is cheapest to write, which is almost always near the bottom. Adding a line to <code class="language-plaintext highlighter-rouge">AGENTS.md</code> costs nothing and feels like progress. The useful move is the opposite: put each rule at the highest layer that can express it, and treat everything below as backstop rather than mechanism.</p>

<h2 id="the-hooks-constraint-chose-the-design">The hook’s constraint chose the design</h2>

<p>I assumed a <code class="language-plaintext highlighter-rouge">PreToolUse</code> hook could hand the agent a document before it wrote the file. It can’t, quite, and finding out why reshaped what I built.</p>

<p>Three things about the API mattered:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">matcher</code> is tool-name-only — <code class="language-plaintext highlighter-rouge">"Write|Edit"</code> — but each handler also takes an <code class="language-plaintext highlighter-rouge">if</code> field using permission-rule syntax, which matches arguments too. So <code class="language-plaintext highlighter-rouge">if: "Write(**/*.test.ts)"</code> gets real path filtering, and you can route <code class="language-plaintext highlighter-rouge">*.api.test.ts</code> to the integration rubric and everything else to the unit one.</li>
  <li><code class="language-plaintext highlighter-rouge">additionalContext</code> is documented to land <em>next to the tool result</em>. That is, after the write. An allow-and-inject hook is a revise-after nudge, not a pre-write guide.</li>
  <li>Hook output is capped at 10,000 characters. My rubrics are 9,382 and 8,963 bytes. Neither inlines comfortably; both together are impossible.</li>
</ul>

<p>The only shape that gets text in front of the agent before the file exists is <code class="language-plaintext highlighter-rouge">permissionDecision: "deny"</code> with the pointer in <code class="language-plaintext highlighter-rouge">permissionDecisionReason</code>. I specified it that way first, then reversed it — and the reversal is the part worth keeping.</p>

<p>A hook that denies the first test-file write in a session is a hook that can stall every test-file write in the repo when it’s wrong. Allow-only removes that failure mode entirely; the worst case becomes an unhelpful nudge. And “after the write” turns out to be exactly the right moment for what the rubric actually needs to say: <em>now go verify what you just wrote against this checklist.</em> The constraint I’d been treating as a limitation was describing the correct design.</p>

<p>Two knock-ons. Once nothing blocks, firing once per session is needlessly stingy — once per test-file path per session is right, because a genuinely new test file deserves its own nudge. And the review depth I’d budgeted for building the hook dropped a full tier, because the risk it was priced against had evaporated.</p>

<p>The hook injects the path plus a short checklist, not the file. Discoverability is what a hook is for; content lives where content lives.</p>

<h2 id="the-config-block-that-did-nothing">The config block that did nothing</h2>

<p>Here’s the part I’d want someone to take away.</p>

<p>The repo runs an external validation pipeline on every push — review, tests, lint, docs, PR, CI. Its config file already carried a 110-line <code class="language-plaintext highlighter-rouge">document.instructions</code> block, added months earlier, pointing the docs step at a README and telling it what to read first. Adding a <code class="language-plaintext highlighter-rouge">review.instructions</code> block with a pointer to the rubrics was obviously the same move at a different step.</p>

<p>I convinced myself it was safe to specify without testing, on three grounds: precedent in the same file, an explicit statement in the pipeline’s own prompt that repo-specific instructions “may narrow or clarify, never weaken” — so per-step injection is a designed feature — and, decisively, a string in the binary reading <code class="language-plaintext highlighter-rouge">contains unknown field %q</code>. A validator that rejects unknown keys means you cannot commit a block that quietly does nothing. I wrote that into the acceptance criteria as <em>self-verifying</em>.</p>

<p>All three grounds were true. The conclusion was wrong.</p>

<p>A sentinel probe settled it: throwaway repo, a unique marker inside <code class="language-plaintext highlighter-rouge">review.instructions</code>, and a diff adding exactly the file type the instruction described. The review agent’s own account afterward:</p>

<blockquote>
  <p>My review prompt contained only the generic review task text. It did not contain the marker, the rubric directive, or any other text from <code class="language-plaintext highlighter-rouge">review.instructions</code>. I only know the marker exists because I read the config off disk myself — not because the pipeline injected it.</p>
</blockquote>

<p>Confirmed on two versions, with <code class="language-plaintext highlighter-rouge">document.instructions</code> running as a positive control in the same probe — that step <em>did</em> receive its marker, which is what makes the negative result mean something rather than just meaning the probe was broken. The <code class="language-plaintext highlighter-rouge">review</code> step simply doesn’t consume the key. It accepts it, and silently discards it.</p>

<p>So the failure mode I’d argued was impossible is the one that was actually there. <strong>A config surface that tolerates unknown keys is the worst thing a gate can be, because a dead block and a live block look identical in the diff, in review, and in the file forever after.</strong> And no amount of reading the binary, the docs, or the precedent distinguishes them. Only a marker travelling end to end does.</p>

<p>The generalisation I’d draw: a gate is not configured when the config is written. It’s configured when you’ve watched a sentinel come out the other side, with a control proving your probe can detect a hit at all.</p>

<h2 id="it-has-to-work-out-of-the-box-or-its-just-a-checklist">It has to work out of the box, or it’s just a checklist</h2>

<p>The obvious replacement worked on the first try. The pipeline takes an <code class="language-plaintext highlighter-rouge">--intent</code> string, and it reaches the reviewer verbatim — the probe quoted its marker back. Better, a bare pointer was enough: given only a path, the reviewer went and read the rubric, then filed an error-severity finding citing a rule <em>by name</em>, identifying the exact trigger in the diff and quoting the rubric’s own checklist. Nothing needed inlining.</p>

<p>It also had a property I hadn’t asked for and now want everywhere: in a repo with no <code class="language-plaintext highlighter-rouge">docs/rubrics/</code> at all, it didn’t skip the check — it hunted, found a copy elsewhere on disk, used it, <em>and filed a separate warning that the substitution was unverified</em>. A gate that tells you its inputs were missing beats a gate that quietly grades you against nothing.</p>

<p>And I dropped the layer anyway, because <code class="language-plaintext highlighter-rouge">--intent</code> is a flag.</p>

<p>Not because I’d forget it — though I would. Because it converts a tooling problem into a human responsibility, and that’s the specific trade I’m unwilling to make. Every mechanism I add to this harness should work the moment the repo is cloned, with nobody told anything. A flag inverts that: the gate now depends on the operator knowing that a thing needs doing, remembering what it is, and getting it right on every push — including the pushes made by automation that was written before the rule existed, and by the next person, who was never in the conversation where we decided this mattered.</p>

<p>That’s not a weaker version of automation. It’s the thing automation was supposed to replace. Anything re-supplied per invocation isn’t a gate, it’s a habit — and habits failing is the entire reason I was doing this. The standard already existed; what didn’t exist was anything that applied it without me. Solving that with a flag I have to remember would have been the same bug one level up.</p>

<p>So: prefer the layer that lives in the repo, even when it’s weaker on paper. Repo-resident and 80% reliable beats invocation-bound and perfect, because the 20% is a known gap you can design a backstop for, while the operator burden is an unbounded one you can only apologise for later. And it’s worth being blunt that “we’ll document that you have to pass <code class="language-plaintext highlighter-rouge">--intent</code>” is not a mitigation — it’s the failure mode wearing a mitigation’s clothes. A doc telling someone to remember a flag has exactly the reliability of the doc nobody read, which is where this whole exercise started.</p>

<h2 id="check-whether-the-layer-is-already-free">Check whether the layer is already free</h2>

<p>Then the probe went one step further, and this is the cheapest lesson in the whole exercise: I stripped the config block <em>and</em> deleted <code class="language-plaintext highlighter-rouge">AGENTS.md</code> from the repo entirely, then pushed a diff with a weak assertion in it. The review agent still located the rubric on its own and filed error-severity findings citing two rules by name, the exact trigger, and the rubric’s headline test.</p>

<p>The backstop was free. It came from the rubric existing at that path, and nothing else. That layer went from an acceptance-criteria block to zero work — and it is, by construction, the out-of-the-box behaviour I’d just spent two dead ends trying to build: no flag, no config, no instruction, nothing for anyone to remember. Clone the repo and it happens.</p>

<p>The honest caveat is in the ticket too: three of three runs is not a guarantee. This is model discretion, not enforcement, and it’s scoped as a backstop with the hook remaining the mechanical layer. But the sequencing lesson stands — before building a mechanism to feed a rule into a gate, check whether the gate already finds it. I nearly shipped configuration for a behaviour I already had.</p>

<h2 id="the-index-nobody-loads">The index nobody loads</h2>

<p>The layer I’d called weakest turned out to be weaker still, and for a reason that has nothing to do with agents ignoring instructions.</p>

<p><code class="language-plaintext highlighter-rouge">AGENTS.md</code> in that repo is 20 KB of conventions with ten <code class="language-plaintext highlighter-rouge">&gt; **Reference:**</code> pointers in it. It is not auto-loaded into a session. There’s no <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>, so session context carries global rules and nothing from the repo’s own guide — the file is read only if something opens it. The probe agent could see this directly in its own injected context block: my global rules were there, the repo’s guide wasn’t.</p>

<p>So all ten existing reference lines are weaker than they look, and the testing pointer I was about to add would have inherited that. The fix is a committed <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> symlink, which fixes every session at once and requires nothing of anyone starting one — with precedent, since the repo already commits eight symlinks at mode <code class="language-plaintext highlighter-rouge">120000</code> for exactly this fresh-clone reason. That property is why a symlink beats the obvious alternative of telling everyone to read <code class="language-plaintext highlighter-rouge">AGENTS.md</code>: one is a file in the repo, the other is a responsibility handed to a person.</p>

<p>One assumption got written down rather than left silent: on a filesystem without symlink support, a committed symlink materialises as a text file containing a path. The layer becomes <em>inert</em> rather than <em>broken</em>. That’s the worse of the two failure shapes, and it’s the same shape as the dead config block — which is why it’s named in the ticket instead of discovered later.</p>

<h2 id="four-plausible-mechanisms">Four plausible mechanisms</h2>

<p>That’s the tally: a deny-shaped hook that would have blocked every test-file write in the repo, a config block accepted and silently discarded, a flag that outsources the work to whoever pushes next, and an index that never loads. Every one was a reasonable design. Two failed silently. One worked perfectly and still had to go, because a gate that needs a person to remember it is just that person, with extra steps.</p>

<p>None of them were distinguishable from working by reading — not the docs, not the binary, not the precedent in the same config file I was editing. The only thing that separated the live layers from the dead ones was a marker travelling end to end, on a repo stripped down until nothing but the mechanism could explain the result.</p>

<p>Which is the whole thing, really. You don’t get to reason your way to a working gate, and you don’t get to delegate the last mile of it back to yourself. You get to watch one fire, on a clone nobody prepared, with nobody told anything.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A long review session left me with a written standard: two Markdown files spelling out what makes a good test in one particular repo, each rule earned from watching a specific assertion fail to distinguish a specific pair of behaviours. That session is A Hard Gate Is Expensive Exactly Once; this post picks up at the sentence that one ends on.]]></summary></entry><entry><title type="html">Steering the AI Without Steering Yourself Into a Rubber Stamp</title><link href="https://germanvalencia.dev/2026/07/26/steering-the-ai-without-steering-yourself-into-a-rubber-stamp.html" rel="alternate" type="text/html" title="Steering the AI Without Steering Yourself Into a Rubber Stamp" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/26/steering-the-ai-without-steering-yourself-into-a-rubber-stamp</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/26/steering-the-ai-without-steering-yourself-into-a-rubber-stamp.html"><![CDATA[<p>Scoping out features with an agentic harness is a fundamentally different process than doing it artisanally. The appealing part is delegation: I can hand the AI an open-ended investigation — go compare these approaches, go summarize these tradeoffs — and get back a report tailored to what I actually need, in a fraction of the time it’d take to do it by hand.</p>

<p>The dangerous part is the same property viewed from the other side. The AI answers the question you asked. Ask a question shaped like a request for approval and you get approval. Ask for execution and you get execution, however under-specified the request was. Nothing about the harness pushes back on a badly framed ask — which means the work that used to go into doing the thing now goes into specifying it, and every failure mode below is a version of that same shift.</p>

<h2 id="steer-by-asking-not-asserting">Steer by asking, not asserting</h2>

<p>The first mitigation is to steer toward a direction by asking questions rather than asking the AI to evaluate your choice directly. “What are the tradeoffs of X vs. Y” invites a real comparison. “Is X good?” invites agreement. The framing matters more than it seems like it should.</p>

<p>But this comes with a tension: the goal isn’t to smuggle your preferred answer in through a longer path. If the questioning is just a more elaborate route to the same rubber stamp, you haven’t gained anything — you still want the AI to genuinely challenge your design, not confirm it with extra steps.</p>

<p>That cuts both ways. I once asserted flatly that a design doc was no longer relevant and should just be deleted. Instead of agreeing, the AI pushed back with a concrete reason it was still load-bearing elsewhere in the codebase — and it was right. Steering-by-question protects against my confirmation bias; the AI declining to rubber-stamp <em>my</em> confident claim is the same discipline running the other direction.</p>

<h2 id="dissent-has-to-be-manufactured-on-purpose">Dissent has to be manufactured on purpose</h2>

<p>If agreement is the default, disagreement is something you have to go build. Three things reliably produce it, and none of them happen by accident.</p>

<p><strong>Give a sub-agent one hostile job and then leave it alone.</strong> A failure mode specific to multi-agent setups: spinning off a sub-agent to research some ancillary concern, then having the parent intervene before it gets to explore independently. If you spun it off specifically so it could investigate without your assumptions in the loop, stepping in early defeats the entire point. I once gave a sub-agent one mandate — attack this design, find what’s wrong with it — and it found the bug that mattered most: a success path that never marked its own work as done, so the next pass would silently redo it forever. Two ordinary review passes hadn’t caught it. The one whose only job was to find fault did.</p>

<p><strong>Hand it something that already works.</strong> Reviewing a design in isolation is weaker than holding it next to a sibling that’s in production. I pointed an agent at another implementation of the same pattern and asked it to compare, not to review harder — and it found a real bug: the two designs kept their deduplication keys at different granularities, so one of them would silently drop legitimate work under load. Staring longer at the same file wouldn’t have caught that. A second working example did.</p>

<p><strong>Ask explicitly for blind spots.</strong> The AI catches some incidentally, just by working through a task, but it’s far better at it when sent looking on purpose. Blind-spot detection isn’t a side effect you can rely on — it’s a mode you have to invoke.</p>

<h2 id="planning-didnt-get-superseded">Planning didn’t get superseded</h2>

<p>There’s a temptation, once the agent is fast enough, to skip straight to execution. Why write a plan when you can just say “do it” and watch code appear? The plan feels like the ceremony you were finally allowed to drop.</p>

<p>It isn’t. Going straight into execution mostly moves the planning to a worse place: you discover the scope in pieces, mid-implementation, one surprise at a time. Every discovery arrives as an interruption to work that’s already half-done, and each one either widens the change or forces you to unwind part of it. Scope extensions ad infinitum, found in the most expensive order possible.</p>

<p>And it wears on you. There’s a specific frustration in watching a task you thought was nearly done sprout another offshoot, then another — the finish line moving every time you get close to it. At that point it’s not a productivity problem, it’s a morale one. Discovering scope up front is tedious; discovering it one interruption at a time, forever, is corrosive.</p>

<p>The plan is where scope gets bounded <em>before</em> anything is built on top of it. That job didn’t get automated away — if anything the AI made it more valuable, because the AI will happily execute an under-specified request all the way to a large, confident, wrong diff.</p>

<p>And no persona saves you here. You can open with “you are my advisor, push back on me” all you like — if the actual request is <em>go do this</em>, it goes and does it. The framing doesn’t outrank the instruction. Wanting to be advised and then asking to be obeyed just gets you obeyed. If you want the plan, you have to ask for the plan.</p>

<h2 id="the-judgment-doesnt-delegate">The judgment doesn’t delegate</h2>

<p>The AI is a very powerful token predictor, but it’s still just that. It doesn’t hold the whole system in its head unless you make it, and it won’t surface the edge case you never thought to ask about. Thinking at the systems level — what happens when these pieces are assembled, what breaks at the boundary, what the unhappy path looks like — is still the engineer’s job.</p>

<p>Two places where that shows up concretely.</p>

<p><strong>Scope boundaries.</strong> Left to its own devices, the AI will jam a piece of logic into whatever place it roughly belongs rather than drawing a hard line. It isn’t being sloppy; it just has no strong prior about where a boundary <em>should</em> sit, because that’s a judgment call, not a prediction. I haven’t found a prompt that reliably fixes this. But the difference shows immediately when you ask for scope explicitly instead of hoping it holds: a rename once touched six places across a repo, and asked to scope it properly, the agent split that out into its own enumerated checklist rather than folding it silently into the change that triggered it — specifically so nothing quietly went un-updated. The hard line held because I asked for it.</p>

<p><strong>Prior art.</strong> Always check what already exists in the repo first. Skipping this is how you end up with duplicate implementations, reinvented utilities, or a “new” design that quietly contradicts a pattern living three directories over. I’ve watched a ticket reopen a design question that had already been settled — the answer was sitting in the architecture docs the whole time, just not in the specific place the check had been told to look. Knowing where to look is the part that doesn’t delegate.</p>

<h2 id="the-upside-once-youre-actually-steering">The upside, once you’re actually steering</h2>

<p>Do all of that and you get something genuinely new in return: throwaway scenarios and what-ifs have become stupid cheap. Spinning up an agent to explore a branch you’re 90% sure you’ll discard used to not be worth the time. Now it’s close enough to free that you explore dead ends on purpose, just to confirm they’re actually dead rather than assuming it — which is exactly the kind of cheap evidence a plan wants.</p>

<p>The same cheapness applies to illustration, not just investigation. <a href="https://github.com/kunchenguid/lavish-axi">lavish-axi</a> has been invaluable for letting the AI explain complex concepts beyond what plain Markdown can carry — diagrams, side-by-side comparisons, interactive artifacts you can annotate directly instead of reading a wall of prose.</p>

<h2 id="is-prompting-the-new-coding">Is prompting the new coding?</h2>

<p>I’m not sure, but it feels like it somehow. Everything above — asking instead of asserting, giving sub-agents room to fail, asking explicitly for blind spots, refusing to let “do it” stand in for a plan — is a skill, and it’s specific to working with these systems rather than a general engineering skill wearing a new hat. Whether it’s a durable discipline or just this generation’s syntax, still figuring out.</p>

<p>It points at something bigger, too. Learning concepts in the AI age is a fundamentally different exercise: you can learn faster than before, or skip the learning altogether and let the agent apply its own judgment. Either way the old-school route — building understanding entirely by hand — isn’t competitive anymore. If you want the learning intact, the adversarial move works here as well as it does on designs: task agents with evaluating your comprehension and your plans directly, so the AI is a skeptic to argue with rather than an oracle to defer to.</p>

<p>That still leaves the question I haven’t resolved. How do you avoid becoming dependent on AI to learn, when the market is pushing everyone to lean on it as hard as possible just to stay relevant? There’s real tension between “use it constantly or fall behind” and “using it constantly is exactly what erodes the underlying skill.” Arguing with a skeptic is the closest thing to an answer I’ve got — and it trades away some of the speed that made delegation attractive in the first place.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Scoping out features with an agentic harness is a fundamentally different process than doing it artisanally. The appealing part is delegation: I can hand the AI an open-ended investigation — go compare these approaches, go summarize these tradeoffs — and get back a report tailored to what I actually need, in a fraction of the time it’d take to do it by hand.]]></summary></entry><entry><title type="html">Two Styles of Agentic SDLC Harness: Gated vs. Fluid</title><link href="https://germanvalencia.dev/2026/07/24/agentic-harness-styles.html" rel="alternate" type="text/html" title="Two Styles of Agentic SDLC Harness: Gated vs. Fluid" /><published>2026-07-24T00:00:00+00:00</published><updated>2026-07-24T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/24/agentic-harness-styles</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/24/agentic-harness-styles.html"><![CDATA[<p>I’ve spent the last few weeks iterating on a homegrown SDLC harness for an agent
(<code class="language-plaintext highlighter-rouge">corpus-ticket</code> + <code class="language-plaintext highlighter-rouge">corpus-work</code>, a pair of Claude Code skills) and comparing notes
against <a href="https://github.com/Fission-AI/OpenSpec">OpenSpec</a>, a maintained,
portable spec-driven-development tool. They solve the same underlying problem —
“AI coding without an agreed plan is chat-history archaeology” — but they land on
opposite answers for how much to enforce.</p>

<h2 id="the-gated-style">The gated style</h2>

<p>My harness splits a feature into four sequential phases — acceptance tests,
implementation, human revision, docs — each ending in its own GitHub pull
request. The reason is mundane and specific: the repo has no CI and no branch
protection, so a human actually reading a diff is the <em>only</em> thing standing
between “committed” and “merged.” Racing ahead to the next phase before that
review happens defeats the point of having phases at all — phase 2 needs
phase 1’s tests to be the reviewed version sitting in the branch, not a version
the session merely remembers writing.</p>

<p>A few mechanisms fall out of that one constraint:</p>

<ul>
  <li><strong>Tests-first as a real contract.</strong> Acceptance tests are their own reviewed PR,
red until implementation exists. Driving them to green is what “done” means,
not a checklist someone can talk themselves out of.</li>
  <li><strong>A phase no diff can replace.</strong> One phase exists purely to put a human in
front of the <em>running</em> feature — copy quality, interaction feel, edge cases
nobody wrote a test for. Code review can’t see any of that.</li>
  <li><strong>An audit trail that survives reverted attempts.</strong> A running “Dev Log” and an
append-only handoff doc record not just what shipped, but what was tried and
walked back, and why — the kind of thing that normally evaporates the moment
you <code class="language-plaintext highlighter-rouge">git revert</code> it.</li>
  <li><strong>Ceremony that scales down on purpose.</strong> A named escape hatch for
diagnose-and-fix work, and a two-tier inspection mode, so a one-line bug fix
doesn’t pay the same toll as a new feature.</li>
</ul>

<p>The cost is exactly what you’d expect: this only works because it’s fitted to
one team’s stack (issue tracker, PR flow, workspace tooling, one repo). It isn’t
going anywhere else without a rewrite.</p>

<h2 id="the-fluid-style">The fluid style</h2>

<p>OpenSpec’s own words for its philosophy: <strong>“enablers, not gates.”</strong> A change
folder builds <code class="language-plaintext highlighter-rouge">proposal → specs → design → tasks</code>, but that arrow describes
what becomes <em>possible</em> next, not what you’re forced to do next. Discover the
design was wrong mid-implementation? Edit <code class="language-plaintext highlighter-rouge">design.md</code> and keep going. Nothing
locks. The human review is front-loaded — read the whole artifact bundle once,
before <code class="language-plaintext highlighter-rouge">/opsx:apply</code> — rather than gating every subsequent diff.</p>

<p>Its standout idea is <strong>delta specs</strong>: a change doesn’t rewrite a domain’s spec,
it writes <code class="language-plaintext highlighter-rouge">ADDED</code>/<code class="language-plaintext highlighter-rouge">MODIFIED</code>/<code class="language-plaintext highlighter-rouge">REMOVED</code> blocks against it, and those deltas merge
into a permanent, per-domain spec on archive. That’s a real answer to the
brownfield problem my harness doesn’t have — a living, queryable “what does
this system guarantee today” that a future planning session can read without
archaeology through old tickets. In exchange, OpenSpec is a generic,
installable CLI that works across 25+ editors and any stack — it trades
per-repo depth for portability.</p>

<h2 id="the-actual-axis">The actual axis</h2>

<p>It’s tempting to call one of these “better,” but the difference isn’t quality —
it’s where each one spends its trust. The gated style doesn’t trust an unreviewed
diff, so it forces a human checkpoint at every phase boundary and treats
“agreed but not yet reviewed” as an unfinished state. The fluid style trusts the
human to keep steering after one upfront agreement, and optimizes for how
cheaply that agreement can be revised. Neither is wrong; they’re just answers
to different failure modes — a bad merge with no safety net, versus a stale
plan nobody bothers to update.</p>

<h2 id="stealing-the-good-idea-without-the-philosophy">Stealing the good idea without the philosophy</h2>

<p>The interesting move isn’t picking a side — it’s grafting OpenSpec’s one
structurally superior piece onto the gated harness without diluting the gates
themselves. Concretely: keep every PR gate, keep the tests-first contract, keep
the human-exercises-it phase — and add a <code class="language-plaintext highlighter-rouge">docs/specs/&lt;domain&gt;/spec.md</code> that
acceptance tests write deltas against instead of one-off narrative docs. The
docs-handoff phase — which already folds a feature’s history into permanent
docs and deletes the scratch copy — is a natural place to also merge that
delta into the living spec, the same way OpenSpec’s archive step does. Same
audit discipline, same enforcement, one more source of truth that outlives a
single feature.</p>

<h2 id="where-ive-landed">Where I’ve landed</h2>

<p>I’m keeping the homegrown harness where it lives — a real project, with real
review gates, is where its maintenance cost actually pays for itself, and
building it has taught me more about which mechanisms matter than adopting a
tool would have. But I’m not planning to rebuild it from scratch on the next
project either. The plan is to run both, on different projects, on purpose:
the homegrown one where control and depth matter, OpenSpec (or something
thinner) where portability or a teammate’s onboarding cost matters more — and
to deliberately compare notes between them, the way this post did, rather than
let two separate muscle memories form in isolation.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[I’ve spent the last few weeks iterating on a homegrown SDLC harness for an agent (corpus-ticket + corpus-work, a pair of Claude Code skills) and comparing notes against OpenSpec, a maintained, portable spec-driven-development tool. They solve the same underlying problem — “AI coding without an agreed plan is chat-history archaeology” — but they land on opposite answers for how much to enforce.]]></summary></entry><entry><title type="html">Under the Hood: corpus-ticket and corpus-work</title><link href="https://germanvalencia.dev/2026/07/24/corpus-ticket-and-corpus-work-under-the-hood.html" rel="alternate" type="text/html" title="Under the Hood: corpus-ticket and corpus-work" /><published>2026-07-24T00:00:00+00:00</published><updated>2026-07-24T00:00:00+00:00</updated><id>https://germanvalencia.dev/2026/07/24/corpus-ticket-and-corpus-work-under-the-hood</id><content type="html" xml:base="https://germanvalencia.dev/2026/07/24/corpus-ticket-and-corpus-work-under-the-hood.html"><![CDATA[<p><a href="/2026/07/24/agentic-harness-styles.html">Last post</a> compared my homegrown
harness against OpenSpec at the philosophy level — gated vs. fluid. This one skips
the comparison and just opens the thing up. It’s two Claude Code skills,
<code class="language-plaintext highlighter-rouge">corpus-ticket</code> and <code class="language-plaintext highlighter-rouge">corpus-work</code>, built for and living inside <strong>Corpus</strong>, a
project I’m building solo (private repo, for now — the harness is the interesting
part, not the product it’s scoping).</p>

<h2 id="corpus-ticket-an-advisor-that-can-say-no">corpus-ticket: an advisor that can say no</h2>

<p><code class="language-plaintext highlighter-rouge">corpus-ticket</code> turns a rough idea into a Linear issue for Corpus’s team — but its
actual job is refusing to write a bad ticket, not writing tickets fast. The system
prompt is explicit about posture: <em>“You are my advisor, not my assistant. Never
open with agreement.”</em> In practice that means every conversation runs through a
fixed set of checkpoints before anything gets drafted:</p>

<ol>
  <li>React to the idea — push back if it’s vague, don’t open with “Great idea!”</li>
  <li>Search Linear for duplicates/overlap. Find one → branch into “is this the same
thing, related-but-distinct, or genuinely separate” instead of drafting.</li>
  <li>Interrogate whatever’s unscoped: problem statement, acceptance criteria, explicit
out-of-scope, priority-with-a-reason, labels, dependencies.</li>
  <li>Right-size it — one ticket, a linked split, or an epic — judged by number of
distinct <em>outcomes</em>, not effort. A single feature that touches a migration, two
endpoints, and some UI is still one (big) ticket; several independently-shippable
pieces get split and linked (<code class="language-plaintext highlighter-rouge">blocks</code>/<code class="language-plaintext highlighter-rouge">relatedTo</code>).</li>
  <li>Place it in an existing Linear project or propose a new one — no ticket ships
project-less.</li>
  <li>Draft it, show it, get an explicit yes, create it.</li>
</ol>

<p>Seven hard gates sit behind step 6 — duplicate search actually ran, every
interrogation item has a real answer, an explicit yes came <em>after</em> seeing the
draft — and none of them are satisfied by “the conversation implied it.” The draft
itself gets rendered as a reviewable HTML artifact (via a separate <code class="language-plaintext highlighter-rouge">lavish</code> skill)
rather than pasted as markdown, because a ticket the user is about to bless is a
sign-off, not a status update.</p>

<h2 id="corpus-work-status-is-the-resume-point">corpus-work: status <em>is</em> the resume point</h2>

<p><code class="language-plaintext highlighter-rouge">corpus-work</code> takes a scoped ticket and drives it to a mergeable PR, one phase per
invocation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Todo/Backlog          → Phase 0: Planning
Testing Scenarios      → Phase 1: Acceptance tests (Playwright or integration)
Initial Implementation → Phase 2: Implementation, drive suite to green
Human Revision          → Phase 3: Human QA + fixes
Impl. Documents         → Phase 4: Fold into permanent docs
In Review               → feature-branch → main PR, awaiting merge
</code></pre></div></div>

<p>The root ticket’s Linear status <em>is</em> the save file — every invocation reads it,
finds the child ticket in flight, and picks up exactly where the last run left
off. That’s what makes “let’s work on COR-5” and “continue COR-5” the same
command.</p>

<p>The one-phase-per-run rule exists for a boring, load-bearing reason: Corpus has no
CI and no branch protection, so a human reading a diff is the only gate between
“committed” and “in the feature branch.” Phase 2 needs phase 1’s tests to be the
<em>reviewed</em> version sitting in the branch, not a version the session merely
remembers writing — so every phase stops at an explicit hard gate before pushing,
and racing ahead defeats the entire premise.</p>

<p>A few mechanisms carry that rule through the details:</p>

<ul>
  <li><strong>Two-tier logging.</strong> A <code class="language-plaintext highlighter-rouge">## Dev Log</code> in <code class="language-plaintext highlighter-rouge">docs/handoffs/&lt;TICKET&gt;.md</code> records every
branch created, in full detail — feature branch, base, why — because a later
run parses <em>that</em> to know what’s in flight. A single Linear comment on the root
ticket gets rewritten (never reposted) with one short line per phase, deliberately
lighter, for a human skimming Linear. Same history, two resolutions, on purpose.</li>
  <li><strong>An escape hatch.</strong> Not every ticket earns four phases — a Bug or a
DX-only Improvement with no new user-observable surface can skip straight to a
single PR, but only after an explicit propose-and-confirm, never an inferred
guess.</li>
  <li><strong>Inspection mode.</strong> Standard by default; Exhaustive (one sentence of <em>why</em> at
every gate, not just a bare yes) for anything touching auth, money, data
destruction, or a ticket that’s already looped back once.</li>
  <li><strong>Loop-back, not silent patching.</strong> If driving tests to green in Phase 2 reveals
the Phase 1 contract itself was wrong, or a human in Phase 3 finds a real gap,
the root ticket goes back to “Testing Scenarios” for another round — logged as
a round, not folded quietly into whatever phase found the gap.</li>
  <li><strong>A PR comment monitor</strong> triages an automated review bot’s comments right after
opening a PR, so that feedback is addressed before the human is asked to look.</li>
  <li><strong>Docs that outlive the ticket.</strong> Phase 4 hands the whole handoff doc — the “why”
behind every decision, not just the diff — to a separate <code class="language-plaintext highlighter-rouge">no-mistakes</code> skill,
which folds it into two permanent <code class="language-plaintext highlighter-rouge">docs/features/&lt;Feature&gt;-business.md</code> /
<code class="language-plaintext highlighter-rouge">-technical.md</code> files and deletes the scratch handoff. Corpus’s own
<code class="language-plaintext highlighter-rouge">DocumentUpload-technical.md</code> is what that produces: architecture, an interface
contract table (every <code class="language-plaintext highlighter-rouge">data-testid</code> and endpoint shape a test can target), failure
modes, and a “Durable Decisions” section recording things like <em>why</em> a
<code class="language-plaintext highlighter-rouge">RAGDocument</code> model got renamed to <code class="language-plaintext highlighter-rouge">RagDocument</code> mid-feature, tagged back to its
source ticket (COR-5).</li>
</ul>

<h2 id="where-this-bites">Where this bites</h2>

<p>None of this is portable, and I’m not pretending otherwise. It’s fitted to one
repo’s specific gap — no CI, no branch protection — and to a team of one where
“the human” and “the person invoking the skill” are the same person. The ceremony
(four child tickets, a handoff doc, a Dev Log at two resolutions) is a real tax,
which is exactly why the escape hatch and inspection-mode dial exist: most of the
overhead is supposed to scale down when the ticket doesn’t need it, not apply
uniformly.</p>

<p>The most recent iteration (<code class="language-plaintext highlighter-rouge">e484887</code>) trimmed the Linear-facing Dev Log down to
one line per phase instead of one per branch, after the fuller version turned out
to be noise nobody actually read on that side. That’s the harness harness — I keep
finding rules that were solving a problem the <em>previous</em> rule created, and cutting
until only the load-bearing part is left.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Last post compared my homegrown harness against OpenSpec at the philosophy level — gated vs. fluid. This one skips the comparison and just opens the thing up. It’s two Claude Code skills, corpus-ticket and corpus-work, built for and living inside Corpus, a project I’m building solo (private repo, for now — the harness is the interesting part, not the product it’s scoping).]]></summary></entry></feed>