DEV Community

Cover image for Compare Against the Schema They Shipped, Not the One You Expected
Self-Correcting Systems
Self-Correcting Systems

Posted on AI-assisted

Compare Against the Schema They Shipped, Not the One You Expected

A brilliant post-mortem on rigid tool assertions

My harness flagged the model for sending the wrong arguments. It compared what the model actually sent against what the run had committed to in advance, and they did not match.

The mismatch was real. The conclusion I drew from it was wrong, and the comparator could not have told me so.

Here is the check that was incomplete, the check that replaced it, why the fix is not "loosen the comparison," and the part of it that will rot.

The setup

The harness prepares an exec call before the model runs, freezes it, tells the model to send exactly that object, then compares the model's actual tool arguments against the frozen one. If they differ, the run fails closed. That comparison is the control.

The expectation was built like this — 5bf10ac, scripts/judgment/candidate.mjs:

const expectedExecArguments = validateExecArguments({
  command: `node ${artifactPath('verifier')} ${artifactPath('payload')} ${artifactPath('manifest')}`,
});
Enter fullscreen mode Exit fullscreen mode

One key. From the run receipt, RUN_004_RECEIPT.json, here is what the model actually sent, verbatim:

{"intent": "Run candidate verification", "command": "node /opt/tf/uploads/candidate-verifier.cjs /opt/tf/uploads/candidate-payload.json /opt/tf/uploads/candidate-command-manifest.json"}
Enter fullscreen mode Exit fullscreen mode

Outcome in the same receipt: EXEC_ARGUMENTS_MISMATCH.

Two contracts, and they disagreed

This is the part I got wrong on the first read, and it is more interesting than "the model deviated."

The provider's exec schema — from the compiled artifact I ran, @truefoundry/[email protected], dist/core/sandbox/Sandbox.mjs:

var sandboxExecSchema = z.object({
  intent: z.string().describe(
    "A brief, user-facing explanation of the purpose of this command. Avoid technical jargon and code details."
  ),
  command: z.string().describe("The shell command to execute in the sandbox."),
  cwd: z.string().optional().describe("Working directory for command execution."),
  env: z.record(z.string(), z.string()).optional().describe("Additional environment variables to set.")
});
Enter fullscreen mode Exit fullscreen mode

Four fields. intent and command required. cwd and env optional. This is the implementation schema wired into the sandbox exec tool; I am quoting what the runtime accepts, not a tool descriptor captured from the session.

My harness's instruction, scripts/judgment/live.mjs, present before the fix and after it:

'Call truefoundry-system exec exactly once with this exact JSON argument object: ' + ...
Enter fullscreen mode Exit fullscreen mode

At that moment the object being interpolated had one key.

So the model was handed two authorities that disagreed, and it followed the provider's. It satisfied the required-field schema and missed my exact-JSON instruction, because my instruction was asking for something the schema forbade. My comparator saw one difference and reported it as deviation. It had no way to represent "both sides are internally consistent and one of them is wrong."

That is the finding. Not that the model was right — that a mismatch establishes difference, not which operand is authoritative.

The fix, and the part that matters

The tempting repair is to compare less — check only command, ignore extra keys, move on. That makes the failure disappear and takes the control with it. An agent could then send any additional argument it liked and still pass.

What landed instead, in 0220a27:

export const CANDIDATE_VERIFICATION_INTENT = 'Run candidate verification';

const expectedExecArguments = {
  command,
  intent: CANDIDATE_VERIFICATION_INTENT,
};
Enter fullscreen mode Exit fullscreen mode

The intent value is harness-authored and constant, not copied from what the model sent. Copying it would make the comparison check the model against itself.

That commit is titled "Implement adopted transport A and B controls" — the correction rode inside a larger transport change rather than shipping as a dedicated fix. Worth saying, since I am asking you to open it.

The expected-object gate also got stricter:

const argumentKeys = Object.keys(expectedArguments).sort();

if (argumentKeys.length !== 2 || argumentKeys[0] !== 'command' || argumentKeys[1] !== 'intent' ||
    expectedArguments.intent !== CANDIDATE_VERIFICATION_INTENT ||
    typeof command !== 'string' || command.length === 0) {
  failures.add('EXEC_ARGUMENTS_MISMATCH');
}
Enter fullscreen mode Exit fullscreen mode

Three separate things, and only one of them changed:

  • The expectation was corrected — one key became two.
  • The expected-object gate got stricter — that block is new.
  • The actual-versus-expected comparison stayed exactly as it was. It already read:
const actual = parseStrictJson(call.function.arguments);
...
} else if (!canonicalJsonBytes(actual).equals(canonicalJsonBytes(prepared.expectedExecArguments))) {
Enter fullscreen mode Exit fullscreen mode

That line is identical before and after. Keys are sorted during canonical serialization, so it is a canonical object compare rather than a raw-byte one, and JSON key order cannot cause a false mismatch. I did not repair a false failure by weakening the comparator. That is the whole point.

What I fixed is narrower than it looks

The provider's contract: intent and command required, cwd and env permitted.
My frozen run contract: exactly command and intent, nothing else, intent fixed to a constant.

Mine is deliberately narrower. A permissive provider does not oblige a harness to accept every schema-valid variation — if the run precommitted to two specific arguments, rejecting a third key is a legitimate harness constraint. But it is my policy, not TrueForge's requirement, and writing it as though the provider demanded it would be the same error in the other direction.

And here is what is not fixed. argumentKeys.length !== 2 hardcodes two things at once: the provider's current required-field set, and my run's decision to prohibit the optional fields. If TrueForge adds a third required field tomorrow, my harness will reject a model for complying with the new schema unless I edit the harness too. The durable version derives the provider-required fields from the actual tool schema, then applies the narrower harness policy explicitly on top. I have not built that. If you copy this pattern, copy the problem with it.

The check you can steal

1. Where did the expected object come from? If it came from your reading of the API, it encodes your assumptions. If it came from the provider's schema, it encodes theirs. Only one is authoritative about what a compliant call looks like.

2. Does a failing comparison tell you which side is wrong? Mine did not. It printed a mismatch and I had to open the provider's source to find the expectation at fault. A mismatch establishes difference, not which operand is authoritative.

3. When you fix a false failure, does the check get weaker? This is the one that bites. The fastest way to clear a red comparison is to compare less, and every time you do it you buy a passing run by selling the control that made passing mean something.

The general shape: a control that fires wrongly is not evidence the control is too strict. It is evidence that something on one of its two sides is wrong, and you have to find out which before you touch it.

What it cost

Two things, and I want both on the record. I drew the wrong first conclusion about which side deviated. And the run still did not verify — the same receipt carries EXEC_RESPONSE_SHAPE_UNEXPECTED alongside the argument mismatch, and the sandbox turned out to have no JavaScript runtime at all. That half is written up separately.

If you have a comparison sitting between you and a model, go read the schema you are comparing against and check that your expected object satisfies it. It takes a minute, and it prevents the afternoon you spend investigating a model that satisfied the provider's tool contract while my own instruction conflicted with it.


Before: 5bf10ac. Correction contained in: 0220a27. Model arguments and outcome from RUN_004_RECEIPT.json. Provider schema read from the compiled @truefoundry/[email protected] artifact in my own node_modules, not from upstream source. The runtime half of this same run is in I Built an Agent That Marked Its Own Finding as Already Known.

Top comments (28)

Collapse
 
salparvez profile image
Salman Parvez

"A mismatch establishes difference, not which operand is authoritative" is the whole problem in one line. The fix we landed on for a multi-author record was to make authority explicit and domain-scoped before the comparison runs: the provider is authoritative on the tool schema, the harness is authoritative on the intent, and a standoff between them is recorded as a conflict rather than resolved by whichever side is easier to edit. Loosening the comparison is the same move as deleting the losing claim — it makes the red go away and destroys the evidence that there was a disagreement.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

"deleting the losing claim" is the sentence i'd have needed six months ago, and i can hand
you the one receipt that says i didn't do it. the comparator across that commit:

sha256(canonicalJsonBytes(prepared.expectedExecArguments))
Enter fullscreen mode Exit fullscreen mode

character identical before and after, only the line number moved, 152 to 159. what changed
was the expected object, one key to two. the red went away because the claim was wrong, not
because the check got softer.

but your first half is the part i can't answer. the provider is authoritative on the schema
is a decision i made, and it exists in a commit message. nothing in the harness records it.
i edited the side that was easier to edit, and it happened to be the correct side, and no
artifact anywhere knows the difference. next time the easy side is the wrong side the same
move produces the same green.

and my code can't hold a standoff even if i declared one. eight paths reach
EXEC_ARGUMENTS_MISMATCH: six conditions in one if, the sha compare, and a catch. a
structural difference, a wrong key, and a thrown exception are the same output.

the half you'd give the harness, authority over intent, is the half i implemented worst.
i asserted it by comparing a constant to itself, so there was no comparison left to record
a conflict in.

Collapse
 
naw103 profile image
Nick Woodhead

The part I keep thinking about is that a receipt is itself a claim, and RUN_004 makes two of
them about the model when neither one is.
EXEC_ARGUMENTS_MISMATCH came from an expectation no compliant call could satisfy.
EXEC_RESPONSE_SHAPE_UNEXPECTED came from a sandbox with no JavaScript runtime in it. Both are recorded in the file whose subject is the model's behavior, and a reader who opens it a year from now sees a run where the model failed twice. It didn't. That run produced no evidence about the model at all, in either direction, and nothing in the artifact says so.
That's your own thesis one layer out. The comparator encoded an authority it couldn't
represent and the receipt encodes a subject it can't represent. A wrong key is a claim about the model, a thrown exception inside the comparator is a claim about your code, and if you build the preflight you described in the other thread, its failure is a claim about the run's setup. Three different subjects, one failures set, one namespace.
The consequence shows up the moment anything counts these. A pass rate over receipts puts RUN_004 in the denominator as a model failure, and so does a human skimming for patterns. The split I'd want isn't finer error codes underneath EXEC_ARGUMENTS_MISMATCH, it's an outcome that isn't a finding at all ie. runs that were never capable of producing evidence get marked as such and drop out of every count of model behavior, instead of resolving to the model's disadvantage by default.
Worth saying that the current shape fails safe.. A broken harness manufactures deviations but it never manufactures a pass. That's the right side to fail on, and it's also why nothing in the run will ever tell you it happened.

Thread Thread
 
salparvez profile image
Salman Parvez

"A receipt is itself a claim" is the correct frame, and it settles the counting problem: a claim has a subject, and a count is only valid over claims that share one. RUN_004 holds two claims whose subject is the harness (an expectation no compliant call could satisfy, a sandbox with no runtime) filed under the model's name. The fix isn't a finer code underneath EXEC_ARGUMENTS_MISMATCH, it's a subject field on every entry, assigned at the site where the failure is caught, so the pass rate is computed over model-subject entries only and the harness-subject entries land in their own count, where two of them in one run is the actual finding.

On "never capable of producing evidence": we treat that as an entry with no evidence grade rather than a low one. It stays in the record, because deleting it would hide that the run happened, but it carries no grade and cannot enter any aggregate. Fail-safe silence is the argument for it. The only way to see a harness that manufactures deviations is to count the harness-subject entries, and you can't count what was filed under the model.

Thread Thread
 
pm25coder profile image
pm25coder

A subject field assigned at the catch site is the more general fix, and it absorbs the rename I suggested rather than competing with it. At the catch site the entry's subject is the harness by construction, so a subject field makes "the comparator machinery errored" a first-class statement about your code, and the finer outcome code becomes an optional sub-division of the harness-subject class instead of the whole fix. The RUN_004 counting argument is the part I'd underline: two harness-subject claims filed under the model's name is a finding about the harness, and no finer failure code underneath the model's namespace can surface it — the count never sees the subject boundary, so the boundary has to be a field.

Keeping never-capable-of-evidence entries with no grade is the right call, and it matches the pattern we've held to on a different artifact for the same reason. Our memory index never deletes rows: entries that stop being load-bearing are marked superseded in place, and a file that never got indexed is retained rather than dropped (current state: 13 detail files, 12 indexed, 1 retained-unindexed, 12/12 index rows backed, zero dropped rows). Nothing is removed because removal is the one action you can't re-derive: a deleted entry can't be re-counted once you build the per-class view, exactly as a harness-subject deviation can't be re-counted once the receipt is written. Keep everything, class it at write time — that is what makes the later audit possible at all.

And "conflict is a state the receipt can hold, not a failure code" deserves its own sentence: it's what lets the comparator emit only difference and the verdict step stay honest about missing authority. A receipt that can represent "difference with no authority entry" never has to guess a subject — the machinery's limits recorded as the machinery's limits, not as the subject's behavior. That is the same property as a subject field, one level up: the record can be wrong about the world, but it should never be wrong about who is speaking.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems

you're right that the outcomes need to say what they're about. one qualification: "no evidence about the model at all" goes further than the receipt supports. it retains the actual tool call. that call satisfied the provider's required-field schema and differed from my exact-object instruction, because those requirements conflicted.

the receipt also already says:

"artifact_written": false
Enter fullscreen mode Exit fullscreen mode

and labels candidate verification as not established. what it doesn't give a reader is a clean separation between setup validity, observed behavior, and whether that behavior can support the assessment they want to make.

i'd keep the run and its observations, mark the conflicting setup, and exclude it from a score that assumes a valid setup. that doesn't require erasing what the model actually sent.

a wrong key isn't automatically a model failure either; this run is the counterexample. attribution needs the applicable contract. and i'd keep "failed closed in this run" separate from "a broken harness can never manufacture a pass." we haven't established that broader guarantee.

 
kenielzep97 profile image
Self-Correcting Systems

you're right that the outcomes need to say what they're about. one qualification: "no evidence about the model at all" goes further than the receipt supports. it retains the actual tool call. that call satisfied the provider's required-field schema and differed from my exact-object instruction, because those requirements conflicted.

the receipt also already says:

"artifact_written": false
Enter fullscreen mode Exit fullscreen mode

and labels candidate verification as not established. what it doesn't give a reader is a clean separation between setup validity, observed behavior, and whether that behavior can support the assessment they want to make.

i'd keep the run and its observations, mark the conflicting setup, and exclude it from a score that assumes a valid setup. that doesn't require erasing what the model actually sent.

a wrong key isn't automatically a model failure either; this run is the counterexample. attribution needs the applicable contract. and i'd keep "failed closed in this run" separate from "a broken harness can never manufacture a pass." we haven't established that broader guarantee.

Collapse
 
salparvez profile image
Salman Parvez

The commit message is the right instinct in the wrong place. "The provider is authoritative on the schema" is a claim about authority, and it belongs in the same artifact as the comparison, with an author and a date, so the edit that added the second key can cite it. Then the next time the easy side is the wrong side, the edit either cites an authority entry that doesn't cover that domain or cites nothing, and either one is visible in the diff of the record rather than the diff of the code.

On the standoff: separate the comparator from the verdict. The comparator's only output is difference (which keys, which values, or "threw"), and a second step assigns that difference to a subject using the recorded authority entries. No authority entry for the domain in question means the outcome is conflict, and conflict is a state the receipt can hold, not a failure code. That is what stops eight paths collapsing into one: the comparator never had the information to name a subject, so it shouldn't emit one.

The constant compared to itself is the same defect as an approved_by column: a verification with nothing bound to it. If the intent check has to survive, bind it to what the model actually sent. Record observed.intent verbatim, hash it into the receipt, and compare the harness's expectation against that hash, so the comparison has two sides again and a mismatch means something actually changed.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems

putting the authority record beside the comparison makes sense. i need to correct something in my previous reply before building on it, though: the actual-versus-expected comparison already exists:

canonicalJsonBytes(actual).equals(
  canonicalJsonBytes(prepared.expectedExecArguments)
)
Enter fullscreen mode Exit fullscreen mode

the constant check i quoted is a separate check on the prepared expectation. it doesn't establish model compliance, but it also doesn't mean the observed operand was discarded. run 004 retains the actual arguments.

the missing part is attributing the mismatch against a declared authority, not restoring a comparison that never existed. i'd separate an observed difference, a failure to perform the comparison, and an unresolved authority question.

retaining and hashing observed.intent is useful for provenance. it doesn't establish that the explanation is true or that the call was authorized. if we change intent from exact equality to a shape check, that's a deliberate change to the frozen policy, while command and any execution settings still need their own constraints.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Freezing intent to a constant costs you the operand this post-mortem most needed. The provider's own describe() calls it "a brief, user-facing explanation of the purpose of this command" — it is the one field in the call that carries the model's account of what it thought it was doing, and requiring expectedArguments.intent !== CANDIDATE_VERIFICATION_INTENT to fail closed turns it into a checksum. So the next time a comparison fires and you are back at "a mismatch establishes difference, not which operand is authoritative," the field that would have spoken to authority is guaranteed to be either your constant or absent.

command is the operand that actually executes, and exact equality there is the control worth keeping. intent could stay unconstrained and be recorded in the receipt under a shape check instead of an equality one, which keeps the fail-closed gate on the thing that runs while leaving something in the payload that separates a deviating model from an instruction that conflicted with the schema.

Collapse
 
naw103 profile image
Nick Woodhead • Edited

The separation that makes this safe is recording the field somewhere the comparator cannot reach. observed.intent written verbatim into the receipt, expected.intent never populated from it, and no code path that reads the first.
The untrusted-text objection is really an objection to routing on it, not to keeping it.
Text you never branch on can't be used against you, and it's the only thing in the payload that would separate "the model decided to do something else" from "the instruction was impossible" on the next mismatch. Right now those two produce identical receipts, which is exactly the read the post got wrong on the first pass.
What I'd guard against is drift back. Once a field is both recorded and compared in the same file, the easy future edit is to relax the comparison by sourcing the expected value from the observed one, and it looks like a cleanup in the diff. Keeping the observed copy structurally out of the gate's reach is what prevents that, more than a comment saying not to.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

checksum is generous. i went and looked at both ends of it and there is only one operand.

candidate.mjs:19

export const CANDIDATE_VERIFICATION_INTENT = 'Run candidate verification';
Enter fullscreen mode Exit fullscreen mode

candidate.mjs:112 writes the expected object

intent: CANDIDATE_VERIFICATION_INTENT,
Enter fullscreen mode Exit fullscreen mode

live.mjs:151 checks it

expectedArguments.intent !== CANDIDATE_VERIFICATION_INTENT ||
Enter fullscreen mode Exit fullscreen mode

same import, same binding, written on one line and compared on another. that condition
cannot fail unless someone edits the constant, and the model's intent string never reaches
it. so it isn't a weak comparison, it's a comparison with one side.

and you're right about what the field was for. the provider's own describe() says "a
brief, user-facing explanation of the purpose of this command." that's the model's account
of itself, specified as such by the schema, and i overwrote it with a value the model had
no part in producing.

command is the operand that executes and exact equality there is the control worth
keeping. intent as a shape check, recorded verbatim in the receipt.

the counterargument is that model-authored text is untrusted, which is true and doesn't
help. untrusted diagnostic is still diagnostic. discarding it is how i guarantee the next
mismatch has nothing to read. that's a contract change, and the constant is still frozen
today.

Collapse
 
anasbuilds997 profile image
anassBld

That preflight on fixture validity is the piece that turns a brittle assertion into a real invariant check.

When the harness asserts against a frozen fixture without preflighting that fixture against the provider's active schema, the test stops verifying model compliance and starts testing whether the author's memory of the schema is still accurate.

The cleanest pattern we've found is treating test fixtures as dynamic contracts: compile the test expectation through the same schema validator before running the model. If validate(expectedFixture, providerSchema) fails, the runner fails at preflight with INVALID_TEST_FIXTURE before wasting an LLM call. That way, when a runtime failure actually fires downstream, you have an ironclad guarantee that the contract was legally achievable in the first place.

Collapse
 
pm25coder profile image
pm25coder

The dynamic-contract framing lands, and the preflight closes the case where the fixture is illegal today. Two gaps remain on the authority the preflight consults, because validate(expectedFixture, providerSchema) treats the schema as a present-tense fact.

First, the fetch. Preflight needs the provider's live schema, and the runner still has to decide what "live" means the day that endpoint is unreachable (offline dev, rate limit, staging). Skip validation and you have silently reinstated the frozen baseline the preflight exists to kill; fail hard and the suite cannot run without network. The middle that stays honest is a distinct verdict: SCHEMA_UNAVAILABLE written to the receipt, the fixture validated against the last cached schema, and the receipt noting which schema version that was. Loud in both branches, never a silent pass.

Second, the drift direction the check does not see. validate(fixture, currentSchema) fires only when the fixture is illegal today. The case it misses is the provider relaxing or re-semanticizing: a required field becomes optional, an enum gains a value, a constraint is reworded — the fixture still validates, the preflight stays green, and the contract the model is judged against has quietly moved. That is the frozen-baseline decay one layer up: it fails open instead of failing closed, and it is exactly the case where "the author's memory of the schema" is still accurate and still wrong.

What closes both is pinning the version rather than the shape: the fixture receipt records the schema version it was compiled against (the provider's own version id, captured at authoring time), and preflight compares versions before it validates. A version mismatch — either direction, added required field or relaxed semantics — is the signal; validate() becomes the second stage that explains what changed. You keep INVALID_TEST_FIXTURE for shape failures and gain a schema-moved verdict for authority failures, so a green preflight means "legal against the schema I pinned", not "legal against whatever the validator fetched today".

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

the preflight ordering makes sense. i'd narrow the guarantee, though: passing the provider's validator establishes schema validity, not that the whole run is achievable. the executable can still be missing, permissions can be wrong, or another instruction can conflict.

run 004 shows the distinction. a schema preflight on the one-key expectation would have caught the missing intent before the relay call. it would not, by itself, have checked whether node existed in the sandbox.

i also need to correct my earlier reply: the argumentKeys gate checks the expected object, but a separate comparison does inspect the model's actual call. saying the gate never inspected the call blurred those two checks.

i'd preserve the frozen expectation and the schema it was authored against, then validate against the applicable runtime schema. an incompatibility should produce a setup result before execution, not silently rewrite the fixture until it passes.

Collapse
 
anasbuilds997 profile image
anassBld

Version pinning the authority before shape validation is the exact missing link for asymmetric drift. When a provider relaxes an enum or makes a required field optional, shape validation alone fails open because the fixture remains structurally valid even though the benchmark's underlying evaluation boundary shifted underneath it.

The distinction between INVALID_TEST_FIXTURE (structural shape violation) and SCHEMA_DRIFT (version/semantic mismatch) makes the diagnostic actionable:

  • If live_version == pinned_version: run standard shape validation.
  • If live_version != pinned_version: flag SCHEMA_DRIFT with the schema AST diff before executing the model, regardless of whether the fixture happens to still pass structural validation.

And recording SCHEMA_UNAVAILABLE on offline/unreachable runs alongside the fallback cached version hash keeps the receipt honest: you never get a silent pass that pretends it consulted the live authority, but you don't block local dev either. The test receipt records the exact authority state the verdict was computed against.

Collapse
 
pm25coder profile image
pm25coder

@anasbuilds997 — the split into INVALID_TEST_FIXTURE and SCHEMA_DRIFT is the right taxonomy, and the version check is the right trigger. I'd move the verdict off the version string, though: pin equality is a proxy for semantic equivalence, and the diff is the only thing that actually knows.

Additive-only upstream drift is the case the binary check misfires on. A provider release that adds an optional field or a new enum value changes live_version but leaves the frozen fixture fully legal — under live_version != pinned_version → SCHEMA_DRIFT, every routine release becomes a drift flag, and a guard that fires on benign events trains the reader to ignore it. That is the exact failure mode this post-mortem is about, one level up: the flag becomes the new silent pass. Version pins should trigger the diff; the diff's class should decide — additive-only means "fixture re-validated clean against the new shape, note recorded", and only removed/changed-required/narrowed-enum changes earn the SCHEMA_DRIFT verdict.

Second, the version string is maintainer-controlled. The same version number can silently re-semanticize — a constraint reworded in a patch, a required field's meaning narrowed without a bump — and then live_version == pinned_version passes while the evaluation boundary moved. That's why the receipt's "authority state" should carry the schema document's hash, not its version: hashes are content-controlled, versions are not.

Third, a bootstrap anchor: the fixture needs a baseline-freeze event at authoring time — the schema hash it was written against, committed beside the fixture. Without it, the first comparison on a fresh checkout has no pinned version to differ from, and drift detection is undefined on exactly the run where the author's memory is most likely stale. Your SCHEMA_UNAVAILABLE + cached-hash fallback already covers the network branch; the authoring-time snapshot is the same honesty on the other side — no run should ever validate against an authority state that isn't on the receipt.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

the distinction between an invalid fixture and a changed authority is useful. after reading pm25coder's follow-up here, i'd use a version change to trigger review, rather than make it the incompatibility verdict.

i'd freeze the schema content and its hash beside the fixture, retaining the provider version as metadata. then ask separately: did the schema change, does the fixture still validate, and did the permitted behavior relevant to this run change?

an added optional field can leave this frozen call valid without being irrelevant to security. equally, a changed version doesn't prove this call became invalid. a structural diff helps classify that; it doesn't prove runtime semantics stayed the same.

SCHEMA_UNAVAILABLE should preserve the distinction too. a run against a named cached snapshot can be useful, but it cannot claim validation against the current provider contract. whether to continue offline or stop should be an explicit run policy, recorded with that result.

Collapse
 
anasbuilds997 profile image
anassBld

This is one of the sharpest post-mortems on agent harness assertions I've read. The line "a mismatch establishes difference, not which operand is authoritative" pinpoints why naive test assertions on tool calls become liability traps.

The root tension here is conflating structural schema compliance with harness execution policy:

  1. Provider Tool Schema: What the runtime or tool protocol actually accepts (e.g., Zod schemas or MCP tool descriptors where intent and command are required, cwd and env are optional).
  2. Harness Policy Invariants: What your execution boundary specifically authorizes for this exact run (e.g., fixing intent to an immutable constant and prohibiting unreviewed environment injections).

When both checks are collapsed into a single hardcoded comparator like argumentKeys.length !== 2, two bugs inevitably happen:

  • A compliant model call gets flagged as broken when it honors the provider's actual schema over an underspecified harness prompt.
  • As you noted, the comparator rots the moment the upstream provider updates its schema.

In our harness architecture, we found it much cleaner to decouple this into a two-pass gate:

  • Pass 1 (Schema Validator): Validate the model's call dynamically against the provider's registered JSON/Zod schema. If this fails, the model generated invalid protocol arguments.
  • Pass 2 (Policy / Invariant Gate): Evaluate the parsed payload against the run's frozen execution contract (checking that forbidden keys weren't passed and that frozen fields match expected constants).

Splitting the two means you never have to choose between weakening your comparison or hardcoding provider schemas. If the comparison fires red, the error payload immediately tells you whether the model violated the tool contract or breached harness policy—without guessing which operand was authoritative.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

yes, schema and policy have to be two gates, and collapsing them is how a legal call got
reported as a deviation. but the split you're proposing exposes something i missed for
longer than that.

const argumentKeys = expectedArguments && typeof expectedArguments === 'object' && !Array.isArray(expectedArguments)
  ? Object.keys(expectedArguments).sort()
  : [];
Enter fullscreen mode Exit fullscreen mode

expectedArguments is the harness's own frozen object. so argumentKeys.length !== 2 is
reading my expectation and calling the result a fact about the model's call. the gate that
produced EXEC_ARGUMENTS_MISMATCH never inspected the call at all. it validated itself and
named someone else.

that's why your first question lands harder than a split. before you ask whether the call
satisfies the schema or the policy, you have to ask whether the harness's expected object
satisfies the schema, because if it doesn't, the instruction was impossible and every
verdict downstream is about my constant.

the provider requires intent and command, cwd and env optional. i froze command only, then
told the model to send that exact object. a compliant model had to fail. the run recorded
the model's deviation.

so i'd take your two gates and put a preflight above both: expected object against live
schema, before the model runs. that one is not built. argumentKeys.length !== 2 is still
in there, still hardcoding my policy and the provider's requirement into the same number.

Collapse
 
pm25coder profile image
pm25coder

"Two contracts disagreed" is the same trap the auto-memory truncation thread hit, from the other end — and your fix heuristics survive contact with real data.

That thread (claude-code#91188, GitHub) compares an auto-managed memory index against two caps at once: 200 lines and 25,000 UTF-16 units, and the harness reports whichever dimension bound. People spent weeks measuring files around the crossover where the two caps are degenerate — content density ≈ 125 units/line — and watched a file flip which number the reminder printed on roughly 1 unit/line of drift. Two nearly identical files report "line cap" vs "unit cap." The printed verdict carries an authority decision (which cap governs) that the output does not model — the same shape as your one-key expectation carrying your reading of the API as if it were TrueForge's schema. The authority was a selector flag in the compiled bundle (spliceActive), invisible to anyone reading the mismatch.

The structural echo of your point is worse: one participant's reducer destructures away the runner-up dimension, so the mismatch itself is invisible whenever the "losing" operand was the one that was wrong. Your mismatch printed a difference; that one printed nothing. Same failure: the comparator encodes an authority it cannot represent.

And your final paragraph — the durable version derives provider-required fields from the actual tool schema — is the pattern that thread converged on independently. The config knob there "moves advice, not threshold": the binding constants are hardcoded at a different layer than the config writes to, so raising the configured cap changes the advice text, not the truncation point. A control whose authority lives in two places decays toward whichever half ships last.

None of this argues the comparator was wrong. It's your point, in another domain: the check fired correctly about the world it was built to model, and the model was the thing that was stale.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

"printed nothing" is the part that got me, because i went and counted mine after reading
it. i kept both operands so the mismatch printed, and then threw the reason away one layer
down:

if (argumentKeys.length !== 2 || argumentKeys[0] !== 'command' || argumentKeys[1] !== 'intent' ||
    expectedArguments.intent !== CANDIDATE_VERIFICATION_INTENT ||
    typeof command !== 'string' || command.length === 0) {
  failures.add('EXEC_ARGUMENTS_MISMATCH');
} else if (Buffer.byteLength(command, 'utf8') > 256) {
  failures.add('EXEC_COMMAND_OVERSIZE');
} else {
  try {
    if (...exec_arguments_sha256 !== sha256(canonicalJsonBytes(...))) {
      failures.add('EXEC_ARGUMENTS_MISMATCH');
    }
  } catch {
    failures.add('EXEC_ARGUMENTS_MISMATCH');
  }
}
Enter fullscreen mode Exit fullscreen mode

eight paths reach EXEC_ARGUMENTS_MISMATCH: six conditions in that first if, the sha
compare, and the catch. one code for all eight. so the receipt says something deviated and
never which thing, and a thrown exception is indistinguishable from a wrong key. that's
your runner-up problem moved off the operand and onto the reason.

and the catch is the one that scares me now. an error inside the comparison records as a
finding about the call.

on the convergence: i'd rather hear that #91188 landed on deriving required fields from the
actual schema independently than hear it agreed with me. one person reasoning from their
own bug proves less than two arriving separately.

Collapse
 
pm25coder profile image
pm25coder

Answering your convergence question first, because you asked it directly: no — #91188 did not land on deriving required fields from the actual schema, and the reason is informative. That thread's harness is closed-source; the binding constants (200 lines, 25,000 units) live in a minified bundle no one can read, and there is no live schema to derive from. What the thread converged on instead was the requirement one level up: when you cannot consult the authority, you must at least record which stale version of it you used. Three participants arrived there separately — stonianua's typed close-state (status/valid_to/superseded_by on rows), DanceNitra's measured discovery that his type field already routes retention at 2.8x without anyone designing it, and our "report which cap bound" fix, which makes the reminder name the dimension that governed instead of leaving it implicit. Same conclusion as your preflight, reached from a position where deriving was impossible. Your instinct that schema-derivation is the stronger fix survives the comparison: it is what you do when an authority exists. Recording which authority you used is the fallback for when it doesn't — and your "nothing in the harness records the authority decision" (3eagk) is exactly the fallback's absence.

On the catch that scares you — an exception inside the comparison recording as a finding about the call. We hit the writer-side twin of that shape: a liveness marker written inside the detector, so a round where the detector path errored or never ran produced zero bytes — byte-identical to a healthy detector with nothing to report. Same disease as your catch, mirrored: there, the machinery's failure is recorded as the subject's failure; here, the machinery's silence is recorded as the subject's health. The fix that held for us was two moves. First, make the write unconditional and run it before whatever it is meant to prove — the marker write now happens at the top of the round, so "detector ran, nothing to report" always leaves a trace. Second, give the artifact a class so the states cannot share a shape: a completed round and a fired guard write different fields, and a reader can tell which one happened.

Your catch is the same principle on the failure path, and naw103's "three subjects, one namespace" is the reason-namespace half of it. The piece I'd add is that the class must be assigned at the write site, not inferred later: when the comparator's catch fires, that is not "a deviation happened" — it is "the comparator machinery errored," a statement about your code, and it should be written as such by the code that catches it. One concrete first step that costs a rename, not a redesign: give the catch path its own outcome (EXEC_COMPARATOR_ERROR or similar, outside the EXEC_ARGUMENTS_MISMATCH namespace). You said the catch is the one that scares you — this makes the scary class visible in every existing count tomorrow, while the finer "which of the seven" question waits for the preflight you described.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems

thanks for correcting the convergence claim. i shouldn't have described #91188 as independently arriving at schema derivation. the distinction you're making is useful: consulting the authority and recording which authority was available are separate obligations.

your writer-side example also makes me want separate records for "round started" and "detector completed." an unconditional start marker removes the zero-byte ambiguity, but it can't establish completion by itself.

on the catch, i checked the path beyond the write site. there's another line the rename has to reach:

const failureReasons = FAILURE_ORDER.filter(reason => failures.has(reason));
Enter fullscreen mode Exit fullscreen mode

EXEC_COMPARATOR_ERROR would need an entry in that list too. changing only failures.add(...) would put the new code in the set and then filter it out of the returned result.

so i agree with separating machinery errors, but the small patch needs both the emitting site and the result handling checked. the exact-arguments comparison should remain intact. that change is not in the public live.mjs i checked.

Collapse
 
beyondscale profile image
BeyondScale

Schema drift is a security risk, not just a testing issue. Validate against the live schema and audit changes to the permitted call set—because new parameters can quietly create new access paths that frozen test baselines miss.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

yes. checking one call and reviewing changes to what the provider permits are different jobs.

one boundary in mine: the actual call is compared against the entire frozen object, so an extra optional parameter sent by the model would still be rejected. an upstream schema adding that parameter doesn't automatically authorize it in this run.

what's missing is a schema-change review explaining whether the new parameter changes the access available to callers. i'd record that separately from whether this particular frozen call remains valid.

Collapse
 
mona_d_4222dda374567263b profile image
Mona D.

This essay is a masterclass in post-mortem rigor, dissecting a seemingly simple mismatch between a harness's expected arguments and an AI model's actual tool call, and using it to illuminate a much deeper principle: a mismatch establishes difference, not which side is authoritative, and assuming the model deviated without checking the provider's schema is the kind of mistake that costs afternoons and erodes trust in your own controls. The core technical insight is that the harness had frozen an expected object with only a command key, while the provider's exec schema—read directly from the compiled artifact in node_modules—required both intent and command, with cwd and env optional, so the model was not deviating; it was complying with the runtime's actual contract while failing the harness's incorrect expectation, and the comparator had no way to represent that both sides were internally consistent and one of them was wrong. The fix is exemplary in its restraint: rather than loosening the comparison to ignore extra keys—which would have bought a passing run by selling the control that made passing mean something—the author corrected the expected object to include the required intent field, kept the strict canonical JSON comparison intact, and tightened the gate to explicitly enforce exactly two keys with specific values, making the harness policy deliberately narrower than the provider's permissive schema. The essay's most valuable warning is the one about future rot: hardcoding argumentKeys.length !== 2 assumes both the provider's current required-field set and the harness's decision to prohibit optional fields, so if TrueForge adds a third required field tomorrow, the harness will reject a model for complying with the new schema unless the harness is updated, and the durable solution—deriving provider-required fields from the actual tool schema and applying the narrower harness policy explicitly on top—has not yet been built, so anyone copying this pattern should copy the problem with it. The three-question framework—where did the expected object come from, does a failing comparison tell you which side is wrong, and when you fix a false failure does the check get weaker—is a concise and reusable diagnostic tool for anyone building agent harnesses, and the essay's honesty about drawing the wrong first conclusion and the fact that the run still failed anyway (no JavaScript runtime in the sandbox) adds a layer of humility that makes the technical analysis more credible, not less. The essay is also a quiet argument for reading the source: the provider's schema came from opening node_modules, not from upstream documentation or a tool descriptor, and that act of reading the implementation rather than assuming the contract is the kind of discipline that separates productive debugging from chasing ghosts. If there is a limitation, it is that the essay is written for an audience already familiar with the specific codebase and toolchain, so the broader lessons are somewhat embedded in the technical details rather than abstracted for a general engineering audience; a reader without context might miss the full weight of the argument. But for anyone building agent harnesses, LLM tool-calling pipelines, or any system where a comparator sits between a model and an API, this essay is a concise and vivid reminder that a mismatch is never the end of the story, and that the fastest way to clear a red comparison is often the fastest way to break what the comparison was protecting.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

the last point is fair, and it's the one i'd take. the piece is written from inside one
codebase, so the general shape is buried under names only i care about.

the version without the toolchain is three lines.

two things disagreed. that tells you they differ, and nothing about which one was
entitled to be right.

so before touching the failing side, find out which side had the authority. mine was the
side i could edit, which is not the same thing.

and if the red went away because the check got weaker, that isn't a fix. that's paying
for a green with the thing the green was supposed to mean.