diff mrjunejune/conversation_api.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 b401627fc49e
children 056790c4fb0d
line wrap: on
line diff
--- a/mrjunejune/conversation_api.c	Thu Aug 06 11:31:30 2026 -0700
+++ b/mrjunejune/conversation_api.c	Fri Aug 07 07:34:12 2026 -0700
@@ -1,7 +1,9 @@
 #include "mrjunejune/conversation_api.h"
 
+#include "mrjunejune/auth_api.h"
 #include "mrjunejune/inference_bridge.h"
 #include "mrjunejune/conversation_store.h"
+#include "auth/auth_store.h"
 #include "seobeo/seobeo.h"
 
 #include <pthread.h>
@@ -20,19 +22,39 @@
 #define CONVERSATION_EVENT_NAME_MAX 64
 #define CONVERSATION_ACTIVE_MAX 4
 #define CONVERSATION_TURNS_PER_MINUTE 60
+/* Buffer large enough for a guest Set-Cookie directive */
+#define CONV_GUEST_COOKIE_CAPACITY (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256)
+
+/* Quota policy — read from env at init, validated at startup. */
+#define CONV_GUEST_DAILY_TURNS_DEFAULT          10
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT  20000
+#define CONV_GUEST_DAILY_TURNS_MIN              1
+#define CONV_GUEST_DAILY_TURNS_MAX              10000
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN      1
+#define CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX      1000000
+#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN    1
+/* Default per-request reservation (tokens); capped to daily at init. */
+#define CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT 2048
 
 static Conversation_Store *g_conversation_store = NULL;
 static Inference_Bridge *g_inference_bridge = NULL;
-static boolean g_anonymous_inference_enabled = FALSE;
+static boolean g_guest_inference_enabled = FALSE;
 static pthread_mutex_t g_pending_mutex = PTHREAD_MUTEX_INITIALIZER;
 static pthread_mutex_t g_admission_mutex = PTHREAD_MUTEX_INITIALIZER;
 static time_t g_admission_window = 0;
 static uint32 g_admission_turns = 0;
 static uint32 g_active_turns = 0;
 
+/* Quota policy (validated at init). */
+static int64 g_guest_daily_turns          = CONV_GUEST_DAILY_TURNS_DEFAULT;
+static int64 g_guest_daily_output_tokens  = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
+static int64 g_guest_request_output_tokens = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
+
 typedef struct Pending_Turn {
   char request_id[37];
   char conversation_id[37];
+  /* owner identity copied before request arena expires (req 8) */
+  Conversation_Owner owner;
   Seobeo_SSE_Stream *p_stream;
   char *content;
   size_t content_length;
@@ -41,7 +63,13 @@
   int64 output_tokens;
   boolean failed;
   boolean aborted;
+  boolean has_usage_event;   /* TRUE once assistant.usage received           */
+  boolean finalized;         /* TRUE once Finalize_Pending has run (once-only) */
   char error_message[512];
+  /* Guest quota fields — copied before arena expires. */
+  boolean is_guest_reserved;   /* TRUE if quota was reserved for this turn */
+  int64   reserved_output_tokens;
+  char    guest_id[37];        /* guest_id from principal (safe copy)      */
   struct Pending_Turn *p_next;
 } Pending_Turn;
 
@@ -110,10 +138,138 @@
   pthread_mutex_unlock(&g_admission_mutex);
 }
 
-static Seobeo_Request_Entry *Conversation_API_JSON_Response(
+static const char *Conversation_API_Request_Value(
+    Seobeo_Request_Entry *p_request,
+    const char *key)
+{
+  void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
+  return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
+}
+
+static boolean Conversation_API_Is_Inference_Ready(void)
+{
+  return g_guest_inference_enabled;
+}
+
+/* UTC midnight for a Unix timestamp. */
+static int64 conv__utc_window_start(int64 unix_ts)
+{
+  return (unix_ts / 86400LL) * 86400LL;
+}
+
+/* Build guest quota JSON into a fixed buffer (null-terminated). */
+static boolean conv_guest_quota_cb(
+    const char *guest_id,
+    int64       current_unix,
+    char       *json_out,
+    size_t      json_capacity)
+{
+  Auth_Store *p_store = Auth_API_Get_Store();
+  if (!p_store || !guest_id || !json_out || json_capacity == 0)
+  {
+    if (json_out && json_capacity > 0)
+      strncpy(json_out, "null", json_capacity);
+    return TRUE;
+  }
+
+  int64 window_start = conv__utc_window_start(current_unix);
+  Auth_Store_Guest_Usage usage;
+  memset(&usage, 0, sizeof(usage));
+  Auth_Store_Guest_Get_Usage(p_store, guest_id, window_start, &usage);
+
+  int64 turns_remaining =
+      g_guest_daily_turns - usage.turns_used;
+  if (turns_remaining < 0) turns_remaining = 0;
+  int64 tokens_remaining =
+      g_guest_daily_output_tokens - usage.output_tokens_used - usage.output_tokens_reserved;
+  if (tokens_remaining < 0) tokens_remaining = 0;
+  int64 resets_at = window_start + 86400LL;
+
+  snprintf(
+      json_out, json_capacity,
+      "{\"turnsLimit\":%lld,\"turnsUsed\":%lld,\"turnsRemaining\":%lld,"
+      "\"outputTokensLimit\":%lld,\"outputTokensUsed\":%lld,"
+      "\"outputTokensReserved\":%lld,\"outputTokensRemaining\":%lld,"
+      "\"resetsAt\":%lld}",
+      (long long)g_guest_daily_turns,
+      (long long)usage.turns_used,
+      (long long)turns_remaining,
+      (long long)g_guest_daily_output_tokens,
+      (long long)usage.output_tokens_used,
+      (long long)usage.output_tokens_reserved,
+      (long long)tokens_remaining,
+      (long long)resets_at);
+  return TRUE;
+}
+
+static Dowa_JSON_Entry *Conversation_API_Parse_Body(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  const char *body = Conversation_API_Request_Value(p_request, "Body");
+  if (!body)
+    return NULL;
+  Dowa_JSON_Value value = Dowa_JSON_Parse(
+      body, (int32)strlen(body), p_arena);
+  return value.type == DOWA_JSON_OBJECT
+      ? (Dowa_JSON_Entry *)value.object_val
+      : NULL;
+}
+
+static const char *Conversation_API_Query_Param(
+    Seobeo_Request_Entry *p_request,
+    const char *param_name,
+    char *output,
+    size_t output_capacity)
+{
+  const char *qs = Conversation_API_Request_Value(p_request, "QueryString");
+  if (!qs || !param_name || !output || output_capacity == 0)
+    return NULL;
+  output[0] = '\0';
+  size_t name_len = strlen(param_name);
+  const char *p = qs;
+  while (*p)
+  {
+    if (strncmp(p, param_name, name_len) == 0 && p[name_len] == '=')
+    {
+      p += name_len + 1;
+      size_t i = 0;
+      while (*p && *p != '&' && i < output_capacity - 1)
+      {
+        if (p[0] == '%' && p[1] && p[2])
+        {
+          char hex[3] = {p[1], p[2], '\0'};
+          output[i++] = (char)(int)strtol(hex, NULL, 16);
+          p += 3;
+        }
+        else if (*p == '+')
+        {
+          output[i++] = ' ';
+          p++;
+        }
+        else
+        {
+          output[i++] = *p++;
+        }
+      }
+      output[i] = '\0';
+      return output[0] != '\0' ? output : NULL;
+    }
+    while (*p && *p != '&') p++;
+    if (*p == '&') p++;
+  }
+  return NULL;
+}
+
+/*
+ * Build a JSON response, optionally setting a guest cookie when one was
+ * freshly issued by Auth_API_Resolve_Principal.
+ */
+static Seobeo_Request_Entry *Conversation_API_JSON_Response_With_Cookie(
     Dowa_Arena *p_arena,
     const char *status,
-    const char *body)
+    const char *body,
+    const char *new_guest_cookie)
 {
   Seobeo_Request_Entry *p_response = NULL;
   Dowa_HashMap_Push_Arena(p_response, "status", (char *)status, p_arena);
@@ -121,9 +277,20 @@
       p_response, "content-type", "application/json; charset=utf-8", p_arena);
   Dowa_HashMap_Push_Arena(p_response, "cache-control", "no-store", p_arena);
   Dowa_HashMap_Push_Arena(p_response, "body", (char *)body, p_arena);
