Summary
createChatTurnRoutes inserts both the user row and the assistant row internally but never surfaces the persisted row ids to the product seams. Consumers that need to anchor product artifacts to the exact message row have to work around the gap, and the second adopter (creative) has now independently hit the same wall as the first (gtm) — which is the signal this belongs in the contract.
onTurnComplete (src/chat-routes/turn-routes.ts, ChatTurnCompleteInput) passes {identity, finalText, context, failed, failureReason} but not the persisted assistant message id. Creative anchors ::: render vocabulary (proposal/task/event/asset chips) and the workspace-metadata proposal's inlineApprovalKey (<assistantMessageId>::0) to that row.
produce (ChatTurnProduceArgs) does not receive the user row id the factory just inserted. Creative's unchanged client requires a chat.turn.started event carrying the real userMessageId (optimistic-bubble swap, retry targeting), and its stop-poll keys on that row.
Current workarounds in tangle-network/creative-agent#432 (src/lib/.server/chat/chat-vertical.ts):
- a per-request store decorator (
captureAssistantInserts) that intercepts store.appendMessage and stashes the assistant row id for onTurnComplete — with a latest assistant row ORDER BY created_at DESC LIMIT 1 fallback that is unsafe under sub-second same-thread turns (created_at is unixepoch() second precision);
- a user-row lookup at the top of
produce (match part.turnId / metadata.clientRunId, fall back to latest user row).
gtm's vertical solves the same problem with its own store wrapping / row queries. Two independent consumers re-deriving ids the factory already holds is the "graduate once ≥2 consumers exercise it" threshold (#227).
Proposed approach
- Add
userMessageId: string | null to ChatTurnProduceArgs (null when resolveChatTurn suppressed the insert and no reused row exists — though in the reuse case the factory knows the reused row's id and should pass it).
- Add
assistantMessageId: string | null to ChatTurnCompleteInput (both the top-level onTurnComplete and lifecycle.onTurnComplete), null when no assistant row was persisted (e.g. stopped turn with empty output).
- The factory already has both values in hand (
store.appendMessage return, resolveChatTurn result) — this is plumbing, not new state.
- Backward compatible: additive fields on existing seam inputs.
Acceptance criteria
Context
Summary
createChatTurnRoutesinserts both the user row and the assistant row internally but never surfaces the persisted row ids to the product seams. Consumers that need to anchor product artifacts to the exact message row have to work around the gap, and the second adopter (creative) has now independently hit the same wall as the first (gtm) — which is the signal this belongs in the contract.onTurnComplete(src/chat-routes/turn-routes.ts,ChatTurnCompleteInput) passes{identity, finalText, context, failed, failureReason}but not the persisted assistant message id. Creative anchors:::render vocabulary (proposal/task/event/asset chips) and the workspace-metadata proposal'sinlineApprovalKey(<assistantMessageId>::0) to that row.produce(ChatTurnProduceArgs) does not receive the user row id the factory just inserted. Creative's unchanged client requires achat.turn.startedevent carrying the realuserMessageId(optimistic-bubble swap, retry targeting), and its stop-poll keys on that row.Current workarounds in tangle-network/creative-agent#432 (
src/lib/.server/chat/chat-vertical.ts):captureAssistantInserts) that interceptsstore.appendMessageand stashes the assistant row id foronTurnComplete— with alatest assistant row ORDER BY created_at DESC LIMIT 1fallback that is unsafe under sub-second same-thread turns (created_atisunixepoch()second precision);produce(matchpart.turnId/metadata.clientRunId, fall back to latest user row).gtm's vertical solves the same problem with its own store wrapping / row queries. Two independent consumers re-deriving ids the factory already holds is the "graduate once ≥2 consumers exercise it" threshold (#227).
Proposed approach
userMessageId: string | nulltoChatTurnProduceArgs(null whenresolveChatTurnsuppressed the insert and no reused row exists — though in the reuse case the factory knows the reused row's id and should pass it).assistantMessageId: string | nulltoChatTurnCompleteInput(both the top-levelonTurnCompleteandlifecycle.onTurnComplete), null when no assistant row was persisted (e.g. stopped turn with empty output).store.appendMessagereturn,resolveChatTurnresult) — this is plumbing, not new state.Acceptance criteria
producereceives the persisted (or reused) user row id.onTurnCompletereceives the persisted assistant row id (null when none).Context
captureAssistantInserts+ fallback query,resolveUserMessagelookup), flagged by the PR's value-audit as an upstream-contract workaround.@experimentalseams once the adoptions give each seam ≥2 consumers).