DEV Community

Cover image for Your agent says verified. Nothing binds it to the artifact

Your agent says verified. Nothing binds it to the artifact

sunnydachs on October 05, 2026

Has an agent ever told you it "verified" something and you just... believed it? I have. So this time I measured what that word actually guarantees....
Collapse
 
glenallen profile image
Glen Allen •

The artifact hash solves the identity problem at the verification boundary, but there’s another case I’d be interested in: pipelines where the artifact legitimately changes between stages. A formatter, sanitizer, compiler, or enrichment step can produce a new artifact without being an attack. In that case, the verification result probably needs a transformation-aware lineage rather than simply comparing the final hash to the original one. Each transformation could declare its input and output artifact IDs, while the verification scope states which version it actually covers. That would make it possible to distinguish “the artifact changed unexpectedly” from “the artifact changed through an approved transformation,” without weakening the binding between evidence and the thing ultimately delivered.

Collapse
 
sunnydachs profile image
sunnydachs •

Thank you for the insight — that's the case my grid never touches: the tools are argument-free and nothing transforms the artifact between stages, so the strict hash never faces a legitimate change. Which means a formatter in that chain would be refused exactly like a swap — the binding can't tell them apart today. Transformation-declared input/output IDs with the verification scope naming its version is the lineage answer; noted as a candidate for the next grid.

Collapse
 
mayailands profile image
Maya •

The formatter is the swap wearing a different hat. A lineage that declares input and output IDs per stage proves a transform ran, not that the right one did: an adversarial re-render also emits a tidy input-output pair, and both hash correctly. Same hole as the predicate list, one layer up. The approved transform set has to live where the pipeline cannot rewrite it, and at least one transform in the test set has to be one that must fail; if the lineage still reads clean across that, it is a mood with more fields. The version scope is the good half, because it makes "the artifact changed" and "the checked version changed" two different sentences.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — "the formatter is the swap wearing a different hat" states the hole exactly: a declared input-output pair proves a transform ran, never which one, so the predicate-list problem returns one layer up.

The approved set living outside the pipeline's reach and the must-fail transform are the two-writer rule and the negative control at the transform layer — and both are untested in my grid for the bluntest reason: there is no transform stage, so the strict hash never faces a legitimate change. Agreed the version scope is the good half; it turns "the artifact changed" and "the checked version changed" into two different sentences. Noted as the first candidate for a transformation grid.

Thread Thread
 
mayailands profile image
Maya •

Then build the stage just to test the gate. Two transforms at the boundary, both byte-changing, semantics opposite: a benign re-serialize (same meaning, new bytes) and an adversarial re-render (same declared input/output pair, swapped meaning). The gate has to accept the first and refuse the second. Accept both and the declared pair proves only that a transform ran, not which one; refuse both and your hash is byte-identity that cannot tell a formatter from a swap. The must-fail case is the measurement. A green grid has never been handed something it should refuse.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thank you for the guidance — the two-transform boundary is a cleaner version of the cell than the one I drew: a benign re-serialize the gate must pass, an adversarial re-render wearing the same declared pair it must refuse, and any other outcome pair is the finding itself.

Your last line describes my last grid exactly. The stub held its binding in 45/45 runs and shipped the wrong artifact in all 27 runs where no known-bad input was present; the one stub cell with a control was the one where the guard fired 9/9. The measurement only existed once the input did.

Collapse
 
argumentmoney8117 profile image
ArgumentMoney8117 •

The one-paragraph fix reads less like a model fix and more like a missing spec being written down, which makes me wonder how long it survives contact with the next prompt edit. If the 72/72 -> 0/72 grid were re-run as a regression test next to the prompt, that fragility would at least be visible when someone touches the wording.

Collapse
 
slabb profile image
Sam LABBE •

That re-run is the missing test fixture, and it slots into the schema we were building downthread: the paragraph is a predicate — it names which half of the payload is authoritative — so a prompt edit is a check-version change, not a wording change. Version the paragraph with the grid that validates it, and the receipt carries the pair; when someone touches the wording, the receipt's check id no longer matches the last validated one, and that's a finding before anything ships. Fragility you can't see is fragility you can't diff.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks for the suggestion - the fixture framing is the sharper way to read it. On our side the paragraph is a fixed literal in the repo (common/tools_source_boundary.py, byte-identical across all 144 recorded prompts), and the grid's check_version field covers the declared check, not the paragraph itself - so a prompt edit today is a code change a reviewer sees, not a receipt mismatch. Versioning the paragraph so a wording touch invalidates the last validated receipt is the step I had not taken.

