diff --git a/docs/design/brain/capture-paths.md b/docs/design/brain/capture-paths.md
new file mode 100644
index 0000000..0416f40
--- /dev/null
+++ b/docs/design/brain/capture-paths.md
@@ -0,0 +1,203 @@
+# What happens to something you want kept
+
+> Status: Map of what exists, plus a proposed direction for the agent's part
+> Created: 2026-08-06
+> Related: [who-answers.md](who-answers.md) (which bot acts at all), [write-layer.md](write-layer.md) (findings 5, 12, 14, 15), [interaction-patterns.md](interaction-patterns.md)
+
+## Two use cases, and why they stay separate
+
+**Document filing** is for an artifact you must be able to produce again.
+A scanned passport, an invoice, a school permission slip. The bytes are
+the point. Paperless is the system of record: it keeps the original,
+OCRs it, and gives it a durable numeric id.
+
+**Notes and bookmarks** are for something you want to recall. A pasted
+list, a link, a voice memo, a line from an email. There is no original to
+retain; the meaning is the whole thing. A markdown page in the vault
+carries it, classified and attributed, versioned by git.
+
+The test is one question: *would I ever need to produce the original?*
+Passport, yes. "Bart has a peanut allergy", no.
+
+Both paths end in the vault. Only one also ends in Paperless, and the
+vault page it writes carries `paperless_id` back to the original. That is
+the entire difference in storage, and it is worth keeping. Everything
+else that differs between the two is accident, not design.
+
+## The paths as they are
+
+| | Document filing | Notes and bookmarks |
+|---|---|---|
+| **Trigger** | a file or URL in the documents room; a `(` … `)` scan session | a paste, a link, an image or PDF outside the documents room, a voice memo, an email source card, `stack memory capture` |
+| **Pipeline** | `DocumentPipeline` (`stacklets/docs/bot/document_pipeline.py`) | `CapturePipeline` (`stacklets/docs/bot/capture_pipeline.py`) |
+| **Storage** | Paperless, plus a mirrored vault page | a vault page, e.g. `homer/notes/2026/08/peanut-allergy-3a338e.md` |
+| **Identity** | `paperless_id` (int) | vault path |
+| **Enrichment** | correspondent, document type, tags, title, date | title, summary, tags, topics, persons, action items to todos |
+| **Correction** | reply in the filing thread, `reprocess(doc_id, hint)` | reply in the filing thread, `reprocess(vault_path, hint)` |
+| **Envelope** | `document.filed` / `document.reclassified` | `capture.filed` / `capture.reclassified` |
+
+**Invariant: anything filed carries an envelope.** The envelope names
+*which* item a message is about; it is not a report of what the
+classifier concluded. Both paths emit one as soon as the artifact exists
+(a Paperless id, a vault path), including when there was nothing to say
+about it. Withholding it makes the bot's own reply a dead end, because
+the reply-to-correct walker finds a filing by its envelope: a scan with
+no text layer was uncorrectable for exactly this reason, and that is the
+case where the family types the classification in themselves.
+
+```mermaid
+flowchart LR
+ subgraph In
+ F[file upload]
+ U[link]
+ P[paste]
+ V[voice memo]
+ E[email source card]
+ C["stack memory capture"]
+ end
+
+ F -->|documents room| DP[DocumentPipeline]
+ U -->|documents room| DP
+ F -->|any other room| CP[CapturePipeline]
+ U --> CP
+ P --> CP
+ V --> CP
+ E --> CP
+ C --> CP
+
+ DP --> PL[(Paperless
the original)]
+ DP --> VM[vault page
+ paperless_id]
+ CP --> VP[vault page]
+
+ VM --> G[(git mirror)]
+ VP --> G
+```
+
+The shape of that picture is right. Two ways in, one of which also keeps
+the bytes. The problem is not here.
+
+## What is actually wrong
+
+Two things, and neither is the split above.
+
+**The grain is per message.** A conversation that produces one packing
+list produces fourteen notes. write-layer finding 5 recorded it happening:
+six re-posts of the same list became six notes and six extractions. The
+archivist sees one message at a time and has no concept of "these belong
+together", so it cannot produce one artifact from a working session.
+
+**The judge is the message shape.** Whether something is kept is decided
+by whether it is a bare URL, has a URL in it, or is at least 100
+characters long. That decision is made with strictly less information
+than anyone in the room has, which is why it produces both silent drops
+and filed cookie banners.
+
+Nobody in the room has that problem. A participant knows the list is
+finished and knows it is the same list as before.
+
+## The direction: the thread is the unit of judgment
+
+Stacky is a participant. Inside a conversation it is already reading
+every turn, so asking it "has this produced something worth keeping" is
+close to free, and it is the only component that can answer.
+
+That maps exactly onto the ownership rule already in force
+([who-answers.md](who-answers.md)):
+
+- **A thread with Stacky is Stacky's.** It reads the turns anyway. When
+ the work is done it files or, better, *updates* one artifact through
+ the capture door. The archivist does not touch that thread.
+- **The main timeline is the archivist's inbox.** Unconditional, no model
+ judgment, nothing lost. Drop a receipt, a link, a paste, and it is
+ kept.
+- **Files and the documents room stay the document path**, unchanged.
+
+The two-tier shape is the load-bearing part. Letting a model decide what
+is worth keeping is only safe when the fallback is "kept anyway, just not
+consolidated". A single-tier design where Stacky is the sole judge turns
+every model failure into a silent, unrecoverable loss, which is the one
+failure mode famstack cannot afford: nobody ever finds out that the thing
+they typed was never kept.
+
+```mermaid
+sequenceDiagram
+ actor Marge
+ participant R as Matrix room
+ participant S as Stacky
+ participant A as archivist
+
+ Marge->>R: "Stacky, what do we still need for camping?"
+ S->>R: answers, opens a thread
+
+ loop the working session
+ Marge->>R: "add the gas cartridge", in the thread
+ R--)A: thread is Stacky's, ignores
+ S->>R: updates vault/family/camping/todos.md
+ end
+
+ Note over S: one page, updated in place
not fourteen notes
+
+ Marge->>R: drops a campsite booking link
on the main timeline
+ R->>A: no thread, no judgment, kept
+ A->>R: "Saved: Campsite booking"
+```
+
+## What Stacky needs
+
+Less than it looks, and one of the three is a bug fix that stands on its
+own.
+
+1. **The capture door.** `stack memory capture` already exists and is
+ explicitly "the same pipeline the archivist runs, not a second way
+ in". It is not in the agent's skill. So today the agent writes vault
+ pages with `write_file`, which skips classification, tags, summary,
+ attribution and the mirror. Two write doors with different guarantees
+ is the split-brain to close first, independent of everything else
+ here. **~1h.**
+
+2. **A consolidation instruction.** At the end of its own turn, decide
+ whether the conversation has produced something durable, and if so
+ *update the page* rather than append a note. Updating is what makes it
+ idempotent, and idempotence is what kills the six-notes problem.
+ `vault_write.py` was built for exactly this. **~3h, mostly prompt.**
+
+3. **Nothing else.** In particular:
+
+**Stacky should not get the document path.** A file upload is already
+unambiguous, so there is no judgment to add, and Paperless is the one
+store where a wrong write is expensive. It keeps read access
+(`stack docs show --content`, which it already has) and files
+nothing. Handing it a filing tool would be scope for its own sake.
+
+## What this deletes
+
+If the thread carries the judgment and the timeline is unconditional,
+the shape tier in `_on_text` has nothing left to decide: `_is_just_url`,
+`_first_url`, `looks_like_paste` and the `else: ignored` branch all
+collapse into "a human posted on the main timeline, keep it". That is
+four branches and the entire class of silent-drop bug.
+
+Not proposed for today. It is the payoff that makes the direction worth
+taking, and it should follow the week of real use, not precede it.
+
+## Open questions
+
+1. **When does Stacky decide it is done?** Per turn is cheap but chatty.
+ End-of-conversation needs a timer, and there is no natural end to a
+ family chat. Explicit ("save that") is reliable and puts the work back
+ on the family. Start with per-turn and idempotent updates, since a
+ wrong "yes" costs a rewrite of a page that keeps its history anyway.
+
+2. **Where does capture live?** finding 14: `capture_pipeline.py` is in
+ the docs stacklet and writes no Paperless document. It is a memory
+ concern wearing a docs coat, and its one docs dependency is
+ `paperless.get_tags()` for the person roster.
+
+3. **Two answerers, still.** The archivist's search and the agent's
+ `memory_search` both answer questions, and `stack docs search` does not
+ exist (finding 12). Out of scope here; noted so it is not rediscovered.
+
+4. **Rooms without Stacky.** The two-tier design assumes the archivist's
+ unconditional timeline capture stays. It does. But a family that never
+ invites Stacky gets tier one only, and that has to remain a complete
+ product on its own.
diff --git a/docs/design/brain/interaction-patterns.md b/docs/design/brain/interaction-patterns.md
index e2b7605..33b7a2c 100644
--- a/docs/design/brain/interaction-patterns.md
+++ b/docs/design/brain/interaction-patterns.md
@@ -165,6 +165,7 @@ A design note, not a prescriptive plan. Captures the direction agreed in the 202
## Related
+- [[who-answers]] — which bot answers at all, once several share a room
- [[topic-rooms]] — room-state-as-intent, the seed of this design
- [[knowledge-architecture]] — the broader event bus and storage layout
- [[wiki-engine]] — the deriver work the reply-chain UX will lean on for confidence signals
diff --git a/docs/design/brain/who-answers.md b/docs/design/brain/who-answers.md
new file mode 100644
index 0000000..a967aba
--- /dev/null
+++ b/docs/design/brain/who-answers.md
@@ -0,0 +1,270 @@
+# Who answers a message?
+
+> Status: Rule in force for threads, gaps named below
+> Created: 2026-08-06
+> Related: [capture-paths.md](capture-paths.md) (what happens once a bot does act), [interaction-patterns.md](interaction-patterns.md) (how one bot reads intent), [write-layer.md](write-layer.md) (finding 9, order-of-work step 4)
+
+## Why this exists
+
+A famstack room holds several bots and several people. The archivist
+watches for material to file, the agent answers questions, the mail bot
+posts email, scribe transcribes voice. Each of them decides on its own
+whether an incoming message is for it, and until now nothing said what
+happens when two of them say yes.
+
+The failure that forced the question, from a real family room:
+
+```
+Homer Merlin, the packing list is still wrong (to the agent)
+ └─ thread
+ Merlin The list is correct now, the shoe rack is ticked off.
+ Homer (to the agent)
+ Archivist Saved: "Error saving the packing list" <- filed it
+ Merlin The file is already saved correctly.
+ Homer No it is not. Write the file! (to the agent)
+ Archivist Reclassified with your hint: "Error saving..." <- and again
+ Homer The saved list still has the unclean version. (to the agent)
+ Archivist Reclassified with your hint: "Packing list ..." <- and again
+```
+
+Nobody addressed the archivist. It filed one side of a conversation as
+knowledge, and because its own card was then sitting in the thread, every
+following line looked like a correction to that card. One paste turned
+into a loop that could not be stopped by talking.
+
+The rule was already written down on the agent's side. `thread_trigger.py`
+says it plainly: *"An agent that claimed every thread would answer into
+every filing discussion in the house, breaking the rule the archivist
+already applies to itself: exactly one component responds to a message."*
+The archivist applied that rule to its own filings and had no idea the
+agent existed. This document is the missing half.
+
+## The invariant
+
+**Exactly one component answers a message.** Every gate below exists to
+make that true, and any new bot has to state which gate it uses.
+
+## The signals, strongest first
+
+| Signal | Meaning | Who reads it today |
+|---|---|---|
+| **Reaction** | This message, this action, chosen per message | archivist `_on_reaction` (🔖 📌 bookmark, 📎 📄 archive, 🔁 🔄 retry) |
+| **@-mention** | Deliberate address, overrides everything ambient | every bot, `MicroBot._is_bot_mentioned`; the agent adds nanobot's own pill check |
+| **Name in the vocative** | "Merlin, what is missing?" - how people actually talk | agent only, `name_trigger.addressed_by_name` |
+| **Thread ownership** | Inside a bounded conversation, the thread is the address | agent `AgentThreads`, archivist `MicroBot.thread_owner` |
+| **Room** | The room's default job, and its `!config process` mode | archivist (`documents` room means search; `react` mode means reactions only) |
+| **Message shape** | A URL, a long paste, a file | archivist only, and only on the main timeline |
+
+The order matters. Everything above "room" is the user saying who they
+mean. Everything at or below it is the bot guessing. A guess must never
+beat an address, which is why the mention check sits in front of the
+thread gate, and the thread gate in front of the shape ladder.
+
+## The thread rule
+
+**A thread belongs to the first bot that replied into it, other than
+whoever started it.** A bot acts on a threaded message only when it owns
+the thread, or when it was addressed explicitly.
+
+Implemented as `MicroBot.thread_owner` (`stacklets/core/bot-runner/microbot.py`).
+Both halves are load-bearing:
+
+- **First reply**, because that is what created the thread. Our
+ convention is that a bot answers by threading under the message it
+ answers, so the root is normally the person's own upload or question
+ and the reply is the bot's claim on it. Being first also never changes,
+ which is what makes ownership stable. Under a "who spoke last" rule the
+ agent could take a filing thread away from the archivist by saying one
+ thing in it, and corrections would silently stop working.
+- **Not the starter**, because a producer posting under its own root is
+ still publishing. The mail bot posts an email as a card, then the full
+ body and the attachments underneath it. None of that is conversation.
+ The archivist's filing is the thread's first real answer, and the
+ family has to be able to correct it there.
+
+**A handoff is exempt.** An event carrying `dev.famstack.source` or
+`dev.famstack.attachment` (`MicroBot.HANDOFF_KEYS`) is addressed by
+contract rather than by who is in the room, so no ambient rule gets a
+say. This is not a special case bolted on: the mail bot posts an email's
+attachments into the thread under its own card seconds before the
+archivist's filing lands there, so at that instant the thread is nobody's
+and an ownership-only gate drops the school permission slip.
+
+Consequences worth knowing:
+
+- A thread no bot answered in belongs to nobody, and no bot acts in it.
+ Two people working something out is a conversation, not material
+ dropped for filing. This is a deliberate narrowing: the archivist used
+ to capture pastes inside any thread.
+- The main timeline is untouched. Dropping something into the room is
+ still how you hand the archivist material.
+- An @-mention reaches any bot in any thread.
+
+## The patterns
+
+Every bot sees every message in the room. What the gates decide is which
+one of them acts, so the interesting part of each diagram is the arrow
+that stops.
+
+### Filing something, then correcting it
+
+The pattern the archivist exists for, and the one the thread rule has to
+keep intact. The upload is on the main timeline, so nothing gates it; the
+archivist's answer creates the thread and claims it, which is what makes
+"this is Marge's" a correction rather than a stray note.
+
+```mermaid
+sequenceDiagram
+ actor Homer
+ participant R as Matrix room
+ participant A as archivist
+ participant M as agent
+
+ Homer->>R: uploads invoice.pdf (main timeline)
+ R-->>M: not addressed, ignores
+ R->>A: _on_file
+ Note over A: not in a thread → ours to route
+ A->>R: "Filed: Duff Insurance (#42)"
opens a thread on the upload
+
+ Homer->>R: "this is Marge's, not Homer's"
in that thread
+ R-->>M: thread is not the agent's, ignores
+ R->>A: _on_text
+ Note over A: thread_owner = archivist
(first to reply) → ours
+ A->>R: re-runs classification with the hint
+```
+
+### Talking to the agent
+
+The tragedy, and where it now stops. Note the two different gates: the
+first line is judged by name and shape, everything after it by the
+thread.
+
+```mermaid
+sequenceDiagram
+ actor Homer
+ participant R as Matrix room
+ participant A as archivist
+ participant M as agent
+
+ Homer->>R: "Merlin, what is missing for camping?"
+ R->>M: name in the vocative → answers
+ R->>A: _on_text, not in a thread
+ rect rgb(90, 60, 60)
+ Note over A: GAP: only the shape ladder guards this.
A long enough opening line is still filed.
+ end
+ M->>R: "The gas cartridge and the mats."
opens a thread on Homer's question
+
+ Homer->>R: pastes the broken list, in that thread
+ R->>M: thread is the agent's → answers
+ R->>A: _on_text
+ Note over A: thread_owner = agent → stop
+ Note over A: nothing filed, so no card in the thread,
so no correction loop to sustain
+```
+
+### Email arriving
+
+Two bots and one thread, settled by the handoff marker rather than by
+ownership. The archivist's filing is the thread's first real answer, so
+the family can still correct it there afterwards.
+
+```mermaid
+sequenceDiagram
+ participant Mail as mail bot
+ participant R as Matrix room
+ participant A as archivist
+ actor Marge
+
+ Mail->>R: source card (dev.famstack.source)
+ Mail->>R: full body, threaded under the card
+ Mail->>R: slip.pdf (dev.famstack.attachment),
threaded under the card
+
+ R->>A: card → handoff, files it
+ R--)A: body → plain bot chatter, ignored
+ R->>A: slip.pdf → handoff, files it
+ Note over A: the thread is still nobody's at this point;
the handoff exemption is what saves the slip
+ A->>R: "Filed: Permission slip"
in the card's thread
+
+ Marge->>R: "this is Bart's, not Lisa's"
in the card's thread
+ Note over A: mail bot started the thread, so it does not
own it; archivist replied first → ours
+ A->>R: re-runs classification with the hint
+```
+
+### Two people in a thread
+
+No bot answered, so nobody owns it and nobody acts. This is the
+deliberate narrowing: the archivist used to capture pastes here.
+
+```mermaid
+sequenceDiagram
+ actor Homer
+ actor Marge
+ participant R as Matrix room
+ participant A as archivist
+
+ Homer->>R: "when are we leaving on Friday?"
+ Marge->>R: "after Lisa's rehearsal", in a thread
+ Homer->>R: pastes the whole plan, in that thread
+ R->>A: _on_text
+ Note over A: thread_owner = nobody → stop
+ Homer->>R: "@archivist save that"
+ R->>A: mention beats ambient → files it
+```
+
+## Where each bot stands
+
+| Bot | Answers when | Gate |
+|---|---|---|
+| **archivist** | mentioned; or on the main timeline; or in a thread it owns; or reacted to | `_on_text` / `_on_file` -> `_should_react`, `_thread_is_ours`; `_on_reaction` |
+| **agent** | pill-mentioned; or named in the vocative; or in a thread it is part of | nanobot's gate, extended by `name_trigger.py` + `thread_trigger.py` shims |
+| **mail bot** | never answers; it only produces source cards | n/a |
+| **scribe** | every voice message in a room it is in | none |
+
+Two producers write into rooms without answering anything (mail bot,
+and the archivist when it posts a filing card). Their output carries
+`dev.famstack.source` / `dev.famstack.event`, and other bots read the
+envelope rather than the prose.
+
+## The gaps
+
+1. **The vocative is not shared.** "Merlin, save this" on the main
+ timeline is an address to the agent, and the archivist cannot see it:
+ `addressed_by_name` lives in the agent stacklet, which mounts no
+ `lib/stack`. A long enough opening line still gets filed. This is
+ step 4 of [write-layer.md](write-layer.md) and the remaining half of
+ the fix above. Moving the matcher into the framework and mounting it
+ both ways is the honest version; duplicating the regex is how the two
+ drift apart (finding 11).
+
+2. **Scribe has no gate at all.** It transcribes every voice message it
+ can see, including ones sent inside another bot's thread, and the
+ archivist has its own voice path. Worth settling before both are in
+ the same room in front of a family.
+
+3. **Ownership is not cached.** Each threaded message costs a root fetch
+ plus one relations page against local Synapse. Ownership never
+ changes once settled, so it is cacheable the way `AgentThreads` caches
+ its positives. Not done, because the cost is noise next to the LLM
+ calls on the same path. Revisit if a busy room says otherwise.
+
+4. **Room-level arbitration is untouched.** `!config process react`
+ quiets one bot in one room. There is no way to say "the archivist does
+ not work in this room at all", which is the blunt instrument a family
+ would reach for first.
+
+## Tests that state these rules
+
+- `tests/stacklets/test_microbot.py::TestThreadOwner` - the ownership
+ rule itself, including the mail bot's shape.
+- `tests/stacklets/test_archivist_corrections.py::TestOnlyOurOwnThreads` -
+ what the family sees: the agent's thread is left alone, an @-mention
+ still lands, the main timeline is unchanged.
+- `tests/stacklets/test_agent_thread_trigger.py` - the same contract from
+ the agent's side.
+- `tests/integration/test_bot_arbitration_e2e.py` - the whole contract
+ against real containers: real Synapse relations, a real second bot in
+ the thread, a real 📌. Marked `unverified` until a green rig run.
+
+Not covered end to end yet: the handoff exemption. A module test pins it
+(`test_bot_attachment_files_inside_the_source_card_thread`), but proving
+it against a live mail bot needs GreenMail plus the archivist in one
+rig lane, which `test_email_imap_e2e.py` does not currently reach.
diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py
index 528c40d..01a31a3 100644
--- a/stacklets/core/bot-runner/microbot.py
+++ b/stacklets/core/bot-runner/microbot.py
@@ -708,6 +708,18 @@ async def _answer(
# person and carries provenance for the capture's tags.
ATTACHMENT_KEY = "dev.famstack.attachment"
+ # The markers above are the two ways one component hands work to
+ # another. An event carrying one is addressed by contract, not by who
+ # is in the room, so the ambient rules that decide ordinary chat
+ # (thread ownership, mention) have no say over it.
+ HANDOFF_KEYS = (SOURCE_KEY, ATTACHMENT_KEY)
+
+ @classmethod
+ def is_handoff(cls, event) -> bool:
+ """Whether ``event`` was posted for another component to act on."""
+ content = (getattr(event, "source", None) or {}).get("content", {})
+ return any(key in content for key in cls.HANDOFF_KEYS)
+
@staticmethod
def is_bot_user(user_id: str) -> bool:
"""Whether a Matrix user is a famstack bot, by convention.
@@ -857,6 +869,67 @@ async def _thread_envelopes(
self.name, root_event_id, e)
return envelopes
+ async def thread_owner(
+ self, room_id: str, root_event_id: str, *, limit: int = 20,
+ ) -> str | None:
+ """Which bot the thread at ``root_event_id`` belongs to.
+
+ famstack's bots all thread, so one room carries several
+ conversations at once: the archivist's filing under an upload,
+ the mail bot's email under its card, the family agent answering a
+ question. Inside a thread nobody repeats a name on every line —
+ the thread *is* the tie between a message and its responder — so
+ a bot that treats every thread it can see as its own ends up
+ answering, and filing, someone else's conversation.
+
+ **A thread belongs to the first bot that replied into it, other
+ than whoever started it.** Two halves, both load-bearing:
+
+ *First reply*, because that is what created the thread. Our
+ convention is that a bot answers by threading under the message
+ it answers, so the root is normally the human's own upload or
+ question; the reply is the bot's claim on it. Being first is also
+ a fact that never changes, which is what makes ownership stable —
+ a bot that merely spoke most recently could take a thread away
+ from the bot the family is actually mid-conversation with.
+
+ *Other than the starter*, because a producer posting under its
+ own root is still publishing, not conversing. The mail bot posts
+ an email's card, then its full body and attachments underneath;
+ the archivist's filing is the first real answer there, and the
+ family must be able to correct it in that thread.
+
+ Returns a bot mxid (possibly this bot's own), or None when no bot
+ has answered — a thread between people, which belongs to nobody.
+ Bounded by ``limit`` so one chat message can never become an
+ unbounded walk. Best-effort: a homeserver failure reads as
+ "nobody's", so a transient error leaves bots quiet rather than
+ letting one act on a thread it may not own.
+ """
+ try:
+ resp = await self.client.room_get_event(room_id, root_event_id)
+ except Exception as e:
+ logger.debug("[{}] thread root fetch failed for {}: {}",
+ self.name, root_event_id, e)
+ return None
+ starter = getattr(getattr(resp, "event", None), "sender", None)
+ try:
+ examined = 0
+ async for related in self.client.room_get_event_relations(
+ room_id, root_event_id, RelationshipType.thread,
+ direction=MessageDirection.front,
+ ):
+ examined += 1
+ if examined > limit:
+ break
+ sender = getattr(related, "sender", None)
+ if sender and sender != starter and self.is_bot_user(sender):
+ return sender
+ except Exception as e:
+ logger.debug("[{}] thread relations fetch failed for {}: {}",
+ self.name, root_event_id, e)
+ return None
+
def _ensure_http(self) -> aiohttp.ClientSession:
"""The shared aiohttp session, created on first use.
diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py
index 84c2f80..943f840 100644
--- a/stacklets/docs/bot/archivist.py
+++ b/stacklets/docs/bot/archivist.py
@@ -1056,6 +1056,52 @@ def _is_filing_envelope(envelope: dict) -> bool:
"""
return envelope.get("type", "").endswith((".filed", ".reclassified"))
+ async def _thread_is_ours(self, room_id: str, event) -> bool:
+ """Whether `event` is ours to act on, as far as threads decide it.
+
+ A thread is a bounded conversation with an owner, so the
+ archivist acts inside one only when the thread is its own -- the
+ filing thread hanging off an upload, where "this is Marge's, not
+ Homer's" is a correction. Every other thread is someone else's
+ conversation and is left alone, including a thread no bot has
+ answered in: two people talking to each other is not material
+ dropped for filing.
+
+ The main timeline is unchanged. A message that is in no thread is
+ always ours to route -- that is where dropping something for the
+ archivist happens.
+
+ This is the gate that was missing. The family agent lives in the
+ same rooms and answers in threads, and a person mid-conversation
+ with it does not repeat its name on every line, so from here
+ those lines looked like free-typed material: an agent error
+ message became a note titled "Fehler beim Speichern der
+ Packliste", and every reply typed afterwards read as a correction
+ to that note, because the thread now held one of our filing
+ cards.
+
+ An @-mention skips this gate at the call site: deliberate address
+ beats ambient context, the same rule corrections already follow.
+ """
+ # A handoff is addressed to us by contract, so no ambient rule
+ # gets a say. The mail bot posts an email's attachments into the
+ # thread under its own card, seconds before our filing lands
+ # there, so at that moment the thread is still nobody's -- and
+ # without this the school permission slip is silently dropped.
+ if self.is_handoff(event):
+ return True
+ root = self.get_thread_root(event)
+ if root is None:
+ return True
+ owner = await self.thread_owner(room_id, root)
+ if owner == self.user_id:
+ return True
+ logger.info(
+ "[archivist] thread {} belongs to {}; ignoring {} in {}",
+ root, owner or "nobody", event.sender, room_id,
+ )
+ return False
+
async def _correction_anchor(
self, room_id: str, event,
) -> tuple[str, dict] | None:
@@ -1482,6 +1528,11 @@ async def _on_file(self, room, event) -> None:
)
return
+ # An upload dropped into someone else's thread is material for
+ # that conversation, not for us. Same gate as `_on_text`.
+ if not mentioned and not await self._thread_is_ours(room.room_id, event):
+ return
+
# On modern Matrix clients an upload can carry a human caption
# alongside the file (Element X, FluffyChat, anything honoring
# MSC4274). When present, the caption rides into the classify
@@ -1622,6 +1673,12 @@ async def _on_text(self, room, event: RoomMessageText) -> None:
)
return
+ # Inside a thread, only our own -- see `_thread_is_ours`. Checked
+ # before every routing branch so a message in someone else's
+ # conversation is neither filed nor read as a correction.
+ if not mentioned and not await self._thread_is_ours(room.room_id, event):
+ return
+
# When the bot is mentioned, the mxid is conversational noise —
# strip it so the remaining body drives command matching and
# the search query. A bare ping with no content becomes "help"
@@ -1746,22 +1803,34 @@ async def _on_text(self, room, event: RoomMessageText) -> None:
sender=event.sender,
)
- elif self._looks_like_paste(query):
- # Capture room + paste-shaped message, no mention → file
- # as text capture. The user is dropping content into the
- # room; without an @-tag we treat it as material to keep,
- # not a question to answer.
+ elif self._looks_like_paste(query) and self._count_humans_in_room(room) < 2:
+ # One person in the room, paste-shaped message, no mention →
+ # file as text capture. Nobody is being talked *to* here, so
+ # a long message is material dropped for us to keep.
+ #
+ # Above one human the room is a conversation, and length
+ # stops meaning anything: people write long messages to each
+ # other all day and almost none of it is meant to be kept.
+ # Guessing there produced notes titled after somebody's error
+ # message. So the ambient path is off and 📌 is how you keep
+ # something -- explicit, visible in the timeline, and already
+ # the one trigger that ignores every room mode.
+ #
+ # Links and files are unaffected in either case: pasting a URL
+ # or dropping a PDF is a deliberate act, not a turn in a
+ # conversation.
await self._handle_text_capture(
room.room_id, query, event.sender, reply_to,
capture_id=event.event_id,
)
else:
- # Short message in a capture room — ignored. Pasting more
- # context will trigger capture; chat-shaped messages don't.
+ # Chat, and nothing else matched. Either short (a capture
+ # room's own threshold) or long in a room with other people
+ # in it, where keeping something is 📌 rather than a guess.
logger.debug(
- "[archivist] capture room {} ignored short text: {!r}",
- room.room_id, query[:60],
+ "[archivist] {} ignored text from {}: {!r}",
+ room.room_id, event.sender, query[:60],
)
# ── Reactions: user → bot per-message routing ────────────────────────
@@ -2277,6 +2346,14 @@ async def _handle_text_capture(
self, room_id: str, text: str, sender_mxid: str,
reply_to: str | None = None, *, capture_id: str | None = None,
) -> None:
+ # 👀 before the work, like every other capture shape: a link gets
+ # it from the pipeline's notifier, an upload from the handler. A
+ # note was the one path that filed in silence, and classification
+ # on a local model is seconds of it. That silence matters more
+ # now than it did -- in a room with other people 📌 is the only
+ # way to keep something, so this is the gesture with no fallback.
+ if reply_to:
+ await self._react(room_id, reply_to, EYES)
binding = await self._topic_binding(
self._room_by_id(room_id), sender_mxid,
)
diff --git a/stacklets/docs/bot/document_pipeline.py b/stacklets/docs/bot/document_pipeline.py
index bca2e75..0eedaf7 100644
--- a/stacklets/docs/bot/document_pipeline.py
+++ b/stacklets/docs/bot/document_pipeline.py
@@ -227,6 +227,7 @@ async def process(
return FilingOutcome(
status="filed_no_details", display_name=display_name,
doc_id=doc_id, link=link,
+ envelope=self._filed_envelope(doc_id, {}),
)
ocr_text = doc.get("content", "") or ""
@@ -278,18 +279,13 @@ async def process(
paperless_tags=paperless_tags, summary=result.summary,
)
- envelope = None
- if classification:
- envelope = build_document_event(
- doc_id, classification,
- resolved_topics=result.resolved_topics,
- resolved_persons=result.resolved_persons,
- resolved_correspondent=result.resolved_correspondent,
- resolved_type=result.resolved_type,
- link_base_url=self.link_base_url,
- actor=self.actor,
- ts=utc_now_isoformat(),
- )
+ envelope = self._filed_envelope(
+ doc_id, classification,
+ resolved_topics=result.resolved_topics,
+ resolved_persons=result.resolved_persons,
+ resolved_correspondent=result.resolved_correspondent,
+ resolved_type=result.resolved_type,
+ )
return FilingOutcome(
status="enriched", display_name=display_name, doc_id=doc_id, link=link,
@@ -376,6 +372,30 @@ def _mirror_body(self, is_text, file_data, formatted, ocr_text, *, reformatted):
return body_text, "ai_formatted", model
return body_text, "ocr", None
+ def _filed_envelope(self, doc_id: int, classification: dict, **resolved) -> dict:
+ """The `document.filed` envelope for a document that reached Paperless.
+
+ Emitted for every filed document, including the ones we could
+ say nothing about. The envelope names *which document a message
+ is about*; it is not a report of what the classifier concluded,
+ and the document exists either way.
+
+ That distinction is load-bearing. It is what makes the bot's
+ reply correctable: the reply-to-correct walker finds a filing by
+ its envelope, so a filing without one silently becomes a dead
+ end, and the user's correction routes to search instead. A scan
+ with no text layer is exactly the case where the family types
+ the classification in themselves, so it is the last filing that
+ can afford to be uncorrectable.
+ """
+ return build_document_event(
+ doc_id, classification,
+ link_base_url=self.link_base_url,
+ actor=self.actor,
+ ts=utc_now_isoformat(),
+ **resolved,
+ )
+
async def reprocess(
self, *, doc_id: int, user_hint: str, date_filed: str | None = None,
initial_classification: dict | None = None,
diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml
index 33c7677..fef7d14 100644
--- a/stacklets/docs/bot/messages/archivist.yml
+++ b/stacklets/docs/bot/messages/archivist.yml
@@ -175,7 +175,8 @@ en:
**What I'll do here:**
- - 📝 **Notes & links:** paste text or paste a URL → filed under `{bucket}/` with the `{slug}` tag
+ - 🔗 **Links:** paste a URL → filed under `{bucket}/` with the `{slug}` tag
+ - 📌 **Notes:** react 📌 to any message to keep it (in a room with just you, a long paste is kept on its own)
- 🎤 **Voice memos:** record straight from the chat → transcribed + filed
- 📷 **Photos & PDFs:** drop any file → text-extracted, summarised, and filed
- 🎤 **Multi-memo notes:** type `(` then send multiple voice memos, then `)` → combined into one note
@@ -202,7 +203,8 @@ en:
**What I'll do here:**
- - 📝 **Notes & links:** paste text or paste a URL → summarised and filed under the sender's bucket
+ - 🔗 **Links:** paste a URL → summarised and filed under the sender's bucket
+ - 📌 **Notes:** react 📌 to any message to keep it (in a room with just you, a long paste is kept on its own)
- 🎤 **Voice memos:** record straight from the chat → transcribed + filed
- 📷 **Photos & PDFs:** drop any file → text-extracted, summarised, and filed
- 🔍 **Ask me anything:** start a message with `?` (e.g. `?did we get the dentist confirmation`) → I search captures and documents
@@ -371,7 +373,8 @@ de:
**Was ich hier mache:**
- - 📝 **Notizen & Links:** Text einfügen oder URL pasten → wird unter `{bucket}/` mit dem Tag `{slug}` abgelegt
+ - 🔗 **Links:** URL einfügen → wird unter `{bucket}/` mit dem Tag `{slug}` abgelegt
+ - 📌 **Notizen:** mit 📌 auf eine Nachricht reagieren, dann merke ich sie mir (bist du allein im Raum, wird ein langer Text automatisch abgelegt)
- 🎤 **Sprachnachrichten:** direkt im Chat aufnehmen → wird transkribiert und abgelegt
- 📷 **Fotos & PDFs:** beliebige Datei droppen → Text wird extrahiert, zusammengefasst und abgelegt
- 🎤 **Mehrere Sprachnachrichten:** `(` tippen, mehrere Memos senden, dann `)` → werden zu einer Notiz kombiniert
@@ -398,7 +401,8 @@ de:
**Was ich hier mache:**
- - 📝 **Notizen & Links:** Text einfügen oder URL pasten → wird zusammengefasst und im Bucket des Absenders abgelegt
+ - 🔗 **Links:** URL einfügen → wird zusammengefasst und im Bucket des Absenders abgelegt
+ - 📌 **Notizen:** mit 📌 auf eine Nachricht reagieren, dann merke ich sie mir (bist du allein im Raum, wird ein langer Text automatisch abgelegt)
- 🎤 **Sprachnachrichten:** direkt im Chat aufnehmen → wird transkribiert und abgelegt
- 📷 **Fotos & PDFs:** beliebige Datei droppen → Text wird extrahiert, zusammengefasst und abgelegt
- 🔍 **Frag mich was:** Nachricht mit `?` beginnen (z.B. `?haben wir die Zahnarzt-Bestätigung bekommen`) → ich durchsuche Captures und Dokumente
diff --git a/tests/integration/test_bot_arbitration_e2e.py b/tests/integration/test_bot_arbitration_e2e.py
new file mode 100644
index 0000000..9238049
--- /dev/null
+++ b/tests/integration/test_bot_arbitration_e2e.py
@@ -0,0 +1,288 @@
+"""Which bot acts on a message, and which stays quiet — end-to-end INTENT.
+
+This file is an executable specification of `docs/design/brain/who-answers.md`.
+It is the contract; the implementation is expected to satisfy it.
+
+> STATUS: verified green through `stacktests` on 2026-08-06. Two things
+> had to change to get there. stacker-bot was left to join the room on
+> its own, but the room is private and `stack messages send` joins as a
+> plain client, so Synapse turned it away and the send guard read the
+> error dict as a pass; it is invited at creation now. And the pinned
+> note filed in silence -- 👀 was missing from the one capture shape
+> that had no notifier -- which is what the first test caught.
+
+A famstack room holds several bots and several people, and the invariant
+is that exactly one component answers a message. Two rules decide it, and
+both were learned the hard way:
+
+ **Chat is not material.** In a room with more than one person in it,
+ the archivist stops guessing from message shape. A long message there
+ is usually just somebody talking, and filing those produced notes named
+ after an agent's error message. Keeping something is 📌, which is
+ explicit, visible in the timeline, and works at any length under any
+ room mode. Alone in a room there is nobody to talk to, so a long paste
+ is still material and is still filed.
+
+ **A thread has an owner.** It belongs to the first bot that replied
+ into it, other than whoever started it. The archivist acts inside a
+ thread only when it owns one. Without this, a family's conversation
+ with the agent was filed as notes, and because the archivist's own card
+ then sat in the thread, every following line read as a correction to
+ it: one paste became a reclassification loop that could not be stopped
+ by talking.
+
+Why the two rooms below are shaped as they are:
+
+ * the group room has two humans, which is what turns the ambient text
+ path off. Reactions, links and files are unaffected, and the test
+ checks that too — the point is that the *guessing* stopped, not that
+ the archivist went deaf.
+ * the solo room has one human, so ambient capture is live. That is what
+ makes it the honest place to test the thread rule: a paste there is
+ filed on the main timeline and ignored inside another bot's thread,
+ so the only thing that can explain the difference is ownership.
+
+stacker-bot stands in for the family agent. The archivist reads the
+framework's `-bot` convention, not a specific bot's name, so any famstack
+bot exercises the same branch, and stacker-bot is already in the rig
+while the agent stacklet may not be.
+"""
+from __future__ import annotations
+
+from nio import AsyncClient
+
+from tests.integration.matrix import (
+ event_type,
+ fetch_room_events,
+ mxid,
+ wait_for_room_event,
+ wait_for_room_events_until,
+)
+
+ARCHIVIST = mxid("archivist-bot")
+STACKER = mxid("stacker-bot")
+MARGE = mxid("marge")
+EYES, CHECK = "👀", "✅"
+
+# Comfortably over `looks_like_paste`'s 100-character threshold, so the
+# only reason the archivist could ignore it is the rule under test.
+#
+# A function, not a constant: the rig runs against a live instance whose
+# vault keeps whatever gets filed, and an LLM titles a capture from its
+# content, so nothing downstream carries the test's scope uid unless the
+# text does. Stamping it here is what makes a run's leavings findable.
+def wall_of_text(scope) -> str:
+ return (
+ "Right, the plan is to load the car at four, leave by five, and stop "
+ "at the halfway services for dinner around seven so nobody has to "
+ f"cook when we arrive. [ref: {scope.uid}]"
+ )
+
+
+def _norm(key: str) -> str:
+ """Drop the variation selector clients append to an emoji key."""
+ return (key or "").replace("\uFE0F", "").strip()
+
+
+def _bot_reacted(events, *, key: str, target: str) -> bool:
+ """Did the archivist add reaction `key` to event `target`?"""
+ return any(
+ event_type(e) == "m.reaction"
+ and getattr(e, "sender", None) == ARCHIVIST
+ and getattr(e, "reacts_to", None) == target
+ and _norm(getattr(e, "key", "")) == key
+ for e in events
+ )
+
+
+def _bot_replied_to(events, target: str) -> bool:
+ """Did the archivist post a message answering `target`?
+
+ Anchored on the reply relation rather than "the archivist said
+ something", because its join welcome is already in every room's
+ timeline and would satisfy the looser check on its own.
+ """
+ for e in events:
+ if getattr(e, "sender", None) != ARCHIVIST:
+ continue
+ relates = (
+ (getattr(e, "source", None) or {})
+ .get("content", {})
+ .get("m.relates_to", {})
+ )
+ if relates.get("m.in_reply_to", {}).get("event_id") == target:
+ return True
+ return False
+
+
+async def _send(client, room_id: str, body: str, **content) -> str:
+ r = await client.room_send(
+ room_id, "m.room.message",
+ {"msgtype": "m.text", "body": body, **content},
+ )
+ return r.event_id
+
+
+async def _send_in_thread(client, room_id: str, root: str, body: str) -> str:
+ return await _send(client, room_id, body, **{"m.relates_to": {
+ "rel_type": "m.thread", "event_id": root,
+ "is_falling_back": True, "m.in_reply_to": {"event_id": root},
+ }})
+
+
+async def _react(client, room_id: str, target: str, key: str) -> None:
+ await client.room_send(room_id, "m.reaction", {"m.relates_to": {
+ "rel_type": "m.annotation", "event_id": target, "key": key}})
+
+
+async def _wait_until_listening(client, room_id: str) -> None:
+ """Wait for the archivist's welcome, not merely for it to join.
+
+ A join is an `m.room.member` state event, so a sender-only predicate
+ clears the instant the invite is accepted, before the bot is
+ processing anything. Its welcome is its own "I am listening" signal.
+ Cold start can take ~40s. (Same trap as test_room_modes_e2e.py.)
+ """
+ posted = await wait_for_room_event(
+ client, room_id,
+ lambda e: (
+ getattr(e, "sender", None) == ARCHIVIST
+ and (getattr(e, "body", "") or "").strip() != ""
+ ),
+ timeout=130,
+ )
+ assert posted, "archivist never posted its welcome, so it is not listening"
+
+
+async def test_chat_between_people_is_not_filed_but_a_pin_is(homer, matrix, scope):
+ """Two humans in a room: the archivist stops reading intent from
+ shape, and 📌 is how you override it.
+
+ 1. A wall of text is left alone. No pickup, no reply.
+ 2. 📌 on that same message files it: 👀 then ✅.
+ 3. A pasted link is still filed with no reaction needed, because
+ pasting a URL is a deliberate act rather than a turn in a
+ conversation.
+ """
+ marge_creds = matrix["marge"]
+ marge = AsyncClient(marge_creds.homeserver, marge_creds.user_id)
+ marge.access_token = marge_creds.access_token
+ marge.device_id = marge_creds.device_id
+ try:
+ created = await homer.room_create(
+ name=f"arbitration-group-{scope.uid}",
+ invite=[MARGE, ARCHIVIST],
+ )
+ room = created.room_id
+ await marge.join(room)
+ await _wait_until_listening(homer, room)
+
+ # 1. Somebody talking is not material to file.
+ chat = await _send(homer, room, wall_of_text(scope))
+ quiet = await fetch_room_events(homer, room, duration=40)
+ assert not _bot_reacted(quiet, key=EYES, target=chat), (
+ "a long message in a room with other people in it is chat; "
+ "the archivist must not pick it up"
+ )
+
+ # 2. The override. 📌 says "this one I do want kept", and it is
+ # the same capture the ambient path would have made.
+ await _react(homer, room, chat, "📌")
+ pinned = await wait_for_room_events_until(
+ homer, room,
+ lambda events: _bot_reacted(events, key=CHECK, target=chat),
+ timeout=120,
+ )
+ assert _bot_reacted(pinned, key=EYES, target=chat), \
+ "📌 should make the archivist pick the message up (👀)"
+ assert _bot_reacted(pinned, key=CHECK, target=chat), \
+ "a successful pin should be marked ✅"
+
+ # 3. Only the guessing stopped. Links still file on their own.
+ link = await _send(homer, room, "https://en.wikipedia.org/wiki/Camping")
+ filed = await wait_for_room_events_until(
+ homer, room,
+ lambda events: _bot_reacted(events, key=CHECK, target=link),
+ timeout=120,
+ )
+ assert _bot_reacted(filed, key=CHECK, target=link), (
+ "a pasted link is a deliberate drop, not conversation, and "
+ "must still be filed without a reaction"
+ )
+ finally:
+ await marge.close()
+
+
+async def test_the_archivist_stays_out_of_another_bots_thread(
+ homer, matrix, test_stack, scope,
+):
+ """One human in the room, so ambient capture is live. The same paste
+ is filed on the main timeline and ignored inside a thread another bot
+ owns, which leaves ownership as the only explanation.
+
+ An @-mention still reaches the archivist in that thread: deliberate
+ address beats ambient context, and it is the escape hatch that keeps
+ the rule from locking the family out.
+ """
+ # stacker-bot is invited at creation, not left to join: the room is
+ # private, `stack messages send` joins as a plain client, and Synapse
+ # turns that away. Bots don't count toward the human total that gates
+ # ambient capture, so its presence leaves the premise intact.
+ created = await homer.room_create(
+ name=f"arbitration-solo-{scope.uid}", invite=[ARCHIVIST, STACKER],
+ )
+ room = created.room_id
+ await _wait_until_listening(homer, room)
+
+ # Baseline: alone in the room there is nobody to talk to, so a long
+ # paste is material and the ambient path files it. Without this the
+ # negative below would also pass on a bot that had simply stopped.
+ solo = await _send(homer, room, wall_of_text(scope))
+ filed = await wait_for_room_events_until(
+ homer, room,
+ lambda events: _bot_reacted(events, key=CHECK, target=solo),
+ timeout=120,
+ )
+ assert _bot_reacted(filed, key=CHECK, target=solo), (
+ "with one human in the room the ambient capture path must still "
+ "file a paste, otherwise this test proves nothing below"
+ )
+
+ # Another bot opens a conversation: Homer asks, the bot answers in a
+ # thread on his question. First reply, and not the thread's starter,
+ # so the thread is the bot's.
+ ask = await _send(homer, room, "what do we still need for camping?")
+ sent = test_stack.run(
+ "messages", "send", room,
+ "The gas cartridge and the sleeping mats.", "--thread", ask,
+ )
+ assert sent.get("ok") is True, f"stacker-bot send failed: {sent}"
+ claimed = await wait_for_room_event(
+ homer, room,
+ lambda e: getattr(e, "sender", None) == STACKER,
+ timeout=60,
+ )
+ assert claimed, "stacker-bot never posted, so no thread was claimed"
+
+ # The same paste, now inside that conversation. It is Homer talking
+ # to the other bot, and the archivist has no part in it.
+ in_thread = await _send_in_thread(homer, room, ask, wall_of_text(scope))
+ ignored = await fetch_room_events(homer, room, duration=40)
+ assert not _bot_reacted(ignored, key=EYES, target=in_thread), (
+ "the thread belongs to the bot that answered in it first; the "
+ "archivist must not pick up a message there"
+ )
+
+ # The escape hatch: addressed on purpose, it answers anyway.
+ asked = await _send_in_thread(
+ homer, room, ask, f"{ARCHIVIST} what did we pack last summer",
+ )
+ answered = await wait_for_room_events_until(
+ homer, room,
+ lambda events: _bot_replied_to(events, asked),
+ timeout=120,
+ )
+ assert _bot_replied_to(answered, asked), (
+ "an @-mention is deliberate address and must reach the archivist "
+ "even inside a thread it does not own"
+ )
diff --git a/tests/stacklets/test_archivist_corrections.py b/tests/stacklets/test_archivist_corrections.py
index 528beac..8bd9d9b 100644
--- a/tests/stacklets/test_archivist_corrections.py
+++ b/tests/stacklets/test_archivist_corrections.py
@@ -23,6 +23,11 @@
A fixture built from our own `_send` output would only prove we agree
with ourselves. The bug these tests pin was exactly a disagreement
between our sender (thread-aware) and our reader (single reply hop).
+
+Reading the thread answers *which* filing a message corrects. It does
+not answer whether the message was for us at all -- other bots thread in
+the same rooms -- so `TestOnlyOurOwnThreads` states the gate in front of
+all of this: inside a thread, the archivist acts only in its own.
"""
from __future__ import annotations
@@ -92,6 +97,25 @@ def _filed(paperless_id=DOC_ID, **data):
}
+def _filed_bare(paperless_id=DOC_ID):
+ """The envelope a filing with nothing to say still carries.
+
+ A scan with no text layer reaches Paperless and the classifier
+ produces nothing, so every field is empty except the one that
+ matters: which document this message is about.
+ """
+ return {
+ "source": "docs", "type": "document.filed",
+ "summary": f"Document #{paperless_id} filed",
+ "data": {
+ "paperless_id": paperless_id, "title": "", "date": None,
+ "topics": [], "persons": [], "correspondent": None,
+ "document_type": None, "summary": "", "facts": [],
+ "action_items": [],
+ },
+ }
+
+
def _reclassified(paperless_id=DOC_ID, **data):
return {
"source": "docs", "type": "document.reclassified",
@@ -106,9 +130,10 @@ class FakeMatrix:
"""The slice of `nio.AsyncClient` the correction path reads.
Holds a room's events and, per thread root, the ids of the events
- hanging off it. `room_get_event_relations` is an async iterator that
- yields newest-first, which is what Synapse returns for the default
- backwards direction.
+ hanging off it in timeline order. `room_get_event_relations` is an
+ async iterator honouring `direction`: newest-first for the default
+ backwards direction, which is what Synapse returns, and oldest-first
+ for `front` — the order that says which reply came first.
"""
def __init__(self):
@@ -126,10 +151,14 @@ async def room_get_event(self, room_id, event_id):
return SimpleNamespace(event=self.events.get(event_id))
async def room_get_event_relations(
- self, room_id, event_id, rel_type=None, **kwargs,
+ self, room_id, event_id, rel_type=None, direction=None, **kwargs,
):
+ from nio.api import MessageDirection
self.relation_calls.append(event_id)
- for child_id in reversed(self.threads.get(event_id, [])):
+ children = self.threads.get(event_id, [])
+ if direction is not MessageDirection.front:
+ children = reversed(children)
+ for child_id in children:
yield self.events[child_id]
@@ -311,6 +340,42 @@ async def test_latest_classification_in_the_thread_wins(self, bot, filed_thread)
assert bot.routed[0][0] == "reprocess"
assert bot.routed[0][3] == {"paperless_id": DOC_ID, "persons": ["Marge"]}
+ @pytest.mark.asyncio
+ async def test_a_filing_with_nothing_to_say_is_still_correctable(self, bot):
+ """The case that matters most, and the one that was broken.
+
+ A scan with no text layer files fine and the archivist has
+ nothing to add: "no text recognised, please tag it manually in
+ Paperless". That is exactly the moment a person types the
+ classification in themselves. Their words are the only
+ description the document will ever have, so the reply has to
+ reach reprocess and carry them as the hint.
+
+ It used to run as a search, which answered a question nobody
+ asked and quietly lost the correction.
+ """
+ client = bot._client
+ client.add(_message("$scan", HOMER, "Gescannt_20260806-1532.pdf"))
+ client.add(
+ _message("$filed", BOT_ID,
+ "Filed: Gescannt_20260806-1532.pdf — no text recognised",
+ content=_thread_relation("$scan"),
+ envelope=_filed_bare()),
+ thread_root="$scan",
+ )
+ hint = ('Classify as "Grundriss". It is a house plan for the '
+ 'Mühlenstr. Tag it "haus" and "mühlenstr".')
+ event = _message(
+ "$correction", HOMER, hint,
+ content=_thread_relation("$scan", falls_back_to="$filed"),
+ )
+ await bot._on_text(_docs_room(), event)
+ assert [r[0] for r in bot.routed] == ["reprocess"], \
+ f"expected a reclassification, got {bot.routed}"
+ assert bot.routed[0][1] == DOC_ID
+ assert bot.routed[0][2] == hint, \
+ "the user's own words are the prompt input for the reclassify"
+
@pytest.mark.asyncio
async def test_capture_thread_reaches_the_capture_pipeline(self, bot):
"""Captures thread the same way and correct the same way; the
@@ -406,6 +471,132 @@ async def test_reply_to_another_users_message_is_not_a_correction(self, bot):
assert bot.routed == [("search", "nice try")]
+class TestOnlyOurOwnThreads:
+ """Reading the thread is what makes a correction findable, and it is
+ also what let the archivist walk into conversations it has no part in.
+
+ The family agent lives in the same rooms and answers in threads, and
+ nobody repeats its name on every line of one. Those lines reached
+ `_on_text` looking like free-typed material: a long one got filed as
+ a note, and from then on the thread held one of our filing cards, so
+ every reply after it read as a correction to that note. One paste
+ became an unstoppable reclassification loop.
+
+ So the thread gate is ownership, not content: inside a thread the
+ archivist acts only where it answered first. `thread_owner` has the
+ framework-level tests; these state what the family sees.
+ """
+
+ AGENT = "@merlin-bot:server"
+
+ @staticmethod
+ def _family_room():
+ """A topic room -- not the documents room, so free text is
+ capture-or-nothing rather than search."""
+ return SimpleNamespace(
+ room_id="!camping:server", canonical_alias="#camping:server",
+ name="Camping",
+ users={uid: object() for uid in (BOT_ID, HOMER, "@merlin-bot:server")},
+ )
+
+ @pytest.fixture
+ def agent_thread(self, bot):
+ """Homer asking the agent something, and the agent answering in a
+ thread on his question -- the ordinary shape of talking to it."""
+ client = bot._client
+ client.add(_message("$ask", HOMER, "Merlin, what is still missing for camping?"))
+ client.add(
+ _message("$agent", self.AGENT, "The gas cartridge and the sleeping mats.",
+ content=_thread_relation("$ask")),
+ thread_root="$ask",
+ )
+ return client
+
+ @pytest.mark.asyncio
+ async def test_a_paste_in_the_agents_thread_is_not_filed(self, bot, agent_thread):
+ """The first wrong turn. Homer pastes the list he is working on
+ into the conversation; it is for the agent to act on, not for us
+ to file as a note."""
+ event = _message(
+ "$paste", HOMER,
+ "Error saving the packing list, please clean it up and save it "
+ "again because the shoe rack entry is still duplicated in there.",
+ content=_thread_relation("$ask", falls_back_to="$agent"),
+ )
+ await bot._on_text(self._family_room(), event)
+ assert bot.routed == []
+
+ @pytest.mark.asyncio
+ async def test_a_reply_in_the_agents_thread_is_not_a_correction(
+ self, bot, agent_thread,
+ ):
+ """The cascade. Even once one of our cards sits in the thread --
+ which is exactly how the loop sustained itself -- Homer's next
+ words are still aimed at the agent."""
+ agent_thread.add(
+ _message("$filed", BOT_ID, "Saved: error saving the packing list",
+ content=_thread_relation("$ask"),
+ envelope=_filed(topics=["Camping"])),
+ thread_root="$ask",
+ )
+ event = _message(
+ "$reply", HOMER, "no it is not, write the file!",
+ content=_thread_relation("$ask", falls_back_to="$filed"),
+ )
+ await bot._on_text(self._family_room(), event)
+ assert bot.routed == []
+
+ @pytest.mark.asyncio
+ async def test_a_mention_in_the_agents_thread_still_reaches_us(
+ self, bot, agent_thread,
+ ):
+ """Ownership is ambient; an @-mention is deliberate address, and
+ that beats it -- the same rule corrections already follow."""
+ event = _message(
+ "$q", HOMER, f"{BOT_ID} what did we pack last summer",
+ content={
+ "m.mentions": {"user_ids": [BOT_ID]},
+ **_thread_relation("$ask", falls_back_to="$agent"),
+ },
+ )
+ await bot._on_text(self._family_room(), event)
+ assert bot.routed == [("search", "what did we pack last summer")]
+
+ @pytest.mark.asyncio
+ async def test_two_people_talking_in_a_thread_are_left_alone(self, bot):
+ """A thread no bot answered in belongs to nobody. Two family
+ members working something out is a conversation, not material
+ dropped for filing."""
+ client = bot._client
+ client.add(_message("$plan", HOMER, "when are we leaving on Friday?"))
+ client.add(
+ _message("$marge", "@marge:server", "after Lisa's rehearsal",
+ content=_thread_relation("$plan")),
+ thread_root="$plan",
+ )
+ event = _message(
+ "$paste", HOMER,
+ "Right, so the plan is to load the car at four, leave by five, and "
+ "stop at the halfway services for dinner around seven in the evening.",
+ content=_thread_relation("$plan"),
+ )
+ await bot._on_text(self._family_room(), event)
+ assert bot.routed == []
+
+ @pytest.mark.asyncio
+ async def test_the_main_timeline_is_untouched(self, bot, agent_thread):
+ """The gate is about threads only. Dropping something into the
+ room itself is still how you hand the archivist material, even
+ while a conversation with the agent is open alongside it."""
+ event = _message(
+ "$paste", HOMER,
+ "Campsite booking reference DUFF-4417, arrival Friday after six, "
+ "pitch 12 by the water, cancellation free up to two days before.",
+ )
+ await bot._on_text(self._family_room(), event)
+ assert [r[0] for r in bot.routed] == ["capture_text"]
+
+
class TestChainedCorrections:
"""Correcting a correction. Each round adds a user turn and a bot
confirmation; the pipeline gets every human turn back to the original
diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py
index 4e5868e..af70d3f 100644
--- a/tests/stacklets/test_archivist_routing.py
+++ b/tests/stacklets/test_archivist_routing.py
@@ -394,6 +394,114 @@ async def test_bot_authored_target_not_bookmarked(self, tmp_path):
assert not cap and not txt
+class TestPinnedNoteCapture:
+ """What a person gets back for pinning a message.
+
+ 📌 is now the only way to keep a message in a room with other
+ people in it: the ambient path reads a long message there as
+ conversation and leaves it alone. That promotion is what makes
+ both of these matter.
+
+ A gesture that is the sole way in has to say it registered. Filing
+ a note runs a classifier, which is seconds of nothing on a local
+ model, and the reactor is left wondering whether the pin took. 👀
+ is the same "picked this up" the bot already gives a pasted link
+ and an uploaded file; a pinned note was the one path without it.
+
+ And a note kept in a topic room belongs to that topic. The reactor
+ is not necessarily the author and neither of them is the point --
+ the room is. Filing it under whoever tapped the emoji buries a
+ shared topic's note in a personal bucket, where the rest of the
+ family's search scopes (`['family/', '/']`) will never see
+ it.
+ """
+
+ TOPIC_BINDING = SimpleNamespace(
+ bucket="family/camping",
+ seed_topics=["camping"],
+ display_name="Camping",
+ scope="shared",
+ slug="camping",
+ )
+
+ def _bot(self, tmp_path, *, binding, target_body="remember the boiler service"):
+ """A bot whose capture pipeline records instead of filing.
+
+ `_reply_for_capture` is stubbed: the outcome-to-reply mapping
+ (✅ / ❌ / the threaded card) is pinned in its own tests, and
+ letting it run here would drag the presenter in for no gain.
+ What this class asserts is what happens *before* the reply --
+ the acknowledgement and the routing.
+ """
+ bot = _build_bot(tmp_path)
+ captures, reactions = [], []
+
+ class _RecordingPipeline:
+ async def capture_text(self, **kwargs):
+ captures.append(kwargs)
+ return SimpleNamespace(status="captured")
+
+ async def _react(room_id, event_id, emoji):
+ reactions.append((event_id, emoji))
+
+ async def _binding(_room, _sender):
+ return binding
+
+ async def _reply(*_a, **_kw):
+ return None
+
+ bot._capture = _RecordingPipeline()
+ bot._react = _react
+ bot._topic_binding = _binding
+ bot._reply_for_capture = _reply
+
+ async def _get_event(room_id, event_id):
+ return SimpleNamespace(event=SimpleNamespace(
+ sender="@marge:server", body=target_body,
+ source={"content": {"body": target_body}},
+ ))
+
+ bot._client = SimpleNamespace(
+ room_get_event=_get_event, rooms={"!r:server": _room()},
+ )
+ return bot, captures, reactions
+
+ @staticmethod
+ def _pin(sender="@homer:server"):
+ return SimpleNamespace(
+ key="📌", reacts_to="$tgt", sender=sender, source={"content": {}},
+ )
+
+ async def test_pinning_acknowledges_the_message_it_will_file(self, tmp_path):
+ """👀 lands on the pinned message, so the reactor knows the pin
+ took while the classifier is still working."""
+ bot, _captures, reactions = self._bot(tmp_path, binding=None)
+ await bot._on_reaction(_room(), self._pin())
+ assert ("$tgt", "\U0001F440") in reactions, (
+ "a pinned message must be acknowledged on the message itself"
+ )
+
+ async def test_a_pin_in_a_topic_room_files_under_that_topic(self, tmp_path):
+ """The room decides the bucket. A note pinned in `Thema: Camping`
+ is the family's camping note, not the pinner's."""
+ bot, captures, _reactions = self._bot(
+ tmp_path, binding=self.TOPIC_BINDING,
+ )
+ await bot._on_reaction(_room(), self._pin())
+ assert len(captures) == 1
+ assert captures[0]["bucket"] == "family/camping"
+ assert captures[0]["seed_topics"] == ["camping"]
+
+ async def test_a_pin_outside_a_topic_room_keeps_sender_routing(self, tmp_path):
+ """No topic binding, no override. A plain room still files under
+ the message's author, which is what the personal bucket is for."""
+ bot, captures, _reactions = self._bot(tmp_path, binding=None)
+ await bot._on_reaction(_room(), self._pin())
+ assert len(captures) == 1
+ assert captures[0]["bucket"] is None
+ assert captures[0]["sender_mxid"] == "@marge:server"
+
+
class TestRetryReaction:
"""🔁 / 🔄 — the recovery gesture for a filing that failed.
@@ -692,17 +800,76 @@ async def test_mention_in_group_room_routes_to_search(self, bot_with_recorder):
assert calls == [("search", "Pollos")]
@pytest.mark.asyncio
- async def test_paste_without_mention_still_captures(self, bot_with_recorder):
+ async def test_paste_alone_in_a_room_still_captures(self, bot_with_recorder):
"""The mention is the gate. Without it, a long paste in a
non-docs room continues to route to text capture — mention is
- an additive signal, not a replacement for the capture flow."""
+ an additive signal, not a replacement for the capture flow.
+
+ One human, so nobody is being talked *to*: a long message here
+ is material dropped for the archivist."""
bot, calls = bot_with_recorder
- room = self._room_obj()
+ room = self._room_obj(members=[BOT_ID, "@homer:server"])
long_text = "x" * 150
event = self._text_event(long_text)
await bot._on_text(room, event)
assert calls == [("capture_text", long_text)]
+ @pytest.mark.asyncio
+ async def test_paste_with_other_people_present_is_left_alone(
+ self, bot_with_recorder,
+ ):
+ """Two or more people means the room is a conversation, and
+ length stops meaning anything there: people write long messages
+ to each other all day and almost none of it is meant to be kept.
+ Guessing produced notes titled after somebody's error message.
+
+ The archivist keeps quiet. Keeping something is 📌, which is
+ explicit and visible in the timeline."""
+ bot, calls = bot_with_recorder
+ room = self._room_obj(
+ members=[BOT_ID, "@homer:server", "@marge:server"],
+ )
+ event = self._text_event("x" * 150)
+ await bot._on_text(room, event)
+ assert calls == []
+
+ @pytest.mark.asyncio
+ async def test_a_link_is_still_bookmarked_with_people_present(
+ self, bot_with_recorder,
+ ):
+ """Only the guessing stops. Pasting a URL is a deliberate act
+ rather than a turn in a conversation, so it is filed in any room
+ exactly as before."""
+ bot, calls = bot_with_recorder
+ room = self._room_obj(
+ members=[BOT_ID, "@homer:server", "@marge:server"],
+ )
+ event = self._text_event("https://example.com/tent-poles")
+ await bot._on_text(room, event)
+ assert calls == [("capture_url", "https://example.com/tent-poles")]
+
+ @pytest.mark.asyncio
+ async def test_pinning_keeps_it_anyway(self, bot_with_recorder):
+ """The escape hatch, and the reason turning the guess off is
+ safe: 📌 on the very message the ambient path ignored files it,
+ at any length, in any room, under any room mode."""
+ bot, calls = bot_with_recorder
+ room = self._room_obj(
+ members=[BOT_ID, "@homer:server", "@marge:server"],
+ )
+ long_text = "x" * 150
+ target = self._text_event(long_text)
+ await bot._on_text(room, target)
+ assert calls == []
+
+ bot._client = SimpleNamespace(
+ room_get_event=lambda _r, _e: _resp(target),
+ )
+ await bot._on_reaction(room, SimpleNamespace(
+ sender="@marge:server", key="📌", reacts_to="$evt:server",
+ ))
+ assert calls == [("capture_text", long_text)]
+
@pytest.mark.asyncio
async def test_mention_overrides_capture_for_long_text(self, bot_with_recorder):
"""A long pasted query *with* a mention is the user explicitly
@@ -741,3 +908,8 @@ async def test_mention_with_url_still_routes_url(self, bot_with_recorder):
async def _none_coro():
return None
+
+
+async def _resp(event):
+ """A `room_get_event` response wrapping one event."""
+ return SimpleNamespace(event=event)
diff --git a/tests/stacklets/test_archivist_source.py b/tests/stacklets/test_archivist_source.py
index 01fedb1..f1aed5f 100644
--- a/tests/stacklets/test_archivist_source.py
+++ b/tests/stacklets/test_archivist_source.py
@@ -342,6 +342,27 @@ async def test_bot_attachment_files_without_bot_as_person(tmp_path):
assert "Sender: office@school.example" in captured["extra_seed_topics"]
+@pytest.mark.asyncio
+async def test_bot_attachment_files_inside_the_source_card_thread(tmp_path):
+ """The shape the mail bot actually sends.
+
+ An email's attachments are posted into the thread under its own
+ source card, moments after the card and well before the archivist's
+ filing lands there. At that instant the thread is nobody's, so a bot
+ that only acts in threads it owns would drop the school permission
+ slip on the floor. A handoff carries its own address; thread
+ ownership decides ordinary chat, not this.
+ """
+ bot = _bot(tmp_path)
+ captured = _wire_file(bot)
+ threaded = {
+ **_ATTACH_CONTENT,
+ "m.relates_to": {"rel_type": "m.thread", "event_id": "$card:server"},
+ }
+ await bot._on_file(_room(), _file_event(threaded))
+ assert captured["filename"] == "slip.pdf"
+
+
@pytest.mark.asyncio
async def test_unmarked_file_keeps_sender_attribution(tmp_path):
# A human upload (no attachment marker) still attributes the sender.
diff --git a/tests/stacklets/test_document_pipeline.py b/tests/stacklets/test_document_pipeline.py
index a1573c9..1f331d1 100644
--- a/tests/stacklets/test_document_pipeline.py
+++ b/tests/stacklets/test_document_pipeline.py
@@ -150,9 +150,45 @@ async def test_no_text_skips_classification(self):
assert out.status == "enriched"
assert out.has_text is False
assert out.classification == {}
- assert out.envelope is None
assert len(mirror.published) == 1 # mirrored regardless
+ @pytest.mark.asyncio
+ async def test_a_filing_with_nothing_to_say_still_names_its_document(self):
+ """A scan with no text layer lands in Paperless and the LLM has
+ nothing to add. It still gets an envelope.
+
+ The envelope's job is to say *which document this message is
+ about*, not to report what we concluded, and that is true the
+ moment the document exists. Withholding it made the reply
+ uncorrectable: the archivist could not tell that "classify this
+ as a floor plan" was aimed at document #5, so it ran the words
+ as a search instead. The document that most needs a human was
+ the only one that could not accept one.
+ """
+ doc = {"id": 5, "content": "x"}
+ out = await _process(
+ _pipeline(FakePaperless(doc_id=5, doc=doc), mirror=FakeMirror()),
+ data=b"x",
+ )
+ assert out.classification == {}, "nothing was classified"
+ assert out.envelope is not None, \
+ "a filed document must be correctable even with no details"
+ assert out.envelope["type"] == "document.filed"
+ assert out.envelope["data"]["paperless_id"] == 5
+
+ @pytest.mark.asyncio
+ async def test_an_unreadable_filing_also_names_its_document(self):
+ """Same rule one branch earlier: the upload was accepted and the
+ document exists, so a reply about it has a target, even though
+ Paperless never gave us anything back to read."""
+ out = await _process(
+ _pipeline(FakePaperless(doc_id=7, doc=None), mirror=FakeMirror()),
+ )
+ assert out.status == "filed_no_details"
+ assert out.envelope is not None, \
+ "a filed document must be correctable even when unreadable"
+ assert out.envelope["data"]["paperless_id"] == 7
+
@pytest.mark.asyncio
async def test_classify_disabled_files_without_llm(self):
doc = {"id": 6, "content": "a fully readable document body here"}
diff --git a/tests/stacklets/test_microbot.py b/tests/stacklets/test_microbot.py
index 7a2eed9..ee2c29e 100644
--- a/tests/stacklets/test_microbot.py
+++ b/tests/stacklets/test_microbot.py
@@ -86,10 +86,14 @@ async def room_get_event(self, room_id, event_id):
return SimpleNamespace(event=self.parent_events.get(event_id))
async def room_get_event_relations(self, room_id, event_id, rel_type=None,
- **kwargs):
+ direction=None, **kwargs):
if self.relations_raise is not None:
raise self.relations_raise
- for event in reversed(self.thread_children.get(event_id, [])):
+ from nio.api import MessageDirection
+ children = self.thread_children.get(event_id, [])
+ if direction is not MessageDirection.front:
+ children = reversed(children)
+ for event in children:
yield event
@@ -891,6 +895,116 @@ async def test_empty_when_the_fetch_fails(self, tmp_path):
assert await bot._thread_envelopes("!r:server", "$root") == []
+# ── Thread ownership ───────────────────────────────────────────────────────
+
+
+class TestThreadOwner:
+ """`thread_owner` answers "whose conversation is this thread".
+
+ A famstack room holds several at once -- the archivist's filing under
+ an upload, the mail bot's email under its card, the family agent
+ answering a question -- and inside a thread nobody repeats a name on
+ every line. Without an owner every bot reads every thread as spoken
+ to it, which is how a family's chat with the agent ended up filed as
+ notes.
+
+ The rule is the first bot to *reply*, ignoring whoever started the
+ thread. First, because that reply is what created the thread and is a
+ fact that never changes; not the starter, because a producer posting
+ under its own root is still publishing."""
+
+ @staticmethod
+ def _msg(event_id, sender):
+ return SimpleNamespace(
+ event_id=event_id, sender=sender,
+ source={"content": {"msgtype": "m.text", "body": "…"}},
+ )
+
+ def _rooted_at(self, client, root_id, sender):
+ client.parent_events[root_id] = self._msg(root_id, sender)
+
+ @pytest.mark.asyncio
+ async def test_the_bot_that_answered_first_owns_the_thread(self, tmp_path):
+ """Our convention: a bot answers by threading under the message it
+ answers, so the root is the human's and the reply is the claim."""
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$upload", "@homer:server")
+ client.thread_children["$upload"] = [self._msg("$filed", "@test-bot:server")]
+ assert await bot.thread_owner("!r:server", "$upload") == "@test-bot:server"
+
+ @pytest.mark.asyncio
+ async def test_a_later_bot_cannot_take_the_thread_over(self, tmp_path):
+ """Ownership is settled by the first answer, so a bot that chimes
+ into an existing conversation does not inherit it. Were it "who
+ spoke last", the family agent replying once inside a filing thread
+ would silently stop corrections landing there."""
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$upload", "@homer:server")
+ client.thread_children["$upload"] = [
+ self._msg("$filed", "@archivist-bot:server"),
+ self._msg("$aside", "@test-bot:server"),
+ ]
+ assert await bot.thread_owner("!r:server", "$upload") == "@archivist-bot:server"
+
+ @pytest.mark.asyncio
+ async def test_posting_under_your_own_root_is_not_answering(self, tmp_path):
+ """The mail bot's shape: it posts an email as a card, then the full
+ body and the attachments underneath. None of that is a
+ conversation, so the archivist's filing is the thread's first real
+ answer and the family can still correct it there."""
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$card", "@mail-bot:server")
+ client.thread_children["$card"] = [
+ self._msg("$body", "@mail-bot:server"),
+ self._msg("$attachment", "@mail-bot:server"),
+ self._msg("$filed", "@test-bot:server"),
+ ]
+ assert await bot.thread_owner("!r:server", "$card") == "@test-bot:server"
+
+ @pytest.mark.asyncio
+ async def test_a_thread_between_people_belongs_to_nobody(self, tmp_path):
+ """Two family members talking is not material dropped for a bot,
+ and no bot should answer into it."""
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$chat", "@homer:server")
+ client.thread_children["$chat"] = [self._msg("$reply", "@marge:server")]
+ assert await bot.thread_owner("!r:server", "$chat") is None
+
+ @pytest.mark.asyncio
+ async def test_an_unanswered_thread_belongs_to_nobody(self, tmp_path):
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$chat", "@homer:server")
+ assert await bot.thread_owner("!r:server", "$chat") is None
+
+ @pytest.mark.asyncio
+ async def test_bounded_by_events_examined(self, tmp_path):
+ """One chat message must never become an unbounded walk, so a bot
+ answering far down a long thread is simply not found."""
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$chat", "@homer:server")
+ client.thread_children["$chat"] = [
+ *[self._msg(f"$chat{i}", "@homer:server") for i in range(5)],
+ self._msg("$late", "@test-bot:server"),
+ ]
+ assert await bot.thread_owner("!r:server", "$chat", limit=3) is None
+
+ @pytest.mark.asyncio
+ async def test_nobodys_when_the_root_cannot_be_read(self, tmp_path):
+ """Without the starter we cannot tell publishing from answering,
+ and a wrong claim files someone else's conversation. Staying quiet
+ is the cheaper failure."""
+ bot, client = _bare_bot(tmp_path)
+ client.get_event_raises = ConnectionError("synapse down")
+ assert await bot.thread_owner("!r:server", "$upload") is None
+
+ @pytest.mark.asyncio
+ async def test_nobodys_when_the_thread_cannot_be_read(self, tmp_path):
+ bot, client = _bare_bot(tmp_path)
+ self._rooted_at(client, "$upload", "@homer:server")
+ client.relations_raise = ConnectionError("synapse down")
+ assert await bot.thread_owner("!r:server", "$upload") is None
+
+
# ── Per-room config + emoji + !config command ────────────────────────────