+  if (new_guest_cookie && new_guest_cookie[0] != '\0')
+    Dowa_HashMap_Push_Arena(
+        p_response, "Set-Cookie", (char *)new_guest_cookie, p_arena);
   return p_response;
 }
 
+static Seobeo_Request_Entry *Conversation_API_JSON_Response(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *body)
+{
+  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, NULL);
+}
+
 static Seobeo_Request_Entry *Conversation_API_Error(
     Dowa_Arena *p_arena,
     const char *status,
@@ -142,47 +309,77 @@
   return Conversation_API_JSON_Response(p_arena, status, body);
 }
 
-static const char *Conversation_API_Request_Value(
+static Seobeo_Request_Entry *Conversation_API_Error_With_Cookie(
+    Dowa_Arena *p_arena,
+    const char *status,
+    const char *code,
+    const char *message,
+    const char *new_guest_cookie)
+{
+  char *escaped = Dowa_JSON_Escape_String(message, 0, p_arena);
+  size_t capacity = strlen(code) + strlen(escaped) + 64;
+  char *body = Dowa_Arena_Allocate(p_arena, capacity);
+  snprintf(
+      body,
+      capacity,
+      "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
+      code,
+      escaped);
+  return Conversation_API_JSON_Response_With_Cookie(p_arena, status, body, new_guest_cookie);
+}
+
+/*
+ * Resolve the principal for this request, optionally setting a new guest
+ * cookie on the response when returned.
+ * Returns FALSE only on internal error (treat as 500).
+ */
+static boolean Conversation_API_Resolve(
     Seobeo_Request_Entry *p_request,
-    const char *key)
+    Auth_Principal *p_principal,
+    char *new_guest_cookie,
+    Dowa_Arena *p_arena)
 {
-  void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key);
-  return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL;
+  return Auth_API_Resolve_Principal(
+      p_request, p_principal, p_arena,
+      new_guest_cookie, CONV_GUEST_COOKIE_CAPACITY);
 }
 
-static boolean Conversation_API_Is_Same_Origin(
-    Seobeo_Request_Entry *p_request)
+/*
+ * Resolve an existing principal for mutation requests.
+ * Does NOT create a new guest identity.
+ * Returns FALSE on internal error (500); sets *p_found=FALSE when no
+ * existing session/guest is present (caller must return 401).
+ */
+static boolean Conversation_API_Resolve_Existing(
+    Seobeo_Request_Entry *p_request,
+    Auth_Principal *p_principal,
+    Dowa_Arena *p_arena,
+    boolean *p_found)
 {
-  const char *host = Conversation_API_Request_Value(p_request, "Host");
-  const char *origin = Conversation_API_Request_Value(p_request, "Origin");
-  if (!host || !origin)
-    return FALSE;
-  const char *origin_host = strstr(origin, "://");
-  if (!origin_host)
-    return FALSE;
-  origin_host += 3;
-  const char *end = strchr(origin_host, '/');
-  size_t length = end ? (size_t)(end - origin_host) : strlen(origin_host);
-  return strlen(host) == length && strncmp(host, origin_host, length) == 0;
+  return Auth_API_Resolve_Existing_Principal(
+      p_request, p_principal, p_arena, p_found);
 }
 
