Skip to content

Link a channel turn's files to the message it wrote - #703

Merged
DEENUU1 merged 3 commits into
mainfrom
fix/channel-turn-links-its-files
Aug 13, 2026
Merged

DEENUU1 merged 3 commits into
mainfrom
fix/channel-turn-links-its-files

Conversation

@DEENUU1

@DEENUU1 DEENUU1 commented Aug 13, 2026 •

Copy link
Copy Markdown
Member

What this fixes

Closes #690. A channel turn that ran perfectly left its ChatFile rows with
message_id NULL. link_files_to_message was called from exactly one place -
persist_user_turn in the web chat path - so a file sent to a Slack, Telegram or
Mattermost bot was stored, fed to the agent, and then orphaned.

chat_files carries no organization_id, so a row with no message is scoped by
user_id alone: reachable through GET /files/{id} by whoever sent it and by
nothing else, while the conversation holding the question does not know the file
exists. A transcript of a channel thread showed the question and not the
spreadsheet it was asked about.

What changed

  • TranscriptService.record takes the turn's attachments and links them to the
    user message it writes. That is the right place because it is where that
    message first exists, and it is the one write every non-streaming surface
    reaches - the same reason the transcript itself lives there (Embed and channel-mention runs record no messages, so a run has a cost and no content #205): a thing
    each surface has to remember is a thing the next surface will not.
  • The link gets a SAVEPOINT of its own inside the transcript's. It is the only
    write there touching rows the conversation does not own, so sharing the outer
    one would mean a run that has already spent money losing its answer and its tool
    calls because a file could not be attached - the opposite of the trade web chat
    makes for the same write. Skipped entirely when nothing was attached: a
    SAVEPOINT and its release on every turn in the deployment is a real cost for a
    list that is almost always empty.
  • AgentRunnerService.execute passes the attachments through _run to get them
    there. A resume passes none and records no prompt, so nothing links.
  • Web chat is untouched. It streams, writes its own transcript and links its own
    files through persist_user_turn, so there is no second link and no double
    write.
  • docs/channels.md, the Files section: a file the turn ran on belongs to that
    turn, why an unlinked row matters given no organization_id, and what the link
    widens - the metadata, never the bytes (see Noticed below).

No repository, schema or route change. chat_file_repo.link_to_message already
existed; only its second caller is new.

How it failed before

  1. Drop a .csv on a channel bot with any question.
  2. The agent answers using the file.
  3. SELECT message_id FROM chat_files WHERE id = … → NULL.

Testing

  • backend/tests/integration/test_transcript_savepoint.py — a channel turn with
    an attachment against a real database, reading the column back, which is
    what A successful channel turn never links its ChatFile rows to a message #690 asked for. The repository boundary is not where this was ever broken:
    link_to_message worked and no surface but web chat called it, so a test that
    stops there proves the half that already worked. Fails against main's
    transcript.py.
  • backend/tests/test_surface_transcripts.py — the same turn through its real
    entry point (ChannelAgentRouter.answer_default), asserting the link carries
    the id of the user message and not the assistant one.
  • backend/tests/test_transcript.py — a link that raises costs the files and not
    the turn: the assistant message and its tool calls are still written.
  • make lint-backend green. make test green: 4378 passed, platform layer still
    at 100%. make test-integration green: 423 passed.
  • Not run locally: make test-e2e (backend-only change). CI's e2e job passed.

Overlaps

Based on origin/main; the code being fixed is on main, so no stacking was
needed. Four PRs are open on this same channel-attachment path and merge order
matters
— whichever lands second needs a rebase, though none of them touch the
files changed here:

Noticed, not fixed

  • A chat turn can link another user's file to its own message #706 (new, security) — chat_file_repo.link_to_message is a blind
    UPDATE … WHERE id IN (…) with no owner predicate, and the web path feeds it
    client-supplied ids, so a chat turn can attach another user's file to its own
    message. Pre-existing and not reachable from the caller added here — a channel
    turn links rows the adapter created server-side for that sender in that turn —
    but adding a second caller is what put the function under review.
  • A caption-less image on a workspace-less agent records no user turn #704 (new) — an attachment that yields no text at all (an inline image on a
    workspace-less agent, a file with no parsed_content, a routing failure) leaves
    prompt empty, so no user turn is written and there is nothing to link to. I
    filed this with the wrong scope first and have corrected it: a caption-less
    .csv is linked, because build_prompt prepends an Attached file: …
    reference.
  • Shared channels widen metadata by design. A channel's conversation is owned
    by whoever spoke first, so a colleague's file now appears in a transcript other
    members can read — name, type, size. The bytes still answer only the owner, so
    the chip is visible and the download is not. Documented in docs/channels.md
    rather than left to be discovered.

`link_files_to_message` was called from one place - `persist_user_turn`
in the web chat path - so every other surface stored a `ChatFile`, fed it
to the agent and then left `message_id` NULL for ever. A spreadsheet
dropped on a Slack, Telegram or Mattermost bot ran the turn perfectly
and left an orphan row behind it.

That is worse than untidy. `chat_files` carries no `organization_id`, so
a row with no message is scoped by `user_id` alone: reachable through
`GET /files/{id}` by whoever sent it and by nothing else, while the
conversation holding the question does not know the file exists - a
transcript of a channel thread showed the question and not the
spreadsheet it was about.

Linked where the user turn is created, which is
`TranscriptService.record` - the one write every non-streaming surface
reaches, for the same reason the transcript itself moved there: a thing
each surface has to remember is a thing the next surface will not. The
attachments travel from `execute` through `_run` to get there. The link
sits inside the existing SAVEPOINT, so a failure rolls back with the
transcript and cannot poison the session the run row commits on. Web
chat is untouched: it streams, writes its own transcript and links its
own files.

Verified by a new test in tests/test_surface_transcripts.py driving a
bot's default agent with an attachment through its real entry point - it
fails on main with the file unlinked. `make lint-backend` and `make test`
are green, the platform layer still at 100%.

Not fixed here: a message carrying a file and no caption records no user
turn at all, so there is still nothing to link it to. Filed separately.

Closes #690
Reviewing the previous commit: the link was written inside the SAVEPOINT
the whole transcript shares, so an exception from it would roll back the
user turn, the settled calls and the assistant message with its tool
calls - for a run that has already spent money, over a file. That is the
opposite trade from the one web chat makes for the same write, where
`persist_user_turn` catches it and keeps the message.

So the link gets a SAVEPOINT of its own inside the transcript's: it is
the only write there touching rows the conversation does not own, and a
failure now costs the link alone. Skipped outright when nothing was
attached, because a SAVEPOINT and its release on every turn in the
deployment is a real cost for a list that is almost always empty.

Also adds the test #690 actually asked for - one that reads the column
back off a real database rather than asserting at the repository
boundary. `link_to_message` was never the broken part; that no surface
but web chat called it was, so a test that stops at the repository proves
the half that already worked. It fails against `main`'s `transcript.py`
and passes here.

Verified: `make lint-backend`, `make test` (4378 passed, platform layer
still 100%) and `make test-integration` (423 passed) against a real
pgvector database.

Refs #690
#634 landed the link itself, so what is left here is the savepoint it
rides: a failed file link no longer rolls back the user turn, the settled
calls and the answer of a run that has already spent money. Main's
`link_to_message` call becomes `_attach`, and its test for an empty
attachment list now asserts no call at all, which is what skipping the
savepoint on the common path means.
@DEENUU1
DEENUU1 merged commit 5ff5609 into main Aug 13, 2026
14 checks passed
@DEENUU1
DEENUU1 deleted the fix/channel-turn-links-its-files branch August 13, 2026 13:58
@DEENUU1 DEENUU1 mentioned this pull request Aug 13, 2026
DEENUU1 added a commit that referenced this pull request Aug 13, 2026
The file link a channel turn writes gets a SAVEPOINT of its own — #703,
refs #690.

#634 landed the linking itself. What was left, and what this releases,
is the
transaction it rides: the link was written inside the SAVEPOINT the
whole
transcript shares, so an exception from it rolled back the user turn,
the settled
calls and the assistant message with its tool calls — for a run that had
already
spent money, over a file.

It also adds the test #690 actually asked for: one that reads the column
back off
a real database rather than asserting at the repository boundary.
`link_to_message` was never the broken part; that no surface but web
chat called
it was.

Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and
`frontend/package.json`. The CHANGELOG entry is written here rather than
promoted
from `[Unreleased]`, because #703 wrote none.
DEENUU1 added a commit that referenced this pull request Aug 13, 2026
## What this fixes

Closes #704, and completes the remainder PR #703 deliberately left: a
channel
turn whose attachment produced no prompt text now leaves a user message
that
names what arrived, and the `ChatFile` row hangs off it.

Since #634/#703, `TranscriptService.record` already wrote the user turn
under
`if prompt or attachments:` and linked the files — so the linking half
of the
issue holds on `main`, and so does the `None`/`""` split the issue asks
for: a
resume passes `None` and no attachments and writes no user turn, while
an
empty caption always arrives with the file that makes it a turn. What
remained
was the body. `content=prompt or ""` wrote a **blank** user message for
a
caption-less upload, and a blank user message reads as somebody sending
nothing: the thread in `/chat` jumped straight to the answer, with the
file
card as the only trace of the question.

## The choice, as the issue asked

Between "make the empty-text routings yield a textual reference" and
"distinguish the cases through the record path", this takes the **record
path**: since the `said` refactor (#634) the transcript records what the
person wrote, never the assembled prompt, so a reference added in
`AttachmentRouter.build_prompt` reaches only the model and would not
have
touched the transcript at all. `record` is also the one write every
non-streaming surface reaches — the same reason the transcript and the
#703
link live there.

`record` now composes the empty turn's body from its attachments —
`Attached image: photo.jpg`, one line per file — reusing the vocabulary
`build_prompt` already writes into the model's briefing, so the two
compose
rather than diverge. A caption is never replaced; the naming fills only
the
turn that said nothing. Web chat already does the equivalent in its
composer
(`trimmed || t("analyzeFiles")`), so channels stop being the one surface
with
blank turns.

## How it failed before

1. Send a Telegram bot a photo with no caption; its agent has no
workspace.
2. The agent answers about the image.
3. The conversation shows a blank user bubble (the run and link were
already
recorded since #634/#703 — the issue's "no user turn at all" described
the
   pre-#634 tree).

## Verified

- All new tests fail without the one-line fix in `transcript.py`.
- `backend/tests/test_surface_transcripts.py` — the issue's exact
sequence
through the real entry point: `ChannelAgentRouter.answer_default` with
empty
  text and an inline image on a workspace-less agent (the router yields
  `["", BinaryContent]`), asserting the user message is named and the
  `ChatFile` links to *that* message.
- `backend/tests/test_transcript.py` — the named body for one file, for
  several (image and non-image), a caption surviving verbatim, and the
existing `test_a_resumed_run_writes_no_user_turn` still pinning the
resume
  half.
- `backend/tests/integration/test_transcript_savepoint.py` and
  `test_channel_attachment_rows.py` — both columns read back from a real
  Postgres: the message content and the `chat_files.message_id`.
- `make lint-backend` green. `make test` green: 4799 passed, platform
layer at
100%. The two tests that pinned the old blank body were updated to pin
the
  named one.
- `docs/channels.md` § Files: one sentence saying a caption-less turn's
  message names its files.

## Noticed, not fixed

- **#746 (new, filed from this work)** — `attachments.py:208/:238` still
hand
  the model nothing for a routing failure or an unparsed file on a
workspace-less agent, so the agent denies receiving a file the
transcript
  now names. Model-side, outside #704's acceptance criteria.

Closes #704
DEENUU1 added a commit that referenced this pull request Aug 13, 2026
## What

A web-chat turn could attach **another user's file** to its own message
by sending that file's id. `chat_file_repo.link_to_message` was a blind
bulk UPDATE — no owner predicate, no unlinked check — and `get_many`
filtered on id alone, with the ids arriving straight off the socket
payload. Two consequences from the one missing predicate: the victim's
filename/MIME/size rendered in the attacker's conversation, and a
non-NULL `message_id` was overwritten, so the file silently moved off
the victim's own message.

`chat_files` carries no `organization_id`, so `user_id` is the only
scope a row has — and now both the read and the update carry it:

- `chat_file_repo.get_many` and `link_to_message` take the owner in
their `WHERE`; the UPDATE also requires `message_id IS NULL`.
- `ConversationService.link_files_to_message` reads the rows first and
**refuses** rather than silently narrowing: a foreign or unknown id
raises `NotFoundError` (deliberately indistinguishable, so an id cannot
be probed for existence), an already-linked one raises
`BadRequestError`. Both name only `file_ids` the caller sent — nothing
of the victim's.
- The refusal escapes `persist_user_turn`'s swallow (which now covers
only infrastructure failures), so the socket answers with an error frame
instead of a turn that quietly dropped the attachment.
- The channel transcript (`TranscriptService._attach`, the second caller
added by #703/#690) links rows as their own uploader; the embed's owner
check moved from Python into the query, and a page whose publisher's
account is gone (`owner_user_id` is `SET NULL`) reads no rows at all
instead of passing `None` into the predicate.

## The re-link decision

**A file already on a message never moves, not even for its owner.** No
caller legitimately re-links: web chat links fresh uploads once per send
(no retry resends `file_ids`), channel rows are created server-side
unlinked in the same turn, and the embed already documented and dropped
a spent id. The silent move only ever rewrote history, so it is refused
outright (`BadRequestError`) rather than allowed for the owner.

## How verified

- `backend/tests/integration/test_chat_file_ownership.py` — the issue's
sequence against a real database, both rows read back: the attacker gets
a refusal, the victim's message keeps its file; the unlinked-file
disclosure case; the repo-level UPDATE skipping a foreign row even
without the service's pre-read; the owner's own upload still linking;
the re-link refusal leaving the row where it was; the scoped read
returning nothing for a foreign id.
- Unit tests for the service refusals, the owner riding through
`persist_user_turn`, `load_attached_files`, the embed session and the
channel transcript, and the ownerless-embed drop.
- The pre-existing channel-turn integration test
(`test_transcript_savepoint.py`) proves server-side rows still link
through the new predicates.
- `make lint` and `make test` (100% platform gate) pass;
`docs/architecture.md` and `docs/file-processing.md` updated in the same
change.

Closes #706
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
## What this fixes

Closes #690. A channel turn that ran perfectly left its `ChatFile` rows
with
`message_id` NULL. `link_files_to_message` was called from exactly one
place -
`persist_user_turn` in the web chat path - so a file sent to a Slack,
Telegram or
Mattermost bot was stored, fed to the agent, and then orphaned.

`chat_files` carries no `organization_id`, so a row with no message is
scoped by
`user_id` alone: reachable through `GET /files/{id}` by whoever sent it
and by
nothing else, while the conversation holding the question does not know
the file
exists. A transcript of a channel thread showed the question and not the
spreadsheet it was asked about.

## What changed

- `TranscriptService.record` takes the turn's `attachments` and links
them to the
user message it writes. That is the right place because it is where that
message first exists, and it is the one write every non-streaming
surface
reaches - the same reason the transcript itself lives there (#205): a
thing
  each surface has to remember is a thing the next surface will not.
- **The link gets a SAVEPOINT of its own** inside the transcript's. It
is the only
write there touching rows the conversation does not own, so sharing the
outer
one would mean a run that has already spent money losing its answer and
its tool
calls because a file could not be attached - the opposite of the trade
web chat
makes for the same write. Skipped entirely when nothing was attached: a
SAVEPOINT and its release on every turn in the deployment is a real cost
for a
  list that is almost always empty.
- `AgentRunnerService.execute` passes the attachments through `_run` to
get them
  there. A resume passes none and records no prompt, so nothing links.
- Web chat is untouched. It streams, writes its own transcript and links
its own
files through `persist_user_turn`, so there is no second link and no
double
  write.
- `docs/channels.md`, the Files section: a file the turn ran on belongs
to that
turn, why an unlinked row matters given no `organization_id`, and what
the link
  widens - the metadata, never the bytes (see Noticed below).

No repository, schema or route change. `chat_file_repo.link_to_message`
already
existed; only its second caller is new.

## How it failed before

1. Drop a `.csv` on a channel bot with any question.
2. The agent answers using the file.
3. `SELECT message_id FROM chat_files WHERE id = …` → NULL.

## Testing

- `backend/tests/integration/test_transcript_savepoint.py` — a channel
turn with
an attachment against a real database, **reading the column back**,
which is
what #690 asked for. The repository boundary is not where this was ever
broken:
`link_to_message` worked and no surface but web chat called it, so a
test that
stops there proves the half that already worked. Fails against `main`'s
  `transcript.py`.
- `backend/tests/test_surface_transcripts.py` — the same turn through
its real
entry point (`ChannelAgentRouter.answer_default`), asserting the link
carries
  the id of the **user** message and not the assistant one.
- `backend/tests/test_transcript.py` — a link that raises costs the
files and not
  the turn: the assistant message and its tool calls are still written.
- `make lint-backend` green. `make test` green: 4378 passed, platform
layer still
  at 100%. `make test-integration` green: 423 passed.
- Not run locally: `make test-e2e` (backend-only change). CI's `e2e` job
passed.

## Overlaps

Based on `origin/main`; the code being fixed is on `main`, so no
stacking was
needed. Four PRs are open on this same channel-attachment path and
**merge order
matters** — whichever lands second needs a rebase, though none of them
touch the
files changed here:

- **#684** (#660) — moves `_receive_files` above `_answer_mention`.
**This one
gates half of the claim above.** Until it lands, a message that names no
handle
stores each file twice — `_answer_mention` receives them, discards them
on
`UnaddressedMessage`, and `_route_inner` receives them again — and only
the
second copy is the one this links. The row the agent actually read comes
out
linked either way; the duplicate stays orphaned until #660 is fixed.
Worth
  merging first.
- **#689** (#661) — discards a *refused* turn's stored files. This PR is
the
success half of the same story: #689 deletes rows for a turn that never
ran,
this links rows for a turn that did. Both touch `docs/channels.md` §
Files in
  different paragraphs — a textual conflict there is likely and trivial.
- **#685** (#662) — Mattermost webhook gating, based on
`feat/agent-surfaces`.
- **#547** — both transports bypassing `parse_incoming`, in progress
elsewhere.

## Noticed, not fixed

- **#706 (new, `security`)** — `chat_file_repo.link_to_message` is a
blind
`UPDATE … WHERE id IN (…)` with no owner predicate, and the *web* path
feeds it
client-supplied ids, so a chat turn can attach another user's file to
its own
message. Pre-existing and not reachable from the caller added here — a
channel
turn links rows the adapter created server-side for that sender in that
turn —
  but adding a second caller is what put the function under review.
- **#704 (new)** — an attachment that yields no text at all (an inline
image on a
workspace-less agent, a file with no `parsed_content`, a routing
failure) leaves
`prompt` empty, so no user turn is written and there is nothing to link
to. I
filed this with the wrong scope first and have corrected it: a
caption-less
`.csv` *is* linked, because `build_prompt` prepends an `Attached file:
…`
  reference.
- **Shared channels widen metadata by design.** A channel's conversation
is owned
by whoever spoke first, so a colleague's file now appears in a
transcript other
members can read — name, type, size. The bytes still answer only the
owner, so
the chip is visible and the download is not. Documented in
`docs/channels.md`
  rather than left to be discovered.
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
The file link a channel turn writes gets a SAVEPOINT of its own — #703,
refs #690.

#634 landed the linking itself. What was left, and what this releases,
is the
transaction it rides: the link was written inside the SAVEPOINT the
whole
transcript shares, so an exception from it rolled back the user turn,
the settled
calls and the assistant message with its tool calls — for a run that had
already
spent money, over a file.

It also adds the test #690 actually asked for: one that reads the column
back off
a real database rather than asserting at the repository boundary.
`link_to_message` was never the broken part; that no surface but web
chat called
it was.

Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and
`frontend/package.json`. The CHANGELOG entry is written here rather than
promoted
from `[Unreleased]`, because #703 wrote none.
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
## What this fixes

Closes #704, and completes the remainder PR #703 deliberately left: a
channel
turn whose attachment produced no prompt text now leaves a user message
that
names what arrived, and the `ChatFile` row hangs off it.

Since #634/#703, `TranscriptService.record` already wrote the user turn
under
`if prompt or attachments:` and linked the files — so the linking half
of the
issue holds on `main`, and so does the `None`/`""` split the issue asks
for: a
resume passes `None` and no attachments and writes no user turn, while
an
empty caption always arrives with the file that makes it a turn. What
remained
was the body. `content=prompt or ""` wrote a **blank** user message for
a
caption-less upload, and a blank user message reads as somebody sending
nothing: the thread in `/chat` jumped straight to the answer, with the
file
card as the only trace of the question.

## The choice, as the issue asked

Between "make the empty-text routings yield a textual reference" and
"distinguish the cases through the record path", this takes the **record
path**: since the `said` refactor (#634) the transcript records what the
person wrote, never the assembled prompt, so a reference added in
`AttachmentRouter.build_prompt` reaches only the model and would not
have
touched the transcript at all. `record` is also the one write every
non-streaming surface reaches — the same reason the transcript and the
#703
link live there.

`record` now composes the empty turn's body from its attachments —
`Attached image: photo.jpg`, one line per file — reusing the vocabulary
`build_prompt` already writes into the model's briefing, so the two
compose
rather than diverge. A caption is never replaced; the naming fills only
the
turn that said nothing. Web chat already does the equivalent in its
composer
(`trimmed || t("analyzeFiles")`), so channels stop being the one surface
with
blank turns.

## How it failed before

1. Send a Telegram bot a photo with no caption; its agent has no
workspace.
2. The agent answers about the image.
3. The conversation shows a blank user bubble (the run and link were
already
recorded since #634/#703 — the issue's "no user turn at all" described
the
   pre-#634 tree).

## Verified

- All new tests fail without the one-line fix in `transcript.py`.
- `backend/tests/test_surface_transcripts.py` — the issue's exact
sequence
through the real entry point: `ChannelAgentRouter.answer_default` with
empty
  text and an inline image on a workspace-less agent (the router yields
  `["", BinaryContent]`), asserting the user message is named and the
  `ChatFile` links to *that* message.
- `backend/tests/test_transcript.py` — the named body for one file, for
  several (image and non-image), a caption surviving verbatim, and the
existing `test_a_resumed_run_writes_no_user_turn` still pinning the
resume
  half.
- `backend/tests/integration/test_transcript_savepoint.py` and
  `test_channel_attachment_rows.py` — both columns read back from a real
  Postgres: the message content and the `chat_files.message_id`.
- `make lint-backend` green. `make test` green: 4799 passed, platform
layer at
100%. The two tests that pinned the old blank body were updated to pin
the
  named one.
- `docs/channels.md` § Files: one sentence saying a caption-less turn's
  message names its files.

## Noticed, not fixed

- **#746 (new, filed from this work)** — `attachments.py:208/:238` still
hand
  the model nothing for a routing failure or an unparsed file on a
workspace-less agent, so the agent denies receiving a file the
transcript
  now names. Model-side, outside #704's acceptance criteria.

Closes #704
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
## What

A web-chat turn could attach **another user's file** to its own message
by sending that file's id. `chat_file_repo.link_to_message` was a blind
bulk UPDATE — no owner predicate, no unlinked check — and `get_many`
filtered on id alone, with the ids arriving straight off the socket
payload. Two consequences from the one missing predicate: the victim's
filename/MIME/size rendered in the attacker's conversation, and a
non-NULL `message_id` was overwritten, so the file silently moved off
the victim's own message.

`chat_files` carries no `organization_id`, so `user_id` is the only
scope a row has — and now both the read and the update carry it:

- `chat_file_repo.get_many` and `link_to_message` take the owner in
their `WHERE`; the UPDATE also requires `message_id IS NULL`.
- `ConversationService.link_files_to_message` reads the rows first and
**refuses** rather than silently narrowing: a foreign or unknown id
raises `NotFoundError` (deliberately indistinguishable, so an id cannot
be probed for existence), an already-linked one raises
`BadRequestError`. Both name only `file_ids` the caller sent — nothing
of the victim's.
- The refusal escapes `persist_user_turn`'s swallow (which now covers
only infrastructure failures), so the socket answers with an error frame
instead of a turn that quietly dropped the attachment.
- The channel transcript (`TranscriptService._attach`, the second caller
added by #703/#690) links rows as their own uploader; the embed's owner
check moved from Python into the query, and a page whose publisher's
account is gone (`owner_user_id` is `SET NULL`) reads no rows at all
instead of passing `None` into the predicate.

## The re-link decision

**A file already on a message never moves, not even for its owner.** No
caller legitimately re-links: web chat links fresh uploads once per send
(no retry resends `file_ids`), channel rows are created server-side
unlinked in the same turn, and the embed already documented and dropped
a spent id. The silent move only ever rewrote history, so it is refused
outright (`BadRequestError`) rather than allowed for the owner.

## How verified

- `backend/tests/integration/test_chat_file_ownership.py` — the issue's
sequence against a real database, both rows read back: the attacker gets
a refusal, the victim's message keeps its file; the unlinked-file
disclosure case; the repo-level UPDATE skipping a foreign row even
without the service's pre-read; the owner's own upload still linking;
the re-link refusal leaving the row where it was; the scoped read
returning nothing for a foreign id.
- Unit tests for the service refusals, the owner riding through
`persist_user_turn`, `load_attached_files`, the embed session and the
channel transcript, and the ownerless-embed drop.
- The pre-existing channel-turn integration test
(`test_transcript_savepoint.py`) proves server-side rows still link
through the new predicates.
- `make lint` and `make test` (100% platform gate) pass;
`docs/architecture.md` and `docs/file-processing.md` updated in the same
change.

Closes #706
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A successful channel turn never links its ChatFile rows to a message

1 participant