Skip to content

fix(backend): restore the released 204 contract for conversation deletion - #21329

Open
aryanorastar wants to merge 6 commits into
BasedHardware:mainfrom
aryanorastar:fix/conversation-delete-204-contract
Open

aryanorastar wants to merge 6 commits into
BasedHardware:mainfrom
aryanorastar:fix/conversation-delete-204-contract

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What changed and why

Deleting a conversation on mobile does not stick: the app shows it deleted, then it reappears about 30 seconds later (Discord "Deleting Convos and Memories", the #10446 recurrence). The server delete succeeds; the client reads it as a failure.

DELETE /v1/conversations/{conversation_id} shipped to mobile as 204 No Content. The 2026-07-04 response_model backfill (e22938a) turned it into a 200 with a StatusResponse body, and the released app gates success on exactly 204 (app/lib/backend/http/api/conversations.dart::deleteConversationServer returns response.statusCode == 204). Every successful delete reads as failed, the app drops its optimistic hide, and the next refresh brings the conversation back.

This restores the released contract. It is the mobile half of #14691, rebuilt on current main: the two backend commits are cherry-picked with -x and keep their authorship; the desktop half of #14691 already landed as #15097. #14691 itself is far behind main and red on an unrelated stale fixture.

  • backend/routers/conversations.py: status_code=204, no body. Everything main added to the handler since — tombstones (fix: prevent deleted conversations from backlog resurrection #21000), the product event — is kept.
  • docs/api-reference/app-client-openapi.json: regenerated with export_openapi.py --surface app-client; only this path changes.
  • check_app_client_openapi_compatibility.py: records (/v1/conversations/{conversation_id}, delete, 204) as a restoration of the released status, with the client evidence cited, so the gate does not flag it as a new success status.
  • Generated Swift/TypeScript clients: regenerated from the spec rather than cherry-picked. No in-tree code calls the generated delete functions.

Released-client compatibility

Released client Behavior on 204
Mobile (Flutter) Fixed: success is gated on exactly 204
macOS Unchanged: the hand-written delete() uses performVoidRequest, which accepts any 2xx and reads no body (checked at 0.12.447)
Windows Unchanged: omiApi.delete is axios, which resolves on 2xx with an empty body; the released generated function already returns undefined for 204
Web Unchanged: only the generated client is used, and it already handles 204

Product invariants affected

none (scripts/pr-preflight --suggest)

How it was verified

  • 270 tests across the 15 backend unit files that exercise conversation delete pass under BACKEND_UNIT_TEST_FILE_LIST bash test.sh.
  • test_conversation_delete_wire_contract.py, 4 tests: the route declaration, the published spec, and the real route over HTTP through FastAPI's TestClient for cascade=false and cascade=true (storage side effects stubbed). Against main's route, the declaration test and both HTTP tests fail — assert 200 == 204.
  • export_openapi.py --surface app-client --check passes on the regenerated spec, and both client generators report fresh with --check.

Not verified

Not exercised against a deployed backend or on a phone. The offline harness needs Docker and a Typesense runtime this machine does not have; the HTTP behavior is proven through TestClient with the production route and FastAPI serialization.

Tests

  • test_conversation_delete_wire_contract.py: the contract from three sides — declaration, spec, and HTTP response.
  • test_conversation_tombstones.py: two assertions change from == {'status': 'Ok'} to is None. Those tests landed with fix: prevent deleted conversations from backlog resurrection #21000 against the 200-with-body regression; their subject, tombstone ordering against retraction and cleanup, is unchanged. External source for the new expected value: the released mobile client's statusCode == 204 gate cited above.

Failure class (fixes)

Failure-Class: none

The cherry-picked commit cites FC-privacy-delete-fails-on-unprovisioned-derived-store, which covers the 5xx-on-unprovisioned-store mechanism fixed in #15097. This half is a different mechanism — a schema backfill changed a released success status — and no registered class describes it. It is guarded by the wire-contract tests and the recorded restoration in the app-client compatibility gate.

UI evidence

UI-Evidence: none -- backend status code and regenerated API clients only; no screen, widget or copy changes

🤖 Generated with Claude Code

View guided diff

ampagent and others added 6 commits October 12, 2026 21:52
…on deletion

DELETE /v1/conversations/{conversation_id} shipped to released mobile clients
as 204 No Content. The 2026-07-04 response_model backfill (e22938a) turned
it into a 200 with a StatusResponse body, and the released app
(app/lib/backend/http/api/conversations.dart::deleteConversationServer) gates
delete success on exactly 204 — so every conversation delete there reads as a
failure: the optimistic tombstone is dropped immediately, the server-cursor
rebase and fetch-revision bookkeeping are skipped, and once the server delete
cannot complete (e.g. cascade retraction failing closed) the deleted
conversation resurfaces on the next refresh. This is the recurrence of

Restore status_code=204 with no body (the folders/mcp convention) and pin the
contract from both sides in backend/tests/unit/test_conversation_delete_wire_contract.py:
the router declaration and the regenerated app-client OpenAPI spec.

Verified: offline harness — cascade delete of a conversation with attributed
memories returned 503 before this change, 204 with an empty body after.

Failure-Class: FC-privacy-delete-fails-on-unprovisioned-derived-store
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b590-2123-744a-9e4a-16706295c2d3
Co-authored-by: Max Carter <undivisible@vk.com>
(cherry picked from commit 21002a8)
…t contract gate

The gate treats the merge-base spec as the released-clients truth. That
baseline can itself carry a spec-only regression: e22938a (2026-07-04)
flipped DELETE /v1/conversations/{conversation_id} from the released 204
No Content to 200 + StatusResponse while every released mobile client
still gates delete success on exactly 204
(app/lib/backend/http/api/conversations.dart::deleteConversationServer,
BasedHardware#10446 recurrence: desktop delete errors, mobile re-adds on refresh).
With the baseline poisoned, the gate blocked restoring the actual
released contract, and the July regression shipped precisely because no
guard existed to catch it.

Add RELEASED_STATUS_RESTORES, patterned on DELIBERATELY_REMOVED_ENDPOINTS:
an explicit, evidence-cited (route, method, status) record whose
released-client contract predates the baseline regression, so restoring
that status is not flagged as a new success status. Entries are narrow —
an unrecorded operation cannot borrow the allowance — and both the
allow-and-refuse directions are unit-tested.

Evidence: app/lib/backend/http/api/conversations.dart:140 in the
released app source returns response.statusCode == 204 for this call.

Failure-Class: none
Amp-Thread-ID: https://ampcode.com/threads/T-01a0b76d-bb38-749d-a0da-1b0837aa8e2e
Co-authored-by: Max Carter <undivisible@vk.com>
(cherry picked from commit 11cc3c5)
…e contract

Regenerated from the updated app-client spec with
backend/scripts/generate_ts_openapi_types.py and
generate_swift_openapi_types.py rather than cherry-picking 2fb1f42 from
BasedHardware#14691, whose generated files predate current main. Only the conversation
delete function changes: Swift returns Void, TypeScript returns without
parsing a body. No in-tree code calls either generated function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…contract

test_conversation_tombstones.py (BasedHardware#21000) landed after the 200-with-body
regression and asserted the handler's {'status': 'Ok'} return. The route is
204 No Content again, the contract the released mobile client gates on
(app/lib/backend/http/api/conversations.dart::deleteConversationServer
returns statusCode == 204), so the handler returns None. The tests' subject,
tombstone ordering against retraction and cleanup, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ody over HTTP

The wire-contract test pinned the route declaration and the published spec.
This adds the HTTP layer: the real route through FastAPI's TestClient, with
only storage side effects stubbed, for cascade=false and cascade=true. Against
main's route both requests answer 200 (assert 200 == 204), which the released
mobile client reads as a failed delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full reasoning lives in test_conversation_delete_wire_contract.py; the
router stays at its line-count baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verified the core regression story end to end — this is a well-evidenced fix.

  • backend/routers/conversations.py: delete_conversation back to status_code=204 with no response_model, keeping the tombstone (fix: prevent deleted conversations from backlog resurrection #21000) and product-event work main added since. Confirmed main still carries the regression (line 1287, response_model=StatusResponse), and that the released Flutter client really gates on exactly 204 (app/lib/backend/http/api/conversations.dart:154, return response.statusCode == 204) — so on a released mobile build every successful delete currently reads as a failure and the conversation resurfaces on refresh. The e22938a backfill is confirmed as the source of the flip.
  • backend/tests/unit/test_conversation_delete_wire_contract.py: good three-layer pin — route declaration (including response_model is None), the published spec, and the real route over HTTP via TestClient asserting 204 + empty body for both cascade values with storage side effects stubbed. Right shape for a wire-contract guard.
  • backend/scripts/check_app_client_openapi_compatibility.py: the RELEASED_STATUS_RESTORES record is narrow (one (route, method, status) triple), documents the evidence requirement, and the two new tests prove both that the record allows this restoration and that other operations cannot borrow it. A sensible pattern for spec-only regressions.
  • docs/api-reference/app-client-openapi.json: single-path change (200/StatusResponse → 204), consistent with the router.
  • Generated clients (desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift, desktop/windows/src/renderer/src/lib/omiApi.generated.ts, web/{admin,app,personas-open-source}/.../omiApi.generated.ts): consistently regenerated; code search confirms no in-tree callers of the changed generated delete function, so the StatusResponse → void signature change is safe. The hand-written macOS APIClient.deleteConversation (void request, any 2xx) and the web app's fetch-based delete are unaffected — matches the compatibility table.
  • backend/tests/unit/test_conversation_tombstones.py: the two {'status': 'Ok'} → None assertion updates are correct consequences of the handler no longer returning a body; the tombstone-ordering subject of those tests is unchanged.

Checks are green on head (backend unit suite, hermetic merge gate, desktop Swift build & tests).

Leaving formal sign-off to a human maintainer: this reverses a deliberately-merged API change on a user-data deletion endpoint (released 204 contract vs the backfilled 200), and it records a standing exception in the app-client compatibility gate — both deserve a maintainer's explicit call on which contract is canonical going forward. Thanks for the exceptionally thorough write-up and for preserving the cherry-pick provenance.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added needs-maintainer-review Needs a human maintainer to sign off before merge backend Backend Task (python) labels Oct 12, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants