fix(mochi): refuse a corrupt pin store instead of rewriting it (#8088) - #8092
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real data-loss class fixed at its actual chokepoint — one strict reader shared by both writers, under the lock they already share — with the alternative (sidecar-preserve) explicitly weighed. [DESIGN-REVIEWED] d6c2c00 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are done — I have what I need for the verdict. First-Principles-Verdict: CONCERNS The pin fix is real and earns its surface, but the same lenient-read→whole-file-rewrite sits unfixed three times in the very file this PR edits. What this change shipsIntent: stop a corrupt pin store from being silently destroyed by any pin mutation, from either writing process — a FIX.
WatchPoint patch with counted siblings in the same file: grepped Subtractions
[FIRST-PRINCIPLES-REVIEWED] d6c2c00 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/apps/builtins/mochi/pinned_files_service.py:121 -- False positive or not applicable? A repository writer can comment: |
## Problem
`PinnedFilesService._reload_pins_from_disk` caught `(FileNotFoundError,
json.JSONDecodeError)` and returned with `_pins` unchanged, and all four of its
callers then `_persist()` a whole-file rewrite. A corrupt `pinned-files.json`
was therefore silently replaced by whatever the in-memory list held, discarding
the rows another process wrote that this one never loaded -- the unparseable
file was their only remaining copy.
The MCP server is the second writer of the same file and had the worse half of
the same bug: `_tool_pin_file` / `_tool_unpin_file` read through `_read_json`
with a `{"version": 1, "pins": []}` default, so a corrupt store was ZEROED
rather than merely reverted to one process's view. Fixing only the service
would have left that writer free to destroy what the service just refused to
touch, so both go through one reader.
Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and
#7805.
## What changed
`read_pins_for_update(file_path)` is the shared update-path reader. It returns
the stored list, `None` when the file is absent (the one case where an empty
base is true, so a first pin on a fresh install still lands), and raises the
new public `PinsCorruptError` on the four shapes that all reach the same
whole-file rewrite: a parse failure, a read error the filesystem raised
(transient EACCES/EIO -- the store is still there), valid JSON whose root is
not an object or whose `pins` is not a list (no parse failure at all), and an
undecodable byte.
That last one decodes STRICTLY where `load` and the Node original use
`errors="replace"`. The leniency only surfaces as a parse failure when the bad
byte falls OUTSIDE a JSON string; inside a quoted value -- a pin label, or a
path on a filesystem that does not enforce UTF-8 -- it becomes U+FFFD,
`json.loads` SUCCEEDS, and the whole-file rewrite persists the mangled text.
Proven directly: a store holding `"label": "caf\xe9 notes"` came back from a
mutation with byte `0xe9` gone and `\ufffd` in its place. Raised by GPT 5.6
review (span=617a8d5961a6).
`load` keeps the lenient decode: it does not write, and a file it accepts is
preserved as a `.bak.<now_ms>` sidecar first. A mangled label can still reach
memory at startup; it can no longer reach disk, because every write path
re-reads through this reader.
The refusal is a named public type rather than the bare `json.JSONDecodeError`
the aws-control readers raise in #8084: that app reached for the plain type
only because the named one lived in another app, which does not apply to a type
declared in the module both writers already import. Modelled on
ops-mission-control's `CorruptDocumentError`.
`add_pin` / `remove_pin` / `mark_seen` propagate it, and the HTTP handlers map
it to `500 {"code": "pins_corrupt"}`. Returning `{"ok": false}` would read as
"no such pin", and for mark-seen -- which has no failure return -- the old
behaviour reported success while the store was being replaced. The exception
text is not echoed: pin labels and paths are agent-authored and redacted on the
way out of the GET handler.
`_process_watch_event` is the one path that swallows the refusal. It fires from
the owner's tick loop, where an escaping error would take down every other tick
(stats, watchlist, presence) over a file it merely wanted to stamp; dropping
the stamp is the same degradation a failed `_persist` already takes there. The
paths a user drove raise, so the operator still learns the store needs repair.
Refuse-only was chosen over extending the sidecar to the update path (#7789's
shape) because this file already has the sidecar where a replacement is
intended, and the update path's whole problem is that no replacement was
intended at all.
## Tests
`test/test_mochi_pinned_files_cov80.py`, 15 new tests. The mutation cases
assert on the FILE's bytes rather than the exception type, so they fail on the
buggy code for the reason that regressed; `TestRefusalIsTheNamedType` pins the
type separately.
Red-before, against pristine `origin/main` source -- 13 failed / 27 passed,
each on a behavioural assertion:
- `add_pin` rewrote the corrupt file from its in-memory list
- `remove_pin` rewrote it, landing `"pins": []`
- `mark_seen` rewrote it
- a debounced watch event rewrote it from the tick loop
- `_tool_pin_file` returned `{"ok": true, "pins": 1}` and zeroed it
- `_tool_unpin_file` zeroed it
The two UTF-8 tests were separately proven against the pre-strict-decode
revision of this branch -- 2 failed / 1 passed, the mutation case on the raw
bytes.
Gates: 612 tests green (all 17 `test_mochi_*` files plus the black-gate
contract), black, isort, flake8, mypy (23 files), sync-io-in-async gate.
## Pattern harvest
Rule candidate: semgrep
Pattern: `read_text(errors="replace")` -- or any lossy decode -- on a read whose
value is written back to the same file.
A lossy decode is a repair, and a repair is only safe on a path that does not
persist what it read. On a read-modify-write path the substituted character is
written over the original bytes, so the loss is silent and irreversible -- and,
uniquely among the corruption shapes, it produces VALID JSON, so no
parse-failure clause downstream can catch it. The same file legitimately keeps
the lenient decode on its display/startup path, which is why the rule has to
key on the read reaching a writer rather than on the decode call alone.
This is the fifth instance of the enclosing class (#7620, #7788, #7794, #7805,
this PR), and the decode variant is the one the earlier four did not cover --
they raised on `UnicodeDecodeError` because they decoded strictly to begin with.
Closes #8088
935fba3 to
d6c2c00
Compare
Legitimate, and the sharpest instance of the very loss this PR exists to prevent. Reproduced before changing anything, against the reviewed commit:
The reasoning I had written into the docstring was wrong, not merely incomplete: I claimed the lenient decode was harmless because U+FFFD would fail in Applied exactly as suggested:
Two tests, both proven red against the pre-fix revision of this branch (2 failed / 1 passed): The PR body's UTF-8 claim was corrected in the same push, and the generalisable form is recorded under |
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: maps the pin store's corruption refusal to a coded HTTP 500 instead of zeroing the file on rewrite (#8088).
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029
The conductor's claim predicate was one prose line, `gh pr list --search`, and it was blind in three directions at once. Each blind spot cost a whole worker dispatch to discover the work did not exist: an item already fixed by a MERGED PR (an `--state open` query structurally cannot see one), four items that each had an OPEN PR carrying `Fixes #N` behind a single field that answered empty, and three items that declared ownership in PROSE. claim_preflight.py asks all five questions in one call and returns one verdict on the exit code: 0 CLAIM, 10 SKIP, 11 CLOSE, 3 UNKNOWN, 2 malformed. The verdict is a pure function of a checks dict, so every precedence branch is a unit test with no forge access, and an unanswerable question yields UNKNOWN, never CLAIM. Six rules earn their own mention because measurement produced each one, not reasoning: - The prose scan reads what an author SAYS, not what they QUOTE: the item specifying this script quotes the closure phrases it detects, and a raw scan returned CLOSE on live work. - The newest human comment is chosen by timestamp, never by position: the comments endpoint ignores `sort`/`direction` and answers oldest-first, so asking for `direction=desc` read the OLDEST of twelve comments on a real item. - A merged PR is coverage only if it CLAIMS to close the item. A bare mention is not closure. Measured on a real item: 7597 has TWO landed merged PRs that merely reference it, one titled "docs: investigation for #7597", and treating either as coverage would have closed an item still being fixed. - A closure request needs standing (the reporter or a repository insider), since CLOSE acts on live work. - A self-claim needs standing too, but downgrades instead of vetoing: a SKIP any commenter can cast is a denial-of-work channel, so an unauthorized claim annotates `risk=high` and takes the live recheck. - An open fork PR still SKIPs, but not silently. Opening a fork PR needs no permission, so rule 2 is a suppression channel anybody can use. Refusing to trust fork PRs is the wrong trade -- 192 of this repo's 301 open PRs come from forks, so that reinstates the duplicate-dispatch class this script was built from -- and the objection was never to the detection but to a response that was unconditional AND silent. So the verdict is unchanged and an unvouched fork's SKIP carries `untrusted-fork=true risk=high`. Standing is an insider association or the item's own reporter, since fixing your own bug from a fork is the ordinary case. The consumer is the conductor's review of untrusted-fork suppressions. - An absent symbol vetoes only when the item's metadata corroborates bug-class: a feature request names the symbol it PROPOSES to add, so an unconditional veto parked that whole class permanently. `closedByPullRequestsReferences` is not consulted at all. It measured `[]` on two items that were closed by merged PRs, and a per-candidate forge call that cannot change the verdict is pure cost against a shared rate limit. Verified against live items: 8088 answers CLOSE merged-pr=#8092 landed=true (that PR carries `Closes #8088`), 7597 and 8029 answer SKIP open-pr=#8035, 8031 answers CLAIM risk=high (no bug-class label), 8007 answers CLAIM risk=low. The fork marker is live too: 6799 and 6509 answer SKIP untrusted-fork=true risk=high behind first-time-contributor fork PRs, while 8071 stays unmarked because its fork PR author reported the item. Refs #8029 Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Problem / Motivation
PinnedFilesService._reload_pins_from_diskcaught(FileNotFoundError, json.JSONDecodeError)and returned with_pinsunchanged, and all four of its callers then_persist()a whole-file rewrite ofpinned-files.json. A corrupt pin store was therefore silently replaced by whatever the in-memory list held.Two writers mutate this file — the gateway service and the MCP server's
pin_file/unpin_file— and the MCP side had the worse half of the same bug: it read through_read_jsonwith a{"version": 1, "pins": []}default, so a corrupt store was zeroed rather than merely reverted to one process's view. Fixing only the service would have left that writer free to destroy what the service had just refused to touch, so both now go through one reader.Same lenient-read-feeding-whole-file-rewrite class as #7620, #7788, #7794 and #7805.
Why it matters
What is lost is exactly the rows another process wrote that this one never loaded, and the unparseable file was their only remaining copy — so the loss is silent and unrecoverable. On the MCP path the whole store goes. The pin list also drives a poller that stamps
updatedAt, so a background tick could trigger the rewrite with no user action at all.The user-visible half was just as quiet:
mark-seenhas no failure return, so the route reported{"ok": true}while the store was being replaced.What changed (motivation → approach → change)
read_pins_for_update(file_path)is the shared update-path reader. It returns the stored list,Nonewhen the file is absent (the one case where an empty base is true, so a first pin on a fresh install still lands), and raises the new publicPinsCorruptErroron the three shapes that all reach the same whole-file rewrite:pinsis not a list — no parse failure at all, so left uncaught it would be the quietest loss of the three.A fourth refusal covers an undecodable byte, and decoding is strict here where
loadand the Node original useerrors="replace". That leniency only surfaces as a parse failure when the bad byte falls outside a JSON string; inside a quoted value — a pin label, or a path on a filesystem that does not enforce UTF-8 — it becomes U+FFFD,json.loadssucceeds, and the whole-file rewrite persists the mangled text. Proven directly: a store holding"label": "caf\xe9 notes"came back from a mutation with byte0xe9gone and\ufffdwritten in its place. Raised by GPT 5.6 review (span=617a8d5961a6) and fixed in this PR.loadkeeps the lenient decode: it does not write, and a file it accepts is preserved as a.bak.<now_ms>sidecar first. A mangled label can still reach memory at startup; it can no longer reach disk, because every write path re-reads through this reader.The refusal is a named public type rather than the bare
json.JSONDecodeErrorthe aws-control readers raise in #8084: that app reached for the plain type only because the named one lived in another app, which does not apply to a type declared in the module both writers already import. Modelled on ops-mission-control'sCorruptDocumentError.add_pin/remove_pin/mark_seenpropagate it, and the two HTTP handlers map it to500 {"code": "pins_corrupt"}. Returning{"ok": false}would read as "no such pin", and for mark-seen as outright success. 500 rather than 503: corruption does not clear on retry, a person has to repair the file, and the bytes it still holds are exactly why the mutation refused. The exception text is not echoed — pin labels and paths are agent-authored and are redacted on the way out of the GET handler._process_watch_eventis the one path that swallows the refusal and logs instead. It fires from the owner's tick loop, where an escaping error would take down every other tick (stats, watchlist, presence) over a file it merely wanted to stamp; dropping the stamp is the same degradation a failed_persistalready takes there. Every path a user drove raises, so the operator still learns the store needs repair.The startup
loadpath is deliberately untouched: there a corrupt file is replaced on purpose and preserved as a.bak.<now_ms>sidecar first, which is a decision with a recovery copy rather than a silent loss under someone else's write.Refuse-only rather than #7789's sidecar-preserve, which the issue asked to weigh: this file already has the sidecar exactly where a replacement is intended, and the update path's whole problem is that no replacement was intended at all. Extending the sidecar there would mint a
.bakon every refused mutation of the same unchanged bytes. This is one more data point for #7789, not a decision taken on its behalf.Tests
test/test_mochi_pinned_files_cov80.py, 13 new tests across three classes.TestReadPinsForUpdate— absence returnsNone; a healthy file returns its list; unparseable bytes, a non-UTF-8 byte outside a string, a non-UTF-8 byte inside a JSON string, a wrong-shaped root, and an unreadable file each refuse; the service's reload leaves_pinsuntouched when refusing.TestCorruptStoreIsNotOverwrittenOnMutation—add_pin,remove_pin,mark_seen, a debounced watch event, and both MCP pin tools all leave the corrupt bytes intact and broadcast nothing; a mutation preserves a non-UTF-8 byte in a label; a missing store still lets the first pin land; the MCP tools still work on a healthy store.TestRefusalIsTheNamedType— each mutator raisesPinsCorruptError.The mutation cases assert on the file's bytes, not on the exception type, so they fail on the buggy code for the reason that regressed rather than because a new symbol is missing; the type is pinned separately.
Red-before, with these tests run against pristine
origin/mainsource — 13 failed / 27 passed, each on a behavioural assertion:mainadd_pinremove_pin"pins": []mark_seen_tool_pin_file{"ok": true, "pins": 1}and zeroed it_tool_unpin_fileAll 13 pass on this branch.
Manual verification
N/A — no UI change. The behaviour is a refusal on a corrupt on-disk file, which the red-before table above exercises end to end through the public mutators, the tick loop and both MCP tools.
Local gates: 610 tests green (all 17
test_mochi_*.pyplustest_black_gate_contract.py),black,isort,flake8,mypy(23 files), and the sync-io-in-async gate.Pattern harvest
Rule candidate: semgrep
Pattern:
read_text(errors="replace")(or any lossy decode) on a read whose value is written back to the same file.A lossy decode is a repair, and a repair is only safe on a path that does not persist what it read. On a read-modify-write path the substituted character is written over the original bytes, so the loss is silent and irreversible — and, uniquely among the corruption shapes, it produces valid JSON, so no parse-failure clause downstream can catch it. The same file legitimately keeps the lenient decode on its display/startup path, which is why the rule has to key on the read reaching a writer rather than on the decode call alone.
This is the fifth instance of the enclosing class (lenient read feeding a whole-file rewrite: #7620, #7788, #7794, #7805, this PR), and the decode variant is the one the earlier four did not cover — they raised on
UnicodeDecodeErrorbecause they decoded strictly to begin with.Closes #8088