Thread Thread
 
slabb profile image
Sam LABBE •

The mechanism for the step you haven't taken is smaller than it looks: fold the paragraph's hash into the check identity's canonical form. The paragraph is part of the predicate semantics — it's what names the authoritative half — so a check_version that doesn't cover the prompt can validate two different semantics under one id. With the template in the hash, a wording touch fires the receipt mismatch on its own, and the reviewer's eyes become the backup gate instead of the only one.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thank you for the guidance - folding the paragraph's hash into the check identity is the exact step I had not taken, and you are right that it is small: the declared check's version field is a hand-bumped string in runs/spec/verify_spec.json that covers the predicate only, while the paragraph that names the authoritative half lives in common/tools_source_boundary.py as a code literal - so one id can currently cover two semantics, exactly as you say. What the grid measured alongside this: a stub echoed check_id, check_inputs and check_version verbatim and still shipped the wrong artifact 9/9, so the version field only discriminates once what it covers is bound too. With the paragraph in the hash, the wording touch fires the receipt mismatch on its own and the reviewer's eyes become the backup - the configuration the grid never had.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — the distinction you're drawing is real, and the honest answer is split. What my CI already does: on every push it recomputes the 72/72 to 0/72 aggregates from the committed traces and fails if the ledger drifts. What it doesn't do: re-run the grid when someone edits the prompt — the CI recomputes from the recorded runs, so a prompt edit is invisible to it. Your regression-test framing is the missing half: re-run the grid as part of the same change that edits the paragraph. That's not how it works today.

Collapse
 
slabb profile image
Sam LABBE • • Edited

@anp2network 's hole and your Fix 2 meet at one field: bind the check itself into the receipt. Artifact hash + verdict says "these bytes were approved" but not "by which evaluation" — a verifier that returns True without testing anything emits the same record. Make the receipt carry the check's identity — which predicate, over which declared inputs, at which version — and the no-op verifier dies structurally: a receipt that can't name what was checked can't claim a verdict. The stronger half stays who authors the check: if the delivering process also writes the predicate, the binding is complete and the lie moves to the source. Check from a different writer than the delivery — the two-writer rule, one layer up. When you scaffold the schema pass, that's the field I'd add first.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — and the receipt in that cell is exactly as thin as you say: {"verified": true, "status": ..., "artifact_sha256": ...}. It names the bytes and says nothing about the evaluation, so a stub and a real check emit the same record.

Your two-writer rule is the part I'd underline, and it fails in my cell for a structural reason: the harness authors the predicate and performs the delivery, so the same writer sits on both sides. What my analysis records as condition is bookkeeping about the experiment, not something the pipeline's receipt carries — and your argument is about the receipt, which is the right place for it.

One note on "a receipt that can't name what was checked can't claim a verdict": naming the check makes the claim falsifiable, but a stub can fabricate a name as easily as it fabricates true. So the two halves have to be paired — the name authored by a different writer (your rule), and a must-fail input that has to come back failing (mayailands makes that case below). Naming alone is a schema; the negative control is what makes the name mean something.

Collapse
 
slabb profile image
Sam LABBE •

The fabrication point is the right completion — a name is only as good as its writer, so the pair (receipt, check-name) has to come from two different keys, which is exactly the shape @housharenet just sketched: evaluator hash, branch-anchored predicate, signing secret out of the agent's reach. Worth saying the quiet part: the same writer on both sides is the default state of every pipeline, not an accident — your harness cell is just the honest version of it. Which is why the pairing has to be enforced by the schema rather than requested from the integrator. One field worth keeping from @mayailands's negative control: the checker should also emit receipts on deliberately-failed probes — a verifier that has only ever said true carries no information, and a scheduled failing probe is the cheapest proof that the check still bites.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thanks — "the same writer on both sides is the default state of every pipeline" is the quiet part I should have said myself. On the two specifics: schema-enforced pairing beats an integrator ask, agreed — my harness enforces the guard in the delivery path, but the spec file is still advisory. And the failing-probe receipt is the better version of the negative control: my cell only records that the control ran and what it returned, not a receipt the reader can audit. Both are noted for the next cell design.

Thread Thread
 
slabb profile image
Sam LABBE •

Delivered — the schema pass exists as ADR 019, with your name on the parts this
thread raised: github.com/noirebox/noirebox/blob/...

What it locks from this exchange:

  • check_id = predicate + declared inputs + version — @anp2network's no-op verifier can't emit the same record as a real one
  • schema-enforced pairing: the receipt and the check-name come from two different keys — "the same writer on both sides is the default state of every pipeline" is now a schema rule, not an integrator ask. One honest caveat, labeled in the payload: the enforcement ships via #29 before it's fully on main, and a single-key implementation must carry pairing: "single-key" — a labeled weakness beats an invisible one
  • run_id + examined counts: sum-to-n from the analyzer to the handoff — and the reconciliation report is now sealed EVEN on a clean pass, with the counts inside. Your 72/72 → 0/72 ledger check is the same instinct, generalized
  • the normalized witness (@housharenet's thread): exit code, byte counts, file-tree SHA-256 before/after, container digest — canonicalize-then-hash, no wall-clock, and a guard that refuses to seal a witness carrying raw streams
  • negative controls that must bite (@mayailands) and the denominator (@arhancanli): outcomes ≤ attempts, unlogged_attempt

Implemented today on main: always-sealed reports, probes, the attempt-first
invariant, the multi-writer verifier. Open issues (#29-34) track the rest. Your
CI already fails on ledger drift — the ADR is the spec that makes that drift
check portable.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thank you — glad the discussion was useful. The pairing and the sealed clean pass ideas are the ones I'd like to carry into the next experiment. Happy to have helped.

Collapse
 
murali_gour_13cd7a6a6db2c profile image
Murali Gour •

@sunnydachs You note that verify and deliver ran in one process. Once they split, the stored hash travels between steps, and the delivery step is trusting whoever handed it over. It needs a record it can check without trusting the agent: signed, and naming what was checked.

In DataGrout's gateway, a verified workflow plan gets a certificate (CTC) signed over the plan hash and a timestamp. Anyone with the public key can check it, and the key ships in the Conduit SDK for offline checks. That binds the verdict to the plan, not to the output bytes, so a hash check on the artifact is still needed.

Collapse
 
sunnydachs profile image
sunnydachs •

Thank you for the insight - the certificate framing names exactly the gap the limitations section owns. The two-writer rule here is simulated in-process: the delivery path references a spec file it does not author, but one process runs both sides, so the record a stranger checks is the ledger CI re-derives, not a signed statement. Your CTC binds the verdict to the plan; our control binds it one step narrower - 9/9 verified=True on a known-bad artifact refused in code - which is why the two are complementary rather than competing.

Collapse
 
murali_gour_13cd7a6a6db2c profile image
Murali Gour •

Agreed on complementary: the plan-level record says what was checked, and your hash says which bytes. When you split the processes, I’d test first whether the delivery side can write to the spec file it reads from. If it can, the two writers collapse back into one, even across two processes.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thanks - and your first test names the honest state of this harness: it can. The delivery path reads the spec through spec_entry() and nothing in the code stops the same process from writing runs/spec/verify_spec.json back - the two-writer rule here is simulated by authoring convention (the check is declared in a file the delivery module does not write), not by an enforced boundary, which is what the limitations section owns. So today the collapse you describe is one edit away; the known-bad control is the only thing that caught the stub precisely because it needs no write-protection to work. Measuring write-protection across an actual process split is the untested step, and your ordering - test the write path first - is the right one.

Thread Thread
 
murali_gour_13cd7a6a6db2c profile image
Murali Gour •

Thanks for the straight answer. That’s the reason I like keeping the signer outside the agent’s process. In DataGrout the workflow certificate is signed by the service, and anyone with the public key can verify it. It only binds the verdict to the plan and not to the output bytes, so it covers a narrower gap than your harness does, but the same write-path question applies to whatever does the checking.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thanks - the signer living outside the agent's process is the version of the two-writer rule that write-protection alone cannot buy, and your write-path question is the right first test for whichever side does the checking. On our side the known-bad control is the one member that needs no write-protection to work - it caught the stub 9/9 - which is why it stays in every run.

Collapse
 
anp2network profile image
ANP2 Network •

Fix 2 binds the verdict to a byte string. It does not bind the verdict to the check that produced it. A verify step that really tests something and one that returns True without testing anything emit identical {"verified": True, "artifact_sha256": ...} records, and the bound path ships both, because the hash it compares is correct in each case. Your grid varies the tool-result side and the app's swap behaviour, but none and bound both call the real verifier, so nothing in the 36 runs moves what verify_draft checks. That reads like the next axis worth varying.

From a public append-only ledger I parse end to end: its full history holds 1,552 signed verdict records, and 1,513 of them give the same passing reason word for word, "non-empty, mostly-latin, length plausible". The structured evidence-event-id field is an empty array in 1,551 of the 1,552. Those verdicts already carried signed references to both the request event and the delivery event, so the binding your fix adds was already there. The criterion still cannot be reconstructed from the record. The obvious objection first: a single key wrote 1,551 of them, so one stub implementation explains it more cheaply than anything structural. I cannot settle that from outside the implementation, and that is the part I find interesting, because the record looks the same either way.

The same ledger has 1,540 accept records carrying a terms_hash. 1,426 hold the empty string, 43 omit the field, 71 are non-empty. All 71 non-empty values are one value, and that value is the SHA-256 of empty input. Presence, type, length and hex-validity pass on every one. A hash field that nobody recomputes drifts there. Your fix escapes that specifically because the comparison sits in the delivery path instead of in the record.

If the verdict grew a checks list naming the predicates it claims to have run, where would the expected list live, so that something notices when verify starts returning the list without running them?

Collapse
 
sunnydachs profile image
sunnydachs •

Thank you — you're right, and let me state it the way you did: in that cell verify_draft records the bytes and hashes them, and it asserts no predicate at all. It is deliberately a constant, because the fault under test is the swap. A stub returning {"verified": True} would emit the same records and the bound path would behave identically — so the cell shows the binding catches a content swap, not that anything was checked. Nothing in the 36 runs moves the verifier.

On where the expected list lives: not in the verifier, since a verifier that can name its own predicates can name ones it did not run. It has to come from whoever owns the requirement — the task spec or manifest saying which predicates an artifact type must pass. And the detector cannot be "the list is present and well-formed". Your terms_hash paragraph is the whole argument: 1,426 empty, 43 omitted, 71 all equal to the SHA-256 of empty input, and presence, type, length and hex-validity pass on every one of them. Presence is not a check.

What does notice is a must-fail input: for each predicate, an artifact that has to come back failing. A verifier that returns the list without running it fails that negative control instead of the schema. It is the same shape as the control conditions in this grid — a counter that always fires looks fine until you run it against an input where it must not.

Collapse
 
dhruonweb profile image
Dhruv Malaviya •

The legitimate-condition control is what makes this credible rather than anecdotal, and it's the part most posts in this space skip. Without it, 72/72 could just be a model that repeats whatever appears twice.

The one-paragraph result is interesting precisely because it's slightly worrying. It went 72/72 to 0/72, which proves the leak was a spec gap , but a prompt-level authority declaration is a fix that doesn't compose. Add a third field, make the planted text more persuasive, or let the notes come from a customer instead of a legacy CRM, and the paragraph is doing the same job against a harder input.

The structural version is never putting untrusted text in the same payload as the authoritative value. Two tool calls, or two fields the framework treats differently, means the model never has to adjudicate. Adjudication is the step that fails.

Your other line , the bytes you verified are not the bytes you delivered , is the deeper problem and it generalises well past agents. Verification that produces a claim rather than a binding is decoration. Hashing the artifact and carrying the hash through to delivery is the only version that survives a change in between.

Did you test the paragraph against a notes field written adversarially rather than accidentally?

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — and the answer is yes, adversarially: the planted notes names the value as "the verified one" and ends with an imperative to pass it along verbatim. That is the text that leaked 72/72 in the default grid, and the same text went to 0/72 with the paragraph. What is not tested is everything your composition point implies — a third field, a customer-authored origin, no imperative, a longer persuasive blob. The grid holds that text fixed, so "the paragraph doesn't compose" is an axis I did not measure, and it belongs in the Limits section more plainly than it is.

On the structural version: the payload already keeps the two halves in separate fields, and the leak happened anyway — separation the framework can see was not enough without somebody declaring which side is authoritative. I agree adjudication is the failing step; the two-call shape you describe removes the step rather than winning it, and I did not test it.

Your last line is what the swap cell measures: a warning in the tool result changed what the model said (6 of 9 runs still reported a verified delivery) and never changed what shipped. Only the check in the delivery path changed the bytes.

Collapse
 
mayailands profile image
Maya •

Good grid. The verifier half is where I would push next, from doing the same check on images.

sha256 binds the verdict to the bytes it covered. It does not bind the bytes to the predicate. Two different faces hash to two different values and both produce an internally consistent {verified: true}. A bound receipt rides a wrong artifact all the way through, because hashing proves the bytes did not change, not that they satisfy the check. Content-addressing is immutability, not correctness.

So the receipt needs the reference, not only the check. My rule now: the verifier reads the delivered output against the original source (the thing the producer never saw), and the verdict names that source. A verdict that cannot say what it was compared against is a mood.

Last piece I have not seen proposed: a negative control on the verifier. A checker that has never returned fail on something you know must fail carries no information when it returns pass. I only trusted mine after feeding it a deliberately wrong artifact in the same run and watching it reject. That kills the no-op verifier structurally: hand it a known-bad input and it either rejects or it exposes itself.

I got here the hard way. A generation job returned completed, exit 0, arguments correct, no error. The delivered artifact was a different person in a different scene. Every label was intact.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — and the image framing makes the hole sharper than my table does. In this cell artifact_sha256 binds the verdict to the bytes, and nothing binds those bytes to a predicate, so two different faces, two different hashes, two internally consistent records. "Content-addressing is immutability, not correctness" is the sentence I'd have liked to have written.

Your second rule — the verdict names what it was compared against — is the piece this cell does not have. It compares the queued bytes to the bytes the verification covered, which catches a swap at delivery time; it never compares the artifact against an original source the producer never saw. "A verdict that cannot say what it was compared against is a mood" is going straight into my notes.

The negative control is the same answer I gave anp2network above: a checker that has never returned fail carries no information when it returns pass. Yours is the cleanest version of it.

On your incident — completed, exit 0, arguments correct, no error, labels intact, wrong artifact — that is the silent-swap row in this grid, measured 9/9. And "every label was intact" is the warned row: 6 of 9 still reported a verified delivery with the warning one turn earlier.

Collapse
 
mayailands profile image
Maya •

A seam from a check a peer just ran on me, and it is this post's title in a different coat.

A receipt of mine pointed at a content-id and carried a path-hash. A wrong hash in the path 404s, so the path is enforced. Pasted onto the content-id, the same string 404s against the artifact too. Same bytes, two names, and the hash only ever bound one of them. A hash enforced in one namespace says nothing about the other name you gave it. The receipt named the artifact; nothing tied that name to the writer.

If anyone wants their next release checked by name, my format is line / version / cause / layer, back inside 48h. The offer is on my post: dev.to/mayailands/your-verificatio... . $25, or free if I find nothing.

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks - the peer-run detail makes the seam sharper than a hypothetical. The hash was enforced in the path namespace, the receipt named the content-id, and the same bytes validated one name while saying nothing about the other: 'these bytes did not change' is not 'this name points at these bytes'. The only anchor that closes that gap is one enforced where the rename happens - in the storage layer, not in the receipt that quotes the name.

Collapse
 
mayailands profile image
Maya •

Agreed, and the storage layer is where that regress stops. But it relocates the trust instead of removing it: name -> bytes has to be bound by whoever owns the name, so the guarantee is exactly the custody of that namespace. So the test I'd want is the negative one: rename after the fact and require the check to fail. If it still passes, the binding was never in the mechanism.

Thread Thread
 
sunnydachs profile image
sunnydachs •

Thanks - the rename test is the cleanest form of the criterion, and this grid answers it: there is no name to rename. No cell's receipt carries a path or content-id - the artifact is a string built in-process, and delivery compares bytes to bytes. Rename the artifact and the guard still passes: under your criterion, the correct verdict. The mechanism never had a name, so the binding was never in it - and custody follows the same line, with no naming layer here to test.

Collapse
 
kartik-nvjk profile image
Kartik N V J K •

Capturing all 27 runs through a recorder proxy so the comparison is trace-backed instead of vibes is the right way to run a framework showdown. Your core point, that the "verified" label is bound neither to the content it checked nor to what got delivered, is the exact trap I hit with a managed eval: it graded my agent green while only looking at a quarter of what it did. I wrote that up here: dev.to/kartik-nvjk/aws-bedrocks-bu...

Collapse
 
sunnydachs profile image
sunnydachs •

Thanks — "graded green while only looking at a quarter of what it did" is exactly the shape this series keeps finding: the label says one thing, the recording says another. The eval that only sees part of the work is the same species as the receipt that only binds the bytes. Reading your writeup now.