Skip to content

refactor(chat): chat_sessions is the one durable home of a conversation turn; transcripts are read-only legacy, found where they really are (#18293) - #18309

Merged
mrveiss merged 18 commits into
mainfrom
issue-18293-conversation-turn-store
Oct 12, 2026
Merged

mrveiss merged 18 commits into
mainfrom
issue-18293-conversation-turn-store

Conversation

@mrveiss

@mrveiss mrveiss commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#18293: every conversation turn was persisted twice, by separate code.

  • A: chat_sessions — chat:session:{id} plus the session JSON. It is the declared system of record, and the UI reads it.
  • B: data/conversation_transcripts/{id}.json, written by chat_workflow/conversation.py. It was undeclared in store_authority, yet the LLM context fell back to it.

The two had already diverged. session_reply_backfill.py rebuilt A from B and called B "the source of truth", and B's path literal was copied. Fix: A is the one durable home, LLM context is projected from A, and B becomes a read-only legacy store. No stored data is moved or deleted by code; the data move is a #17038 proposal.

A read-only check on a live install found a second defect: the data-storage view and session-delete cleanup resolved B's directory against the project root, while the writer had used the backend's working directory, so they never saw the real files.

What Changed

  • 3b67603fcc:

    • _persist_conversation writes no durable copy, and the transcript writers are deleted.
    • _load_conversation_history reads the Redis cache, then projects from A (chat_history/turn_projection.project_turn_pairs). It reads B only when A has no completed turns. Since round 3 it never reads B: when A has no completed turns, or can't be read, it returns [].
    • store_authority: chat_workflow/conversation.py joins chat_sessions.write_sites (it writes the projection cache). B is declared as legacy_conversation_transcripts, with write_sites=().
    • chat_history/legacy_transcripts.py holds B's only content reader, read_legacy_transcript_pairs. Since round 3 it serves only the human-run chore(chat): backfill assistant turns into sessions damaged before #13214 #13293 repair; no request path reads B, and the guard pins this.
    • session_reply_backfill.py (chore(chat): backfill assistant turns into sessions damaged before #13214 #13293) is kept as the B-only repair. It reads B through the legacy reader, and its docstring names A as the record.
    • chat:conversation:{id} now caches A's projection.
  • a9c7bf36d2:

    • B's location is one resolver, store_authority.legacy_conversation_transcripts_dir(), resolved the way the retired writer resolved it. Every reader uses it: the legacy reader, the backfill, the delete cleanup, data-storage and the infra migrate script. Session ids containing /, \ or .. are rejected.
    • Session delete clears chat:conversation:{id} together with chat:session:{id}.
    • The architecture, database and security docs mark B as legacy read-only, and the old "dual-write" plan is marked superseded.
  • Guard (repo_tests/conversation_turn_single_store_18293_test.py, 20 tests):

    • the location appears only in store_authority, and is joined onto a path only there;
    • no function that names it creates files;
    • every chat:conversation:* writer is a declared write site, and the legacy entry has no write sites.

    Against the old main it flags each of the old sites.

Behaviour changes

Case Before Now
New session, LLM context read B projected from A (last 10 completed exchanges); nothing is written to B
Session in both A and B B could disagree with the UI A only, so the model sees what the UI shows
B-only legacy session B no prior context until an approved backfill (round 3; ownership of a legacy file can't be proven, so it is never served to a claimer)
Any session delete (owner or not) transcript stayed on disk (cleanup looked in the wrong directory) transcript always kept; a #17038 proposal records who asked; only an approved proposal removes it (round 2)
Data-storage "Conversation Transcripts" showed an empty, nonexistent directory shows the real legacy directory; bulk cleanup is protected (403), since that is #17038's queue
Session delete, Redis chat:conversation:{id} survived until its TTL cleared together with the session

#17038 data-move proposal (not implemented)

Sessions whose turns exist only in conversation_transcripts/{id}.json are invisible in the GUI and, since round 3, are not served to LLM context either. Until a backfill is approved, a B-only session starts with no prior context. Proposed:

  1. For each transcript whose session has no turns in chat_sessions, write its exchanges into chat_sessions tagged backfilled_from=legacy_conversation_transcripts.
  2. Run session_reply_backfill --session-id for the damaged sessions.
  3. Once both are verified, retire the directory and the legacy reader.

Needs human approval. No agent runs any step.

Verification

  • chat_workflow + chat_history + the two API tests: 788 passed.
  • Guards, each run alone: store_authority 16, the new guard 20, symbol-fork 17, size ratchet 31, ceiling parity 11, guard-reach meta 23, reach_scope_claim_17844 10.
  • manager.py's ceiling is lowered to 3975 in both baselines.

Not done, stated rather than implied

Closes #18293
Closes #18316
Refs #18315

Review round 1 fixes (0795940a8a)

  • HIGH, cross-user deletion (the owner-verified removal below was superseded in round 2: a delete never removes a transcript). The delete now reads the recorded owner first, then deletes the session, and runs cleanup only after that succeeds. A missing session gives 404 with no cleanup.
    • A legacy transcript is removed only when reason == "owner_match" and the owner recorded on the session file is the caller (api/legacy_transcript_disposal.py:41). A Redis-only claim is not enough.
    • Under legacy_migration, org_admin, log_only_mode and every other grant, the file stays and a Umbrella: SLM Data hygiene page — detect and safely clean up orphaned data across services #17038 destructive_action proposal is raised (:70). An approved proposal is executed by services/legacy_transcript_disposal_action.py, with an approval comment and an audit entry.
    • remove_legacy_transcript has exactly two callers: the owner-verified delete and the approved proposal.
    • 8 tests: another user's owned session, a claimer on a legacy-only session, four non-durable grants, the owner deleting their own, and a nonexistent session. Contrast at the old head: 6 failed, 2 passed.
  • MED: DELETE /data-storage/conversations/{id} returns 403 when a legacy transcript exists, the same as bulk cleanup.
  • MED: the location is the SSOT setting legacy_transcripts_dir (AUTOBOT_LEGACY_TRANSCRIPTS_DIR, default data/conversation_transcripts under base_dir). There is no working-directory dependence. On a standard install the Ansible template sets AUTOBOT_BASE_DIR to the backend code directory, so the default resolves to where the retired writer wrote. That is established from the template; the live env file is readable only by the service account and was not read.
  • More behaviour changes:
    • a delete of a nonexistent session no longer runs file, terminal or KB cleanup;
    • an owner whose session file predates owner persistence gets a proposal instead of a removal;
    • the data-storage category id is now legacy_conversation_transcripts (no frontend reference).
  • Tests: chat 773, session-ownership, delete, location and backfill 102; guards passed; reach_scope alone 10.
  • Filed: the in-flight reply re-creating chat:conversation:{id} after a delete (LOW) is fix(chat): a reply in flight can re-create chat:conversation:{id} after its session was deleted #18313.

Review round 2 fixes (ca39d44854)

The "session file records the caller" premise could be manufactured, by H2 (create over a legacy-only id) and by H1 (a legacy_migration claim, then POST /chats/{id}/save stamping metadata.owner). Ownership of a legacy file cannot be proven from current state, so:

  • Deletion rule: always a Umbrella: SLM Data hygiene page — detect and safely clean up orphaned data across services #17038 proposal. A session delete never removes a legacy transcript, whoever asks. When one exists, the file stays and a destructive_action proposal is raised. Its context records the requesting user ({"action": "legacy_transcript_delete", "session_id", "requested_by"}), so the approver sees who asked.
    • The owner-verified unlink path and is_verified_owner/read_durable_owner are removed.
    • remove_legacy_transcript has exactly one caller, the approved-proposal executor, and the guard pins this.
    • A proposal that cannot be recorded is reported in transcript_cleanup.error instead of returning a silent proposal_id: None.
  • 409 on a legacy id. _reject_session_id_collision refuses creating a session whose id has a legacy transcript and no chat session, so a legacy-only id can never be taken over (this also closes reading another user's legacy context through the LLM fallback).
  • End-to-end tests (api/session_delete_legacy_transcript_18293_test.py, 5). These replace the round-1 tests that stubbed reason/owner. The real SessionOwnershipValidator runs in enforced mode with an in-memory Redis, a real ChatHistoryManager writes under tmp_path, and the real create_session / save_chat_by_id / delete_session bodies run. Only identity, the side cleanups and the Postgres proposal write are stubbed.
    • H2: create over a legacy id returns 409, and the file is byte-identical.
    • H1: claim (legacy_migration, decided by the validator), then save (the file is stamped owner=alice), then owner_match, then delete. The file stays and the proposal is recorded with requested_by=alice.
    • The owner deleting their own session that has a legacy transcript gets a proposal, and the file stays.
    • Another user gets 403.
    • A legacy-only delete returns 404 with a proposal and no cleanup.
    • Contrast at 0795940a8a: 3 failed (H2 did not raise; H1 and the owner case deleted the file), 2 passed.
  • Location pinned in deploy templates.
    • backend.env.j2 sets AUTOBOT_LEGACY_TRANSCRIPTS_DIR={{ backend_code_dir | default(backend_install_dir) }}/data/conversation_transcripts, which is the unit's own WorkingDirectory, where the old writer wrote.
    • docker-compose.yml (autobot-backend) sets /app/data/conversation_transcripts, under the image WORKDIR (/app; the entrypoint keeps that working directory).
    • Guard tests derive the expected value from the unit's WorkingDirectory and the Dockerfile's WORKDIR, so the pin cannot drift from them.
  • Tests: chat_workflow + chat_history 773; e2e, location, delete, proposer and session-ownership 74; chat_save_owner_test 11.
  • Guards, each run alone: store_authority 16, the new guard 23, symbol-fork 17, size ratchet 31, ceiling parity 11, guard-reach meta 23, reach_scope_claim_17844 10, reach_declarations 411, configuration_guide_env_vars 3.
  • chat_sessions.py's ceiling is lowered to 1938 in both baselines.

Review round 3 fixes (43de3788a7; origin/main merged as 4191498f88; reach fixes a6f1e9f9f6)

  • No request path reads a legacy transcript. The LLM-context fallback is removed. _load_conversation_history reads the cache and then A's projection, and returns [] when A has no turns. load_legacy_context_pairs is deleted.

  • Tests:

    • New e2e test (no stub of the context loader; the real validator and the real ConversationHandlerMixin._load_conversation_history): a B-only id claimed as legacy_migration gets empty context and the file is untouched. It fails on ca39d44854 (B's turns were served).
    • New e2e test: a normal session's context is its own turns. This one passes on both heads, so behaviour is unchanged.
    • The unit test now asserts a B-only session gets [].
  • Results: chat_workflow + chat_history 773; e2e, location, delete, save-owner, proposer and session-ownership 87. Guards each run alone: store_authority 16, the new guard 25, symbol-fork 17, size ratchet 31, ceiling parity 11, guard-reach meta 23, reach_scope_claim_17844 10, reach_declarations 411.

  • After merging origin/main (75 commits; one import-line conflict in reach_scope_claim_17844_test.py, resolved by keeping both lines), a6f1e9f9f6:

    • Run alone, reach_scope_claim_17844 failed on clean origin/main (6c683455c5). Its scope record names deployment-mode-detector-scan and the two egress-default-* declarations, but never imports their modules, so the declarations were never registered. The modules are now imported. No open PR or issue covered this.
    • Three floors fell into the bottom-20 band once this branch's new files grew the populations. Each is re-pinned mid-window from REACH.window(): kb-admin-read-bypass 2567→2650, model-revision-pinning-sweep 2900→2983, collectable-modules-inert-on-import 2730→2815.
    • After the merge: chat 773, targeted 87; guards each run alone pass, including reach_scope_claim_17844 10 and reach_declarations 477.

#18293 AC6, stated plainly

AC6 reads: "Turns from sessions that exist only in B (older than the #13214 fix) stay readable, through #13293's backfill or a read-only fallback, until a human has approved their disposition."

AC6 ruling (owner, 2026-10-11): AC6 is amended on #18293. The transcript files are kept untouched, and their content returns only through an approved backfill. The backfill tool is #18315. This PR therefore closes #18293.

Round 4: #18318 folded in, train fallout (1f334dd4cc, 486f0c84a3; base origin/main @ 5efe902e72 merged as fa082b2853)

  • fix(chat): DELETE /chats/{id} deletes again — it delegates to the one canonical session delete (#18316) #18318 is folded into this PR. Its two commits (4ac328ea60, 65e3bb2c70) are merged from issue-18316-chat-delete. The two PRs depended on each other, so they ship as one. DELETE /chats/{id} delegates to the canonical session delete (api/chat_sessions.delete_session); its tests (api/chat_delete_by_id_test.py) and guard are on this branch.
  • Guard chat_single_delete_implementation_18316_test fixed:
    • Since canonical(state): every chat turn is persisted twice — chat_sessions and an undeclared transcript store, already diverged once #18293, the one place that calls chat_history_manager.delete_session( is api/legacy_transcript_disposal.delete_session_or_refer. The guard now names that module and function as canonical.
    • The "matcher is blind" check asserts the canonical module's delete-calling functions are exactly [delete_session_or_refer]. The old check looked for the call in chat_sessions.py, which no longer contains it.
    • The hand-rolled _MIN_FILES_SCANNED = 100 is migrated to declare(): the chat-single-delete-implementation floor is 396 (mid-window of 416 measured, growth 40), with roots autobot-backend/api. A _reach_policy ABSOLUTE/SUBSET_DRIFTS verdict, a _DECLARED_SCOPES entry and the reach_scope_claim import are added.
  • E2E tests under fix(security): one session-ownership validator with a STRICT posture, and an unreadable owner denies (#14016, #17481) #18258's two-posture validator:
    • The 4 reds came from identity: the validator now asks security.session_posture.authenticated_user, which the harness did not patch, so every request authenticated as the dev-mode default user. The harness now sets the caller's identity in every namespace that asks.
    • New test: the delete and save routes really use the DEFAULT posture (the ownership dependency is validate_session_ownership / validate_chat_ownership, and neither selects STRICT). So SessionOwnershipValidator, the DEFAULT body, is the right validator to drive.
    • No assertion was loosened; the H1, H2, owner and claimer outcomes are unchanged.
  • CodeQL py/path-injection at chat_history/legacy_transcripts.legacy_transcript_file, in the sink's own scope:
    • an allowlist fullmatch on [A-Za-z0-9_-]{1,255}. That covers every id utils.chat_utils.validate_chat_session_id accepts (UUIDs, UUID-with-suffix, [a-zA-Z0-9_-]+);
    • then os.path.normpath on the join with a startswith(root + os.sep) guard;
    • the existing traversal checks are kept.
    • _transcript_path carries the same checks. Tests cover 9 hostile ids and 4 accepted shapes.
  • Reach floors that this branch's growth pushed into the bottom band are re-pinned mid-window from REACH.window(): credential-field-name-only-callers 6262→6384. (collectable-modules-inert-on-import came in from the train at 2822.)
  • Tests on the merged tree:
    • chat_workflow + chat_history: 776.
    • Delete, ownership, posture, e2e, location, chat-delete-by-id, save-owner and proposer: 156.
    • Guards, each run alone: conversation guard 25, chat_single_delete_implementation_18316 3, reach_scope_claim_17844 10, reach_declarations 507, reach_floor_migration 9, guard-reach meta 23, store_authority 16, symbol-fork 18, size ratchet 31, ceiling parity 11.

Round 5: #18316 AC2 and AC3 delivered (26a5ae8b93)

  • AC2: DELETE /chats/{id} is tested against a REAL ChatHistoryManager (api/chat_delete_by_id_real_store_18316_test.py).
    • The setup is real: the class writes its own session files under tmp_path, with a fakeredis cache in front (already in the venv; nothing installed). The real two-posture validator decides, and delete_chat_by_id itself runs.
    • After the delete, the session file is gone, the chat:session:{id} cache entry is gone, get_session returns None, and load_session returns [].
  • AC3: one AST guard for un-awaited coroutines (repo_tests/chat_history_async_calls_awaited_18316_test.py).
    • The method set is derived from the class: every async def that ChatHistoryManager exposes through its MRO (53 today).
    • Receivers are anything that names a chat-history manager, or a local name bound from one.
    • A call fails unless it is awaited, returned, or handed to create_task/gather/ensure_future/wait_for/shield.
    • Contrast fixtures: the bug(chat): DELETE /chats/{id} never awaits delete_session — reports success and deletes nothing #18316 bug shape, a bound name, awaited/returned/gathered calls, another store's delete_session, and a sync method.
    • Reach is via declare("chat-history-async-calls-awaited") (floor 396, roots autobot-backend/api), with a _reach_policy verdict, a _DECLARED_SCOPES entry and the import.
    • Sweep result: 0 found. 39 recognised calls in autobot-backend/api, all consumed. The detector does flag the one bug(chat): DELETE /chats/{id} never awaits delete_session — reports success and deletes nothing #18316 fixed: run on the pre-fix api/chat.py (4ac328ea60^) it reports 1941:chat_history_manager.delete_session.
    • A broader receiver match first produced 5 hits (browser_mcp.py:1283,1321, playwright.py:742,834, scrape_templates.py:204). Each is the research browser manager's synchronous get_session, not a chat-history call, which is why receivers are matched by type evidence and not by method name.
  • Behaviour widening, intended: DELETE /chats/{id} now deletes the session's conversation files (file_action="delete", the canonical route's default). The old route was a no-op: it deleted nothing, files included. It now performs the same cleanup as DELETE /chat/sessions/{id}, apart from the legacy transcript, which is always only proposed.
  • alice vs bob through delete_chat_by_id:
    • bob gets 403 from the route's own ownership dependency (DEFAULT posture);
    • alice's session file is byte-identical and her messages still load;
    • no conversation-file cleanup ran.
  • Tests:
    • chat_workflow + chat_history: 776.
    • Chat API, delete, ownership, posture and e2e: 155.
    • Guards each run alone: the new guard 9, chat-single-delete 3, conversation guard 25, reach_scope_claim_17844 10, reach_declarations 513, guard-reach meta 23, reach_floor_migration 9.
  • Re-synced with origin/main (40 commits behind after the push, merged as b48a0144d8). The conflicts were floor values and import lines; main's re-measured floors were taken, and the size ceilings kept at this branch's measured counts.
    • Two more floors drifted into the bottom band on the merged tree: redaction-concept-census 3247→3371 (mid-window), and audio-extension-allowlist 5937→6000.
    • audio-extension-allowlist could not go to its mid-window value (6100): its sweep completes only 6068 files, because it skips more than its declared skips=300. 6000 clears the bottom band and stays under what completes. The declared-skips mismatch is that guard's own defect and is left to its owner.
    • On the merged tree, all ran green: chat 776; chat API, delete and ownership 155; the new guard 9; chat-single-delete 3; conversation guard 25; reach_scope_claim_17844 10; reach_declarations 537; guard-reach meta 23; floor migration 9; size ratchet 31; ceiling parity 11; store_authority 16; symbol-fork 18.

Model Used

Claude Opus 5.5 (master session: brief, host check, PR). Implementation by a Claude Opus subagent.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat history now uses saved sessions as its source of truth, with the latest 10 completed exchanges available as conversation context.
    • Removing a legacy transcript now requires human approval; existing transcripts are preserved until approved.
  • Bug Fixes

    • Creating a session with an ID reserved by a legacy transcript now returns a conflict instead of overwriting it.
    • Chat deletion now consistently checks ownership and clears related cached history. Deleting a legacy-only session reports that it was not found and refers the transcript for review.

…ssion delete clears the LLM-context cache, docs name chat_sessions the record (#18293)
@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

Walkthrough

Chat sessions now provide durable conversation turns and the source for LLM context. Legacy transcripts use a configured read-only path, support repair tooling, and require approval for removal. Chat deletion uses the canonical session deletion implementation.

Changes

Conversation turn store and legacy transcript retirement

Layer / File(s) Summary
Store authority and transcript path
autobot_shared/ssot_config.py, autobot_shared/store_authority.py, autobot-infrastructure/shared/scripts/utilities/init_memory_graph_redis.py, autobot-slm-backend/ansible/roles/backend/templates/backend.env.j2, docker-compose.yml, docs/architecture/*, docs/database/REDIS_MEMORY_GRAPH_SPECIFICATION.md, docs/security/OWNERSHIP_BACKFILL_ANALYSIS.md
Adds the configurable legacy transcript directory and declares chat sessions as the durable turn store. Migration, deployment settings, and documentation use or describe the configured path.
Session-backed context and persistence
autobot-backend/chat_history/turn_projection.py, autobot-backend/chat_workflow/conversation.py, autobot-backend/chat_workflow/manager.py, autobot-backend/chat_history/session.py, autobot-backend/chat_workflow/conversation_turn_store_18293_test.py, autobot-backend/chat_history/turn_projection_test.py, autobot-backend/chat_history/persist_conversation_dedup_test.py, docs/guides/chat-ollama-configuration.md
Projects up to 10 completed exchanges from chat-session messages. Redis remains a context cache; the conversation workflow no longer reads or writes legacy transcript files.
Legacy transcript access and repair
autobot-backend/chat_history/legacy_transcripts.py, autobot-backend/chat_history/session_reply_backfill.py, autobot-backend/chat_history/session_reply_backfill_test.py
Adds validated transcript lookup, reading, and removal functions. Session repair reads transcript pairs and does not use Redis as a backfill source.
Session deletion and transcript approval
autobot-backend/api/chat_sessions.py, autobot-backend/api/chat_sessions_delete_cleanup.py, autobot-backend/api/legacy_transcript_disposal.py, autobot-backend/services/legacy_transcript_disposal_action.py, autobot-backend/api/data_storage_categories.py, autobot-backend/api/data_storage.py, autobot-backend/api/chat.py, autobot-backend/api/*delete*test.py, autobot-backend/api/session_delete_legacy_transcript_18293_test.py, autobot-backend/api/legacy_transcript_location_18293_test.py
Session deletion now precedes cleanup and preserves existing transcripts while proposing removal for approval. Administrative deletion blocks protected transcripts. The chat DELETE endpoint delegates to canonical session deletion.
Repository checks for store and deletion rules
repo_tests/conversation_turn_single_store_18293_test.py, repo_tests/chat_history_async_calls_awaited_18316_test.py, repo_tests/chat_single_delete_implementation_18316_test.py, repo_tests/reach_scope_claim_17844_test.py, repo_tests/_reach_policy.py, repo_tests/*guard_test.py
Adds repository checks for store and deletion invariants, awaited async calls, and the canonical deletion implementation. Updates guard scopes and measured reach floors.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to a12b7

Deletion can leave conversation data available again when a reply finishes concurrently, and legacy transcript handling still has conditional file-safety and malformed-data failures. Resolve or explicitly accept these risks before merging.

Pre-merge checks | Passed 3 | Failed 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check Warning Most changes support #18293 or #18316. However, the PR also registers unrelated deployment-mode and egress guards and re-pins unrelated reach floors such as kb-admin-read-bypass, `audio-extension-al… Remove the unrelated guard registrations and reach-floor changes from this PR, or move them to a separate maintenance PR. Keep only guard and floor updates required to validate #18293 and #18316.
Docstring Coverage Warning Docstring coverage is 42.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 187 functions across 32 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarises the main change: chat_sessions becomes the durable store, while transcripts become read-only legacy data. It is specific and related to the pull request.
Linked Issues check Passed The PR meets the coding requirements for #18293 and #18316. For #18293, conversation turns are persisted in chat_sessions, context is projected from that store, transcript writes and request-path tr…

Full details: Out of Scope Changes check

Explanation

Most changes support #18293 or #18316. However, the PR also registers unrelated deployment-mode and egress guards and re-pins unrelated reach floors such as kb-admin-read-bypass, audio-extension-allowlist, and redaction-concept-census. These changes do not implement chat storage or chat deletion requirements. The PR description also states that no open issue covered at least part of this work.


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR





















🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 26 open PRs — past the runaway threshold (25)

This is not a request to defer this PR. The actual limit is the open-PR cap (AUTOBOT_OPEN_PR_CAP, default 40), enforced at pre-push against NEW branches only -- pushing more commits to this PR's own branch is never blocked by it.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

@mrveiss

mrveiss commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Merger review at a9c7bf3: BLOCKED. One HIGH. Fixing the resolver turns a dormant ordering bug into a live cross-user delete.

Sev Where Finding Fix
HIGH autobot-backend/api/chat_sessions.py delete_session: _perform_all_session_cleanup (line +30 of the function) runs before _delete_session_and_verify (+32); security/session_ownership.py:562-564 For a session with no recorded owner (every B-only legacy session), validate_session_ownership assigns it to the caller (reason: "legacy_migration"). The cleanup then removes the transcript, before the existence check returns 404. Before this PR the cleanup looked in the wrong directory, so this was harmless; now any authenticated user can delete another user's legacy transcript by session id. This also breaks #17038 (retiring B never deletes data directly). Delete the transcript only after _delete_session_and_verify succeeds, and never when ownership["reason"] == "legacy_migration". A transcript with no session in A is refused and routed to the #17038 queue. Add a test: user B's ownerless legacy transcript survives user A's delete.
MED api/data_storage.py:421-425 DELETE /data-storage/conversations/{id} (admin) now really unlinks legacy transcripts, including B-only sessions whose disposition is still pending under #17038. Return 403 for the legacy file, like the bulk path, or enqueue it.
MED autobot_shared/store_authority.py:90-99 The resolver is Path.cwd()/data/..., not SSOT config. A script started from another working directory (e.g. init_memory_graph_redis.py --migrate) silently reads an empty directory. Add an SSOT config path whose default is the backend's working directory.
LOW chat_workflow/conversation.py:224 chat:conversation:{id} is rewritten from the in-process history, so a session deleted mid-flight can repopulate the key. Re-project from A after the turn, or drop the in-memory history on delete.

#18293 ACs: AC5 ("retiring B never deletes data directly") is not met, and AC6 is partial, both because of the HIGH. Closes #18293 is premature until it is fixed.

Verified and fine: id path-safety (no /, \, .., realpath containment); bulk cleanup 403s with no override; the Redis keys are exact (no wildcard).

…r does not durably own; admin route 403s; location from SSOT config (#18293)
@mrveiss

mrveiss commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Merger delta review, a9c7bf3 → 0795940: still BLOCKED.

Fixed:

The ownership premise is still bypassable:

Sev Where (at 0795940) Finding Fix
HIGH (H2) api/chat_sessions.py:777 _reject_session_id_collision, called at :849 It checks only chat_history_manager.get_session(session_id) and never the legacy transcripts. User A creates a session with the client-supplied id of B's legacy-only transcript, which makes A the recorded file owner. A's DELETE then sees owner_match, file owner A, and removes B's transcript with no approval. Refuse creation (409) when legacy_transcript_file(session_id) exists. Add an end-to-end test: create over a legacy id, then delete, and the transcript survives.
HIGH (H1) api/chat.py:1831-1851,1894; security/session_ownership.py:564 The claim does not write only to Redis. After a legacy_migration grant, POST /chats/{id}/save stamps metadata.owner=A onto the session file via _durable_owner_metadata, so a second DELETE passes the "file records the caller" check. Record owner_source="legacy_migration" when a claim stamps the file, and refuse disposal on it. Or compare against the owner the transcript records. Or always propose when a legacy transcript exists. Add the end-to-end test: claim, save, delete.
MED session_delete_legacy_transcript_18293_test.py:140-170 The tests stub reason and durable_owner, so neither cross-user path is exercised. Add both end-to-end cases.
LOW api/legacy_transcript_disposal.py _refer A failed proposal returns 200 with proposal_id: None. Report the failure.
UNVERIFIED ssot_config.py:1330 The base_dir default comes from project_root(). The old writer used the backend service's working directory. It's not confirmed that these resolve to the same directory on a standard install, so data loss on update isn't ruled out. Confirm on a host (read-only), or set AUTOBOT_LEGACY_TRANSCRIPTS_DIR explicitly in the deployed env template.

#18293: AC5 and AC6 are not met at this head (H1 and H2 delete without a proposal).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 2e826d5b-a6b1-4627-a78f-7658b6660508
📥 Commits

Reviewing files that changed from the base of the PR and between 1349f43 and 0795940.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (27)
  • autobot-backend/api/chat_sessions.py
  • autobot-backend/api/chat_sessions_delete_cleanup.py
  • autobot-backend/api/data_storage.py
  • autobot-backend/api/data_storage_categories.py
  • autobot-backend/api/legacy_transcript_disposal.py
  • autobot-backend/api/legacy_transcript_location_18293_test.py
  • autobot-backend/api/session_delete_legacy_transcript_18293_test.py
  • autobot-backend/chat_history/legacy_transcripts.py
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_history/session.py
  • autobot-backend/chat_history/session_reply_backfill.py
  • autobot-backend/chat_history/session_reply_backfill_test.py
  • autobot-backend/chat_history/turn_projection.py
  • autobot-backend/chat_history/turn_projection_test.py
  • autobot-backend/chat_workflow/conversation.py
  • autobot-backend/chat_workflow/conversation_turn_store_18293_test.py
  • autobot-backend/chat_workflow/manager.py
  • autobot-backend/services/legacy_transcript_disposal_action.py
  • autobot-infrastructure/shared/scripts/utilities/init_memory_graph_redis.py
  • autobot_shared/ssot_config.py
  • autobot_shared/store_authority.py
  • docs/architecture/AUTOBOT_MEMORY_GRAPH_ARCHITECTURE.md
  • docs/database/REDIS_MEMORY_GRAPH_SPECIFICATION.md
  • docs/guides/chat-ollama-configuration.md
  • docs/security/OWNERSHIP_BACKFILL_ANALYSIS.md
  • repo_tests/conversation_turn_single_store_18293_test.py
  • repo_tests/reach_scope_claim_17844_test.py
💤 Files with no reviewable changes (2)
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_workflow/manager.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread autobot-backend/chat_history/legacy_transcripts.py
Comment on lines +80 to +84
real = os.path.realpath(str(validate_relative_path(f"{session_id}.json", root)))
if not real.startswith(os.path.realpath(str(root)) + os.sep):
raise ValueError("Invalid session ID")
if await asyncio.to_thread(os.path.exists, real):
await asyncio.to_thread(os.remove, real)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Reject symlinked transcript entries for both operations. The read and removal paths follow symlinks rather than operating only on the requested transcript entry. A symlink within the directory can make removal delete another session’s file; a symlink outside it can make the reader use content outside the legacy store.

  • autobot-backend/chat_history/legacy_transcripts.py#L80-L84: reject a symlinked session entry and do not pass its resolved target to os.remove.
  • autobot-backend/chat_history/legacy_transcripts.py#L109-L113: enforce containment and open the session entry without following a final-component symlink.
    Based on learnings, symlink-sensitive file operations must reject symlink entries and use no-follow opening where applicable.
🧰 Tools
🪛 Ruff (0.16.8)

[warning] 80-80: Async functions should not perform blocking os.path operations

(ASYNC240)


[warning] 81-81: Async functions should not perform blocking os.path operations

(ASYNC240)


[warning] 82-82: Abstract raise to an inner function

(TRY301)


[warning] 82-82: Avoid specifying long messages outside the exception class

(TRY003)

📍 Affects 1 file
  • autobot-backend/chat_history/legacy_transcripts.py#L80-L84 (this comment)
  • autobot-backend/chat_history/legacy_transcripts.py#L109-L113

Source: Learnings

Comment on lines +74 to +79
try:
messages = await ChatHistoryManager().load_session(session_id)
except (PermissionError, ValueError, OSError) as exc:
logger.error("Cannot read chat session %s for LLM context: %s", session_id, exc)
return None
return project_turn_pairs(messages, LLM_CONTEXT_TURN_LIMIT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A missing session file disables the legacy fallback.

load_session documents FileNotFoundError as a propagated exception (session.py Line 300). FileNotFoundError is a subclass of OSError. The except on Line 76 therefore returns None for it. _load_conversation_history then returns [] and never reads the legacy transcript.

The trigger is a session whose file is removed between _resolve_session_file_path and aiofiles.open. A B-only legacy session, which has no chat_sessions file at all, normally takes the chat_data is None → [] path. That path still works. The race narrows the impact, but this behaviour contradicts the "None = unreadable" contract in the docstring. A missing file is the case where the legacy fallback must apply.

Proposed fix
--- "a/autobot-backend/chat_workflow/conversation.py"
+++ "b/autobot-backend/chat_workflow/conversation.py"
@@ -71,9 +71,11 @@
         from chat_history import ChatHistoryManager
         from chat_history.turn_projection import LLM_CONTEXT_TURN_LIMIT, project_turn_pairs
 
         try:
             messages = await ChatHistoryManager().load_session(session_id)
+        except FileNotFoundError:
+            messages = []
         except (PermissionError, ValueError, OSError) as exc:
             logger.error("Cannot read chat session %s for LLM context: %s", session_id, exc)
             return None
         return project_turn_pairs(messages, LLM_CONTEXT_TURN_LIMIT)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
messages = await ChatHistoryManager().load_session(session_id)
except (PermissionError, ValueError, OSError) as exc:
logger.error("Cannot read chat session %s for LLM context: %s", session_id, exc)
return None
return project_turn_pairs(messages, LLM_CONTEXT_TURN_LIMIT)
try:
messages = await ChatHistoryManager().load_session(session_id)
except FileNotFoundError:
messages = []
except (PermissionError, ValueError, OSError) as exc:
logger.error("Cannot read chat session %s for LLM context: %s", session_id, exc)
return None
return project_turn_pairs(messages, LLM_CONTEXT_TURN_LIMIT)
🧰 Tools
🪛 Ruff (0.16.8)

[warning] 77-77: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

Comment on lines +50 to +57
result = await remove_legacy_transcript(session_id)
outcome = "removed" if result["transcript_deleted"] else "did NOT remove"
body = f"Legacy transcript cleanup {outcome} the transcript of session {session_id}"
if result["error"]:
body += " (refused or failed; the backend log has the reason)"
session.add(ApprovalComment(approval_id=approval.id, author="system", author_type="system", body=body))
await session.commit()
await audit_log(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Inspect the audit contract and the approval dispatcher's failure path.
rg -n -A35 -B5 'async def audit_log\b|def audit_log\b' autobot-backend/services
rg -n -C8 'register_post_approval_action|post_approval_action|execute_legacy_transcript_delete' autobot-backend/services

Repository: mrveiss/AutoBot-AI

Length of output: 39155


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path

for name, start, end in [
    ("autobot-backend/services/legacy_transcript_disposal_action.py", 41, 64),
    ("autobot-backend/services/approval_execution.py", 99, 170),
]:
    path = Path(name)
    lines = path.read_text().splitlines()
    print(f"--- {name}:{start}-{end} ---")
    for number in range(start, min(end, len(lines)) + 1):
        print(f"{number}: {lines[number - 1]}")
PY

Repository: mrveiss/AutoBot-AI

Length of output: 4684


Guarantee the legacy_transcript.delete audit entry after removal.

execute_legacy_transcript_delete commits an approval comment before it calls audit_log. If that call raises, the dispatcher commits a durable failure comment, but its fallback also swallows failures from its own audit_log. The deletion therefore retains an approval trail, but the promised legacy_transcript.delete audit entry is not guaranteed. Make the audit record durable when either audit call fails.

…cy-only id cannot be taken, and deploy templates pin the location (#18293)
…no chat_sessions turns starts with empty context (#18293)
…ion-turn-store

# Conflicts:
#	repo_tests/reach_scope_claim_17844_test.py
…s, and re-pin three floors mid-window after the tree grew (#18293)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 594befd5-7f37-448e-8f0f-e033a4bcb5fa
📥 Commits

Reviewing files that changed from the base of the PR and between 0795940 and a6f1e9f.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (23)
  • autobot-backend/api/chat_sessions.py
  • autobot-backend/api/chat_sessions_delete_cleanup.py
  • autobot-backend/api/data_storage.py
  • autobot-backend/api/legacy_transcript_disposal.py
  • autobot-backend/api/session_delete_legacy_transcript_18293_test.py
  • autobot-backend/chat_history/legacy_transcripts.py
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_history/session.py
  • autobot-backend/chat_history/session_reply_backfill.py
  • autobot-backend/chat_history/session_reply_backfill_test.py
  • autobot-backend/chat_workflow/conversation.py
  • autobot-backend/chat_workflow/conversation_turn_store_18293_test.py
  • autobot-backend/chat_workflow/manager.py
  • autobot-backend/services/legacy_transcript_disposal_action.py
  • autobot-infrastructure/shared/scripts/utilities/init_memory_graph_redis.py
  • autobot-slm-backend/ansible/roles/backend/templates/backend.env.j2
  • autobot_shared/store_authority.py
  • docker-compose.yml
  • repo_tests/collected_modules_inert_on_import_test.py
  • repo_tests/conversation_turn_single_store_18293_test.py
  • repo_tests/kb_admin_read_bypass_guard_test.py
  • repo_tests/model_revision_pinning_enforced_17804_test.py
  • repo_tests/reach_scope_claim_17844_test.py
💤 Files with no reviewable changes (2)
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_workflow/manager.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment on lines +40 to +49
async def _pending_proposal_id(db, session_id: str) -> str | None:
from models.approval import Approval, ApprovalStatus

query = select(Approval.id).where(
Approval.status == ApprovalStatus.PENDING.value,
Approval.context["action"].astext == ACTION,
Approval.context["session_id"].astext == session_id,
)
found = (await db.execute(query)).scalars().first()
return str(found) if found else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial | ⚖️ Poor tradeoff

Pending-proposal lookup and creation are not atomic.

_pending_proposal_id and create_approval run in one session without a lock. Two concurrent deletes for the same ID can both see no pending proposal. Both then create one. The result is a duplicate approval, and an approver may handle the same transcript twice. The second approval will report "did NOT remove". The impact is low, and the second decision is harmless.

Use a unique partial index, or an advisory lock keyed on session_id, if duplicates matter.

Comment on lines +72 to +78
async def _refer(session_id: str, requested_by: str | None, why: str) -> str | None:
"""Propose disposal; a failure to propose leaves the file and is reported, never raised."""
try:
return await propose_transcript_disposal(session_id, requested_by, why)
except Exception as exc:
logger.error("Could not record a #17038 proposal for session %s's transcript: %s", session_id, exc)
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Proposal failure is reported, but the delete_session_or_refer 404 hides it.

In delete_session_or_refer, a failed _refer returns None. The 404 message then carries no hint that the proposal was not recorded. The legacy-only transcript stays on disk with no review proposal and no signal to the caller. dispose_session_transcript reports this case in its error field. The 404 path does not.

Add a short suffix to the message when _refer returns None for an existing legacy transcript.

The blind except Exception here is intentional and logged. Ruff's BLE001 can be silenced with a noqa and a reason. Use logger.exception instead of logger.error so the traceback is kept.

🧰 Tools
🪛 Ruff (0.16.8)

[warning] 76-76: Do not catch blind exception: Exception

(BLE001)


[warning] 77-77: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

Source: Linters/SAST tools

chat_mgr,
redis_client=None,
transcript_dir: str = _TRANSCRIPT_DIR,
transcript_dir: str | None = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔵 Trivial

🔎 Supported by static analysis

🏁 Script executed:

set -o pipefail
printf '%s\n' '--- target file ---'
nl -ba autobot-backend/chat_history/session_reply_backfill.py | sed -n '1,260p'
printf '%s\n' '--- repair_session references ---'
rg -n -F --glob '*.py' -- 'repair_session(' autobot-backend || test "$?" -eq 1
printf '%s\n' '--- ownership/session identifiers ---'
rg -n -F --glob '*.py' -- 'legacy_conversation_transcripts' autobot-backend || test "$?" -eq 1
rg -n -F --glob '*.py' -- 'session_owner' autobot-backend || test "$?" -eq 1
rg -n -F --glob '*.py' -- 'owner_id' autobot-backend/chat_history autobot-backend/api autobot-backend || test "$?" -eq 1

Repository: mrveiss/AutoBot-AI

Length of output: 40947


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- legacy transcript reader ---'
nl -ba autobot-backend/chat_history/legacy_transcripts.py | sed -n '1,180p'
printf '%s\n' '--- session owner implementation ---'
nl -ba autobot-backend/chat_history/session.py | sed -n '860,940p'
printf '%s\n' '--- focused backfill tests ---'
nl -ba autobot-backend/chat_history/session_reply_backfill_test.py | sed -n '220,355p'

Repository: mrveiss/AutoBot-AI

Length of output: 16945


Require explicit owner confirmation before backfilling.

repair_session matches legacy pairs by user text and writes the assistant text to chat_sessions. The legacy reader does not provide owner metadata, so this path cannot compare transcript and session owners. Document that the operator must verify ownership for every named session, or require an explicit confirmation flag in _main.

Comment on lines +22 to +25
# #18293: the retired chat-transcript store (read-only legacy). It lives where its old
# writer put it -- data/conversation_transcripts under the service's WorkingDirectory
# (autobot-backend.service.j2), NOT under AUTOBOT_DATA_DIR. Pinned so it is never inferred.
AUTOBOT_LEGACY_TRANSCRIPTS_DIR={{ backend_code_dir | default(backend_install_dir) }}/data/conversation_transcripts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Set the legacy transcript directory from the same variable as AUTOBOT_BASE_DIR.

Line 12 sets AUTOBOT_BASE_DIR={{ backend_code_dir }}. Line 25 uses backend_code_dir | default(backend_install_dir). If backend_code_dir is undefined, Line 12 already fails to render. The default on Line 25 is therefore dead code. It also suggests a fallback that does not exist.

Use {{ backend_code_dir }} on Line 25. Alternatively, derive the value from AUTOBOT_BASE_DIR. The path instructions say an override variable keeps its override and only the fallback is derived from the SSOT.

Source: Path instructions

@mrveiss

mrveiss commented Oct 11, 2026

Copy link
Copy Markdown
Owner Author

Merger note: this PR rides merge train #3 (branch train-2026-10-11-3). Its two reach_scope_claim_17844 import lines and its three floor re-pins are superseded in-train. Train #2 (#18314) fixed the run-alone failure once as a class: the test imports the full registry. Train #3 re-pins the floors from a measurement on the combined tree. This PR body's 'fixed while merging' section is history, not the landed state.

…18293-conversation-turn-store

# Conflicts:
#	repo_tests/collected_modules_inert_on_import_test.py
#	repo_tests/reach_scope_claim_17844_test.py
…sue-18293-conversation-turn-store

# Conflicts:
#	repo_tests/python_file_size_ratchet_baseline.py
#	scripts/python_file_size_known_large.py
… canonical delete, drive the e2e tests through the two-posture validator, and allowlist legacy ids at the path sink (#18293, #18316)
…ob cannot delete alice's chat, and one guard for un-awaited ChatHistoryManager coroutines in the API (#18316)
…ion-turn-store

# Conflicts:
#	repo_tests/credential_field_name_only_17336_test.py
#	repo_tests/kb_admin_read_bypass_guard_test.py
#	repo_tests/model_revision_pinning_enforced_17804_test.py
#	repo_tests/python_file_size_ratchet_baseline.py
#	repo_tests/reach_scope_claim_17844_test.py
#	scripts/python_file_size_known_large.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Correct the test docstring for the B-only session. · session_reply_backfill_test.py:317-318

autobot-backend/chat_history/session_reply_backfill_test.py:317-318
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the test docstring for the B-only session.

The docstring says that a B-only session "is served read-only for LLM context". After #18293, no request path reads legacy transcripts, so a B-only session gets empty context. The docstring therefore contradicts the module contract. Update the docstring.

Proposed fix
-        """A B-only session is served read-only for LLM context, never moved by this repair."""
+        """A B-only session is never created or moved by this repair; it awaits an approved #17038 backfill."""

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 6cf38a05-deb8-4ef0-9a10-24cb634799f6
📥 Commits

Reviewing files that changed from the base of the PR and between a6f1e9f and a12b734.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (23)
  • autobot-backend/api/chat.py
  • autobot-backend/api/chat_delete_by_id_real_store_18316_test.py
  • autobot-backend/api/chat_delete_by_id_test.py
  • autobot-backend/api/chat_sessions_delete_cleanup.py
  • autobot-backend/api/data_storage.py
  • autobot-backend/api/legacy_transcript_location_18293_test.py
  • autobot-backend/api/session_delete_legacy_transcript_18293_test.py
  • autobot-backend/chat_history/legacy_transcripts.py
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_history/session.py
  • autobot-backend/chat_history/session_reply_backfill.py
  • autobot-backend/chat_history/session_reply_backfill_test.py
  • autobot-backend/chat_workflow/conversation.py
  • autobot-backend/chat_workflow/manager.py
  • autobot-infrastructure/shared/scripts/utilities/init_memory_graph_redis.py
  • autobot_shared/store_authority.py
  • repo_tests/_reach_policy.py
  • repo_tests/audio_extension_allowlist_test.py
  • repo_tests/chat_history_async_calls_awaited_18316_test.py
  • repo_tests/chat_single_delete_implementation_18316_test.py
  • repo_tests/kb_admin_read_bypass_guard_test.py
  • repo_tests/reach_scope_claim_17844_test.py
  • repo_tests/redaction_concept_census_test.py
💤 Files with no reviewable changes (2)
  • autobot-backend/chat_history/persist_conversation_dedup_test.py
  • autobot-backend/chat_workflow/manager.py

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment on lines +97 to +116
@pytest.mark.asyncio
async def test_a_non_owner_is_refused_and_nothing_is_deleted():
"""The ownership Depends is declared on the route and rejects before the handler runs."""
from fastapi.routing import APIRoute

route = next(
r
for r in chat_api.router.routes
if isinstance(r, APIRoute) and r.path == "/chats/{chat_id}" and "DELETE" in r.methods
)
dependants = {d.call for d in route.dependant.dependencies}
assert chat_api.validate_chat_ownership in dependants

def _forbidden(*_a, **_k):
raise HTTPException(status_code=403, detail="not your session")

with patch.object(chat_api, "validate_chat_ownership", side_effect=_forbidden):
with pytest.raises(HTTPException) as excinfo:
await chat_api.validate_chat_ownership("c1", _request())
assert excinfo.value.status_code == 403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🔎 Supported by static analysis

🏁 Script executed:

sed -n '90,120p' autobot-backend/api/chat_delete_by_id_test.py
sed -n '60,92p' autobot-backend/api/chat_delete_by_id_real_store_18316_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 2594


🏁 Script executed:

rg -n -F --glob '*.py' -- 'validate_chat_ownership' autobot-backend/api/chat.py autobot-backend/api/chat_delete_by_id_test.py autobot-backend/api/chat_delete_by_id_real_store_18316_test.py
sed -n '1,180p' autobot-backend/api/chat_delete_by_id_real_store_18316_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 6284


🏁 Script executed:

sed -n '80,112p' autobot-backend/api/chat.py
sed -n '1588,1620p' autobot-backend/api/chat.py
rg -n -F -- 'def ownership' autobot-backend/api/session_delete_legacy_transcript_18293_test.py
sed -n '1,180p' autobot-backend/api/session_delete_legacy_transcript_18293_test.py

Repository: mrveiss/AutoBot-AI

Length of output: 10540


Remove the self-call from the dependency-registration test.

The patched call invokes chat_api.validate_chat_ownership directly, so it only checks the mock’s 403 side effect. The real-store test bypasses FastAPI dependency resolution, but it uses the real ownership validator and handler and asserts 403 with unchanged data. The route metadata assertion already checks dependency registration.

This is redundant cleanup, not a missing high-value ownership regression.

Suggested cleanup
-async def test_a_non_owner_is_refused_and_nothing_is_deleted():
-    """The ownership Depends is declared on the route and rejects before the handler runs."""
+async def test_delete_route_declares_ownership_dependency():
+    """The DELETE route declares the ownership dependency."""
     from fastapi.routing import APIRoute
@@
     dependants = {d.call for d in route.dependant.dependencies}
     assert chat_api.validate_chat_ownership in dependants
-
-    def _forbidden(*_a, **_k):
-        raise HTTPException(status_code=403, detail="not your session")
-
-    with patch.object(chat_api, "validate_chat_ownership", side_effect=_forbidden):
-        with pytest.raises(HTTPException) as excinfo:
-            await chat_api.validate_chat_ownership("c1", _request())
-    assert excinfo.value.status_code == 403
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.mark.asyncio
async def test_a_non_owner_is_refused_and_nothing_is_deleted():
"""The ownership Depends is declared on the route and rejects before the handler runs."""
from fastapi.routing import APIRoute
route = next(
r
for r in chat_api.router.routes
if isinstance(r, APIRoute) and r.path == "/chats/{chat_id}" and "DELETE" in r.methods
)
dependants = {d.call for d in route.dependant.dependencies}
assert chat_api.validate_chat_ownership in dependants
def _forbidden(*_a, **_k):
raise HTTPException(status_code=403, detail="not your session")
with patch.object(chat_api, "validate_chat_ownership", side_effect=_forbidden):
with pytest.raises(HTTPException) as excinfo:
await chat_api.validate_chat_ownership("c1", _request())
assert excinfo.value.status_code == 403
@pytest.mark.asyncio
async def test_delete_route_declares_ownership_dependency():
"""The DELETE route declares the ownership dependency."""
from fastapi.routing import APIRoute
route = next(
r
for r in chat_api.router.routes
if isinstance(r, APIRoute) and r.path == "/chats/{chat_id}" and "DELETE" in r.methods
)
dependants = {d.call for d in route.dependant.dependencies}
assert chat_api.validate_chat_ownership in dependants
🧰 Tools
🪛 Ruff (0.16.8)

[warning] 110-110: Missing return type annotation for private function _forbidden

Add return type annotation: NoReturn

(ANN202)


[warning] 110-110: Missing type annotation for *_a

(ANN002)


[warning] 110-110: Missing type annotation for **_k

(ANN003)


[warning] 113-114: Use a single with statement with multiple contexts instead of nested with statements

(SIM117)

@mrveiss
mrveiss merged commit 5779356 into main Oct 12, 2026
57 of 88 checks passed
@mrveiss
mrveiss deleted the issue-18293-conversation-turn-store branch October 12, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant