fix(gateway): preserve the client sendId through the slot queue drain - #8853
Conversation
A dispatched send persists the client-minted `meta.sendId` on its user row; a send that arrived while the slot was busy (mid-turn, mid-plan, or held behind running sub-agents) went through the slot queue instead, and the queue entry carried only the containment snapshot -- so the row the drain wrote was id-less. A client that has to prove ITS message landed (an unconfirmed-send notice deciding whether to retire) had nothing to match on but text, which a same-text resend or an injection can share. `queue_for_next_turn` gains a `send_id` keyword and both composer-reachable queue producers in `/api/chat` (the busy-slot branch and the sub-agent-hold branch) pass the client's id through `normalize_send_id` -- the same deny-by-default gate the steer path uses, so an unusable value is treated as absent, never rewritten. The drain's existing meta union carries the key onto the row; when `merge_queued_messages` folds several entries into one row, that row now also names every id it stands for as `sendIds`. Additive: a send whose POST carried no usable id stores nothing and the entry / row shape is unchanged. The `queue_push` broadcast is untouched.
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real correlation gap (queued sends losing their delivery-proof id), closed additively at the right seam, with the drain contract properly pinned. Suggestions
[DESIGN-REVIEWED] e4b38cd |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. I have what I need for the review. First-Principles-Verdict: CONCERNS The fix is real and consumed today, but the sub-agent-hold branch hand-copies the helper this same PR just taught to do the job. What this change shipsIntent: make a send queued into a busy slot provably deliverable by id, so ChatEmbed's unconfirmed-send notice (#8599) never retires on a text match. A FIX.
The core fix is at cause level: the drain's existing meta union is reused, no new drain branch, and the frontend already reconciles user rows by Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] e4b38cd |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
|
First Principles — disposition for
|
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix threading client sendId through the slot queue drain so a queued send's row carries the correlation id, 4 files with a clear root cause.
bolichen97
left a comment
There was a problem hiding this comment.
Approved after a full Tech Lead review of e4b38cd80a10a9f9b051cb2bc772e35e1df53ff7.
Correctness of the id preservation, verified against the code rather than the description:
containment_meta(state, slot)returns a fresh dict per call (session_control.py), so stampingmeta["sendId"]on it cannot pollute shared state.- The drain's union (
_drained_meta.update(...)overconsumed) does carry the entry key onto the row it appends, and the containment plumbing key is still excluded — so the queued row lands with the samemeta.sendIdshape a dispatched send's row gets fromslot.append(..., meta=user_meta). - No id can land on the wrong role:
_dequeue_next_messagebreaks at the firstis_system_injection_item, so a merged batch is only composer-originated entries — a cron notification or sub-agent completion can never fold asendIdonto aninject/subagentrow. - No loss or collision: the accumulator dedupes,
sendIdsis written only when several distinct ids were consumed, andsendIdstays as the union's last writer so the key is never absent when any entry had one. - No frontend regression. The old id-less drained row reconciled through the
else if (meta?.mid)fallback inapplyMessageToArray, which requireslast.content === contenton the tail row; the id-bearing row now goes throughreconcileOptimisticEcho, a 50-row backward scan bysendId. That is strictly wider, and the merged-row case never matched the old exact-content fallback either (the row text is[N queued messages merged]…), so nothing that used to reconcile stops reconciling. normalize_send_idis the right gate to reuse: deny-by-default on alphabet, length, and aredact_credentialsscan, and an unusable value is treated as absent rather than rewritten — a rewritten id would silently mismatch the client's copy.- Contracts held:
queue_pushpayload is unchanged (nosendIdon the broadcast), thequeued: truereceipt still names the entry,queue_iduntouched.
Checks: PR Readiness success; the PR Hygiene failure at 00:54 is superseded by the 01:16 success on the same head. GPT 5.6, Opus 4.8, Design, UX all PASS; SAST and CodeQL clean. Diff is 4 files / +288-2 with no website/src or electron change, so the UX-review ruleset does not apply.
First Principles CONCERNS treated as advisory, and I agree with the author's disposition: sendIds has its reader in #8599 (the PR this exists to unblock), and collapsing the sub-agent-hold branch onto queue_for_next_turn(..., send_id=...) is a pure deletion worth doing on the next change to that branch rather than costing two PRs a full review round now.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (4 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: queue entries carried only the containment snapshot, so a send that landed while the slot was busy produced an id-less drained row -- the fix stamps the already-normalized client meta.sendId onto the queue entry (both busy-slot and sub-agent-hold branches) and adds sendIds on a merged row, leaving the single-entry shape byte-identical to a dispatched send.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
ChatEmbed (the app-sdk's embeddable chat) posted to the bare /api/chat, read its SSE stream as JSON, and called the resulting SyntaxError success; a refused POST rejected the mutation nothing read. Either way the composer had already been cleared, so a failed send looked sent and the text was gone. sendTurn gains an injectable wire (the fetch seam: one POST that resolves on every HTTP status and rejects only when the request never left or the deadline fired); dashboardSendWire is the default, so ChatPane and ChatPage are unchanged. appSendWire re-expresses the scoped AppApi outcomes in that shape (2xx JSON -> ok; 2xx non-JSON -> unknown; non-2xx -> refused with the server's reason via a typed AppApiError; permission denial -> refused with a human sentence; rejected fetch -> transport-error; deadline -> response-late). ChatEmbed sends through sendTurn over that wire. refused / transport-error render an error row and hand the draft back as typed; response-late renders a "Delivery not confirmed" notice and hands the draft back, retired only when the poll shows THIS send's own user row past the send-start point -- by identity only (meta.sendId, or membership in a merged row's meta.sendIds), never by text. The server keeps the id on every path a send can take: a dispatched send persists it on its row, and a send queued behind a busy slot carries it through the queue entry onto the drained row (#8853), so there is no text-matching fallback to false-retire on. unknown does nothing; dispatched / queued let the poll render. mintSendId now lives once in chat-core/transport; ChatPane's and ChatPage's inline minters use it. useChatSession's seed send moves off the bare SSE endpoint onto the same wire (fire-and-forget; the slot is the deliverable). The composer swap (#8631): ChatEmbed's bare <input> + send button becomes the native ChatInput inside a SlotProvider for the embedded slot, narrowed by omission (no upload, voice, agent/model/project chrome, typed command menus, prompt optimizer or slot-approval chrome) with a fail-closed `embedded` preset, a `sending` prop, and sendOnEnter via the new useChatConfig hook. Enter/IME handling, autosize, the running-state send affordance and the composer's a11y contract are now the shared ones; recovered drafts keep their paragraph breaks. Two now-unreferenced app-sdk keys (send, send_message) are removed from every catalog.
Problem / Motivation
A dispatched send persists the client-minted
meta.sendIdon its user row (chat_handlers.py,slot.append("user", …, meta=user_meta)), and a steer does too — accepted (steer_into_running_turn) or requeued (_requeue_unconsumed_steers, #6751). A plain send into a busy slot did not: the busy-slot branch and the sub-agent-hold branch queued the text with only the containment snapshot in the entry's meta, and the drain (_start_next_queued_turn) wrote an id-less user row.That is the gap the GPT lane blocked #8599 on. ChatEmbed's unconfirmed-send notice needs to prove that its message landed before retiring; with no id on the drained row the only available proof was exact-text equality, which a same-text resend or a cron/sub-agent injection can share — so a genuinely undelivered draft could be silently withdrawn. Both frontend-side workarounds were rejected by GPT and Opus; the server has to keep the id.
What changed
chat_delivery.queue_for_next_turngains asend_id: str | None = Nonekeyword. When present it is stamped onto the queue entry's meta next to the containment snapshot. Additive:Noneleaves the entry shape exactly as before.chat_handlers.api_chat: the busy-slot branch passesnormalize_send_id(user_meta.get("sendId")); the sub-agent-hold branch stamps the same key onto its owncontainment_meta(...)dict.normalize_send_idis the steer path's existing deny-by-default gate (id alphabet, length bound, credential scan) — an unusable value is treated as absent, never truncated or rewritten, since a rewritten id would silently mismatch the client's copy.chat_runner._start_next_queued_turn: the drain's existing meta union already carries the key onto the row (it is how a merged row names itssteer_delivery_ids). One addition for themerge_queued_messagescase: when several consumed entries carry distinct ids, the merged row also getssendIds(every id, queue order).sendIdstays present (the union's last writer) so the key is never absent when any entry had one; on the common single-entry row the shape issendIdalone — identical to a dispatched send's row.No change to the
queued: truereceipt, thequeue_pushbroadcast, or thequeue_idcontract.Tests
test/test_queue_drain_preserve_send_id.py(new, 10 cases):meta.sendId→ entry meta carries it; receiptqueue_idstill names the entrySEND_ID_MAX_LEN, non-string) → absentsendId, nosendIds, and no containment plumbing keymerge_queued_messageson) of two id-bearing entries + one id-less →sendIds == [a, b],sendIdis one of themqueue_for_next_turnseam: id stamped only when given;queue_pushpayload unchangedLocal: black, isort, flake8, mypy clean on the touched files; the test file is left to CI.
Related
Unblocks #8599 (chat-core P2: ChatEmbed on
sendTurn), which will depend on this behaviour for its delivery proof and delete its text-match fallback. no linked issue: surfaced as a review finding on #8599, not filed separately.Pattern harvest
Rule candidate: a queue producer that drops request-scoped correlation meta. Pattern: three producers write
slot.queue_append(..., meta=containment_meta(...))for a composer-originated message, and only the steer requeue carried the client'ssendId(#6751) — the two plain-send producers built the same meta dict without it. A structural check (semgrep or a unit pin) that everyqueue_append/queue_insertcall reachable fromapi_chatwith auser_metain scope forwardsnormalize_send_id(user_meta.get("sendId"))would have caught this at the second producer; alternatively fold the id intocontainment_meta's caller contract so a producer cannot build the entry meta without deciding about it.Checklist
Contribution License Agreement
N/A — repository placeholder only; no OSPO CLA text has been supplied.