## Thinking Path
The detail-load endpoint (and the foreign-session synthesizer) already
distinguish "session owned by a KNOWN other profile" (return ``409
session_profile_mismatch`` so the client can offer to switch to it
per #5419) from "session missing/legacy with no profile stamped"
(return ``404 Session not found`` so the frontend's self-heal
clears the stale URL + localStorage).
The generic request-guard
``_session_id_visible_to_request_profile`` flattened both cases to a
``404 Session not found``, so any cross-profile POST/PATCH/DELETE
(archive, rename, pin, delete, session-attach, anchor-scene, …) and
the stream-related status/cancel/replay paths failed with a
misleading "Session not found" instead of the actionable 409 the
detail endpoint emits. The reporter's wire evidence:
```
POST /api/session/archive {"session_id":"<id>","archived":true}
-> 404 {"error": "Session not found"}
GET /api/session?session_id=<same id>
-> 409 {"error": "Session belongs to a different profile",
"code": "session_profile_mismatch", "session_id": "<id>", "profile": "def"}
```
Same session id, same moment: the detail endpoint reports a profile
mismatch, archive masks it as 404.
The fix mirrors the detail endpoint's contract in the generic guard
and in every other inline check that previously went to
``bad(handler, "Session not found", 404)`` for a known-other-profile
session.
## What Changed
1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile``
now emits ``409 session_profile_mismatch`` for a session owned by
a known other profile, and keeps ``404 Session not found`` only
for the None-profile (unknown/legacy) case so the frontend
self-heal still fires for actually-missing sids.
2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994``
(compression-recovery start), ``:24385`` (chat/start with
the inline placeholder-retag rules preserved), ``:28621``
(session import_cli's already-imported branch) — the same
409-vs-404 split, with the existing branches kept intact (the
chat/start retag path, the import_cli ``allow_all_profiles`` short
circuit).
3. The ``_stream_id_visible_to_request_profile`` wrapper already
delegates to ``_session_id_visible_to_request_profile`` for the
owner-session check, so the stream status / cancel / replay
endpoints get the new contract for free.
## Why It Matters
A user picking the configured default model in the dropdown sees two
visually identical entries for the same model id, picks the wrong
"prefixed" one (the one matching the dropdown's order), the agent
returns 400, and the session state is corrupted with a non-functional
model id until the user manually clears it. The dropdown's own
deduplication already handles `@provider:model` correctly via the
documented prefix form; only the synthesised ``{provider}/{model}``
form was wrong.
The cross-profile write paths (anchor-scene persist,
compression-recovery start, chat/start, session import) all had the
same gap: a successful auth under profile A could persist or recover
state against a session owned by profile B, surfacing a 404 to
profile A's user that reads like "the session is gone" — but the
session is in fact intact, just owned by another profile. The 409
contract is what closes the gap: the client gets a structured signal
it can act on, and the session state stays consistent.
## Verification
- ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests):
- 5 behavioural: profile-mismatch path returns 409 with the
``session_profile_mismatch`` payload, the None-profile path
keeps 404 for the frontend self-heal, visible session passes
silently, missing session passes silently, ``emit_error=False``
suppresses the response.
- 1 source-shape: the helper body MUST contain a 409 branch with
the ``session_profile_mismatch`` code; a silent reversion to
the single-line ``bad(..., 404)`` fails the suite.
- ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion
updated to the new 409 payload (the SSE route walks through the
generic guard).
- ``tests/test_session_active_profile_authorization.py`` — 7
assertions updated to the 409 payload (duplicate, file, chat/start
foreign, chat/stream/status, chat/stream/cancel, chat/stream
replay). Stream tests use ``foreign_session`` (the owner session
id), NOT the stream id, because ``_stream_id_visible_to_request_profile``
delegates to the owner-session check.
- ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated
to the 409 payload. The 3 ``Assistant message not found`` 404
cases (lines 454/561/611) are kept as-is — they're not profile
mismatches, they're missing-message 404s.
- ``tests/test_issue4067_import_cli_cross_profile_guard.py`` —
1 assertion updated to the 409 payload, plus the ``_capture`` mock
signature updated to accept the ``status`` keyword that
``j(handler, payload, status=200, ...)`` already passes.
Other tests in the existing suite: **161 passed across 8 related
test files** (session active-profile authorization, anchor scene,
issue 4067, issue 4812 SSE, issue 7710 new, chat start claim,
workspace stale recovery, issue 5420 profile switch).
## Contract Change
The following routes now return ``409 session_profile_mismatch``
(``{error, code, session_id, profile}``) for a session owned by a
known other profile, where they previously returned
``404 Session not found``:
- ``POST /api/session/duplicate`` (now also goes through
``_guard_request_session_visibility`` for the body session id)
- ``GET /api/file?session_id=…``
- ``POST /api/chat/start`` (the inline profile check after
``_get_or_materialize_session``)
- ``GET /api/chat/stream/status?stream_id=…``
- ``GET /api/chat/stream/cancel?stream_id=…``
- ``GET /api/chat/stream?stream_id=…`` (the dead-stream replay
branch)
- ``POST /api/session/anchor-scene``
- ``POST /api/session/compression-recovery/start``
- ``POST /api/session/import_cli`` (the already-imported branch
when ``allow_all_profiles`` is False)
The ``404 Session not found`` self-heal path is preserved for the
None-profile (unknown/legacy) case across all of these routes, so
the frontend's existing stale-URL/localStorage cleanup behaviour is
unchanged for actually-missing sessions.
## Risks
- A consumer that previously relied on the 404 to detect "this
session belongs to another profile" will now see a 409 with a
``code`` field. The detail-load endpoint has emitted this exact
contract since #5419, and the frontend already handles it
(offers to switch to the owning profile). The cross-profile write
paths now match the detail-load contract; the frontend's
already-shipped switcher UI handles both surfaces consistently.
- The anchor-scene / compression-recovery / chat-start tests that
were updated are a small set (5 total); the existing
``Assistant message not found`` 404 paths in
``test_anchor_scene_persistence`` are kept untouched (those are
not profile mismatches).
## Related
- #7710 — original issue (elkpvl-commits, 2026-09-22) with the
full repro and the suggested fix shape.
- #7549 — the underlying cross-profile archive bug, already
tracked by @webtecnica in #7559 (which is the "business-level"
fix: row-profile carry + frontend sends profile + materialize
into the row's own profile). This PR is the orthogonal
error-code-level fix: the request-guard's 404-vs-409 split.
- #5419 — the original auto-switch-profiles-for-session-links
feature, whose frontend switcher UI consumes the
``session_profile_mismatch`` code on the 409.
When a settled assistant turn carries _anchor_activity_scene and has mixed
content[] (text interleaved with tool_use), promote that ordering into anchor
scene rows (prose / tool / thinking rows in sequence) instead of only the raw
content[] fallback, and mirror the same scene model in the backend hydration
path so a cold reload reconstructs an identical transcript.
Also fixes: post-tool_use text/thinking in a NON-FINAL assistant message was
silently dropped — only the turn-final assistant's post-last-tool text is the
"final answer" tail; earlier assistant messages keep post-tool content as
activity rows. Tail thinking is excluded from the final-answer text but still
emitted as a thinking row (verified on both JS and Python paths).
Tool-row de-duplication is conservative — ID-equal always merges; name/
invocation matches are cardinality-gated and same-message; body-prefix is only
a secondary confirmation inside the id-flexible branch (also requires matching
started_at + compatible name + invocation). It biases toward an extra visible
tool card over ever silently merging two distinct calls.
Gate: Codex SAFE (tail-thinking edge verified already-handled both sides),
Opus SHIP (dedup fails toward duplicate-not-loss; non-final preservation, XSS,
non-mixed regression all clean), full suite 10778 passed (only the 4 known
pre-existing flakes). One non-blocking follow-up noted: bring the prose/thinking
peer-dedup into JS↔Python parity (cosmetic cold-reload extra-narration row).
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Mirror the write-side _find_anchor_scene_message ambiguity guard on the read side:
_hydrate_anchor_activity_scenes now counts ref occurrences and skips ref-based
attachment for any ref resolving to >1 assistant message (falls through to the
positional index match), so a duplicate content-digest ref can't double-render the
same worklog scene. Low-severity defense-in-depth on the crown-jewel render surface.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Codex ship-gate (CORE): _handle_session_anchor_scene loaded the session via
_get_or_materialize_session (by-id, no profile scoping) and persisted
anchor_activity_scenes without the active-profile visibility check GET /api/session
applies — an authenticated request under profile A could write anchor scenes onto a
profile-B session. Add the _session_visible_to_active_profile guard (404, no write),
mirroring routes.py:~8922. Regression test verified red-without/green-with the guard.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
franksong2702's activity_scene_v1 anchor-scene model: live-stream/settled/refresh/re-entry
show the same ordered turn. Rebased clean (no conflicts) onto current master.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>