fix: the six critical defects from the 2026-09-08 field export - #902
Open
divshekhar wants to merge 15 commits into
Open
fix: the six critical defects from the 2026-09-08 field export#902divshekhar wants to merge 15 commits into
divshekhar wants to merge 15 commits into
Conversation
An app rewrote one localStorage key thousands of times a minute with
byte-identical content. Every one of those no-ops took a ring-buffer slot:
`held: 2000, dropped: 70482`. The verdict taken in that window then reported
`net.total: 0`, `stateDiffs: []` and "state never changed" — while a POST that
had returned 200 inside the same window carried the entire root cause in its
body. The agent read the zero and was one step from reporting "clicking Accept
fires no network request", which sends a developer to the click handler instead
of to the payload the server rejected.
Dropped at the observer rather than at the verdict: the cost is the buffer slot,
and a diff whose `old` equals its `new` carries nothing to render either way.
The same no-ops were padding verdicts with twenty identical entries and
`elided: { storageDiffs: 1881 }`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
`honesty.integrity.losses` already carried `buffer_loss`, and it was not enough. The fields an agent reads to form a verdict are `summary.net.total`, `stateDiffs` and `firstDivergence`, and a bare `0` several levels above a nested integrity flag reads as a fact. In the field it produced a wrong root cause. With the buffer starved, one verdict reported `net.total: 0`, `stateDiffs: []` and `state "cad" never changed` — while a POST that returned 200 in that exact window carried the whole explanation in its body. The agent concluded that clicking Accept "does nothing — no network request is made", which points a developer at the click handler instead of at the payload the server rejected. A tool whose purpose is to stop an agent inventing explanations handed it a fabricated one, quietly enough that it nearly shipped. That is worse than a crash: a crash is obvious, this is a confident, specific, wrong number. So `CausalSummary` carries `truncated`, and `firstDivergence` stops asserting what did not happen over a window it did not fully see: "no surviving event shows a request to /apply — capture truncated, so this is not evidence there was none". A consequence answered by an event that DID survive is still stated plainly; the caveat qualifies absence, never evidence. The absolute and the caveat must not be separable. Absence of evidence must not be typed as evidence of absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…the step
The wait for a step's declared consequence was a fixed 4s with no env var, no
flow field and no replay parameter to raise it. That is not a tuning knob — it
decides whether an honest flow can ever be green.
Two field reports, the same shape. A login whose POST measures 5.5s against a
remote Postgres, and a geometry import whose model-backed perception takes ~22s.
Both were verified live with `act_and_wait { timeout_ms }` and returned
`verified: "yes"`. The identical saved flow drifted at ~4020ms with
`signal_not_observed`, and the summary said NO LONGER TRUE — a working feature
reported to the user as a regression. The only ways to green them were to weaken
or delete the assertion, which the rules correctly forbid, so the flow stayed
honest and permanently red.
One reporter tried a step-level `timeout_ms` and an `expect.timeout_ms`. Both
were dropped in silence, which is the worse half: a knob that reads as accepted
and does nothing.
`FlowStep.timeoutMs` wins, then `FlowFile.signalTimeoutMs`, then the shipped
default. Per step rather than per flow, because one slow step in an otherwise
fast journey is the common shape and making the whole suite wait for the slowest
step trades a false red for a slow gate. The success oracle honours the flow's
declaration for the same reason — greening every step and then failing the
outcome at 4s is the same false red one layer down.
Optional and back-compat: a flow declaring neither replays exactly as before and
the on-disk version stays FLOW_FILE_VERSION 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
The recorder and the replayer disagreed about what an upload step IS, and the
disagreement was total. `reticle_record` writes
`{"action":"upload","args":{"path":"test-fixtures/pipe.step"}}` — the only form
the live `reticle_act` accepts, and the form that works interactively. Replay
dispatched it straight at the browser, which refused: "upload does not read
path, so it would be dropped". So a recorded upload could never replay as
recorded, and no flow touching a file upload could be green — which rules out
document ingestion, avatar upload, CSV import.
The capability was there the whole time. An agent who hand-patched the flow JSON
to `{name, content, type}` got a real upload, a real `POST /api/v0/files -> 200`,
and the app went on to perceive the file. Only the wiring was missing:
`rewriteUploadArgs` is called from `actCommand`, and replay does not go through
it.
Resolved once, before step 1, through that same helper — so the two paths cannot
drift again, which is the whole reason this existed. Not at save time: the flow
file stays small and legible, the fixture stays on disk where it can be reviewed
and updated, and a flow naming a file nobody checked in fails saying so instead
of carrying a stale copy of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
… spinning Absorbing a disconnect and continuing is right for ONE disconnect: the client closed a socket, the proxy rebuilds it. It is catastrophic for a stream of them, because the write that failed is retried at once, fails identically, and is absorbed again — a tight loop with no backoff and no exit. Measured in the field: one `reticle mcp` ran four days after its editor closed, at 97-98% of a core, 1473 minutes of CPU time, writing ~930 MB/hour of identical `client_disconnected` EPIPE lines with every entry stamped to the same millisecond. Log rotation kept the disk footprint at 17 MB, which hid ~22 GB/day of SSD writes and a permanently burnt core. Nothing in Reticle's own output showed it — the daemon beside it reported healthy with `sessions: 0` — so it was findable only by running `ps` by hand. Twenty absorbed disconnects is well above the handful a genuine reconnect produces and far below a runaway. The proxy exists to be the stdio server its editor launched; once that editor is gone there is nobody left to serve, and the correct thing is to leave rather than burn a core proving it. ECONNREFUSED is untouched — nobody went away there, the daemon has not booted yet, and the proxy is built to tolerate that and wake it. The idle pairs from earlier sessions are the other half of the same report and are filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
… key
`write-field-ignored` overrode a passing assertion to `verified: "no"` on a
write that had applied correctly.
The request asked `{"slot":"_dim","value":"3D"}`. The server applied it —
`brief.dim: "3D"`, and the interview advanced from `{kind:"question"}` to
`{kind:"geometry", payload:{module:"fluids", dim:"3D"}}`. The kind reported "its
own echo shows 1 field(s) NOT applied — value: asked 3d, got cfd", where `cfd`
is `brief.slots._family.value`: a different slot, set earlier in the same
interview.
A container key cannot be compared across documents by name. `value` in the
request means "the value of the field named by `slot`"; `value` in the response
means "the value of whichever field this node belongs to", and a document
holding many addressed fields holds many `value` nodes. The flat by-key
comparison was measuring the request's value against an unrelated sibling's.
Proof it was the heuristic and not the app: the next call on the same endpoint,
`{"slot":"geometry.openings.main_top.boundary_condition","value":"inlet"}`, does
mirror into `slots.*` and passed clean. So it misfired exactly where a server
persists an addressed field somewhere other than a mirror named after it — a
normal API shape.
A manufactured red costs more than a missed one here. Reticle's proposition is
that only green means green and that an agent must not talk itself past a red;
a heuristic that invents them taught the reporter, inside one session, that a
`contradicted` verdict may be noise worth arguing with. That is precisely the
reflex this product exists to suppress.
Narrow, in this file's idiom: the addressing key itself is still compared, so a
server that echoes a DIFFERENT slot than the one requested is still a real
dropped write and still says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
`duplicate-request ×2` and `ui-advanced-request-failed 401` both fired on one correct mechanism: an api-client that meets a 401, re-hydrates the session and retries once. Driving one extraction produced a 401 (51ms, 35 bytes) then a 200 (2278ms, 605 bytes), with exactly one extraction in state, and Reticle called it two contradictions. Both were false and both cost calls to disprove. A heuristic that manufactures reds undercuts "only green means green" more than a missed bug does. The discriminator is structural, not a timing heuristic. A failure that a LATER success to the same method and url replaced is a failure the app recovered from: the write landed, so the UI was entitled to move and a success signal was entitled to fire. Filtered where `failed` is defined rather than at the two reading sites, because `signal-contradicted` makes the same claim about the same evidence and a fix in one would have been a fix in half. The same window is not a double submit either. A double submit is a write that LANDED twice, so the count is now of writes that succeeded: one 401 plus one 200 is a retry, and two failures are not a duplicate because nothing applied even once. The negative controls are in the test: two 200s still report, a failure with no success after it still reports, and a success to a different endpoint or by a different method does not excuse it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…payload Six `act_and_wait` calls through one interview each returned the server's entire answer as `verdict.evidence` — the same document growing by one slot per step, so almost every byte was a re-send of what the agent already held. The evidence block was 9,693 of an 11,194-byte result, and thirty such calls came to 181KB. This file already stated the rule for the failing path: "enough of a body to see what differed, without paying for a whole payload in every verdict". It had never been applied to the passing one. The line is not that bodies are expensive. It is that evidence should answer the claim. A predicate asking about status and url is proved by status and url, so its body is clipped to a visible-elided head. A predicate asking `bodyContains` is proved BY the body — but by the MATCH, not by the document it was found in, so that case now quotes the needle in context and reports `bodyMatchAt`. That is strictly more precise than the payload it replaces, and `responseSize` still carries the true length so a quote is never read as a short answer. Measured by replaying the field session's own results through the new rule: 181,543 bytes to 126,548, a 30.3% cut in act_and_wait traffic, with no verdict changing and no body a check consumed being shortened. The helpers moved to net-evidence.ts because predicate-eval.ts crossed the size backstop, and because deciding an answer and justifying it are separate jobs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
divshekhar
force-pushed
the
fix/field-export-2026-09-08
branch
from
September 8, 2026 19:24
1ce7bdf to
464da9a
Compare
divshekhar
enabled auto-merge (squash)
September 8, 2026 19:24
… user typed An agent filled a CFD app's "What are you trying to find out?" box with "Determine the pressure drop and velocity distribution of water flowing through a 3-way pipe junction", pressed Enter, and Reticle refused: `potentially destructive action blocked`. The trigger was "pressure drop" against `\bdrop\b`. `dangerousActionContext` joined the element's `textContent` and `value` into the string it classifies. That is right for a button, whose text IS its label, and wrong for a text entry, where the content is the user's DATA and the only label is the accessible name. Measured in jsdom, the typed content reaches the classifier through three ordinary shapes: a textarea rendered with its value as a child, an input carrying a `value` attribute, and any contenteditable. The same argument already removed `send`, `logout`, `deploy` and `publish` from this pattern. A false block costs a round trip and teaches the agent to pass `confirmDangerous` reflexively, and the routing-around generalises to the controls that do matter. The reporter said exactly that, having done exactly that. The guard was wrong the other way round too, and it is the same defect. Enter in a form submits the FORM, so the control being driven is the submit button, not the field the cursor is in — and only the field was read, so Enter in a form whose submit button says "Delete account" was never confirmed at all. Enter is now judged by both. Any other key submits nothing, so the form is none of its business. `<input type="submit" value="Delete">` keeps its value read: that is the one input whose value is the word on it. Split into danger-context.ts because actions.ts crossed the size backstop, and because deciding what a control says is not the same job as driving it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…ally has Reported from the field as ergonomics that cost real calls: role+name matching is exact, so a query for the button "Mesh" finds nothing on a page whose button reads "2 Mesh", and the recovery is a snapshot to discover a label the caller was one word away from. The exactness stays. Substring matching on `name` would let a query for "Save" also select "Save and close" and "Autosave", and acting on the WRONG control is a worse failure than not finding the right one — which is why Testing Library keeps `getByRole(name)` exact too. Loosening the matcher would trade a round trip for a class of silent mis-drives. So the miss answers instead. A zero-match role+name query now carries `hint.nameNearMiss`: the names that role does have which contain, or are contained by, the one asked for, normalised and case-folded exactly as the matcher is. Scoped to the requested role, because a link called "2 Mesh" is not a recovery for a button called "Mesh" — pointing at it would recommend the very mis-drive the exact match exists to prevent. Declared in the tool's outputSchema in the same commit, because this object has lost a field to that omission twice: `presentRegions` was emitted and undeclared, so it was stripped from structuredContent while its deprecated predecessor was all an agent could see, and the fix then dropped `sample` one level down. The new test parses a hint through the declared schema rather than introspecting it, so a declaration that is present but shaped wrong still fails, and it was confirmed to go red with the declaration removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…stall it" `sourceMapping` stamps `data-reticle-source` on JSX host elements. In a react-three-fiber tree those elements are three.js objects and a dashed prop is read as a property path, so the stamp throws from the commit phase and unmounts the whole app. A field reporter disabled the option, correctly, and every red verdict afterwards carried `no-source-mapping`, whose remedy is "add the Reticle build plugin" — already installed, and the source of the crash. A remedy that cannot be followed is worse than silence: it costs a round trip and teaches the reader to skip the gap block, where the honest gaps live too. Any app on a non-DOM reconciler has to turn the stamp off to run at all, so this is a supported configuration and the verdict has to speak to it. The page now announces the opt-out in HELLO, following `captureBodies` exactly: the build plugin is the only thing that knows, since a page cannot tell "no plugin" from "plugin with the stamp muted", and the two need opposite fixes. Only a definite `false` travels. Absent stays absent through every layer and is read as unknown, never as an opt-out, so an older SDK does not silently mute the honest gap for an app that genuinely has no source mapping. It reports as its own kind rather than reusing the old one with a different sentence, because the remedy map is deliberately keyed on kind so that a kind cannot be reported with a fix that has drifted from it. The new fix says there is nothing to install, and says why turning it back on may not be an option — a remedy reading only "re-enable it" would send a three.js app back to the white screen it escaped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
`reticle_act_and_wait({ ref, action, until: { kind: "element", query, timeout_ms:
45000 } })` was rejected with "unknown field timeout_ms". Everything else in that
reply was right — nothing ran, here is what `element` accepts, here is a valid
example — and the one thing it did not say is that `timeout_ms` is a real
argument in the wrong place.
That is the whole retry. "Unknown field" reads as "there is no such thing", so
the obvious next move is to delete it, and deleting it silently gives up the
longer budget that was the reason for writing it — on a call that had already
timed out once at the default. The rejection now names the destination: these are
arguments of the CALL, move them up beside `until` rather than dropping them.
Only for fields that really do have a home elsewhere. A plain misspelling gets
nothing extra, because offering it a destination would be inventing one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…ing slow
Filed with the react-three-fiber crash. The source-mapping stamp threw inside
R3F's commit phase, React tore down the whole tree, the page went white — and
`reticle_snapshot` answered `{ tree: "", nodes: 0 }`, which is exactly what a
page that has not rendered yet answers. A diagnosis pass went on telling the two
apart. The reporter's note: detecting a dead root "would have pointed me at the
real cause immediately".
Two notes already exist for this shape of answer, and neither can reach this
cause. `leanSkipped` and `hiddenSkipped` both explain an empty tree by what the
WALK passed over, and a walk that visited nothing has nothing to have skipped.
So the count is of the DOM instead: how many elements exist under the scope at
all, reported only when the tree came back empty. It separates the cases
outright. One element is a mount container with nothing rendered into it;
forty-four with a hidden count is the case the hidden note already owns. The
note says the app is unmounted rather than slow, that waiting will not change it,
and sends the reader to reticle_console, where an app that was rendering and then
stopped has left its reason.
Deliberately a number and not a diagnosis, for the reason `hiddenSkipped` gives:
the browser knows the count for certain and cannot know why the app unmounted.
The raw count stays internal like its two siblings — the note is the surface —
and the note's own schema description, which claimed one cause when there are now
four, is corrected in the same commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
Creating an instruction file and APPENDING to one somebody else owns are different acts, and the plan said the same sentence for both. Reported from a monorepo audit: `init` appended 68 lines to `CLAUDE.md` — the repo's binding agent contract, the file every agent there reads first — described only as "teach the agent to verify features with Reticle after building them". The reporter reverted the entire install. `--dry-run` already exists, so the consent MECHANISM was never missing. What was missing was the plan telling the truth loudly enough to act on: the size of the edit, and the fact that the file was already theirs. Both were known at the moment the step was built, and both were being withheld. The markers are named too, because a reversible edit and an irreversible one are also different acts, and this one is reversible. Advances #790. Items 1, 3 and 4 of that report are already fixed; the artifact path item remains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
…Mode A default of `SnapshotMode.FULL` narrowed the parameter to the literal "full", so the two cases that pass "interactive" and "status" did not compile. Vitest does not typecheck, so the suite was green and the BUILD was red — caught by running the gates against a clean checkout of the committed tree rather than against a working directory that still held the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Divyanshu Shekhar <imdshekhar@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Six fixes from the 2026-09-08 agent feedback export (64 reports, all from 2.13.1). Each is a false verdict, a crash, or a total feature blocker. The non-critical remainder is filed as #875–892 and #894–899, labelled
2.14.0.The seventh critical defect — the react-three-fiber white screen — is #901, extracted from where it was buried in another PR.
What is here
1.
fix(browser): a storage write that changes nothing emits nothingAn app rewrote one localStorage key thousands of times a minute with byte-identical content. Every no-op took a ring-buffer slot:
held: 2000, dropped: 70482. Dropped at the observer, because the cost is the buffer slot and a diff whoseoldequals itsnewcarries nothing to render either way.2.
fix(server): a truncated window reports a floor, not a totalThe most dangerous output in the export. With the buffer starved by (1), a verdict reported
net.total: 0,stateDiffs: []andstate "cad" never changed— while a POST that returned 200 in that exact window carried the whole explanation in its body. The agent concluded that clicking Accept "does nothing — no network request is made", which sends a developer to the click handler instead of to the payload the server rejected.honesty.integrity.lossesalready saidbuffer_lossand it was not enough: the fields an agent reads to form a verdict are these, and a bare0several levels above a nested flag reads as a fact.CausalSummarynow carriestruncated, andfirstDivergencestops asserting what did not happen over a window it did not fully see. A consequence answered by a surviving event is still stated plainly — the caveat qualifies absence, never evidence.3.
fix(flows): replay is no longer stricter than the tool that recorded the stepThe signal wait was a hard-coded 4s with no env var, no flow field, no parameter. Two reports, same shape: a login measuring 5.5s against a remote Postgres, and a geometry import taking ~22s. Both passed live with
act_and_wait { timeout_ms }; both drifted at ~4020ms on replay and were reported to the user as NO LONGER TRUE. The only ways to green them were to weaken or delete the assertion, which the rules correctly forbid.FlowStep.timeoutMs→FlowFile.signalTimeoutMs→ the shipped default. Per step, because one slow step in a fast journey is the common shape. Optional and back-compat; the on-disk version staysFLOW_FILE_VERSION 1.4.
fix(flows): a recorded upload replays as recordedThe recorder wrote
{"action":"upload","args":{"path":"..."}}— the only form livereticle_actaccepts. Replay dispatched it at the browser, which refused: "upload does not read path, so it would be dropped". A recorded upload could never replay, so no flow touching document ingestion, avatar upload or CSV import could be green. The capability was there: an agent who hand-patched the JSON got a realPOST /files → 200. Only the wiring was missing. Now resolved once, before step 1, through the same helper the live path uses — so the two cannot drift again.5.
fix(server): the MCP proxy leaves once its client is goneAbsorbing a disconnect is right for one and catastrophic for a stream: the failed write is retried at once, fails identically, and is absorbed again. In the field one proxy ran four days after its editor closed at 97-98% of a core, writing ~930 MB/hour of identical EPIPE lines. Log rotation hid ~22 GB/day of SSD writes; the daemon beside it reported healthy with
sessions: 0, so it was findable only byps.ECONNREFUSEDis untouched — nobody went away there.6.
fix(server): an addressed write is not compared through its container keywrite-field-ignoredoverrode a passing assertion toverified: "no". The request asked{"slot":"_dim","value":"3D"}; the server applied it; the kind reported "asked 3d, got cfd", wherecfdis a different slot set earlier in the same interview. A container key cannot be compared across documents by name. The addressing key itself is still compared, so a server echoing a different slot is still a real dropped write.A manufactured red costs more than a missed one: it taught the reporter, inside one session, that a
contradictedverdict may be noise worth arguing with — precisely the reflex this product exists to suppress.Verification
pnpm format:check,pnpm lint,pnpm typecheck,pnpm test:unit(674 files) andpnpm test:e2e(36/36 specs + soak gate) all green. The e2e battery was run because this touches an observer and the flow contract.Not claimed
These are fixed against unit and battery evidence, not against the field apps that produced the reports. Five of the six were reproduced from the reporters' traces rather than from a running CAD/CFD app — which is the gap #891 exists to close.
🤖 Generated with Claude Code