{"slug": "your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue", "title": "Your AI agent checked its queue, found nothing, and went back to sleep. The queue was full.", "summary": "Elevare Digital's autonomous AI system ARIA silently failed to process jobs because a Postgres Row-Level Security policy filtered out all rows for the orchestrator's connection without raising an error. The orchestrator polled the work queue, received an empty result set, and idled while jobs piled up. The issue was caused by a missing service-role bypass in the RLS policy, which was added for user-facing queue visibility but inadvertently blocked the service account.", "body_md": "Postgres Row-Level Security doesn't raise an error when it blocks you. It returns zero rows. Your query succeeds. Your agent sees an empty queue. Your agent idles. Your jobs pile up.\n\nThis is what happened to ARIA, our autonomous AI system at Elevare Digital.\n\n`SELECT`\n\n— a blocked read and a genuinely empty table look identical to the callerThe orchestrator polls a work queue table. Normal operation:\n\nThe logs showed healthy idle heartbeats. Monitoring showed the agent alive and polling. From the outside, everything looked fine.\n\nJobs were not being processed.\n\nSomeone added a Row-Level Security policy to the queue table, scoped to `auth.uid()`\n\n. Correct for user-facing reads. But the orchestrator connects under the service role — and no service-role bypass was added to the policy.\n\nHere's the policy as it was written:\n\n```\n-- Added for user-facing queue visibility\nCREATE POLICY \"users_see_own_jobs\"\n  ON work_queue\n  FOR SELECT\n  USING (auth.uid() = user_id);\n```\n\nAnd RLS was enabled on the table:\n\n```\nALTER TABLE work_queue ENABLE ROW LEVEL SECURITY;\n```\n\nNo bypass for the service role. No `FOR ALL`\n\nescape. The orchestrator's `SELECT`\n\nnow matched zero rows — because RLS filtered every row out before returning results.\n\nPostgres does not raise. It does not warn. The query completes with status 200 and an empty array.\n\n```\n// Orchestrator poll — looks completely normal\nconst { data: jobs, error } = await supabase\n  .from('work_queue')\n  .select('*')\n  .eq('status', 'pending');\n\n// error is null. jobs is []. Agent concludes: nothing to do.\nif (!jobs || jobs.length === 0) {\n  await recordIdleHeartbeat();\n  return;\n}\n```\n\nThe `error`\n\nis null. The `jobs`\n\narray is empty. The agent's logic is correct for the data it received. The data it received was wrong in a way that produced no signal.\n\nIf RLS blocks a write, you often get an error — the row you tried to insert violates a policy, or returns nothing when you expected a rowcount. Writes are easier to notice.\n\n`SELECT`\n\nunder RLS is different. The design intent is that users should not be able to distinguish \"this row doesn't exist\" from \"this row exists but you can't see it.\" That's a security feature. It's also exactly the wrong behavior for an orchestrator that needs to know the difference between \"queue is empty\" and \"I am blind.\"\n\nThe service role in Supabase bypasses RLS by default — but only if you're using the service-role key on the client. If your edge function or backend is using the anon key, or if it's operating in a context where `auth.uid()`\n\nresolves to null, RLS applies and silently filters.\n\n```\n-- This is what the orchestrator needed, but wasn't there\nCREATE POLICY \"service_role_bypass\"\n  ON work_queue\n  FOR ALL\n  USING (auth.role() = 'service_role');\n\n-- Or, simpler: grant the service role explicit bypass at the table level\nALTER TABLE work_queue FORCE ROW LEVEL SECURITY; -- applies to all\n-- and then in your Supabase client: use the service-role key, which bypasses RLS automatically\n```\n\nThe actual bypass mechanism in Supabase is that the service-role JWT includes `\"role\": \"service_role\"`\n\n, and Supabase's Postgres config grants that role `BYPASSRLS`\n\n. If your client is initialized with the service-role key, you're fine. If it's not, RLS applies — no error, just silence.\n\nThe real repair has two parts: fix the RLS policy, and add a check that will catch this failure mode again.\n\n**Part 1 — Fix the policy:**\n\n```\n-- Preserve user-facing policy\nCREATE POLICY \"users_see_own_jobs\"\n  ON work_queue\n  FOR SELECT\n  USING (auth.uid() = user_id);\n\n-- Add explicit service-role access\n-- (Or: initialize your orchestrator client with the service-role key,\n--  which bypasses RLS automatically via BYPASSRLS grant)\nCREATE POLICY \"orchestrator_full_access\"\n  ON work_queue\n  FOR ALL\n  USING (auth.role() = 'service_role')\n  WITH CHECK (auth.role() = 'service_role');\n```\n\n**Part 2 — The canary check:**\n\nThe orchestrator now runs a canary query before trusting an empty result. The canary queries a row it knows exists — a sentinel row inserted specifically for this purpose, or a count from an unrestricted table the orchestrator owns.\n\n```\nasync function pollQueue() {\n  const { data: jobs, error: queueError } = await supabase\n    .from('work_queue')\n    .select('*')\n    .eq('status', 'pending');\n\n  if (queueError) {\n    await alertOpsChannel('queue_poll_error', queueError);\n    return;\n  }\n\n  // Jobs came back — process normally\n  if (jobs && jobs.length > 0) {\n    await processJobs(jobs);\n    return;\n  }\n\n  // Empty result — but is it genuinely empty, or are we blind?\n  const canaryOk = await verifyReadAccess();\n  if (!canaryOk) {\n    // This is the case we were missing before\n    await alertOpsChannel('queue_read_access_lost', {\n      message: 'Orchestrator received empty queue result but canary check failed. Possible RLS or permission regression.',\n    });\n    return; // Do NOT record idle heartbeat — we don't know the real state\n  }\n\n  // Canary passed — empty really means empty\n  await recordIdleHeartbeat();\n}\n\nasync function verifyReadAccess(): Promise<boolean> {\n  // Query a sentinel row that always exists in a known state\n  // This could be a dedicated canary table, or a system-health row\n  const { data, error } = await supabase\n    .from('orchestrator_canary')\n    .select('id')\n    .eq('id', 'heartbeat-sentinel')\n    .single();\n\n  if (error || !data) {\n    // We expected exactly one row. Getting nothing means access is broken.\n    return false;\n  }\n\n  return true;\n}\n```\n\nThe canary table is simple:\n\n```\nCREATE TABLE orchestrator_canary (\n  id TEXT PRIMARY KEY\n);\n\nINSERT INTO orchestrator_canary (id) VALUES ('heartbeat-sentinel');\n\n-- No RLS on this table — it exists only so the orchestrator can verify it can read\n-- If you want RLS: add only a service-role policy, nothing user-facing\n```\n\nNow the orchestrator has three states instead of two:\n\nThe third state was always real. We just had no way to observe it.\n\nHuman-in-the-loop systems fail loudly. A user tries to load their queue, sees nothing, and files a ticket. An autonomous agent has no user. It reads, decides, acts. If the read is silently wrong, the decision is wrong, and nothing complains.\n\nThis failure mode matters more as systems get more autonomous. ARIA processes queued work without someone watching every poll cycle. The assumption baked into the polling loop was: *an empty read means there is nothing to do.* That assumption held until it didn't, and the system had no way to question it.\n\nThe fix is to make the orchestrator skeptical of its own empty results. Not paranoid — just skeptical enough to verify the precondition that makes \"empty\" meaningful.\n\nIf you're running any kind of queue worker against Postgres or Supabase:\n\n**Check which key your worker is using.** Supabase service-role key bypasses RLS. Anon key does not. Confirm which one is in your edge function or backend environment.\n\n**List your RLS policies and check for missing bypasses.** `SELECT * FROM pg_policies WHERE tablename = 'your_queue_table';`\n\n**Look at your idle heartbeats.** If idle logging increased around the time you last modified permissions or added RLS, that's worth investigating.\n\n**Add a canary.** Takes an hour. Catches this entire class of problem permanently.\n\nAn empty queue result is not evidence of an empty queue. It's evidence that the query ran. Those are different things, and in autonomous systems, the difference matters.\n\n— Mike Clarke, founder of Elevare Digital.", "url": "https://wpnews.pro/news/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue", "canonical_source": "https://dev.to/mike_clarke_50a95013f5c59/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue-was-full-1m4", "published_at": "2026-07-17 14:00:09+00:00", "updated_at": "2026-07-17 14:32:35.015057+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["Elevare Digital", "ARIA", "Postgres", "Supabase"], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue", "markdown": "https://wpnews.pro/news/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue.md", "text": "https://wpnews.pro/news/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-checked-its-queue-found-nothing-and-went-back-to-sleep-the-queue.jsonld"}}