-static boolean Conversation_API_Is_Enabled(void)
-{
-  return g_anonymous_inference_enabled;
-}
-
-static Dowa_JSON_Entry *Conversation_API_Parse_Body(
-    Seobeo_Request_Entry *p_request,
-    Dowa_Arena *p_arena)
+/*
+ * Map a resolved principal to a Conversation_Owner.
+ * Always succeeds for USER and GUEST principals.
+ */
+static void Conversation_API_Owner_From_Principal(
+    const Auth_Principal *p_principal,
+    Conversation_Owner *p_owner)
 {
-  const char *body = Conversation_API_Request_Value(p_request, "Body");
-  if (!body)
-    return NULL;
-  Dowa_JSON_Value value = Dowa_JSON_Parse(
-      body, (int32)strlen(body), p_arena);
-  return value.type == DOWA_JSON_OBJECT
-      ? (Dowa_JSON_Entry *)value.object_val
-      : NULL;
+  if (p_principal->kind == AUTH_PRINCIPAL_USER)
+  {
+    p_owner->kind = CONVERSATION_OWNER_KIND_USER;
+    strncpy(p_owner->id, p_principal->user_id, sizeof(p_owner->id) - 1);
+    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
+  }
+  else
+  {
+    p_owner->kind = CONVERSATION_OWNER_KIND_GUEST;
+    strncpy(p_owner->id, p_principal->guest_id, sizeof(p_owner->id) - 1);
+    p_owner->id[sizeof(p_owner->id) - 1] = '\0';
+  }
 }
 
 static void Conversation_API_Send_Stream_Error(
@@ -255,6 +452,30 @@
 
 static void Conversation_API_Finalize_Pending(Pending_Turn *p_turn)
 {
+  /* Idempotence guard — called with g_pending_mutex held. */
+  if (p_turn->finalized)
+    return;
+  p_turn->finalized = TRUE;
+
+  /* Reconcile or release guest quota reservation before persistence. */
+  if (p_turn->is_guest_reserved)
+  {
+    Auth_Store *p_auth_store = Auth_API_Get_Store();
+    if (p_auth_store)
+    {
+      if (!p_turn->failed && !p_turn->aborted)
+      {
+        int64 actual = p_turn->has_usage_event
+            ? p_turn->output_tokens
+            : p_turn->reserved_output_tokens;
+        Auth_Store_Guest_Reconcile(
+            p_auth_store, p_turn->request_id, actual);
+      }
+      else
+        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
+    }
+  }
+
   Conversation_Store_Result persistence;
   if (p_turn->failed || p_turn->aborted)
   {
@@ -623,6 +844,7 @@
   {
     p_turn->input_tokens = p_event->input_tokens;
     p_turn->output_tokens = p_event->output_tokens;
+    p_turn->has_usage_event = TRUE;
     char usage[256];
     snprintf(
         usage,
@@ -686,48 +908,9 @@
         g_inference_bridge, abort_request_id, abort_conversation_id);
 }
 
-static Seobeo_Request_Entry *Conversation_API_Create(
-    Seobeo_Request_Entry *p_request,
-    Dowa_Arena *p_arena)
-{
-  if (!Conversation_API_Is_Enabled())
-    return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
-    return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
-  if (!g_conversation_store)
-    return Conversation_API_Error(
-        p_arena, "503", "store_unavailable", "Conversation store unavailable");
-
-  const char *title = "";
-  const char *body = Conversation_API_Request_Value(p_request, "Body");
-  if (body && body[0] != '\0')
-  {
-    Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
-    if (!object)
-      return Conversation_API_Error(
-          p_arena, "400", "invalid_json", "Request body must be a JSON object");
-    char *parsed_title = Dowa_JSON_Get_String(object, "title");
-    if (parsed_title)
-      title = parsed_title;
-  }
-  if (strlen(title) > CONVERSATION_TITLE_MAX)
-    return Conversation_API_Error(
-        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
-
-  char conversation_id[37];
-  if (Conversation_Store_Create_Conversation(
-          g_conversation_store,
-          title,
-          conversation_id) != CONVERSATION_STORE_OK)
-    return Conversation_API_Error(
-        p_arena, "500", "create_failed", "Unable to create conversation");
-
-  char *response_body = Dowa_Arena_Allocate(p_arena, 64);
-  snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
-  return Conversation_API_JSON_Response(p_arena, "201", response_body);
-}
+/* ------------------------------------------------------------------ */
+/* HTTP handler helpers                                                  */
+/* ------------------------------------------------------------------ */
 
 static boolean Conversation_API_Append(
     char *output,
@@ -749,55 +932,281 @@
   return TRUE;
 }
 
+/* ------------------------------------------------------------------ */
+/* GET /api/conversations?cursor=<ts>_<id>&limit=20                    */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_List(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
+  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (principal.must_change_password)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "403", "password_change_required",
+        "Password change required", new_guest_cookie);
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
+  /* Parse cursor and limit from query string */
+  char cursor_buf[80] = {0};
+  char limit_buf[8] = {0};
+  Conversation_API_Query_Param(p_request, "cursor", cursor_buf, sizeof(cursor_buf));
+  Conversation_API_Query_Param(p_request, "limit", limit_buf, sizeof(limit_buf));
+
+  int64 cursor_updated_at = 0;
+  char cursor_id[37] = {0};
+  if (cursor_buf[0] != '\0')
+  {
+    /* cursor format: <updated_at>_<uuid> */
+    const char *underscore = strchr(cursor_buf, '_');
+    if (!underscore || underscore == cursor_buf ||
+        strlen(underscore + 1) != 36)
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor format invalid",
+          new_guest_cookie);
+    char ts_part[32] = {0};
+    size_t ts_len = (size_t)(underscore - cursor_buf);
+    if (ts_len >= sizeof(ts_part))
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor format invalid",
+          new_guest_cookie);
+    memcpy(ts_part, cursor_buf, ts_len);
+    cursor_updated_at = (int64)atoll(ts_part);
+    if (cursor_updated_at <= 0)
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "400", "invalid_cursor", "Cursor timestamp invalid",
+          new_guest_cookie);
+    strncpy(cursor_id, underscore + 1, 36);
+    cursor_id[36] = '\0';
+  }
+
+  int32 limit = 20;
+  if (limit_buf[0] != '\0')
+  {
+    int parsed = atoi(limit_buf);
+    if (parsed >= 1 && parsed <= 50)
+      limit = parsed;
+    else if (parsed > 50)
+      limit = 50;
+  }
+
+  Conversation_Summary *summaries = NULL;
+  int32 count = 0;
+  Conversation_Store_Result result = Conversation_Store_List(
+      g_conversation_store, &owner,
+      cursor_updated_at, cursor_id[0] ? cursor_id : NULL,
+      limit, &summaries, &count, p_arena);
+  if (result != CONVERSATION_STORE_OK)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "list_failed", "Unable to list conversations",
+        new_guest_cookie);
+
+  /* Estimate response size */
+  size_t capacity = 128;
+  for (int32 i = 0; i < count; i++)
+  {
+    Conversation_Summary *s = &summaries[i];
+    capacity += (s->title ? strlen(s->title) : 0) * 6 +
+                (s->last_message_preview ? strlen(s->last_message_preview) : 0) * 6 +
+                256;
+  }
+  char *body = Dowa_Arena_Allocate(p_arena, capacity);
+  if (!body)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
+        new_guest_cookie);
+
+  size_t offset = 0;
+  if (!Conversation_API_Append(body, capacity, &offset,
+                               "{\"conversations\":["))
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+
+  for (int32 i = 0; i < count; i++)
+  {
+    Conversation_Summary *s = &summaries[i];
+    char *esc_title = Dowa_JSON_Escape_String(
+        s->title ? s->title : "", 0, p_arena);
+    char *esc_preview = Dowa_JSON_Escape_String(
+        s->last_message_preview ? s->last_message_preview : "", 0, p_arena);
+    if (!Conversation_API_Append(
+            body, capacity, &offset,
+            "%s{\"id\":\"%s\",\"title\":\"%s\",\"status\":\"%s\","
+            "\"created_at\":%lld,\"updated_at\":%lld,"
+            "\"turn_count\":%lld,\"last_message_preview\":\"%s\"}",
+            i == 0 ? "" : ",",
+            s->id ? s->id : "",
+            esc_title ? esc_title : "",
+            s->status ? s->status : "",
+            (long long)s->created_at,
+            (long long)s->updated_at,
+            (long long)s->turn_count,
+            esc_preview ? esc_preview : ""))
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "serialize_failed", "Response too large",
+          new_guest_cookie);
+  }
+
+  /* Next cursor: last item's (updated_at, id) */
+  char cursor_out[80] = "null";
+  if (count == limit && count > 0)
+  {
+    Conversation_Summary *last = &summaries[count - 1];
+    if (last->id)
+      snprintf(cursor_out, sizeof(cursor_out), "\"%lld_%s\"",
+               (long long)last->updated_at, last->id);
+  }
+  if (!Conversation_API_Append(body, capacity, &offset,
+                               "],\"cursor\":%s}", cursor_out))
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+
+  return Conversation_API_JSON_Response_With_Cookie(
+      p_arena, "200", body, new_guest_cookie);
+}
+
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations                                              */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_Create(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
+    return Conversation_API_Error(
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
+    return Conversation_API_Error(
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
+  const char *title = "";
+  const char *body = Conversation_API_Request_Value(p_request, "Body");
+  if (body && body[0] != '\0')
+  {
+    Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
+    if (!object)
+      return Conversation_API_Error(
+          p_arena, "400", "invalid_json", "Request body must be a JSON object");
+    char *parsed_title = Dowa_JSON_Get_String(object, "title");
+    if (parsed_title)
+      title = parsed_title;
+  }
+  if (strlen(title) > CONVERSATION_TITLE_MAX)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
+
+  char conversation_id[37];
+  if (Conversation_Store_Create_Owned(
+          g_conversation_store, title, &owner,
+          conversation_id) != CONVERSATION_STORE_OK)
+    return Conversation_API_Error(
+        p_arena, "500", "create_failed", "Unable to create conversation");
+
+  char *response_body = Dowa_Arena_Allocate(p_arena, 64);
+  snprintf(response_body, 64, "{\"id\":\"%s\"}", conversation_id);
+  return Conversation_API_JSON_Response(p_arena, "201", response_body);
+}
+
+/* ------------------------------------------------------------------ */
+/* GET /api/conversations/:conversation_id                              */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Get(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  char new_guest_cookie[CONV_GUEST_COOKIE_CAPACITY] = {0};
+  if (!Conversation_API_Resolve(p_request, &principal, new_guest_cookie, p_arena))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (principal.must_change_password)
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "403", "password_change_required",
+        "Password change required", new_guest_cookie);
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   if (!conversation_id)
-    return Conversation_API_Error(
-        p_arena, "400", "missing_id", "Conversation ID is required");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "400", "missing_id", "Conversation ID is required",
+        new_guest_cookie);
 
   Conversation_Record record;
-  Conversation_Store_Result result = Conversation_Store_Get(
-      g_conversation_store, conversation_id, &record, p_arena);
+  Conversation_Store_Result result = Conversation_Store_Get_Owned(
+      g_conversation_store, conversation_id, &owner, &record, p_arena);
   if (result == CONVERSATION_STORE_NOT_FOUND)
-    return Conversation_API_Error(
-        p_arena, "404", "not_found", "Conversation not found");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "404", "not_found", "Conversation not found",
+        new_guest_cookie);
   if (result != CONVERSATION_STORE_OK)
-    return Conversation_API_Error(
-        p_arena, "500", "load_failed", "Unable to load conversation");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "load_failed", "Unable to load conversation",
+        new_guest_cookie);
 
   if (!record.id || !record.title || !record.status)
-    return Conversation_API_Error(
-        p_arena, "500", "load_failed", "Conversation data exceeded limits");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "load_failed", "Conversation data exceeded limits",
+        new_guest_cookie);
   size_t history_size = strlen(record.title) + strlen(record.status);
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
     Conversation_Turn *p_turn = &record.turns[i];
     if (!p_turn->role || !p_turn->content || !p_turn->status ||
         !p_turn->request_id || !p_turn->error_message)
-      return Conversation_API_Error(
-          p_arena, "500", "load_failed", "Conversation data exceeded limits");
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "load_failed", "Conversation data exceeded limits",
+          new_guest_cookie);
     history_size +=
         strlen(p_turn->role) + strlen(p_turn->content) +
         strlen(p_turn->status) + strlen(p_turn->request_id) +
         strlen(p_turn->error_message);
     if (history_size > CONVERSATION_HISTORY_MAX)
-      return Conversation_API_Error(
+      return Conversation_API_Error_With_Cookie(
           p_arena,
           "413",
           "history_too_large",
-          "Conversation history exceeds response limit");
+          "Conversation history exceeds response limit",
+          new_guest_cookie);
   }
   char *escaped_title = Dowa_JSON_Escape_String(record.title, 0, p_arena);
   if (!escaped_title)
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Unable to serialize conversation");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Unable to serialize conversation",
+        new_guest_cookie);
   size_t capacity = strlen(escaped_title) + 512;
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
@@ -809,8 +1218,9 @@
   }
   char *body = Dowa_Arena_Allocate(p_arena, capacity);
   if (!body)
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response exceeds memory limit");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response exceeds memory limit",
+        new_guest_cookie);
   size_t offset = 0;
   if (!Conversation_API_Append(
           body,
@@ -823,8 +1233,9 @@
           record.status,
           (long long)record.created_at,
           (long long)record.updated_at))
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response too large");
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
 
   for (size_t i = 0; i < Dowa_Array_Length(record.turns); i++)
   {
@@ -857,25 +1268,48 @@
             (long long)p_turn->output_tokens,
             (long long)p_turn->created_at,
             (long long)p_turn->completed_at))
