fix(channel): a reply carries what it answers, and a correction arrives as a correction #126
No reviewers
Labels
No labels
blocked
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
waiting-on-julian
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/matrix-mcp!126
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "agents-tag-cleanup"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two commits. The fix is the second and the reason for the PR; the first is the pitfall from this morning's tag.
fix(channel): a correction now arrives as a correction — closes #125An
m.replaceis an ordinarym.room.message, so a corrected message reached a session as a second, near-identical instruction carrying the*-prefixed fallback body with nothing marking it a replacement. Nothing absent, nothing malformed, no error anywhere, and a reader that acts twice on something its sender sent once. On replay both the original and its correction were carried, in order, as two separate messages.The
*fallback exists for clients that cannot render an edit and a session is not one, som.new_contentgoes out instead and both paths mark the event withreplaces="$id". Replay additionally sends only the newest version — the third option from #125's table, which pays neither of the other two costs: a session that never saw the original has nothing stale, and one that did can match the id and say which of its instructions was withdrawn.Two judgements lifted out of functions a test cannot reach, for the reason
replay_deliverablewas lifted out ofreplay_roominv0.10.5.live_deliverywas four lines insidepush_message, which needs a liveRoomandPeer;replay_body_sourcewas insidereplay_body, whose only other ingredient is aRoom. A judgement reachable only through a homeserver handle is one no test pins.Verified by removal, one mutation at a time, each restored before the next, and each naming which test caught it:
>for>=on the timestamp tie*fallbackan_edit_delivers_its_new_content_and_not_the_asterisk_fallbackan_edit_is_classified_by_what_it_replaces_the_message_withreplaces_ofalwaysNonecontent.bodyreplacesThe control for the controls is
the_edit_filter_does_not_drop_unedited_messages: a supersession pass returning nothing satisfies every assertion about what must not be replayed, so an assertion that only checked for absence would pass against a filter that had destroyed the channel.Decided rather than inherited: an edit whose original sits outside the replay window is delivered, marked, since the receipt already covers the original and dropping the edit would lose the correction entirely. A malformed edit with no usable
m.new_contentfalls back to its own body rather than being dropped. Ties onorigin_server_tsgo to the later position in the batch, which arrives in timeline order — event id is not a tiebreak, being opaque, and sorting by it would pick a winner for a reason unrelated to which edit came second.CHANNEL_INSTRUCTIONSnow says what the attribute means, with a test that the two cannot drift apart silently. An attribute nobody was told about leaves a correction reading as a repeat exactly as before.docs(agents): the tag annotation loses any line starting with#v0.10.5's annotation shipped without#113 trap. And the live_peers(mxid) == 0 early return dropped verdicts for, so the release note breaks mid-clause and two of the four fixes it describes are unnamed.git tag -adefaults to--cleanup=strip, which removes commentary lines;#107survived in the same annotation only because it sits mid-line. Measured with a control on a throwaway tag at the same sha: identical message, line kept under--cleanup=verbatim, gone under the default. The entry recommends reflowing over passing the flag, because a paragraph where no line starts with#survives the next person writing the tag command without it. The tag itself was left alone —matrix-mcp:v0.10.5was already published atsha256:8c25c57e…, and force-pushing a ref an artefact points at is the worse trade.It also carries the allowlist rule extended to reactions, ahead of #124: an
m.reactionfrom anyone in the room reaches/relations, so once an emoji means "approve this tool call", approval is available to whoever is standing in the room.Gates
fmt, clippy
-D warnings,252 passed; 0 failed,cargo auditclean,cargo deny check bans licenses sourcesok. Toolchain 1.98.0.Third commit: three ways the first fix dropped a message
Codex was asked one question about
646cb84— is there any input for which it drops a message the old code delivered, other than an original a newer edit supersedes. It found three, and the suite was green through all of them.An edit whose
m.new_contentis present but the wrong shape vanished. The classifier was selected by a shape check (msgtypeandbodyboth present), which{"msgtype": 7}satisfies; classification then failed and nothing was delivered, where the old code classified the outer content and delivered it. Now the classifier is asked rather than guessed at —.and_then(classify_content).or_else(|| classify_content(outer))— which cannot disagree with the classifier because it is the classifier. Covered for a non-stringmsgtype, a non-stringbody, and an unsupportedmsgtype.An edit naming itself, and two edits naming each other, vanished. Every id involved is both a winner and a superseded target, and the superseded check ran first, so the correction disappeared entirely: worse than the defect
646cb84fixed. The winner check now runs first and keeps exactly one version of each. Malformed relations are the ordinary case for this, not an exotic one.A replacement with no
event_idwas dropped once any other edit made the winner set non-empty, because a missing id read as""and""is nobody's winner. Invisible to a single-event test, which the early return covers. This pass now leaves such events alone;replay_roomskips them for its own reasons and that is where the decision belongs.Three more mutation controls, each reddening only its own test: the shape check restored, the two membership checks reordered, the missing id read as
"".Two
AGENTS.mdentries, both generalising past this file: a shape check that guesses what a parser accepts turns deliverable input into dropped input, and when two membership checks can both match one event their order decides whether it vanishes.Gates re-run after: fmt, clippy
-D warnings,256 passed; 0 failed, audit clean, deny ok.Fourth commit: the mirror question, and one more
Alan's suggestion, and it paid. The first review asked what this drops that the old code delivered. The mirror is what it now delivers that the old code correctly withheld, or delivers twice.
One found.
carried_ofconsultedm.new_contenton any event carrying the key, without first asking whether the event is a replacement. So anm.location— which the channel does not carry — with anm.textinm.new_contentwas classified as a message and went out carrying the outer body. Replay only: the live path reads a typedRelationand has no such key to be fooled by. So it is also the two paths disagreeing about one event, the #107 shape for the third time in this file.The key is now read only when
replaces_ofsays the event is a replacement, with the control being the same event with the relation added, which must still classify from the new content. Mutation: drop the relation check and the decoy test alone reddens.No duplicate delivery found, and
take(budget)after supersession changes which message occupies a slot without exceeding the budget.Third
AGENTS.mdentry, the general rule rather than the instance: a field that only means something under a relation must be read only when that relation is present, and when adding a field to one path, ask what the other path reads instead of it.Why this is not merged on green
Five gates were green when the first fix was pushed, and four defects have arrived since — three from one narrow question to the other engine, one from its mirror. Green is what makes it reviewable, not what makes it ready.
256 passedwas also green through every one of the three drops.Worth aiming a third question somewhere I have not: the live path has one test and its parent
push_messagestill needs a liveRoomandPeer, so everything afterlive_deliveryreturns is unexercised.Gates after the fourth commit: fmt, clippy
-D warnings,257 passed; 0 failed, audit clean, deny ok. Clippy caught aredundant_clonein the new test on the first run, which is why the whole set is re-run after every change rather than only the suite.Fifth commit:
#124's reply half —775e073Julian asked for this directly and it has cost him three times. He replied to a gate-chase message with "Who is this and what is this for?", the session guessed, answered a question he had not asked, and he had to screenshot his own client. Later he sent "Yes it is wrong. Fix it!" and the receiving session had to ask which thing, because there was no way to tell.
Both paths now emit
in_reply_towith a truncated quotation beside it, plusin_reply_to_sender,in_reply_to_unresolved,in_reply_to_suspiciousandin_reply_to_untrusted_sender. The instructions say what each means, with the same drift test as the edit work.The relation the replay path already held is necessary and not sufficient.
ReadEvent::in_reply_tois populated for a thread fallback as well, wherem.in_reply_topoints at the latest message in the thread for clients without threading. Reading that field alone would quote the wrong message every time somebody posted in a thread, and attribute a choice to the sender they did not make. Both paths read the relation and refuse whenis_falling_backis set.The quotation is escaped by the same functions as the primary body, and the test compares
quote_excerpt's output against whatcontent_sandbox::evaluateproduces for the same input, so the two cannot drift. A hand-written expected string would agree with whichever implementation it was copied from. Suspicion runs on the whole body and the cut happens after, so a marker past the cut still raises the flag; the control is the same body with no marker in it.Five mutations: thread fallback treated as a reply on each path, suspicion measured on the cut excerpt, the quotation left to
attr_escapealone, an unresolvable reference omitting its attribute.Two more from a cross-engine review, folded into the same commit
My sequencing error: these deserved their own commit and
775e073's message does not describe them. The code and the tests are there; the account is here.A quotation could carry a blocked sender's prose. The quotable batch is built before the allowlist filter, so an allowlisted person replying to a blocked sender pulled that sender's words in, escaped and attributed but silently. Dropping it is wrong, because the ordinary case is a reply to our own message and refusing would make the case the feature exists for unresolvable. The excerpt travels and says who wrote it:
in_reply_to_untrusted_sender="true"for anyone neither on the allowlist nor this identity.A reply target fetched by id was never checked against the id asked for. The requested id stayed as the attribute while the sender and body came from whatever the homeserver returned, so a nonconforming response could attribute words to a message that never contained them — which is the whole point of quoting. The response is now compared to the request. The same class inside a batch is two events claiming one id, where the
HashMapkept one and answered for the other; neither is quotable now.Two more mutations: colliding ids keeping the last writer, and the untrusted marker dropped from the attributes.
Three
AGENTS.mdentries, all general: a quotation is escaped by the same functions as the body it quotes and the test compares against the body's own output; a lookup keyed on something the sender controls needs a collision policy, and so does a response fetched by id; and trust follows the route content came in by rather than the content.Gates after: fmt, clippy
-D warnings,266 passed; 0 failed, audit clean, deny ok. Clippy caught aredundant_clone, aredundant_pub_crateand a hundred-line function on the way, none of which the suite would have.Running count: nineteen self-mutations, zero defects. Three narrow questions to the other engine, six defects, every one of them surviving five green gates.
Sixth commit:
read_reactions—888cdafUnblocks
mantis-32's permission gate.read_thread's shape withRelationType::Annotation, so the SDK path, the joined-room guard, the chunk decoding and the sandboxing are the ones already in use.There is no bundled-aggregation route, and it changed the design in the right direction. The spec states
m.annotationrelationships are not aggregated by the server and are not inm.relations, so there is no count to read./relationsreturns the annotation events themselves, carrying each one's sender and timestamp, which is what makes "only this person, and only after I asked" checkable at all. A count would have been easier to read and useless.The key travels byte for byte: no NFC pass, no folding of variation selectors, no stripping of skin-tone modifiers, and no normalisation crate in the tree, so a consumer quoting the emoji back quotes the one that was tapped. Matching is the consumer's job and it can only strip modifiers while it still holds the original.
Redaction is deliberately undocumented, because it is unmeasured. The spec says a redacted child breaks the relation and
m.relates_tolives incontent, which redaction strips, so it should not come back. That is an argument rather than a measurement, and a contract asserted from the spec is one nobody checked. The live fixture runs next.One mutation stayed green, and that was the finding
Deleting the
rel_type == "m.annotation"check broke no test. Every entry in the control set was also refused by the missingkey, so the guard was load-bearing and nothing measured it.m.relates_tois sender-controlled and accepts arbitrary fields, so{"rel_type": "m.thread", "event_id": "$t", "key": "👍"}is one message to write and would have been read as a thumbs-up — an approval, once an emoji means one. The fixture that pins a guard is the one that satisfies every other check and fails only this one.Three more from a cross-engine review
A reaction on a different event was returned as one on this event. The response is fetched by event id and was checked against it nowhere, and the returned entry did not carry its target either, so a caller could not detect it. Now compared, and the target is in the result.
A single page could hide a present reaction permanently.
/relationsreturns newest first, so a reaction older than one page is invisible to a single request and stays invisible however often the caller polls, since every poll starts at the same newest page. Pagination is followed to exhaustion or the cap, and hitting the cap setstruncatedrather than looking like the end of the list.Undecodable entries collapsed into "no reactions yet", contradicting this tool's own documented contract: only request failures were errors, while per-entry failures became empty success. A chunk holding only an undecryptable reaction returned
reactions: []. Now counted asunreadable, and an empty list beside a non-zerounreadableis a third answer that a caller must treat as "cannot tell".Three mutation controls for those. Two more
AGENTS.mdentries: a per-entry decoding failure that drops the entry turns "cannot tell" into "none", and a single page is not the answer to "is it there".Gates: fmt, clippy
-D warnings,274 passed; 0 failed, audit clean, deny ok.Running count: twenty-two self-mutations, one defect — and that one was a test pinning nothing rather than wrong code. Four narrow questions to the other engine, nine defects, every one past five green gates.
docs(agents): the tag annotation loses any line starting with a hashto fix(channel): deliver a correction as a correction, not as a second messageCI on
e7d7b7f:cargosuccess,dockerskipped. Development is paused for the evening; this is the run finishing and nothing after it.The
dockertick on this PR is a skip, and its green carries no information about the image..forgejo/workflows/ci.yml:76readsif: github.event_name != 'pull_request', so the job cannot run here; Forgejo still reportsCI / docker (pull_request)assuccess. Every one of this branch's four pushes shows the same pair, and so does #123 before it. A build of this change has therefore not happened anywhere and will not until it reachesmain.Two things about reading it, since the shape is easy to misread:
successfor a job that never executed./actions/tasksis whereskippedis visible, which is another instance of the rule that the status tick and the task are different questions.head_branchon a pull-request task is the PR number (#126), not the branch name. A predicate matchinghead_branch == "agents-tag-cleanup"finds nothing, always, for every PR — the same silent-empty failure as matching a tag on/actions/runs.Not merging tonight. The reason is on the PR above and it is not the pause: five gates were green when the first fix was pushed and four defects have arrived since, three from one narrow question to the other engine and one from its mirror. Green is what makes this reviewable rather than ready, and
dockernever having built it is now a second reason.Both worktrees are clean, nothing is half-done, and #124 is not started.
fix(channel): deliver a correction as a correction, not as a second messageto fix(channel): a reply carries what it answers, and a correction arrives as a correction