Mouth: follow upgrades only a plain basis; @room keeps room #76

Merged
heimdall merged 1 commit from heimdall/follow-null-only into main 2026-11-12 12:28:40 +00:00
3 changed files with 21 additions and 9 deletions

View file

@ -255,13 +255,13 @@ Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries
### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`)
A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because Huginn's parser refuses an unknown basis until its `Basis::Follow` (and the one `is_for` line) lands. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state.
A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because the basis is Huginn's `Basis::FollowUp`, wire string `follow` (`FOLLOW_UP_BASIS`, f09bc56e), and a Huginn that predates it refuses the string. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state.
With it on:
- **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window.
- **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count.
- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` when it would otherwise have no basis or `room`; `tag`, `reply` and `direct` are never replaced. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had.
- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` only when it would otherwise have no basis (a plain message); `room` (@room), `tag`, `reply` and `direct` keep their own basis, and an @room neither wakes via follow nor spends the peer's K. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had.
- **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room.
State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows.

View file

@ -176,7 +176,7 @@ test("follow: at most three an hour per room and peer, counted independently; a
assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" });
});
test("follow: an edit and a notice are never follow; a room-basis message from a counted sender is; a tag keeps its own basis", async () => {
test("follow: an edit and a notice are never follow; a room-basis message from a counted sender stays room; a tag keeps its own basis", async () => {
const w = world({ rounds: [
{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] },
{ [ROOM]: [mine("$mine", T0)] },
@ -187,7 +187,7 @@ test("follow: an edit and a notice are never follow; a room-basis message from a
msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }),
] },
] });
assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "follow"], ["$tag", "tag"]]);
assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "room"], ["$tag", "tag"]]);
});
test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => {
@ -376,3 +376,15 @@ test("follow: an echo that arrives while the send's anchor is still reading wait
assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1);
assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's");
});
test("follow: an @room from a followed peer stays room and spends none of the cap; plain messages still upgrade to follow", async () => {
const w = world({ rounds: [
{ [ROOM]: [msg("$k0", KIKO, T0 - MIN)] },
{ [ROOM]: [mine("$mine", T0)] },
{ [ROOM]: [1, 2, 3, 4].map(n => msg(`$r${n}`, KIKO, T0 + n * SEC, { "m.mentions": { room: true } })) },
{ [ROOM]: [1, 2, 3].map(n => msg(`$p${n}`, KIKO, T0 + 10 * SEC + n)) },
] });
const out = await run(w, 4);
assert.deepEqual(out[2], [["$r1", "room"], ["$r2", "room"], ["$r3", "room"], ["$r4", "room"]]);
assert.deepEqual(out[3], [["$p1", "follow"], ["$p2", "follow"], ["$p3", "follow"]]); // all three of K still unspent
});

View file

@ -25,8 +25,8 @@
//
// Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room
// for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages
// inside the window, at most 3 an hour per peer and room. Memory only. Off, no basis "follow" is ever emitted: Huginn's parser refuses it until its
// Basis::Follow lands.
// inside the window, at most 3 an hour per peer and room; only a plain message (no basis) is upgraded, an @room keeps `room`. Memory only. Off, no
// basis "follow" is ever emitted. Huginn's variant is Basis::FollowUp, wire string "follow" (FOLLOW_UP_BASIS, f09bc56e).
//
// Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename.
// An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured
@ -137,7 +137,7 @@ export function createMouth({
persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake,
now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS,
presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS,
follow = false, // the follow basis (CHAT_FOLLOW): an untagged message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her
follow = false, // the follow basis (CHAT_FOLLOW): a plain (no-basis) message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her
replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind)
images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line
isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with
@ -580,7 +580,7 @@ export function createMouth({
if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event);
noteSpeaker(roomId, event);
let basis = deliverable ? await basisOf(roomId, event) : null;
if (deliverable && (basis === null || basis === "room") && await followed(roomId, event)) basis = "follow";
if (deliverable && basis === null && await followed(roomId, event)) basis = "follow";
const replyTo = deliverable ? await replyToOf(roomId, event) : null;
processed.add(event.event_id);
if (deliverable) {
@ -1185,7 +1185,7 @@ export function loadRuntime(cultRoot) {
export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1";
// The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind.
export const fetchFromEnv = env => env.CHAT_FETCH === "1";
// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's parser refuses an unknown basis until its Basis::Follow lands).
// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's variant is Basis::FollowUp, wire string "follow", FOLLOW_UP_BASIS f09bc56e).
export const followFromEnv = env => env.CHAT_FOLLOW === "1";
function required(env, name) {