Skip to content

fix: the six critical defects from the 2026-09-08 field export - #902

Open
divshekhar wants to merge 15 commits into
mainfrom
fix/field-export-2026-09-08
Open

fix: the six critical defects from the 2026-09-08 field export#902
divshekhar wants to merge 15 commits into
mainfrom
fix/field-export-2026-09-08

Conversation

@divshekhar

Copy link
Copy Markdown
Contributor

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 nothing

An 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 whose old equals its new carries nothing to render either way.

2. fix(server): a truncated window reports a floor, not a total

The most dangerous output in the export. With the buffer starved by (1), a 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 sends a developer to the click handler instead of to the payload the server rejected.

honesty.integrity.losses already said buffer_loss and it was not enough: the fields an agent reads to form a verdict are these, and a bare 0 several levels above a nested flag reads as a fact. CausalSummary now carries truncated, and firstDivergence stops 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 step

The 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.timeoutMsFlowFile.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 stays FLOW_FILE_VERSION 1.

4. fix(flows): a recorded upload replays as recorded

The recorder wrote {"action":"upload","args":{"path":"..."}} — the only form live reticle_act accepts. 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 real POST /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 gone

Absorbing 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 by ps. ECONNREFUSED is untouched — nobody went away there.

6. fix(server): an addressed write is not compared through its container key

write-field-ignored overrode a passing assertion to verified: "no". The request asked {"slot":"_dim","value":"3D"}; the server applied it; the kind reported "asked 3d, got cfd", where cfd is 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 contradicted verdict 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) and pnpm 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

divshekhar and others added 8 commits September 9, 2026 00:51
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
divshekhar force-pushed the fix/field-export-2026-09-08 branch from 1ce7bdf to 464da9a Compare September 8, 2026 19:24
@divshekhar
divshekhar enabled auto-merge (squash) September 8, 2026 19:24
divshekhar and others added 6 commits September 9, 2026 01:00
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant