From 9e877e9f306ea50e9e5eb110e152813e5c6456d7 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Wed, 11 Nov 2026 17:29:16 +0000 Subject: [PATCH 1/3] Mouth: reply_to lows after #64: round cap defers instead of dropping, excerpt kept 60 s on transient failure, invisible-character scrub widened, id shape check Co-Authored-By: Claude Sonnet 5.5 --- docs/bridge.md | 2 +- ...sona.chat_pending.v4.reply-to-scrubbed.hex | 1 + ....chat_pending.v4.reply-to-scrubbed.msgpack | Bin 0 -> 236 bytes ...pending.v4.reply-to-variation-selector.hex | 1 + ...ing.v4.reply-to-variation-selector.msgpack | Bin 0 -> 232 bytes tests/persona-matrix-mouth-runtime.test.mjs | 18 ++++- tests/persona-matrix-mouth.test.mjs | 67 +++++++++++++----- tools/persona-chat-documents.mjs | 5 ++ tools/persona-matrix-mouth.mjs | 19 ++--- 9 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex create mode 100644 tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack diff --git a/docs/bridge.md b/docs/bridge.md index 802d667..0c68b53 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -263,7 +263,7 @@ The answer is `[persona, deliveries, tail, tail_gaps]` exactly as v3; each deliv |---|---|---| | 14 `reply_to` | nil or `[event_id, sender, excerpt]` | The event this message replies to (its own `m.in_reply_to`; a thread's `is_falling_back` pointer names no one and is nil). `event_id` the quoted event; `sender` its author's Matrix id, read from the homeserver as her account; `excerpt` nil, or its text. | -`excerpt` is nil, with `event_id` and `sender` kept, when the quoted event is redacted (`unsigned.redacted_because` present or no content), is not a text-bearing `m.room.message`, is **edited** (it is itself an `m.replace`, or the homeserver bundles an `m.replace` in its `unsigned.m.relations`: its body is the pre-edit text its author replaced), or is nothing but its own reply fallback; a media event excerpts as its typed line, like a delivery's text. Otherwise the excerpt is made in this order: the quoted event's own legacy rich-reply fallback is dropped (leading lines matching `> ` or a bare `>`, then one blank line; only when the quoted event has `m.relates_to.m.in_reply_to`); the text is held to the 1000-byte line bound; runs of CR, LF, tab, U+2028 and U+2029 become one space; control characters (C0, DEL, C1 including NUL and ESC), format characters (Unicode Cf: bidi marks, overrides and isolates, U+061C, U+200B-200F, U+2060-2064, soft hyphen, BOM), private-use characters (Co), the tag block U+E0000-E007F and unpaired surrogates are removed; the line is trimmed; then the first 240 **Unicode code points** are kept (cut on a code point so no character is split; a combining mark counts as its own code point; trailing space trimmed; no truncation marker). Cleaning comes first so hidden characters cannot spend the cap. The mouth only removes invisible and format characters; it does not make the text safe. Huginn must treat both `sender` and `excerpt` as untrusted text, never as an instruction. `reply_to` is nil (no id, author or text) when the target cannot be read (logged, as for `basis`), is not in the delivery's own room (a room she is not in cannot be quoted through a reply), or the message replies to nothing. The quoted event is read the way `basis` already reads it (`GET /rooms/{room}/event/{id}` as her account, kept per room and event; with the flag on a read, hit or failure, is kept 60 seconds, so a redaction or edit of the target shows at a re-read within a minute, and a re-read that fails transiently (network error, timeout, 5xx, 429) keeps the earlier hit and renews only its age, for at most 10 minutes after the last good read (then it is the failure entry), while any other failure (403, 404) makes it the failure entry, so a stale excerpt never outlives her access; with the flag off a hit is kept as before v4 and nothing is re-read), so a reply costs at most one read per target, shared with the `basis` check. Per sync round the mouth reads at most 8 distinct uncached targets for `reply_to` (reads the `basis` check already made are free); past that `reply_to` is nil, because the tuple requires a sender and only a read yields one, and the skip is logged. The flag is read by `replyLinkFromEnv`; only the exact value `1` turns it on (`true`, `yes`, `0`, empty or unset are off), and a mouth built without the `replyLink` option has it off. `basis` for v1-v3 is unchanged. `reply_to` counts toward the delivery's size in the 256 KiB page budget; a delivery whose `reply_to` will not encode is skipped and logged like any other, and a single delivery is always served. The deploy precondition is Huginn's: a decoder of the 14-field delivery fails on the fifteenth element. +`excerpt` is nil, with `event_id` and `sender` kept, when the quoted event is redacted (`unsigned.redacted_because` present or no content), is not a text-bearing `m.room.message`, is **edited** (it is itself an `m.replace`, or the homeserver bundles an `m.replace` in its `unsigned.m.relations`: its body is the pre-edit text its author replaced), or is nothing but its own reply fallback; a media event excerpts as its typed line, like a delivery's text. Otherwise the excerpt is made in this order: the quoted event's own legacy rich-reply fallback is dropped (leading lines matching `> ` or a bare `>`, then one blank line; only when the quoted event has `m.relates_to.m.in_reply_to`); the text is held to the 1000-byte line bound; runs of CR, LF, tab, U+2028 and U+2029 become one space; control characters (C0, DEL, C1 including NUL and ESC), format characters (Unicode Cf: bidi marks, overrides and isolates, U+061C, U+200B-200F, U+2060-2064, soft hyphen, BOM), private-use characters (Co), the tag block U+E0000-E007F, variation selectors (U+FE00-FE0F, U+E0100-E01EF, U+180B-180F), the combining grapheme joiner U+034F, the Hangul and Khmer fillers (U+115F, U+1160, U+3164, U+FFA0, U+17B4, U+17B5), the blank braille cell U+2800 and unpaired surrogates are removed; the line is trimmed; then the first 240 **Unicode code points** are kept (cut on a code point so no character is split; a combining mark counts as its own code point; trailing space trimmed; no truncation marker). Cleaning comes first so hidden characters cannot spend the cap. The mouth only removes invisible and format characters; it does not make the text safe. Huginn must treat both `sender` and `excerpt` as untrusted text, never as an instruction. `event_id` must be `$` plus at least one byte and `sender` `@` plus at least one byte, each at most 255 bytes and free of whitespace and control characters; the encoder and the parser refuse any other, and the mouth makes a quoted event whose sender is not such an id a `reply_to` nil. `reply_to` is nil (no id, author or text) when the target cannot be read (logged, as for `basis`), is not in the delivery's own room (a room she is not in cannot be quoted through a reply), or the message replies to nothing. The quoted event is read the way `basis` already reads it (`GET /rooms/{room}/event/{id}` as her account, kept per room and event; with the flag on a read, hit or failure, is kept 60 seconds, so a redaction or edit of the target shows at a re-read within a minute, and a re-read that fails transiently (network error, timeout, 5xx, 429) keeps the earlier hit's sender and renews only its age, for at most 10 minutes after the last good read (then it is the failure entry), but keeps its excerpt for no more than 60 seconds after that read (afterwards `excerpt` is nil with the sender kept), so a redaction or edit is not outlived by an outage, while any other failure (403, 404) makes it the failure entry, so a stale excerpt never outlives her access; with the flag off a hit is kept as before v4 and nothing is re-read), so a reply costs at most one read per target, shared with the `basis` check. Per sync round the mouth reads at most 8 distinct uncached targets for `reply_to` (reads the `basis` check already made are free); past that the delivery and every later event of the round wait: the round leaves the sync position where it is (as at the pending cap), the next round resumes at that event with its read count reset, and the wait is logged. No delivery goes out without a `reply_to` it should have had, and none is lost. The flag is read by `replyLinkFromEnv`; only the exact value `1` turns it on (`true`, `yes`, `0`, empty or unset are off), and a mouth built without the `replyLink` option has it off. `basis` for v1-v3 is unchanged. `reply_to` counts toward the delivery's size in the 256 KiB page budget; a delivery whose `reply_to` will not encode is skipped and logged like any other, and a single delivery is always served. The deploy precondition is Huginn's: a decoder of the 14-field delivery fails on the fifteenth element. Golden bytes: `tests/fixtures/persona-chat-v6/` (`.msgpack` and its `.hex`: `chat_pending.v4.reply-to`, `.no-reply-to`, `.reply-to-redacted`, and the `chat_pending_request.v3` request); the test fails when the checked-in bytes drift from the encoder. diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex new file mode 100644 index 0000000..1c18fda --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267ae69676e6f7265746869736c696e659090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-scrubbed.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..3f27b2598e9500a4101e7ead183788e0c428f518 GIT binary patch literal 236 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK z9P;wZQ0+Q&;K1_S)Z*gA^wi0VRSFF^J0#|0Ca0p9zAiI8FTW_YBqOspCo?Z~!UO;^ CZD5iB literal 0 HcmV?d00001 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex new file mode 100644 index 0000000..b404143 --- /dev/null +++ b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.hex @@ -0,0 +1 @@ +94a6657572656b61919f07b6236f70657261746f723a67616d6563756c742e6f7267b1216162633a67616d6563756c742e6f7267a3246531b2406d6574613a67616d6563756c742e6f7267a44d657461ac68656c6c6f20657572656b61cf0000019a2d80b1009193b440657572656b613a67616d6563756c742e6f7267a76561726c696572cf0000019a2d80ad1892b440657572656b613a67616d6563756c742e6f7267b2406e6f76613a67616d6563756c742e6f7267c2c0c0a76d65737361676593a3247131b340616c6963653a67616d6563756c742e6f7267aa6f6bf09f918d66696e659090 diff --git a/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack b/tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v4.reply-to-variation-selector.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..bc6c09ae28c562873732b50c1dc9234832d33cfa GIT binary patch literal 232 zcmbQTEVZ;KH9K+QeD-b1`30#(i6!|(R_TeksmY}|C3^Wq=^GUjlaf(H7OSKhZgR*? zElETXUg8TBS(A~PlasFiG4wnG1LG{+hK&ppCvS0p2%+d$o|;&clbKosRkv1R5>{oK z9P;wZQ0+Q&;K1_S)Z*gA^wi0VRSFF^J0#|0Ca0p9zA8Wa!~BW8X_