diff mrjunejune/conversation_store.c @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents 1f9877b637e9
children
line wrap: on
line diff
--- a/mrjunejune/conversation_store.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_store.c	Fri Aug 07 07:34:12 2026 -0700
@@ -89,6 +89,173 @@
   return result;
 }
 
+/* ------------------------------------------------------------------ */
+/* Schema migration helpers (called from Conversation_Store_Create)    */
+/* ------------------------------------------------------------------ */
+
+static const char *Conversation_Store_Owner_Kind_String(
+    Conversation_Owner_Kind kind)
+{
+  switch (kind)
+  {
+  case CONVERSATION_OWNER_KIND_USER:   return "user";
+  case CONVERSATION_OWNER_KIND_GUEST:  return "guest";
+  case CONVERSATION_OWNER_KIND_LEGACY: return "legacy";
+  default:                             return "legacy";
+  }
+}
+
+/* Returns TRUE if column exists in table (mutex must NOT be held). */
+static boolean Conversation_Store_Column_Exists(
+    Conversation_Store *p_store,
+    const char *table_name,
+    const char *column_name)
+{
+  char sql[256];
+  snprintf(sql, sizeof(sql),
+           "SELECT 1 FROM pragma_table_info('%s') WHERE name = '%s'",
+           table_name, column_name);
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result =
+      Deita_Query_Execute_Prepared(p_store->p_connection, sql, 0, NULL, p_arena);
+  boolean exists = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  return exists;
+}
+
+/*
+ * Migration 1: add owner_kind / owner_id columns and listing index.
+ * Existing rows become owner_kind='legacy', owner_id=NULL.
+ * Safe to call on a database that was created by new code (idempotent).
+ */
+static boolean Conversation_Store_Apply_Migration_1(
+    Conversation_Store *p_store)
+{
+  /* Check migrations ledger */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 1",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_kind"))
+  {
+    if (Deita_Query_Execute_Update(
+            p_store->p_connection,
+            "ALTER TABLE conversations ADD COLUMN owner_kind TEXT "
+            "NOT NULL DEFAULT 'legacy'") < 0)
+      return FALSE;
+  }
+  if (!Conversation_Store_Column_Exists(p_store, "conversations", "owner_id"))
+  {
+    if (Deita_Query_Execute_Update(
+            p_store->p_connection,
+            "ALTER TABLE conversations ADD COLUMN owner_id TEXT") < 0)
+      return FALSE;
+  }
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing "
+          "ON conversations(owner_kind, owner_id, updated_at DESC, id)") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (1)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
+/*
+ * Migration 2: create the guest-to-user transfer mapping table.
+ * Records a permanent mapping from guest_id → user_id set at transfer time.
+ * Used by Create_Owned to redirect stale guest creates to the mapped user.
+ */
+static boolean Conversation_Store_Apply_Migration_2(
+    Conversation_Store *p_store)
+{
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 2",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE TABLE IF NOT EXISTS conversation_guest_transfers ("
+          "guest_id TEXT PRIMARY KEY,"
+          "user_id TEXT NOT NULL,"
+          "transferred_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+          ")") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (2)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
+/*
+ * Migration 3: recreate the owner listing index with id DESC so that
+ * keyset pagination is stable when updated_at values collide.
+ * Drops and recreates the index atomically from the migration ledger's
+ * perspective; safe to run on any database that has migration 1 applied.
+ */
+static boolean Conversation_Store_Apply_Migration_3(
+    Conversation_Store *p_store)
+{
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversation_schema_migrations WHERE version = 3",
+      0, NULL, p_arena);
+  boolean already_done = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (already_done)
+    return TRUE;
+
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "DROP INDEX IF EXISTS idx_conversations_owner_listing") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "CREATE INDEX IF NOT EXISTS idx_conversations_owner_listing "
+          "ON conversations(owner_kind, owner_id, updated_at DESC, id DESC)") < 0)
+    return FALSE;
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection,
+          "INSERT OR IGNORE INTO conversation_schema_migrations (version) "
+          "VALUES (3)") < 0)
+    return FALSE;
+  return TRUE;
+}
+
 Conversation_Store *Conversation_Store_Create(const char *database_path)
 {
   if (!database_path)
@@ -150,12 +317,31 @@
       "CREATE INDEX IF NOT EXISTS idx_conversations_updated "
       "ON conversations(updated_at DESC);"
       "CREATE INDEX IF NOT EXISTS idx_turns_conversation_sequence "
-      "ON conversation_turns(conversation_id, sequence);";
+      "ON conversation_turns(conversation_id, sequence);"
+      "CREATE TABLE IF NOT EXISTS conversation_schema_migrations ("
+      "version INTEGER PRIMARY KEY,"
+      "applied_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))"
+      ");";
   if (Deita_Query_Execute_Update(p_store->p_connection, schema) < 0)
   {
     Conversation_Store_Destroy(p_store);
     return NULL;
   }
+  if (!Conversation_Store_Apply_Migration_1(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
+  if (!Conversation_Store_Apply_Migration_2(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
+  if (!Conversation_Store_Apply_Migration_3(p_store))
+  {
+    Conversation_Store_Destroy(p_store);
+    return NULL;
+  }
   if (Deita_Query_Execute_Update(
           p_store->p_connection,
           "UPDATE conversation_turns "
@@ -170,6 +356,8 @@
   return p_store;
 }
 
+
+
 void Conversation_Store_Destroy(Conversation_Store *p_store)
 {
   if (!p_store)
@@ -494,3 +682,757 @@
     return CONVERSATION_STORE_ERROR;
   return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
 }
+
+/* ------------------------------------------------------------------ */
+/* Owner-aware APIs                                                     */
+/* ------------------------------------------------------------------ */
+
+Conversation_Store_Result Conversation_Store_Create_Owned(
+    Conversation_Store *p_store,
+    const char *title,
+    const Conversation_Owner *p_owner,
+    char output_id[37])
+{
+  if (!p_store || !p_owner || !output_id)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+  if (!Conversation_Store_Generate_UUID(output_id))
+    return CONVERSATION_STORE_ERROR;
+
+  /* Resolved owner fields — may be overridden by transfer mapping below */
+  const char *resolved_kind = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  char resolved_id[37];
+  strncpy(resolved_id, p_owner->id, 36);
+  resolved_id[36] = '\0';
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* For guest owners: check transfer mapping within the same transaction.
+   * If the guest was transferred to a user, assign to that user instead. */
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_GUEST)
+  {
+    Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+    if (!p_arena)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+    const char *check_params[] = {p_owner->id};
+    Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+        1, check_params, p_arena);
+    if (p_result && Deita_Result_Set_Next(p_result))
+    {
+      const char *mapped_user = Deita_Result_Set_Get_Text(p_result, 0);
+      if (mapped_user && mapped_user[0] != '\0')
+      {
+        strncpy(resolved_id, mapped_user, 36);
+        resolved_id[36] = '\0';
+        resolved_kind = "user";
+      }
+    }
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+  }
+
+  const char *parameters[] = {
+    output_id,
+    output_id,
+    title ? title : "",
+    resolved_kind,
+    resolved_id,
+  };
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "INSERT INTO conversations "
+      "(id, copilot_session_id, title, owner_kind, owner_id) "
+      "VALUES (?, ?, ?, ?, ?)",
+      5,
+      parameters);
+  if (result < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return result < 0 ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Get_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    Conversation_Record *p_record,
+    Dowa_Arena *p_arena)
+{
+  if (!p_store || !conversation_id || !p_owner || !p_record || !p_arena)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+  memset(p_record, 0, sizeof(*p_record));
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, copilot_session_id, title, status, created_at, updated_at "
+      "FROM conversations "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters,
+      p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+  p_record->id = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 0), p_arena);
+  p_record->copilot_session_id = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 1), p_arena);
+  p_record->title = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+  p_record->status = Conversation_Store_Copy_Text(
+      Deita_Result_Set_Get_Text(p_result, 3), p_arena);
+  p_record->created_at = Deita_Result_Set_Get_Integer(p_result, 4);
+  p_record->updated_at = Deita_Result_Set_Get_Integer(p_result, 5);
+  Deita_Result_Set_Free(p_result);
+
+  const char *turn_params[] = {conversation_id};
+  p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT id, sequence, role, content, status, request_id, "
+      "error_message, input_tokens, output_tokens, created_at, completed_at "
+      "FROM (SELECT id, sequence, role, content, status, request_id, "
+      "error_message, input_tokens, output_tokens, created_at, completed_at "
+      "FROM conversation_turns WHERE conversation_id = ? "
+      "ORDER BY sequence DESC LIMIT 20) ORDER BY sequence",
+      1,
+      turn_params,
+      p_arena);
+  if (!p_result)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  while (Deita_Result_Set_Next(p_result))
+  {
+    Conversation_Turn turn = {0};
+    turn.id = Deita_Result_Set_Get_Integer(p_result, 0);
+    turn.sequence = Deita_Result_Set_Get_Integer(p_result, 1);
+    turn.role = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+    turn.content = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 3), p_arena);
+    turn.status = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 4), p_arena);
+    turn.request_id = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 5), p_arena);
+    turn.error_message = Conversation_Store_Copy_Text(
+        Deita_Result_Set_Get_Text(p_result, 6), p_arena);
+    turn.input_tokens = Deita_Result_Set_Get_Integer(p_result, 7);
+    turn.output_tokens = Deita_Result_Set_Get_Integer(p_result, 8);
+    turn.created_at = Deita_Result_Set_Get_Integer(p_result, 9);
+    turn.completed_at = Deita_Result_Set_Get_Integer(p_result, 10);
+    Dowa_Array_Push_Arena(p_record->turns, turn, p_arena);
+  }
+  boolean turns_error = Deita_Result_Set_Has_Error(p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+  return turns_error ? CONVERSATION_STORE_ERROR : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Update_Title_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *title)
+{
+  if (!p_store || !conversation_id || !p_owner || !title)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {title, conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE conversations SET title = ?, "
+      "updated_at = strftime('%s','now') "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      4,
+      parameters);
+  pthread_mutex_unlock(&p_store->mutex);
+  if (result < 0)
+    return CONVERSATION_STORE_ERROR;
+  return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Delete_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner)
+{
+  if (!p_store || !conversation_id || !p_owner)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  pthread_mutex_lock(&p_store->mutex);
+  int32 result = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "DELETE FROM conversations WHERE id = ? "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters);
+  pthread_mutex_unlock(&p_store->mutex);
+  if (result < 0)
+    return CONVERSATION_STORE_ERROR;
+  return result == 0 ? CONVERSATION_STORE_NOT_FOUND : CONVERSATION_STORE_OK;
+}
+
+/* Checks conversation ownership without loading turns (called under mutex). */
+static boolean Conversation_Store_Owns_Locked(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner)
+{
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  const char *parameters[] = {conversation_id, kind_str, p_owner->id};
+  Dowa_Arena *p_arena = Dowa_Arena_Create(1024);
+  if (!p_arena)
+    return FALSE;
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT 1 FROM conversations "
+      "WHERE id = ? AND status != 'deleted' "
+      "AND owner_kind = ? AND owner_id = ?",
+      3,
+      parameters,
+      p_arena);
+  boolean owns = p_result && Deita_Result_Set_Next(p_result);
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  return owns;
+}
+
+Conversation_Store_Result Conversation_Store_Begin_Turn_Owned(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const Conversation_Owner *p_owner,
+    const char *request_id,
+    const char *prompt)
+{
+  if (!p_store || !conversation_id || !p_owner || !request_id || !prompt)
+    return CONVERSATION_STORE_ERROR;
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  if (!Conversation_Store_Owns_Locked(p_store, conversation_id, p_owner))
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(2048);
+  const char *seq_params[] = {conversation_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT COALESCE(MAX(sequence), 0), "
+      "SUM(CASE WHEN role = 'assistant' AND status = 'active' "
+      "THEN 1 ELSE 0 END) "
+      "FROM conversation_turns WHERE conversation_id = ?",
+      1,
+      seq_params,
+      p_arena);
+  if (!p_result || !Deita_Result_Set_Next(p_result))
+  {
+    if (p_result)
+      Deita_Result_Set_Free(p_result);
+    Dowa_Arena_Free(p_arena);
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  int64 next_sequence = Deita_Result_Set_Get_Integer(p_result, 0) + 1;
+  int64 active_count  = Deita_Result_Set_Get_Integer(p_result, 1);
+  Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+  if (active_count > 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  char user_sequence[32];
+  char assistant_sequence[32];
+  snprintf(user_sequence, sizeof(user_sequence), "%lld",
+           (long long)next_sequence);
+  snprintf(assistant_sequence, sizeof(assistant_sequence), "%lld",
+           (long long)(next_sequence + 1));
+  const char *user_params[] = {
+    conversation_id, user_sequence, prompt, request_id,
+  };
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO conversation_turns "
+          "(conversation_id, sequence, role, content, status, request_id, "
+          "completed_at) VALUES (?, ?, 'user', ?, 'complete', ?, "
+          "strftime('%s','now'))",
+          4,
+          user_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *asst_params[] = {conversation_id, assistant_sequence, request_id};
+  const char *conv_params[]  = {conversation_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "INSERT INTO conversation_turns "
+          "(conversation_id, sequence, role, status, request_id) "
+          "VALUES (?, ?, 'assistant', 'active', ?)",
+          3,
+          asst_params) < 0 ||
+      Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE conversations SET updated_at = strftime('%s','now') "
+          "WHERE id = ?",
+          1,
+          conv_params) < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_List(
+    Conversation_Store *p_store,
+    const Conversation_Owner *p_owner,
+    int64 cursor_updated_at,
+    const char *cursor_id,
+    int32 limit,
+    Conversation_Summary **pp_summaries,
+    int32 *p_count,
+    Dowa_Arena *p_arena)
+{
+  if (!p_store || !p_owner || !pp_summaries || !p_count || !p_arena)
+    return CONVERSATION_STORE_ERROR;
+  *pp_summaries = NULL;
+  *p_count = 0;
+
+  /* Legacy conversations are never listed */
+  if (p_owner->kind == CONVERSATION_OWNER_KIND_LEGACY ||
+      p_owner->id[0] == '\0')
+    return CONVERSATION_STORE_OK;
+
+  if (limit < 1) limit = 1;
+  if (limit > 50) limit = 50;
+
+  const char *kind_str = Conversation_Store_Owner_Kind_String(p_owner->kind);
+  char limit_str[16];
+  snprintf(limit_str, sizeof(limit_str), "%d", limit);
+
+  Deita_Result_Set *p_result;
+  pthread_mutex_lock(&p_store->mutex);
+
+  if (cursor_updated_at > 0 && cursor_id && cursor_id[0] != '\0')
+  {
+    char ts_str[32];
+    snprintf(ts_str, sizeof(ts_str), "%lld", (long long)cursor_updated_at);
+    const char *params[] = {
+      kind_str, p_owner->id, ts_str, ts_str, cursor_id, limit_str,
+    };
+    p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT id, title, status, created_at, updated_at, "
+        "(SELECT COUNT(*) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id) AS turn_count, "
+        "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id "
+        " ORDER BY sequence DESC LIMIT 1) AS last_msg "
+        "FROM conversations "
+        "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' "
+        "AND (updated_at < ? OR (updated_at = ? AND id < ?)) "
+        "ORDER BY updated_at DESC, id DESC LIMIT ?",
+        6, params, p_arena);
+  }
+  else
+  {
+    const char *params[] = {kind_str, p_owner->id, limit_str};
+    p_result = Deita_Query_Execute_Prepared(
+        p_store->p_connection,
+        "SELECT id, title, status, created_at, updated_at, "
+        "(SELECT COUNT(*) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id) AS turn_count, "
+        "(SELECT SUBSTR(content, 1, 201) FROM conversation_turns "
+        " WHERE conversation_id = conversations.id "
+        " ORDER BY sequence DESC LIMIT 1) AS last_msg "
+        "FROM conversations "
+        "WHERE owner_kind = ? AND owner_id = ? AND status != 'deleted' "
+        "ORDER BY updated_at DESC, id DESC LIMIT ?",
+        3, params, p_arena);
+  }
+
+  if (!p_result)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  Conversation_Summary *summaries = NULL;
+  while (Deita_Result_Set_Next(p_result))
+  {
+    Conversation_Summary s = {0};
+    s.id     = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 0), p_arena);
+    s.title  = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 1), p_arena);
+    s.status = Conversation_Store_Copy_Text(Deita_Result_Set_Get_Text(p_result, 2), p_arena);
+    s.created_at  = Deita_Result_Set_Get_Integer(p_result, 3);
+    s.updated_at  = Deita_Result_Set_Get_Integer(p_result, 4);
+    s.turn_count  = Deita_Result_Set_Get_Integer(p_result, 5);
+    const char *last_msg_raw = Deita_Result_Set_Get_Text(p_result, 6);
+    if (last_msg_raw && last_msg_raw[0] != '\0')
+    {
+      /* Truncate to 200 chars maximum */
+      size_t msg_len = strlen(last_msg_raw);
+      if (msg_len > 200) msg_len = 200;
+      char *preview = Dowa_Arena_Allocate(p_arena, msg_len + 1);
+      if (preview)
+      {
+        memcpy(preview, last_msg_raw, msg_len);
+        preview[msg_len] = '\0';
+      }
+      s.last_message_preview = preview;
+    }
+    else
+    {
+      s.last_message_preview = Conversation_Store_Copy_Text("", p_arena);
+    }
+    Dowa_Array_Push_Arena(summaries, s, p_arena);
+  }
+  boolean has_error = Deita_Result_Set_Has_Error(p_result);
+  Deita_Result_Set_Free(p_result);
+  pthread_mutex_unlock(&p_store->mutex);
+
+  if (has_error)
+    return CONVERSATION_STORE_ERROR;
+
+  *pp_summaries = summaries;
+  *p_count = (int32)Dowa_Array_Length(summaries);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Claim_Legacy(
+    Conversation_Store *p_store,
+    const char *conversation_id,
+    const char *user_id)
+{
+  if (!p_store || !conversation_id || !user_id || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {conversation_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT owner_kind FROM conversations "
+      "WHERE id = ? AND status != 'deleted'",
+      1, check_params, p_arena);
+  boolean found = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_kind = found
+      ? Deita_Result_Set_Get_Text(p_result, 0)
+      : NULL;
+  boolean is_claimable =
+      existing_kind && strcmp(existing_kind, "legacy") == 0;
+  if (p_result) Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (!found)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_NOT_FOUND);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_NOT_FOUND;
+  }
+  if (!is_claimable)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  const char *update_params[] = {user_id, conversation_id};
+  int32 updated = Deita_Query_Execute_Update_Prepared(
+      p_store->p_connection,
+      "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+      "updated_at = strftime('%s','now') "
+      "WHERE id = ? AND owner_kind = 'legacy'",
+      2, update_params);
+  if (updated < 0 ||
+      Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return updated > 0 ? CONVERSATION_STORE_OK : CONVERSATION_STORE_NOT_FOUND;
+}
+
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id)
+{
+  if (!p_store || !guest_id || !user_id ||
+      guest_id[0] == '\0' || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* Check for an existing mapping for this guest_id */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+      1, check_params, p_arena);
+  boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_user = mapping_exists
+      ? Deita_Result_Set_Get_Text(p_result, 0) : NULL;
+  boolean same_user = mapping_exists && existing_user &&
+      strcmp(existing_user, user_id) == 0;
+  boolean conflict = mapping_exists && !same_user;
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (conflict)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  if (!mapping_exists)
+  {
+    /* Record the mapping so future Create_Owned for this guest uses user */
+    const char *map_params[] = {guest_id, user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "INSERT OR IGNORE INTO conversation_guest_transfers "
+            "(guest_id, user_id) VALUES (?, ?)",
+            2, map_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+
+    /* Transfer existing conversations from guest to user */
+    const char *transfer_params[] = {user_id, guest_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+            "updated_at = strftime('%s','now') "
+            "WHERE owner_kind = 'guest' AND owner_id = ?",
+            2, transfer_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+  }
+  /* same_user mapping already exists: idempotent no-op */
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}
+
+Conversation_Store_Result Conversation_Store_Transfer_Guest_To_User_Atomic(
+    Conversation_Store *p_store,
+    const char *guest_id,
+    const char *user_id)
+{
+  if (!p_store || !guest_id || !user_id ||
+      guest_id[0] == '\0' || user_id[0] == '\0')
+    return CONVERSATION_STORE_ERROR;
+
+  pthread_mutex_lock(&p_store->mutex);
+  if (Deita_Query_Execute_Update(
+          p_store->p_connection, "BEGIN IMMEDIATE") < 0)
+  {
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  /* Check for an existing mapping for this guest_id. */
+  Dowa_Arena *p_arena = Dowa_Arena_Create(512);
+  if (!p_arena)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  const char *check_params[] = {guest_id};
+  Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(
+      p_store->p_connection,
+      "SELECT user_id FROM conversation_guest_transfers WHERE guest_id = ?",
+      1, check_params, p_arena);
+  boolean mapping_exists = p_result && Deita_Result_Set_Next(p_result);
+  const char *existing_user = mapping_exists
+      ? Deita_Result_Set_Get_Text(p_result, 0) : NULL;
+  boolean same_user = mapping_exists && existing_user &&
+      strcmp(existing_user, user_id) == 0;
+  boolean conflict = mapping_exists && !same_user;
+  if (p_result)
+    Deita_Result_Set_Free(p_result);
+  Dowa_Arena_Free(p_arena);
+
+  if (conflict)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_CONFLICT);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_CONFLICT;
+  }
+
+  if (!same_user)
+  {
+    const char *map_params[] = {guest_id, user_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "INSERT OR IGNORE INTO conversation_guest_transfers "
+            "(guest_id, user_id) VALUES (?, ?)",
+            2, map_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+
+    const char *transfer_params[] = {user_id, guest_id};
+    if (Deita_Query_Execute_Update_Prepared(
+            p_store->p_connection,
+            "UPDATE conversations SET owner_kind = 'user', owner_id = ?, "
+            "updated_at = strftime('%s','now') "
+            "WHERE owner_kind = 'guest' AND owner_id = ?",
+            2, transfer_params) < 0)
+    {
+      Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+      pthread_mutex_unlock(&p_store->mutex);
+      return CONVERSATION_STORE_ERROR;
+    }
+  }
+
+  /*
+   * Quota cleanup — auth tables reside in the same SQLite file so the write
+   * lock already held by this transaction covers them too.  Decrement
+   * output_tokens_reserved and delete reservation rows for this guest.
+   */
+  const char *quota_upd_params[] = {guest_id, guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "UPDATE guest_usage"
+          "  SET output_tokens_reserved = MAX(0, output_tokens_reserved - ("
+          "    SELECT COALESCE(SUM(r.output_tokens_reserved), 0)"
+          "    FROM guest_usage_reservations r"
+          "    WHERE r.guest_id = ? AND r.window_start = guest_usage.window_start"
+          "  ))"
+          "  WHERE guest_id = ?",
+          2, quota_upd_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  const char *quota_del_params[] = {guest_id};
+  if (Deita_Query_Execute_Update_Prepared(
+          p_store->p_connection,
+          "DELETE FROM guest_usage_reservations WHERE guest_id = ?",
+          1, quota_del_params) < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+
+  if (Deita_Query_Execute_Update(p_store->p_connection, "COMMIT") < 0)
+  {
+    Conversation_Store_Rollback(p_store, CONVERSATION_STORE_ERROR);
+    pthread_mutex_unlock(&p_store->mutex);
+    return CONVERSATION_STORE_ERROR;
+  }
+  pthread_mutex_unlock(&p_store->mutex);
+  return CONVERSATION_STORE_OK;
+}