-      return Conversation_API_Error(
-          p_arena, "500", "serialize_failed", "Response too large");
+      return Conversation_API_Error_With_Cookie(
+          p_arena, "500", "serialize_failed", "Response too large",
+          new_guest_cookie);
   }
   if (!Conversation_API_Append(body, capacity, &offset, "]}"))
-    return Conversation_API_Error(
-        p_arena, "500", "serialize_failed", "Response too large");
-  return Conversation_API_JSON_Response(p_arena, "200", body);
+    return Conversation_API_Error_With_Cookie(
+        p_arena, "500", "serialize_failed", "Response too large",
+        new_guest_cookie);
+  return Conversation_API_JSON_Response_With_Cookie(
+      p_arena, "200", body, new_guest_cookie);
 }
 
+/* ------------------------------------------------------------------ */
+/* PATCH /api/conversations/:conversation_id                            */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Update(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
     return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
@@ -887,8 +1321,8 @@
     return Conversation_API_Error(
         p_arena, "400", "invalid_title", "Title exceeds 200 bytes");
 
-  Conversation_Store_Result result = Conversation_Store_Update_Title(
-      g_conversation_store, conversation_id, title);
+  Conversation_Store_Result result = Conversation_Store_Update_Title_Owned(
+      g_conversation_store, conversation_id, &owner, title);
   if (result == CONVERSATION_STORE_NOT_FOUND)
     return Conversation_API_Error(
         p_arena, "404", "not_found", "Conversation not found");
@@ -898,29 +1332,51 @@
   return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
 }
 
+/* ------------------------------------------------------------------ */
+/* DELETE /api/conversations/:conversation_id                           */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Delete(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
     return Conversation_API_Error(
-        p_arena, "503", "inference_disabled", "Inference API is disabled");
-  if (!Conversation_API_Is_Same_Origin(p_request))
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  if (principal.must_change_password)
     return Conversation_API_Error(
-        p_arena, "403", "origin_rejected", "Same-origin request required");
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   if (!conversation_id)
     return Conversation_API_Error(
         p_arena, "400", "missing_id", "Conversation ID is required");
-  Conversation_Store_Result result = Conversation_Store_Delete(
-      g_conversation_store, conversation_id);
+
+  Conversation_Store_Result result = Conversation_Store_Delete_Owned(
+      g_conversation_store, conversation_id, &owner);
   if (result == CONVERSATION_STORE_NOT_FOUND)
     return Conversation_API_Error(
         p_arena, "404", "not_found", "Conversation not found");
   if (result != CONVERSATION_STORE_OK)
     return Conversation_API_Error(
         p_arena, "500", "delete_failed", "Unable to delete conversation");
+
   if (Inference_Bridge_Is_Ready(g_inference_bridge))
   {
     char request_id[37];
@@ -935,13 +1391,76 @@
   return p_response;
 }
 
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations/claim  (body: {"conversationId":"<uuid>"}) */
+/* ID stays in request body — never appears in URL or request log.    */
+/* ------------------------------------------------------------------ */
+
+static Seobeo_Request_Entry *Conversation_API_Claim_Body(
+    Seobeo_Request_Entry *p_request,
+    Dowa_Arena *p_arena)
+{
+  if (!g_conversation_store)
+    return Conversation_API_Error(
+        p_arena, "503", "store_unavailable", "Conversation store unavailable");
+
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+    return Conversation_API_Error(p_arena, "500", "internal_error", "Session error");
+  if (!found)
+    return Conversation_API_Error(
+        p_arena, "401", "auth_required", "Bootstrap session first");
+  /* Guests may not claim; forced-password-change users blocked */
+  if (principal.kind != AUTH_PRINCIPAL_USER)
+    return Conversation_API_Error(
+        p_arena, "403", "forbidden", "Must be authenticated to claim");
+  if (principal.must_change_password)
+    return Conversation_API_Error(
+        p_arena, "403", "password_change_required",
+        "Password change required");
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+    return Conversation_API_Error(
+        p_arena, "403", "csrf_invalid", "Same-origin and CSRF token required");
+
+  Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
+  if (!object)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_json", "Request body must be a JSON object");
+  const char *conversation_id = Dowa_JSON_Get_String(object, "conversationId");
+  if (!conversation_id || conversation_id[0] == '\0')
+    return Conversation_API_Error(
+        p_arena, "400", "missing_id", "conversationId is required");
+  if (strlen(conversation_id) > 36)
+    return Conversation_API_Error(
+        p_arena, "400", "invalid_id", "conversationId is invalid");
+
+  Conversation_Store_Result result = Conversation_Store_Claim_Legacy(
+      g_conversation_store, conversation_id, principal.user_id);
+  if (result == CONVERSATION_STORE_NOT_FOUND)
+    return Conversation_API_Error(
+        p_arena, "404", "not_found", "Conversation not found");
+  if (result == CONVERSATION_STORE_CONFLICT)
+    return Conversation_API_Error(
+        p_arena, "409", "already_owned", "Conversation is already owned");
+  if (result != CONVERSATION_STORE_OK)
+    return Conversation_API_Error(
+        p_arena, "500", "claim_failed", "Unable to claim conversation");
+
+  return Conversation_API_JSON_Response(p_arena, "200", "{\"ok\":true}");
+}
+
+/* ------------------------------------------------------------------ */
+/* GET /api/inference/health                                            */
+/* ------------------------------------------------------------------ */
+
 static Seobeo_Request_Entry *Conversation_API_Health(
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
   (void)p_request;
   boolean ready =
-      Conversation_API_Is_Enabled() &&
+      Conversation_API_Is_Inference_Ready() &&
       g_conversation_store &&
       Inference_Bridge_Is_Ready(g_inference_bridge);
   return Conversation_API_JSON_Response(
@@ -950,29 +1469,92 @@
       ready ? "{\"status\":\"ready\"}" : "{\"status\":\"unavailable\"}");
 }
 
+/* ------------------------------------------------------------------ */
+/* POST /api/conversations/:conversation_id/turns  (streaming)          */
+/* ------------------------------------------------------------------ */
+
+/*
+ * SSE client-disconnect callback.
+ * Called by Seobeo_SSE_Server_Detach_Handle after g_sse_mutex is released.
+ * Finds the pending turn for the disconnected stream and finalizes it once.
+ * Must NOT hold g_sse_mutex when called; acquires g_pending_mutex.
+ */
+static void Conversation_API_On_SSE_Detach(
+    Seobeo_SSE_Stream *p_stream,
+    void              *ctx)
+{
+  (void)ctx;
+  pthread_mutex_lock(&g_pending_mutex);
+  for (Pending_Turn *p_turn = g_pending_turns;
+       p_turn;
+       p_turn = p_turn->p_next)
+  {
+    if (p_turn->p_stream == p_stream && !p_turn->finalized)
+    {
+      p_turn->aborted = TRUE;
+      snprintf(p_turn->error_message, sizeof(p_turn->error_message),
+               "Client disconnected");
+      Conversation_API_Finalize_Pending(p_turn);
+      break;
+    }
+  }
+  pthread_mutex_unlock(&g_pending_mutex);
+}
+
 static void Conversation_API_Turn_Stream(
     Seobeo_Handle *p_handle,
     Seobeo_Request_Entry *p_request,
     Dowa_Arena *p_arena)
 {
-  if (!Conversation_API_Is_Enabled())
+  /* Resolve existing identity first so we can apply controls per-principal. */
+  Auth_Principal principal;
+  boolean found = FALSE;
+  if (!Conversation_API_Resolve_Existing(p_request, &principal, p_arena, &found))
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 500, "internal_error", "Session error");
+    return;
+  }
+  if (!found)
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 401, "auth_required", "Bootstrap session first");
+    return;
+  }
+  if (principal.must_change_password)
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 403, "password_change_required",
+        "Password change required");
+    return;
+  }
+  if (!Auth_API_Verify_CSRF(p_request, &principal))
+  {
+    Conversation_API_Send_Stream_Error(
+        p_handle, 403, "csrf_invalid", "Same-origin and CSRF token required");
+    return;
+  }
+
+  /* Guests require inference to be explicitly enabled; authenticated users
+   * always proceed subject to the global bridge-ready check below. */
+  if (principal.kind == AUTH_PRINCIPAL_GUEST &&
+      !Conversation_API_Is_Inference_Ready())
   {
     Conversation_API_Send_Stream_Error(
         p_handle, 503, "inference_disabled", "Inference API is disabled");
     return;
   }
-  if (!Conversation_API_Is_Same_Origin(p_request))
-  {
-    Conversation_API_Send_Stream_Error(
-        p_handle, 403, "origin_rejected", "Same-origin request required");
-    return;
-  }
   if (!g_conversation_store || !Inference_Bridge_Is_Ready(g_inference_bridge))
   {
     Conversation_API_Send_Stream_Error(
         p_handle, 503, "inference_unavailable", "Inference runtime unavailable");
     return;
   }
+
+  /* Map principal to owner — copy IDs before arena may expire (req 8) */
+  Conversation_Owner owner;
+  Conversation_API_Owner_From_Principal(&principal, &owner);
+
   const char *conversation_id =
       Conversation_API_Request_Value(p_request, ":conversation_id");
   Dowa_JSON_Entry *object = Conversation_API_Parse_Body(p_request, p_arena);
@@ -992,8 +1574,25 @@
   }
   if (!Conversation_API_Acquire_Turn_Slot())
   {
-    Conversation_API_Send_Stream_Error(
-        p_handle, 429, "rate_limited", "Inference capacity exhausted");
+    /* Temporary capacity limit — advise client to retry in 5 s. */
+    static const char rate_body[] =
+        "{\"error\":{\"code\":\"rate_limited\","
+        "\"message\":\"Inference capacity exhausted\"}}";
+    char rate_header[512];
+    snprintf(rate_header, sizeof(rate_header),
+        "HTTP/1.1 429 Too Many Requests\r\n"
+        "Content-Type: application/json; charset=utf-8\r\n"
+        "Content-Length: %zu\r\n"
+        "Retry-After: 5\r\n"
+        "Connection: close\r\n"
+        "\r\n",
+        sizeof(rate_body) - 1);
+    Seobeo_Handle_Queue(
+        p_handle, (const uint8 *)rate_header, (uint32)strlen(rate_header));
+    Seobeo_Handle_Queue(
+        p_handle, (const uint8 *)rate_body,
+        (uint32)(sizeof(rate_body) - 1));
+    Seobeo_Handle_Flush(p_handle);
     return;
   }
 
@@ -1005,13 +1604,113 @@
         p_handle, 500, "id_failed", "Unable to create request ID");
     return;
   }
-  Conversation_Store_Result result = Conversation_Store_Begin_Turn(
-      g_conversation_store,
-      conversation_id,
-      request_id,
-      prompt);
+
+  /* --- Guest quota reservation (before persisting turn) --- */
+  boolean is_guest_reserved = FALSE;
+  char    quota_guest_id[37] = {0};
+  if (principal.kind == AUTH_PRINCIPAL_GUEST)
+  {
+    Auth_Store *p_auth_store = Auth_API_Get_Store();
+    if (!p_auth_store)
+    {
+      Conversation_API_Release_Turn_Slot();
+      Conversation_API_Send_Stream_Error(
+          p_handle, 503, "store_unavailable", "Auth store unavailable");
+      return;
+    }
+
+    int64 now_unix     = (int64)time(NULL);
+    int64 window_start = conv__utc_window_start(now_unix);
+    int64 resets_at    = window_start + 86400LL;
+
+    Auth_Store_Guest_Quota_Result quota_result = Auth_Store_Guest_Reserve(
+        p_auth_store,
+        principal.guest_id,
+        request_id,
+        window_start,
+        g_guest_request_output_tokens,
+        g_guest_daily_turns,
+        g_guest_daily_output_tokens,
+        now_unix + 3600);
+
+    if (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED ||
+        quota_result == AUTH_STORE_GUEST_QUOTA_TOKENS_EXHAUSTED)
+    {
+      /* Fetch current usage for 429 payload. */
+      Auth_Store_Guest_Usage usage;
+      memset(&usage, 0, sizeof(usage));
+      Auth_Store_Guest_Get_Usage(
+          p_auth_store, principal.guest_id, window_start, &usage);
+
+      int64 turns_remaining =
+          g_guest_daily_turns - usage.turns_used;
+      if (turns_remaining < 0) turns_remaining = 0;
+      int64 tokens_remaining =
+          g_guest_daily_output_tokens
+          - usage.output_tokens_used
+          - usage.output_tokens_reserved;
+      if (tokens_remaining < 0) tokens_remaining = 0;
+
+      const char *code = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
+          ? "guest_quota_turns_exhausted"
+          : "guest_quota_tokens_exhausted";
+      const char *message = (quota_result == AUTH_STORE_GUEST_QUOTA_TURNS_EXHAUSTED)
+          ? "Daily turn limit reached"
+          : "Daily output token limit reached";
+
+      char quota_body[1024];
+      int qlen = snprintf(
+          quota_body, sizeof(quota_body),
+          "{\"error\":{\"code\":\"%s\",\"message\":\"%s\","
+          "\"quota\":{\"turnsLimit\":%lld,\"turnsUsed\":%lld,"
+          "\"turnsRemaining\":%lld,\"outputTokensLimit\":%lld,"
+          "\"outputTokensUsed\":%lld,\"outputTokensReserved\":%lld,"
+          "\"outputTokensRemaining\":%lld,\"resetsAt\":%lld}}}",
+          code, message,
+          (long long)g_guest_daily_turns,
+          (long long)usage.turns_used,
+          (long long)turns_remaining,
+          (long long)g_guest_daily_output_tokens,
+          (long long)usage.output_tokens_used,
+          (long long)usage.output_tokens_reserved,
+          (long long)tokens_remaining,
+          (long long)resets_at);
+      if (qlen <= 0 || (size_t)qlen >= sizeof(quota_body))
+      {
+        Conversation_API_Release_Turn_Slot();
+        Conversation_API_Send_Stream_Error(
+            p_handle, 429, code, message);
+        return;
+      }
+      char header[512];
+      Seobeo_Web_Header_Generate(header, 429,
+          "application/json; charset=utf-8", qlen);
+      Seobeo_Handle_Queue(
+          p_handle, (const uint8 *)header, (uint32)strlen(header));
+      Seobeo_Handle_Queue(
+          p_handle, (const uint8 *)quota_body, (uint32)qlen);
+      Seobeo_Handle_Flush(p_handle);
+      Conversation_API_Release_Turn_Slot();
+      return;
+    }
+    if (quota_result != AUTH_STORE_GUEST_QUOTA_OK)
+    {
+      Conversation_API_Release_Turn_Slot();
+      Conversation_API_Send_Stream_Error(
+          p_handle, 500, "quota_error", "Unable to check guest quota");
+      return;
+    }
+    is_guest_reserved = TRUE;
+    snprintf(quota_guest_id, sizeof(quota_guest_id), "%s",
+             principal.guest_id);
+  }
+
+  Conversation_Store_Result result = Conversation_Store_Begin_Turn_Owned(
+      g_conversation_store, conversation_id, &owner, request_id, prompt);
   if (result == CONVERSATION_STORE_NOT_FOUND)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 404, "not_found", "Conversation not found");
@@ -1019,6 +1718,8 @@
   }
   if (result == CONVERSATION_STORE_CONFLICT)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 409, "turn_in_progress", "Conversation already has an active turn");
@@ -1026,6 +1727,8 @@
   }
   if (result != CONVERSATION_STORE_OK)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_API_Send_Stream_Error(
         p_handle, 500, "turn_failed", "Unable to persist turn");
@@ -1037,6 +1740,8 @@
       "/api/conversations/turns");
   if (!p_stream || !Seobeo_SSE_Retain(p_stream))
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_Store_Fail_Turn(
         g_conversation_store,
@@ -1053,6 +1758,8 @@
   Pending_Turn *p_turn = calloc(1, sizeof(*p_turn));
   if (!p_turn)
   {
+    if (is_guest_reserved)
+      Auth_Store_Guest_Release(Auth_API_Get_Store(), request_id);
     Conversation_API_Release_Turn_Slot();
     Conversation_Store_Fail_Turn(
         g_conversation_store,
@@ -1073,8 +1780,17 @@
       sizeof(p_turn->conversation_id),
       "%s",
       conversation_id);
+  /* Copy owner and quota fields before arena expires (req 8) */
+  p_turn->owner              = owner;
+  p_turn->is_guest_reserved  = is_guest_reserved;
+  p_turn->reserved_output_tokens =
+      is_guest_reserved ? g_guest_request_output_tokens : 0;
+  snprintf(p_turn->guest_id, sizeof(p_turn->guest_id), "%s", quota_guest_id);
   p_turn->p_stream = p_stream;
 
+  /* Register detach callback so client disconnect triggers finalization. */
+  Seobeo_SSE_Set_Detach_Callback(p_stream, Conversation_API_On_SSE_Detach, NULL);
+
   pthread_mutex_lock(&g_pending_mutex);
   p_turn->p_next = g_pending_turns;
   g_pending_turns = p_turn;
@@ -1103,16 +1819,72 @@
   }
 }
 
-boolean Conversation_API_Init(const char *database_path)
+boolean Conversation_API_Init(
+    const char                          *database_path,
+    const Conversation_API_Guest_Policy *p_policy)
 {
   if (g_conversation_store)
     return TRUE;
-  const char *allow_anonymous = getenv(
-      "MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
-  g_anonymous_inference_enabled =
-      allow_anonymous &&
-      (strcmp(allow_anonymous, "1") == 0 ||
-       strcasecmp(allow_anonymous, "true") == 0);
+
+  if (p_policy)
+  {
+    /* Validate policy ranges. */
+    if (p_policy->daily_turns < CONV_GUEST_DAILY_TURNS_MIN ||
+        p_policy->daily_turns > CONV_GUEST_DAILY_TURNS_MAX)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] daily_turns must be %d..%d\n",
+                 CONV_GUEST_DAILY_TURNS_MIN, CONV_GUEST_DAILY_TURNS_MAX);
+      return FALSE;
+    }
+    if (p_policy->daily_output_tokens < CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN ||
+        p_policy->daily_output_tokens > CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] daily_output_tokens must be %d..%d\n",
+                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MIN,
+                 CONV_GUEST_DAILY_OUTPUT_TOKENS_MAX);
+      return FALSE;
+    }
+    if (p_policy->request_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_MIN ||
+        p_policy->request_output_tokens > p_policy->daily_output_tokens)
+    {
+      Seobeo_Log(SEOBEO_ERROR,
+                 "[CONV] request_output_tokens must be 1..%lld\n",
+                 (long long)p_policy->daily_output_tokens);
+      return FALSE;
+    }
+    g_guest_inference_enabled     = p_policy->guest_inference_enabled;
+    g_guest_daily_turns           = p_policy->daily_turns;
+    g_guest_daily_output_tokens   = p_policy->daily_output_tokens;
+    g_guest_request_output_tokens = p_policy->request_output_tokens;
+  }
+  else
+  {
+    /* No explicit policy: read from environment (backwards compat). */
+    const char *allow_guest = getenv("MRJUNEJUNE_ALLOW_GUEST_INFERENCE");
+    if (!allow_guest)
+    {
+      const char *allow_anon = getenv("MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE");
+      if (allow_anon)
+      {
+        Seobeo_Log(SEOBEO_WARNING,
+                   "[CONV] MRJUNEJUNE_ALLOW_ANONYMOUS_INFERENCE is deprecated;"
+                   " use MRJUNEJUNE_ALLOW_GUEST_INFERENCE\n");
+        allow_guest = allow_anon;
+      }
+    }
+    g_guest_inference_enabled =
+        allow_guest &&
+        (strcmp(allow_guest, "1") == 0 ||
+         strcasecmp(allow_guest, "true") == 0);
+
+    g_guest_request_output_tokens =
+        g_guest_daily_output_tokens < CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT
+        ? g_guest_daily_output_tokens
+        : CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
+  }
+
   g_conversation_store = Conversation_Store_Create(database_path);
   return g_conversation_store != NULL;
 }
@@ -1141,11 +1913,40 @@
   return TRUE;
 }
 
+/* Guest-to-user transfer hook registered with Auth_API on init */
+static boolean Conversation_API_Guest_Transfer_Hook(
+    const char *guest_id,
+    const char *user_id,
+    void *context)
+{
+  (void)context;
+  if (!g_conversation_store)
+    return FALSE;
+
+  /* Atomically transfer conversation ownership AND clear outstanding quota
+   * reservations in one transaction (auth and conv tables share the same
+   * SQLite file, so the write lock covers both). */
+  return Conversation_Store_Transfer_Guest_To_User_Atomic(
+      g_conversation_store, guest_id, user_id) == CONVERSATION_STORE_OK;
+}
+
 void Conversation_API_Register_Routes(void)
 {
+  /* Wire guest-to-user transfer on login */
+  Auth_API_Register_Guest_Transfer_Hook(
+      Conversation_API_Guest_Transfer_Hook, NULL);
+
+  /* Wire quota callback for the session endpoint. */
+  Auth_API_Register_Guest_Quota_Cb(conv_guest_quota_cb);
+
   Seobeo_Router_Register(
       "GET", "/api/inference/health", Conversation_API_Health);
+  Seobeo_Router_Register(
+      "GET", "/api/conversations", Conversation_API_List);
   Seobeo_Router_Register("POST", "/api/conversations", Conversation_API_Create);
+  /* Body-based claim: ID stays in request body, not in URL or logs. */
+  Seobeo_Router_Register(
+      "POST", "/api/conversations/claim", Conversation_API_Claim_Body);
   Seobeo_Router_Register(
       "GET", "/api/conversations/:conversation_id", Conversation_API_Get);
   Seobeo_Router_Register(
@@ -1167,6 +1968,13 @@
   {
     Pending_Turn *p_turn = g_pending_turns;
     g_pending_turns = p_turn->p_next;
+    /* Release guest quota reservation on server shutdown (keep turn charge). */
+    if (p_turn->is_guest_reserved)
+    {
+      Auth_Store *p_auth_store = Auth_API_Get_Store();
+      if (p_auth_store)
+        Auth_Store_Guest_Release(p_auth_store, p_turn->request_id);
+    }
     Conversation_Store_Fail_Turn(
         g_conversation_store,
         p_turn->conversation_id,
@@ -1182,5 +1990,8 @@
   pthread_mutex_unlock(&g_pending_mutex);
   Conversation_Store_Destroy(g_conversation_store);
   g_conversation_store = NULL;
-  g_anonymous_inference_enabled = FALSE;
+  g_guest_inference_enabled       = FALSE;
+  g_guest_daily_turns             = CONV_GUEST_DAILY_TURNS_DEFAULT;
+  g_guest_daily_output_tokens     = CONV_GUEST_DAILY_OUTPUT_TOKENS_DEFAULT;
+  g_guest_request_output_tokens   = CONV_GUEST_REQUEST_OUTPUT_TOKENS_DEFAULT;
 }