+ ),
+}));
+
+import { NeedsYouInboxPageClient } from "./needs-you-inbox-page-client";
+
+function bundle(id: string): ClarificationInboxBundle {
+ return {
+ pending_id: id,
+ task_id: "t1",
+ session_id: "s1",
+ session_state: "WAITING_FOR_INPUT",
+ task_title: "Task",
+ created_at: "2026-09-03T04:54:02Z",
+ context: "",
+ messages: [
+ {
+ id: "m1",
+ session_id: toSessionId("s1"),
+ task_id: toTaskId("t1"),
+ author_type: "agent",
+ content: "",
+ type: "clarification_request",
+ created_at: "2026-09-03T04:54:02Z",
+ metadata: {
+ pending_id: id,
+ session_id: "s1",
+ question: { id: "q1", title: "Q", prompt: "", options: [] },
+ },
+ },
+ ],
+ };
+}
+
+beforeEach(() => {
+ mocks.bumpRefreshTick.mockReset();
+ state = { status: "idle", bundles: [], hiddenCount: 0, hasMore: false };
+});
+
+afterEach(() => cleanup());
+
+describe("NeedsYouInboxPageClient", () => {
+ it("titles the route Inbox, which the app top bar owns (design-03#D4)", () => {
+ render();
+
+ expect(screen.getByTestId("stub-page-shell").getAttribute("data-title")).toBe("Inbox");
+ });
+
+ it("renders a row per listed bundle", () => {
+ state = {
+ status: "ready",
+ bundles: [bundle("p1"), bundle("p2")],
+ hiddenCount: 0,
+ hasMore: false,
+ };
+ render();
+
+ expect(screen.getAllByTestId("stub-row")).toHaveLength(2);
+ });
+
+ it("renders the empty state when the read succeeded with no rows and no truncation", () => {
+ state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: false };
+ render();
+
+ expect(screen.getByTestId(EMPTY_TESTID)).not.toBeNull();
+ });
+
+ it("renders the error state, not the empty state, when the read failed (AC .21)", () => {
+ state = { status: "error", bundles: [], hiddenCount: 0, hasMore: false };
+ render();
+
+ expect(screen.getByTestId("needs-you-inbox-error")).not.toBeNull();
+ expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull();
+ });
+
+ it("renders the error state for a zero-row truncated page rather than caught up (F42)", () => {
+ state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: true };
+ render();
+
+ expect(screen.getByTestId("needs-you-inbox-error")).not.toBeNull();
+ expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull();
+ });
+
+ it("shows the truncation notice when the page is bounded (AC .11)", () => {
+ state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: true };
+ render();
+
+ expect(screen.getByTestId("needs-you-inbox-truncated")).not.toBeNull();
+ });
+
+ it("does not show the truncation notice when the page is exhaustive", () => {
+ state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: false };
+ render();
+
+ expect(screen.queryByTestId("needs-you-inbox-truncated")).toBeNull();
+ });
+
+ it("shows the hidden panel alongside a non-empty list when bundles are hidden", () => {
+ state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 2, hasMore: false };
+ render();
+
+ expect(screen.getByTestId("stub-hidden-panel").textContent).toBe("2");
+ });
+
+ it("shows a loading indicator on the very first read, not the empty state", () => {
+ state = { status: "loading", bundles: [], hiddenCount: 0, hasMore: false };
+ render();
+
+ expect(screen.getByRole("status")).not.toBeNull();
+ expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull();
+ });
+});
diff --git a/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx
new file mode 100644
index 0000000000..eab4dd0351
--- /dev/null
+++ b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { PageShell } from "@/components/page-shell";
+import { useAppStore } from "@/components/state-provider";
+import {
+ selectNeedsYouInboxBundles,
+ selectNeedsYouInboxHasMore,
+ selectNeedsYouInboxHiddenCount,
+ selectNeedsYouInboxRevision,
+ selectNeedsYouInboxStatus,
+} from "@/lib/state/slices/needs-you-inbox/selectors";
+import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox";
+import { NeedsYouInboxRow } from "@/components/needs-you-inbox/needs-you-inbox-row";
+import { NeedsYouInboxEmptyState } from "@/components/needs-you-inbox/needs-you-inbox-empty-state";
+import { NeedsYouInboxErrorState } from "@/components/needs-you-inbox/needs-you-inbox-error-state";
+import { NeedsYouInboxHiddenPanel } from "@/components/needs-you-inbox/needs-you-inbox-hidden-panel";
+
+type ViewMode = "error" | "loading" | "empty" | "list";
+
+// A page reporting truncation while listing zero rows means enrichment
+// emptied a page the query had filled, so this resolves to the retryable
+// "error" state rather than "empty" (design-01#Data-and-contracts).
+function resolveViewMode(
+ status: string,
+ bundleCount: number,
+ hiddenCount: number,
+ hasMore: boolean,
+): ViewMode {
+ if (status === "error" || (bundleCount === 0 && hasMore)) return "error";
+ if (status === "loading" && bundleCount === 0 && hiddenCount === 0) return "loading";
+ if (bundleCount === 0) return "empty";
+ return "list";
+}
+
+function NeedsYouInboxList({
+ bundles,
+ hasMore,
+ hiddenCount,
+ listRevision,
+}: {
+ bundles: readonly ClarificationInboxBundle[];
+ hasMore: boolean;
+ hiddenCount: number;
+ listRevision: number;
+}) {
+ const { t } = useTranslation();
+ return (
+ <>
+
+ {bundles.map((bundle) => (
+
+ ))}
+
+ {hasMore && (
+
+ {t("needsYouInbox:truncatedNotice")}
+
+ )}
+ {hiddenCount > 0 && (
+
+ )}
+ >
+ );
+}
+
+// v1 renders no tab strip and no in-page title (design-01#Components). The
+// title belongs to the app top bar, which is PageShell's, so this route mounts
+// the same chrome every other top-level route does rather than an unlabelled
+// bare div -- that chrome also carries the phone nav trigger (design-03#D4).
+export function NeedsYouInboxPageClient() {
+ const { t } = useTranslation();
+ const status = useAppStore(selectNeedsYouInboxStatus);
+ const bundles = useAppStore(selectNeedsYouInboxBundles);
+ const hiddenCount = useAppStore(selectNeedsYouInboxHiddenCount);
+ const listRevision = useAppStore(selectNeedsYouInboxRevision);
+ const hasMore = useAppStore(selectNeedsYouInboxHasMore);
+ const bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick);
+ const retry = useCallback(() => bumpRefreshTick(), [bumpRefreshTick]);
+
+ const viewMode = resolveViewMode(status, bundles.length, hiddenCount, hasMore);
+
+ return (
+
+ {viewMode === "error" && }
+ {viewMode === "loading" && (
+
+ {t("common:loading")}
+
+ )}
+ {viewMode === "empty" && (
+
+ )}
+ {viewMode === "list" && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
index 2247e7c5e1..a88d18dd6c 100644
--- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx
@@ -16,6 +16,8 @@ type AppSidebarNavItemProps = {
href?: string;
badge?: number;
badgeVariant?: "primary" | "muted";
+ /** Appended after the number, e.g. "+" for a capped/truncated count. */
+ badgeSuffix?: string;
activity?: QuickChatActivityState;
onClick?: () => void;
collapsed: boolean;
@@ -86,12 +88,21 @@ function sidebarBadgeClass(variant: NonNullable{label}
- {typeof badge === "number" && badge > 0 && (
- {badge}
- )}
+ {badgeLabel && {badgeLabel}}
>
)}
>
@@ -138,7 +148,7 @@ export function AppSidebarNavItem({
{buttonOrLink}
{label}
- {typeof badge === "number" && badge > 0 ? ` (${badge})` : ""}
+ {badgeLabel ? ` (${badgeLabel})` : ""}
);
diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx
index b2a1299dc0..2d589d4044 100644
--- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx
@@ -2,6 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultState } from "@/lib/state/default-state";
+import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types";
const mocks = vi.hoisted(() => ({
openQuickChat: vi.fn(),
@@ -10,7 +11,9 @@ const mocks = vi.hoisted(() => ({
const state = {
workspaces: { activeId: "ws-1" as string | null },
userSettings: { ...defaultState.userSettings },
+ features: { ...defaultFeatureFlags } as FeatureFlags,
office: { inboxCountByWorkspaceId: {} as Record },
+ needsYouInbox: { byWorkspaceId: {} as Record },
quickChat: {
isOpen: false,
sessions: [] as Array<{
@@ -67,6 +70,8 @@ describe("AppSidebarPrimaryNav", () => {
beforeEach(() => {
state.workspaces.activeId = "ws-1";
state.userSettings = { ...defaultState.userSettings };
+ state.features = { ...defaultFeatureFlags };
+ state.needsYouInbox = { byWorkspaceId: {} };
state.office.inboxCountByWorkspaceId = {};
state.quickChat.isOpen = false;
state.quickChat.sessions = [];
@@ -162,3 +167,64 @@ describe("AppSidebarPrimaryNav", () => {
expect(screen.queryByRole("link", { name: "Home" })).toBeNull();
});
});
+
+describe("AppSidebarPrimaryNav — Needs-you Inbox nav entry", () => {
+ beforeEach(() => {
+ state.workspaces.activeId = "ws-1";
+ state.userSettings = { ...defaultState.userSettings };
+ state.features = { ...defaultFeatureFlags };
+ state.needsYouInbox = { byWorkspaceId: {} };
+ state.office.inboxCountByWorkspaceId = {};
+ mode = "kanban";
+ pathname = "/";
+ });
+
+ afterEach(() => cleanup());
+
+ it("is hidden when the feature flag is off", () => {
+ state.features.needsYouInbox = false;
+ renderNav(false);
+
+ expect(screen.queryByRole("link", { name: "Inbox" })).toBeNull();
+ });
+
+ it("names the destination Inbox, not the bucket it renders (design-03#D4)", () => {
+ state.features.needsYouInbox = true;
+ mode = "kanban";
+ renderNav(false);
+
+ const link = screen.getByRole("link", { name: "Inbox" });
+ expect(link.getAttribute("href")).toBe("/needs-you-inbox");
+ expect(screen.queryByRole("link", { name: "Needs you" })).toBeNull();
+ });
+
+ it("still renders while in Office mode, distinguishable from Office's own Inbox (design-03#D4)", () => {
+ state.features.needsYouInbox = true;
+ mode = "office";
+ renderNav(false);
+
+ // AC .3 keeps both entries present, so they must not share a name.
+ expect(screen.getByRole("link", { name: "Needs you" }).getAttribute("href")).toBe(
+ "/needs-you-inbox",
+ );
+ expect(screen.getByRole("link", { name: "Inbox" }).getAttribute("href")).toBe("/office/inbox");
+ });
+
+ it("shows the count from the active workspace's needs-you-inbox state as a badge", () => {
+ state.features.needsYouInbox = true;
+ state.needsYouInbox.byWorkspaceId["ws-1"] = { count: 3 };
+ renderNav(false);
+
+ const link = screen.getByRole("link", { name: "Inbox" });
+ expect(link.textContent).toContain("3");
+ });
+
+ it("presents the count as capped when the list is truncated (AC .14)", () => {
+ state.features.needsYouInbox = true;
+ state.needsYouInbox.byWorkspaceId["ws-1"] = { count: 50, hasMore: true };
+ renderNav(false);
+
+ const link = screen.getByRole("link", { name: "Inbox" });
+ expect(link.textContent).toContain("50+");
+ });
+});
diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
index 1664d9f10a..9c0ce4a4e1 100644
--- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
+++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx
@@ -4,10 +4,16 @@ import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { selectOfficeInboxCount } from "@/lib/state/slices/office/selectors";
+import {
+ selectNeedsYouInboxCount,
+ selectNeedsYouInboxHasMore,
+} from "@/lib/state/slices/needs-you-inbox/selectors";
+import { useFeature } from "@/hooks/domains/features/use-feature";
import { useOfficeModeState } from "@/hooks/use-in-office";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity";
import { homeDestinationHref } from "@/lib/navigation/core-destinations";
+import { NEEDS_YOU_INBOX_HREF } from "@/lib/navigation/needs-you-inbox-destination";
import { AppSidebarNavItem } from "./app-sidebar-nav-item";
import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item";
@@ -20,6 +26,9 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const startupPage = useAppStore((s) => s.userSettings.startupPage);
const inboxCount = useAppStore(selectOfficeInboxCount);
+ const needsYouInboxEnabled = useFeature("needsYouInbox");
+ const needsYouInboxCount = useAppStore(selectNeedsYouInboxCount);
+ const needsYouInboxHasMore = useAppStore(selectNeedsYouInboxHasMore);
const mode = useOfficeModeState();
const inOffice = mode === "office";
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
@@ -48,6 +57,20 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
collapsed={collapsed}
/>
)}
+ {/* The destination is the Inbox (design-03#D4). "Needs you" names the one
+ bucket it renders, not the place, and is used only in Office mode,
+ where AC .3 keeps this entry present alongside Office's own Inbox row
+ and two identically named rows would be indistinguishable. */}
+ {needsYouInboxEnabled && (
+
+ )}
{workspaceId && collapsed && (
({
+ listHiddenClarificationInbox: vi.fn().mockResolvedValue({ bundles: [], count: 0, total: 0 }),
+ restoreClarificationInboxBundle: vi.fn(),
+}));
+
+let workspaceItems: Array<{ id: string; name: string }> = [{ id: "w1", name: "Kegmil V2" }];
+
+vi.mock("@/components/state-provider", () => ({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ useAppStore: (selector: (s: any) => unknown) =>
+ selector({
+ workspaces: { activeId: "w1", items: workspaceItems },
+ bumpNeedsYouInboxRefreshTick: vi.fn(),
+ }),
+}));
+
+import { NeedsYouInboxEmptyState } from "./needs-you-inbox-empty-state";
+
+afterEach(() => {
+ cleanup();
+ workspaceItems = [{ id: "w1", name: "Kegmil V2" }];
+});
+
+describe("NeedsYouInboxEmptyState", () => {
+ it("names the workspace rather than claiming the whole instance is quiet (design-03#D2)", () => {
+ render();
+
+ expect(screen.getByText("Nothing is waiting on you in Kegmil V2.")).not.toBeNull();
+ });
+
+ it("falls back to a workspace-less sentence when the active workspace is unresolved", () => {
+ workspaceItems = [];
+ render();
+
+ expect(screen.getByText("Nothing is waiting on you in this workspace.")).not.toBeNull();
+ });
+
+ it("does not congratulate: an empty queue is a normal state, not an achievement (design-03#D2)", () => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toBeNull();
+ expect(container.textContent).not.toContain("!");
+ expect(container.textContent).not.toContain("caught up");
+ });
+
+ it("names all four things it does not count (AC .20)", () => {
+ render();
+
+ const description = screen.getByText(/does not count bundles in other workspaces/);
+ expect(description.textContent).toContain("bundles in other workspaces");
+ expect(description.textContent).toContain("pending permission requests");
+ expect(description.textContent).toContain("parent-question records");
+ expect(description.textContent).toContain("snoozed or dismissed rows");
+ });
+
+ it("does not imply anything is hidden when nothing is (AC .20)", () => {
+ render();
+
+ expect(screen.queryByTestId("needs-you-inbox-hidden-panel")).toBeNull();
+ });
+
+ it("discloses and offers to restore hidden bundles when the operator has some hidden", () => {
+ render();
+
+ expect(screen.getByTestId("needs-you-inbox-hidden-panel")).not.toBeNull();
+ });
+});
diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx
new file mode 100644
index 0000000000..61955df185
--- /dev/null
+++ b/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import { useAppStore } from "@/components/state-provider";
+import { NeedsYouInboxHiddenPanel } from "./needs-you-inbox-hidden-panel";
+
+// An empty queue is a normal state, not an achievement: no icon, no
+// congratulation, and the workspace is named so the sentence is never read as a
+// claim about the whole instance (design-03#D2). Hidden bundles are disclosed
+// only when the operator actually has some (AC .20).
+export function NeedsYouInboxEmptyState({
+ hiddenCount,
+ listRevision = 0,
+}: {
+ hiddenCount: number;
+ listRevision?: number;
+}) {
+ const { t } = useTranslation();
+ const workspaceName = useAppStore(
+ (s) => s.workspaces?.items?.find((w) => w.id === s.workspaces.activeId)?.name,
+ );
+ return (
+
+
+ {/* The row body toggles the answer panel, so without this the task is
+ unreachable from the Inbox (design-03#D1). Withheld below `sm`,
+ where it would squeeze the title; the actions menu carries it at
+ every width. */}
+
+
+ void runDismiss(() => dismissClarificationInboxBundle(bundle.pending_id))
+ }
+ onSnooze={(duration) =>
+ void runSnooze(() => snoozeClarificationInboxBundle(bundle.pending_id, duration))
+ }
+ />
+
- );
-}
-
-function AuthRouteRedirect() {
- const router = useRouter();
- useEffect(() => {
- router.replace("/");
- }, [router]);
- return null;
-}
-
/**
* Renders the plugin-registered component for a `kind: "plugin"` route,
* inside the normal app shell. `PluginPageFrame` gives it the same title-bar
diff --git a/docs/plans/needs-you-inbox/plan.md b/docs/plans/needs-you-inbox/plan.md
new file mode 100644
index 0000000000..987991a8a7
--- /dev/null
+++ b/docs/plans/needs-you-inbox/plan.md
@@ -0,0 +1,65 @@
+---
+created: 2026-09-12
+status: complete
+requirements:
+ - REQ-UI-NEEDS-YOU-INBOX-001
+system_design:
+ - ../../specs/ui/system-design/needs-you-inbox-01.md
+ - ../../specs/ui/system-design/needs-you-inbox-02.md
+legacy_specs: []
+---
+
+# Implementation Plan: Needs-you Inbox
+
+## Overview
+
+Deliver a workspace-scoped sidebar destination that lists every clarification
+bundle genuinely waiting on a human and lets the operator answer it without
+leaving the page, per
+[requirements](../../specs/ui/requirements/needs-you-inbox.md) and
+[system design](../../specs/ui/system-design/needs-you-inbox-01.md) (part 2:
+[control flow, failure, persistence, security](../../specs/ui/system-design/needs-you-inbox-02.md)).
+
+This feature was built through Kandev's own spec-driven-development workflow
+(5 spec-review rounds, 4 build rounds, security/test/code review, an
+operator-authorized direct fix round, then PR integration) with its running
+record kept in the task's own plan store rather than as narrative here. This
+document exists to satisfy the repository's PR documentation coverage gate
+(`ci: enforce pull request documentation coverage`), which was not wired into
+that workflow when this feature's spec canon was authored.
+
+## Scope
+
+### In scope
+
+- Sidebar nav entry and route for the Needs-you Inbox, gated on
+ `features.needsYouInbox`.
+- A bounded, workspace-scoped list-and-count read path built on
+ `clarificationBundleQuery` / `currentTurnAuthority`.
+- In-place answer via the shipped `ClarificationPanelSection`.
+- Per-user dismiss/snooze/restore sidecar that never mutates the underlying
+ clarification record.
+- i18n in all five shipped locales.
+
+### Out of scope
+
+Re-ask, the history bucket, the failed-task bucket, cross-workspace rows, and
+any change to the current-turn predicate's meaning. See the requirements
+document's own "Out of scope" section for the full list.
+
+## Work orders
+
+- [x] [Task 01: Needs-you Inbox delivery](task-01-needs-you-inbox-delivery.md)
+
+Single work order; no dependents.
+
+## Verification results
+
+See the work order for the full verification record. Summary: backend and
+frontend build/lint/typecheck clean; targeted and full package test suites
+pass (pre-existing, unrelated failures proven against merge-base in a scratch
+worktree); desktop and mobile E2E specs for the answer-in-place flow pass;
+`pnpm run i18n:check` and `pnpm run i18n:ratchet` clean; `python3
+scripts/lint-spec-files.py --all` passes.
+
+PR: https://github.com/kdlbs/kandev/pull/3641
diff --git a/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md b/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md
new file mode 100644
index 0000000000..0eb29c09ba
--- /dev/null
+++ b/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md
@@ -0,0 +1,125 @@
+---
+id: "01-needs-you-inbox-delivery"
+title: "Needs-you Inbox delivery"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-NEEDS-YOU-INBOX-001
+acceptance_criteria:
+ - AC-UI-NEEDS-YOU-INBOX-001.1
+ - AC-UI-NEEDS-YOU-INBOX-001.2
+ - AC-UI-NEEDS-YOU-INBOX-001.3
+ - AC-UI-NEEDS-YOU-INBOX-001.4
+ - AC-UI-NEEDS-YOU-INBOX-001.5
+ - AC-UI-NEEDS-YOU-INBOX-001.6
+ - AC-UI-NEEDS-YOU-INBOX-001.7
+ - AC-UI-NEEDS-YOU-INBOX-001.8
+ - AC-UI-NEEDS-YOU-INBOX-001.9
+ - AC-UI-NEEDS-YOU-INBOX-001.10
+ - AC-UI-NEEDS-YOU-INBOX-001.11
+ - AC-UI-NEEDS-YOU-INBOX-001.12
+ - AC-UI-NEEDS-YOU-INBOX-001.13
+ - AC-UI-NEEDS-YOU-INBOX-001.14
+ - AC-UI-NEEDS-YOU-INBOX-001.15
+ - AC-UI-NEEDS-YOU-INBOX-001.16
+ - AC-UI-NEEDS-YOU-INBOX-001.17
+ - AC-UI-NEEDS-YOU-INBOX-001.18
+ - AC-UI-NEEDS-YOU-INBOX-001.19
+ - AC-UI-NEEDS-YOU-INBOX-001.20
+ - AC-UI-NEEDS-YOU-INBOX-001.21
+ - AC-UI-NEEDS-YOU-INBOX-001.22
+ - AC-UI-NEEDS-YOU-INBOX-001.23
+ - AC-UI-NEEDS-YOU-INBOX-001.24
+ - AC-UI-NEEDS-YOU-INBOX-001.25
+ - AC-UI-NEEDS-YOU-INBOX-001.26
+ - AC-UI-NEEDS-YOU-INBOX-001.27
+ - AC-UI-NEEDS-YOU-INBOX-001.28
+ - AC-UI-NEEDS-YOU-INBOX-001.29
+ - AC-UI-NEEDS-YOU-INBOX-001.30
+ - AC-UI-NEEDS-YOU-INBOX-001.31
+ - AC-UI-NEEDS-YOU-INBOX-001.32
+ - AC-UI-NEEDS-YOU-INBOX-001.33
+ - AC-UI-NEEDS-YOU-INBOX-001.34
+ - AC-UI-NEEDS-YOU-INBOX-001.35
+ - AC-UI-NEEDS-YOU-INBOX-001.36
+ - AC-UI-NEEDS-YOU-INBOX-001.37
+ - AC-UI-NEEDS-YOU-INBOX-001.38
+ - AC-UI-NEEDS-YOU-INBOX-001.39
+ - AC-UI-NEEDS-YOU-INBOX-001.40
+ - AC-UI-NEEDS-YOU-INBOX-001.41
+system_design:
+ - ../../specs/ui/system-design/needs-you-inbox-01.md
+ - ../../specs/ui/system-design/needs-you-inbox-02.md
+---
+
+# Task 01: Needs-you Inbox Delivery
+
+## Summary
+
+Add the workspace-scoped Needs-you Inbox: sidebar entry and route, a bounded
+list-and-count read path built on `clarificationBundleQuery` /
+`currentTurnAuthority`, in-place answering through the shipped
+`ClarificationPanelSection`, and a per-user dismiss/snooze/restore sidecar.
+Gate the feature (routes, nav entry, and boot-hydration producer) behind
+`features.needsYouInbox` / `KANDEV_FEATURES_NEEDS_YOU_INBOX`.
+
+## In scope
+
+- Backend: `internal/clarification/inbox_handlers.go` (4 endpoints under
+ `/api/v1/clarification-inbox`, flag-gated route registration), the sidecar
+ schema/repository for dismiss/snooze/restore, and a boot-hydration producer
+ that runs the identical bounded read as the live endpoint.
+- Frontend: `needsYouInbox` store slice, sidebar nav entry, `/needs-you-inbox`
+ route, row/empty/error/hidden-panel components, an additive `onOutcome`
+ callback on `ClarificationPanelSection`, and i18n in all five locales.
+- Desktop and mobile E2E coverage for the list -> expand -> answer -> resume
+ flow (AC .30).
+
+## Out of scope
+
+Re-ask, the history bucket, the failed bucket, cross-workspace rows, and any
+change to the current-turn predicate's meaning.
+
+## Acceptance
+
+All 41 acceptance criteria under `REQ-UI-NEEDS-YOU-INBOX-001`; see
+[requirements](../../specs/ui/requirements/needs-you-inbox.md) for the full
+text. Notable ones with dedicated regression coverage:
+
+- AC .10: row ordering (`question_index` ascending, ties broken by
+ `question_id` ascending) — `TestHttpListInbox_OrderInboxMessages_NegativeAndAbsentIndexTieBreakByQuestionID`.
+ covers negative and absent index normalization.
+- AC .20/.21: `200` + zero rows + `next_cursor` present renders the error
+ state, never "caught up" — `resolveViewMode`.
+- AC .30: list an answerable bundle, expand, answer, row removed, task resumed
+ without navigation — `apps/web/e2e/tests/chat/needs-you-inbox-answer.spec.ts`
+ and its mobile counterpart.
+- AC .34/.40/.41: boot-hydration producer shares the live endpoint's bound
+ (limit 50, same sidecar exclusion) — `TestBootNeedsYouInbox_*` /
+ `TestInboxBootSummary_*`.
+- AC .37: hidden-panel disclosed total reconciles with the enumeration read,
+ not a stale prop.
+
+## Verification
+
+- `go build ./...`, `go vet ./...` clean (apps/backend).
+- `go test ./internal/clarification/... ./internal/backendapp/...
+ ./internal/mcp/handlers/...` — all packages `ok`.
+- `make -C apps/backend lint` — 0 issues.
+- `pnpm run typecheck`, `pnpm exec eslint` (touched files) — clean.
+- `pnpm exec vitest run` (targeted `needs-you-inbox`, `clarification-inbox`) —
+ all passing, including boundary and workspace-switch regression tests.
+- `pnpm run i18n:check` / `pnpm run i18n:ratchet` — five locales complete, no
+ em-dash violations, guard allowlist intact.
+- `pnpm e2e:run` — desktop `needs-you-inbox-answer.spec.ts` and mobile
+ `mobile-needs-you-inbox-answer.spec.ts` both pass with a full rebuild.
+- `python3 scripts/lint-spec-files.py --all` — all specification files
+ passed.
+- Full-suite pre-existing failures (worktree/launcher/config/task-service,
+ macOS TMPDIR-symlink signature) proven pre-existing against
+ `git merge-base HEAD origin/main` in a scratch worktree; unrelated to this
+ branch's diff.
+
+PR: https://github.com/kdlbs/kandev/pull/3641
diff --git a/docs/public/configuration.md b/docs/public/configuration.md
index 0088720899..9f6c8cb450 100644
--- a/docs/public/configuration.md
+++ b/docs/public/configuration.md
@@ -508,6 +508,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym
| `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. |
| `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. |
| `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. |
+| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Needs-you Inbox for clarification questions across tasks in the active workspace. |
| `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. |
| `features.agentSurvival` | `KANDEV_FEATURES_AGENT_SURVIVAL` | off | Experimental. Lets the `agentctl` control server and its running agents outlive a backend restart or upgrade, for the `local_pc` and worktree executors only. The restarted backend takes the surviving server over and reconnects to the sessions still running on it. **Unavailable on Windows**, where surviving the backend means giving up the Job Object that guarantees agent processes are cleaned up. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. |
@@ -532,6 +533,11 @@ The database can contain canvas migrations, but Kandev does not read or change
canvas data while the flag is off. See [Agent-authored Canvases](canvases.md)
for the experimental user workflow.
+`features.needsYouInbox` is off in the production profile and on in the
+development and E2E profiles. Restart Kandev after changing it because the
+backend registers the Inbox routes at startup. With the flag off, the Inbox
+routes, boot count, sidebar entry, and page are unavailable.
+
For a risky release feature that has not graduated, keep the flag off in the
shipped profiles, enable it only on a selected install through an admin override
or explicit environment, restart, and test it there. Promote the `prod` profile
@@ -633,6 +639,7 @@ no public YAML key:
- Runtime flags and diagnostics: `KANDEV_FEATURES_OFFICE`,
`KANDEV_FEATURES_AUTH`,
`KANDEV_FEATURES_MULTI_TENANCY`,
+ `KANDEV_FEATURES_NEEDS_YOU_INBOX`,
`KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF`,
`KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`,
`KANDEV_DEBUG_AGENT_MESSAGES`, `KANDEV_DEBUG_ACP_MAX_FILES`,
diff --git a/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html b/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html
new file mode 100644
index 0000000000..e0bf079453
--- /dev/null
+++ b/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html
@@ -0,0 +1,688 @@
+
+
+
+
+
+Kandev Inbox — mockup r4, aligned to the shipped shell
+
+
+
+
+
Inbox — drawn on Kandev's own shell
+
Revision 4. The previous revisions got the colour tokens exactly right and the chrome
+ around them wrong: the sidebar, the tabs, the rows and the badges were invented. This pass
+ redraws every frame from the shipped components, and applies your two answers — the badge
+ and the list both read the clarification-bundle path, and permission rows
+ appear in the history tab, labelled.
+
Only colour Kandev already paints appears below. Everything in grey is commentary.
+
+ repo 76ab36270
+ data 2026-09-12 01:06 UTC, mode=ro
+ reference your screenshot, 01:11 UTC
+ scope workspace Kandev
+
+
+
+
+
01
The destination, on the real sidebar
+
The nav row exists already — app-sidebar-primary-nav.tsx:41-49 renders
+ IconInbox with badge={inboxCount}, wrapped in {inOffice && …}.
+ Kanban never sees it. Three details the earlier revisions drew wrong, each fixed here:
+
The active row is a 3px left bar, not a filled block.
+ app-sidebar-constants.ts says why, in the file: "a saturated fill reads as garish
+ against the brand theme."The badge is gated on badge > 0 — so with
+ Kandev at zero answerable questions, no badge renders at all, and the "0" pills I drew beside other
+ workspaces cannot exist in that component. And the sections carry their count only while shut
+ (collapsedSummary) — that is the Automations 4 in your screenshot.
+
+
+
+
+ Kandev/
+
+
+
+
Inbox
+
+
+
+
+
+
+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+
+
+
+
Nothing is waiting on you in Kandev.
+
115 questions were asked here and never answered. The agents that asked have moved on, so they sit read-only in Asked, unanswered.
+
+
+
+
+
+
This is the honest first frame: measured at 01:06 UTC, the Kandev workspace has
+ 0 answerable questions, so the Inbox opens empty and the sidebar badge does not render.
+ The row design is in frame 02, drawn on the one real answerable question in the instance.
+
+
+
02
The row, and answering without leaving
+
Rebuilt on the shipped row: inbox-item-row.tsx — flat rows divided inside one
+ bordered container, a 32px bg-muted icon chip, title, description, and timeAgo as
+ plain muted text. Actions are Button size="sm" on the right. Gone: the per-row card border, the
+ indigo ring, the red age chip, and the → on the link.
+
The icon carries the type, in the hue the board already uses.
+ lib/ui/state-icons.ts assigns yellow + IconMessageQuestion to
+ WAITING_FOR_INPUT and amber + IconShieldQuestion to a pending permission —
+ the same marks on the kanban card. My earlier draft spent amber on a tab count, which already means
+ "over WIP limit" in StepCount, and red on an age chip, where red already means failed.
+
+
+
Kandev/
+
+
Inbox
+
+
+
+
Needs you 1
+
Asked, unanswered
+
+
+
+
+
+
Pick the reply archetype before the #39 hand-off draft can be written
+
[FE] #39 — Cross-surface MA+WA pilot hand-off
+
2 questions, asked 8 days ago
+
+
+
+
+
The draft needs one decision before I can write it: which archetype the reply follows.
+I have the thread, the pilot scope and the two prior hand-offs. What I do not have is your call on tone.
+
Which reply archetype should the hand-off follow?
+
+
Status hand-off: what shipped, what is open, who owns each thread
+
Decision request: lead with the two open calls, evidence underneath
+
Narrative: walk the pilot chronologically, decisions in place
+
+
+
+
+
+ Resumes session e154c20c in place
+
+
+
+
+
+
Answer and resume is the row that earns the feature. It mounts
+ ClarificationPanelSection unchanged — that component takes
+ {pending, messages, onResolved} and reads no task-chat context, so the Inbox can render the
+ shipped answer UI rather than a copy of it. Display-only is the version that fails: you would still be
+ jumping around, just from a tidier list.
+
This row lives in Kegmil V2, not Kandev — it is the only answerable question in
+ the whole instance, asked 2026-09-03 04:54 UTC. It is drawn here so the row design is visible on real
+ content; on the Kandev board this tab is empty today.
+
+
+
03
Asked, unanswered — now with permissions, labelled
+
Your answer applied: permission requests appear here, read-only, with a label that says
+ what they are. On the Kandev board that is 109 clarification rows across 25 live tasks
+ plus 6 permission rows carrying no terminal status — 115 in total. Every one sits on a
+ superseded turn, so nothing here is answerable; the tab has no write path at all.
+
85 of the 109 are parked in a step where nothing runs — 70 in
+ PR Review, 15 in PR Fixup, whose own prompt says "NOTHING RUNS HERE." An
+ invisible question on a parked card with no agent to re-raise it. Grouping by task rather than by row
+ is what keeps one stuck task from filling the screen.
+
+
+
Kandev/
+
+
Inbox
+
+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+
+
+
Wakeup-gate transient failure retries 9 questions, parked in PR Review, oldest 16 days ago
+
+
+
Should the retry budget be per-run or per-workspace?
+
asked 16 days ago, superseded by a later turn
+
+
+
+
+
Run git push --no-verify to bypass the secret-scanner hook
+
asked 16 days ago, and the push has since landed
+ Permission
+
+
+
Chunk run-id batches in workspace sweep 4 questions, parked in PR Fixup, oldest 10 days ago
+
+
+
Chunk size 100 or 500? The sweep holds the lock for the whole batch.
+
asked 10 days ago, superseded by a later turn
+
+
+
+
+
+
Two things this tab must never do. It must not feed the badge — none of it is
+ answerable, and a badge you cannot drive to zero is a badge you stop reading. And it must not offer
+ Re-ask, which you cut: five of the nine push-blocked questions measured on 09-11 had already been
+ settled by events, so re-asking restarts an agent on a question that answered itself. The honest
+ consequence is that these 115 have no exit in v1. They had none before either — the Inbox makes them
+ visible, it does not clear them.
+
+
+
+
04
Failed — present, and deliberately off the badge
+
Correcting the reason I gave you. Revision 2 kept Failed off the badge
+ because "16 failed tasks, newest 6 days old — nothing urgent." That reason has expired: measured now,
+ 17 live Kandev tasks have a failed primary session, the newest 9.6 hours
+ ago, and 3 inside 24 hours. The exclusion still stands, on a reason that does not expire:
+ a failed task is triage, not a decision you owe. The badge counts questions only.
+
+
+
Kandev/
+
+
Inbox
+
+
+
+
Needs you
Asked, unanswered 115
+
Failed 17
+
+
+
Lifecycle sweep: GetTask failure path
+
Orchestrator
+
failed 10 hours ago
+
+
+
Guard MCP_TOOL_TIMEOUT override
+
Agent runtime & ACP
+
failed 19 hours ago
+
+
+
Restore legacy dynamic routes sweep
+
Dynamic Agent
+
failed 23 hours ago
+
+
+
+
+
+
+
05
The empty state is the product
+
Measured 25 hours apart and again this morning: the Kandev workspace has 0
+ answerable questions. This is not an edge case to sketch and move past — on your board it is the screen
+ you open most days. It gets the most design attention of anything in this deck.
+
One rule governs all three variants: an empty inbox must name what it is not
+ counting. "You're all caught up" while 115 questions sit invisible is how this feature loses your
+ trust in week one, and it is the same failure as the 351-row version — just quieter.
+
+
+
Kandev/
Inbox
+
+
+
Needs you
Asked, unanswered 115
Failed 17
+
+
Nothing is waiting on you in Kandev.
+
115 questions were asked here and never answered, and 17 tasks failed. Neither needs a decision from you, so both sit in the other two tabs.
+
+
+
+
+
+
Quiet here, busy next door.
+ The count comes from inboxCountByWorkspaceId, already a per-workspace map — a number,
+ never a row, so nothing crosses a permission boundary.
+
+
+
Nothing is waiting on you in Kandev.
+
1 question is waiting in Kegmil V2.
+
+
+
+
+
+
Empty because you filtered it.
+ A different sentence, because it is a different situation and the fix is one click.
+
+
+
No questions match "rebase".
+
115 questions are in this tab.
+
+
+
+
+
+
+
+
Copy notes, since the words are the design here. No apology and no exclamation mark —
+ an empty queue is a normal state, not a celebration. The number is the subject of the second line, not a
+ footnote. And the verb on every button says exactly what happens: Switch to Kegmil V2, not
+ View.
+
+
+
+
06
An entry gate, not a filter over a noisy state
+
The issue proposed showing every task that is not Running. Same database, same instant,
+ Kandev workspace, 2026-09-12 01:06 UTC:
+
+
+
What the view would show
Rows
Of which you can act on
+
"Not Running" — sessions in WAITING_FOR_INPUT on live tasks
275
0
+
Every pending question row, any turn
109
0
+
Answerable now — the shipped clarification-bundle path
0
0
+
Same, measured across the whole instance
1
1
+
+
WAITING_FOR_INPUT on this board mostly means
+ "the agent finished its turn and is idle", not "a human is blocking it". A 275-row inbox at a 100%
+ false-positive rate trains you to stop opening it — the same failure as an empty one that lies.
+ Paperclip's rule is the fix, and it is an entry rule rather than a filter: a row cannot enter unless
+ it carries something concrete you can do. Rows 1 and 2 above are exactly the rows that rule
+ excludes.
+
+
+
+
07
Which tab control, and why it is not the default one
+
A design call I can make from the shipped component rather than from taste.
+ packages/ui/src/tabs.tsx has two variants. The Office inbox uses default. On the
+ dark theme that variant's selected state resolves to the track colour it sits on:
+ bg-muted is #2a2a2a, and the active trigger paints
+ dark:bg-input/30 over it — --input is also #2a2a2a, so 30% of
+ #2a2a2a on #2a2a2a is #2a2a2a. The active border is
+ --input, the same colour again. Selection is carried by the label's brightness alone.
+
+
+
default — the shipped Office inbox
+
+
+ Needs you
+ Asked, unanswered
+ Failed
+
+
A secondary count badge here would be
+ 17
+ on a #2a2a2a track — invisible.
+
+
+
line — recommended
+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+
Transparent track, a 2px
+ after:bg-foreground underline. Selection reads at a
+ glance, and the counts sit on --background where a
+ secondary badge has contrast.
+
+
+
+
Both variants ship today, so this costs nothing to choose —
+ <TabsList variant="line">. It also settles a smaller question: counts belong in the tab,
+ not in the page header, because the header is where a title would go and this page has no title. The
+ app's top bar owns that, as your screenshot shows with Home.
+
+
+
+
+
08
Beside Threads, on one shared signal
+
Your answer was "beside, shared selector", and the code makes that literal.
+ threads-view-filter-registry.ts already ships a threadStatus dimension whose
+ default value is needs_action and a pendingAction dimension defaulting to
+ clarification. What is missing is that the one built-in view ships filters: []
+ (thread-view-builtins.ts), so nobody ever sees the preset — and there is no badge and no
+ destination.
+
The Inbox is a second consumer of resolveThreadStatus, not a second
+ classifier. That is the whole reason the two surfaces cannot drift: one function decides what
+ needs action, two surfaces render it.
+
+
+
+
+
Inbox
+
Decisions you owe, one row each, answerable in place. Badge = this count.
+
+
same source
+
+
Threads
+
Every conversation, filterable. A new built-in view carries threadStatus is needs_action.
+
+
+
+
both read
+
resolveThreadStatus() → clarification-bundle path
+
+
+
Your read-path answer, applied. The badge and the Needs you list both
+ come from the clarification-bundle path (clarification_bundle_query.go:119), which excludes
+ lifecycle_only turns. The other shipped resolver,
+ pending_interactions.go:69, does not exclude them, and the two disagree today on exactly
+ the row frame 02 draws — 1 bundle against 0. Binding the badge to one and the list to the other would
+ give you a badge reading 1 over an empty page. One path, both consumers.
+
+
+
+
09
What you reuse, what you build
+
Correcting myself again, and in the expensive direction. I told you this
+ was "a kanban route and a different count source, not a new component." The Office inbox shares the nav
+ slot, not the feature: its InboxItem has eight types — approval,
+ budget_alert, agent_run_failed, task_review,
+ provider_degraded and others — and none of them is a clarification. Its rows
+ come from /api/v1/office/workspaces/{id}/inbox. A kanban Inbox needs its own row model, read
+ path and count producer.
+
+
+
Piece
Status
Where
+
Sidebar nav row + badge
reuse unchanged
AppSidebarNavItem — drop the inOffice gate
+
Answer UI
reuse unchanged
ClarificationPanelSection — props only, no task-chat context
+
Page shell, list container, empty block
copy the pattern
inbox-page-client.tsx — p-6 space-y-4, bordered divided list
+
Tabs
reuse, variant="line"
packages/ui/src/tabs.tsx
+
Row icons and hues
reuse the board's vocabulary
lib/ui/state-icons.ts
+
needs-action classification
reuse
resolveThreadStatus / lib/utils/needs-action.ts
+
Row model + read path
build
new — the Office InboxItem has no clarification type
+
Count producer
build, carefully
inboxCountByWorkspaceId has one writer today; a second on the same key collides
+
+
+
+
+
+
10
The rules this mockup encodes
+
Lift these into the card as acceptance criteria; each one is a decision the drawings
+ already make.
+
+
+
Entry gate, not a state filter. A row enters only if it carries an action you can take now.
+ "Not Running" is 275 rows at a 100% false-positive rate; the gate is 0.
+
The badge counts decisions you owe, and nothing else. Never the queue size, never the
+ history tab, never failures. It must be drivable to zero, and badge > 0 already means a
+ zero renders nothing.
+
One read path. Badge and list both read the clarification-bundle path. Two shipped
+ resolvers disagree today; pick one for both consumers.
+
Answering happens in the row. A display-only inbox still makes you jump around, from a
+ tidier list. Answer and resume posts the answer and restarts the session in place.
+
An empty inbox names what it is not counting. Never "all caught up" alone.
+
History is read-only. No Re-ask: five of nine push-blocked questions had already settled
+ themselves. Permissions appear here, labelled, and never in the badge.
+
Dismiss and snooze are a per-user sidecar and never mutate the queue — Paperclip's rule,
+ adopted verbatim.
+
An agent may only name itself as the owner of a row it creates. 430 of 430 questions in the
+ last ten days were agent-authored and Kandev has no equivalent gate. Paperclip enforces this one
+ against its own agents — I had it returned to me as a 403 while trying to hand you a row.
+
Workspace-scoped. Other workspaces contribute a count in the empty state and the switcher,
+ never a row.
+
Reuse the board's colour vocabulary. Yellow is a question, amber is a permission, red is a
+ failure, emerald is running, violet is background work. The Inbox introduces no new hue.
+
+
+
+
+
+
—
What is still unverified
+
Three things, so nobody reads this deck as more settled than it is. The frames are
+ drawn from source, not from the running build — I had no browser to walk :8817 with,
+ so the fidelity claims rest on the component files at 76ab36270 and on your screenshot, not on
+ a live comparison. Button and input metrics are approximated to within a pixel or two;
+ the sidebar, tabs, badge, row and list metrics are taken from the class strings verbatim.
+ And the numbers are 01:06 UTC, reused rather than re-measured — thirteen minutes on a
+ population that turns over in days would look like verification and would not be.
+
+
+
+
diff --git a/docs/specs/ui/requirements/needs-you-inbox.md b/docs/specs/ui/requirements/needs-you-inbox.md
new file mode 100644
index 0000000000..d850e9bf38
--- /dev/null
+++ b/docs/specs/ui/requirements/needs-you-inbox.md
@@ -0,0 +1,337 @@
+---
+status: draft
+system: ui
+created: 2026-09-12
+owners:
+ - nova28
+---
+
+# Needs-you Inbox Requirements
+
+## Overview
+
+A Kandev agent that calls `ask_user_question_kandev` blocks until a human
+answers, and that question is answerable in exactly one place: a panel over the
+chat input of the owning task's session. Nothing in the kanban workspace says a
+question exists, so a blocked agent is discoverable only by opening the task
+that is already blocked. Measured with the shipped bundle read path on
+2026-09-12 at 01:06Z, the Kandev workspace held zero answerable bundles and the
+whole instance held one, created 2026-09-03T04:54:02Z and unanswered for 8 days
+and 20 hours. That is the failure this capability removes.
+
+The Needs-you Inbox is a workspace-scoped destination that lists every
+clarification bundle genuinely waiting on a human and lets the operator answer
+it without leaving the page. UI owns this contract because the observable
+outcome is a navigation destination, a count, and an answer affordance. The
+durable clarification record, its resolution semantics, and the bundle
+visibility rules remain owned by the integrations system. Because the
+population is small on a healthy board, the empty state is the primary screen
+rather than an edge case, and must always name what it is not counting.
+
+## Terminology
+
+- **Bundle:** Every question an agent asked in one `ask_user_question_kandev`
+ call, resolved as a unit and identified by a `pending_id`.
+- **Answerable bundle:** A bundle a caller can resolve right now, as the
+ clarification contract already defines it: at least one question still open,
+ the bundle's turn is its session's current turn, the session is not terminal,
+ every question resolves an identifier, and it is not a parent-question record.
+- **Actionable count:** The number of rows the operator can act on now, never a
+ queue size or a lifetime total.
+- **Sidecar:** Per-user dismiss and snooze state stored beside the
+ clarification record, never inside it.
+
+## Requirements
+
+### REQ-UI-NEEDS-YOU-INBOX-001: Needs-you Inbox
+
+**Intent:** Give an operator one workspace-scoped place that names every
+question blocking an agent, and lets them clear it to zero without navigating
+to each task.
+
+**User story:** As an operator, I want one list of the questions waiting on me,
+so that a blocked agent is never invisible and I can unblock it in place.
+
+#### Acceptance criteria
+
+**Destination and gating**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.1:** Where the Needs-you Inbox feature flag is
+ enabled, the workspace sidebar shall present a Needs-you entry that navigates
+ to the Inbox destination for the active workspace.
+- **AC-UI-NEEDS-YOU-INBOX-001.2:** Where the feature flag is disabled, the
+ sidebar shall present no Needs-you entry and the Inbox destination shall not
+ render Inbox content.
+- **AC-UI-NEEDS-YOU-INBOX-001.3:** The Needs-you entry's presence shall not
+ depend on Office mode. When Office mode is off and the feature flag is on,
+ the entry shall be present; enabling or disabling Office mode alone shall not
+ change whether the entry is present.
+- **AC-UI-NEEDS-YOU-INBOX-001.4:** The feature flag shall default to disabled
+ in the production profile and enabled in the development and end-to-end
+ profiles.
+
+**Row set**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.5:** The Inbox shall list exactly the answerable
+ bundles whose owning task belongs to the active workspace, and no others.
+ Switching the active workspace shall replace the list with that workspace's
+ bundles.
+- **AC-UI-NEEDS-YOU-INBOX-001.6:** A row shall exist only when its bundle is
+ answerable. The Inbox shall not derive a row from session state. Given a
+ fixture of sessions in a waiting state that hold no answerable bundle, the
+ Inbox shall list zero rows.
+- **AC-UI-NEEDS-YOU-INBOX-001.7:** Given a bundle whose newest turns are
+ lifecycle-only, the Inbox shall still list that bundle, because the current
+ turn is resolved by the shared turn-authority rule that excludes
+ lifecycle-only turns.
+- **AC-UI-NEEDS-YOU-INBOX-001.8:** The Inbox shall not list a bundle that is a
+ parent-question record, that has no resolvable question identifier, whose
+ turn is superseded, or whose session is terminal.
+- **AC-UI-NEEDS-YOU-INBOX-001.31:** If a read names a workspace outside the
+ caller's visible workspace set, then the server shall reject the read with a
+ non-disclosing not-found outcome rather than an empty success, and the Inbox
+ shall render the error state of AC-UI-NEEDS-YOU-INBOX-001.21 rather than the
+ empty state of AC-UI-NEEDS-YOU-INBOX-001.20, so a permission boundary is
+ never reported to the operator as "nothing is waiting". Where the scope an
+ endpoint requires is one that a caller reaching the workspace can lack, the
+ server shall reject with a forbidden outcome and the Inbox shall render the
+ same error state; where the required scope is the reach scope itself, the
+ not-found outcome above is the only reachable rejection and the forbidden
+ outcome shall be unreachable by construction rather than unimplemented. The
+ design shall name, per endpoint, which of the two applies. Workspace
+ scoping shall be enforced by the server from the request identity; a
+ client-supplied workspace identifier shall not by itself grant access. Where
+ authentication is disabled, the single-user identity shall be treated as
+ having access to every workspace, and this AC shall be satisfied by the
+ identity resolution rather than bypassed.
+
+**Ordering and boundaries**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.9:** Rows shall be ordered by bundle creation
+ time ascending, and ties shall be broken by `pending_id` ascending. This
+ order shall be total and reproducible: the same set of bundles shall always
+ produce the same row order. The oldest waiting question shall appear first.
+- **AC-UI-NEEDS-YOU-INBOX-001.10:** Questions inside an expanded row shall
+ appear in the bundle's canonical question order, the same order the task
+ session's own clarification panel uses. That order shall be question index
+ ascending, ties broken by question identifier ascending. A question whose
+ index is absent, negative, or not a number shall sort as index zero and then
+ by identifier, so the order is total and reproducible for any bundle.
+- **AC-UI-NEEDS-YOU-INBOX-001.11:** A single read shall return at most a
+ bounded page, defaulting to 50 rows and capped at 200. When more listable
+ bundles exist than the page returns, the Inbox shall indicate that the list
+ is truncated rather than silently showing a partial list as complete. A
+ bundle hidden by the operator's own dismiss or snooze shall not consume a
+ page slot: given a fixture of 60 answerable bundles of which 20 are hidden
+ and a limit of 50, the first page shall return 40 rows and shall not report
+ truncation.
+- **AC-UI-NEEDS-YOU-INBOX-001.38:** A read shall reject a missing or empty
+ workspace identifier, a non-numeric or non-positive limit, and a cursor it
+ cannot decode, each as a client error that lists no rows and renders the
+ error state; it shall not silently substitute a default for any of them. A
+ read that matches nothing shall return an empty list rather than an absent
+ one, and shall render the empty state rather than the error state. When two
+ reads for different workspaces are in flight, only the response matching the
+ currently active workspace shall be applied, so a slower earlier response can
+ neither replace the active workspace's list nor resurrect a resolved row.
+
+**Count**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.12:** The sidebar count and the list shall be
+ derived from the same response of the same read path. For any fixture, the
+ count shall equal the number of rows the Inbox lists, including a fixture
+ whose newest turns are lifecycle-only. Any change the Inbox applies to the
+ listed rows before the next response confirms it shall apply to the count in
+ the same update, so no rendered state exists in which the count disagrees
+ with the number of listed rows.
+- **AC-UI-NEEDS-YOU-INBOX-001.13:** When the actionable count is zero, no count
+ shall be rendered on the sidebar entry. Removing every listed row shall behave
+ identically whether those rows were answered or hidden. Where the list is not
+ truncated, removing every listed row shall drive the rendered count to absent. Where the list is truncated,
+ removing every listed row shall replace the page with the next one and the
+ count shall continue to reflect that page, so the count reaches absent only
+ when no listable bundle remains in the workspace. Given a fixture of 60
+ listable bundles with a limit of 50, dismissing all 50 listed rows shall leave
+ the Inbox listing the remaining 10 and shall not render the empty state, so a
+ truncated page emptied by hiding can never be reported as caught up.
+- **AC-UI-NEEDS-YOU-INBOX-001.14:** When the page is truncated, the rendered
+ count shall present the bounded value as a capped indicator rather than as an
+ exact total.
+- **AC-UI-NEEDS-YOU-INBOX-001.34:** The sidebar count shall be present and
+ correct before the operator has opened the Inbox in the current browser
+ session. Given a fixture with answerable bundles in the active workspace, a
+ fresh load of any workspace route other than the Inbox shall render the count
+ on the sidebar entry, and resolving a bundle elsewhere shall reduce that count
+ without the Inbox route ever being mounted. A count that becomes correct only
+ after the destination is visited does not satisfy this criterion, because the
+ operator would have to open the Inbox to learn that they need to.
+- **AC-UI-NEEDS-YOU-INBOX-001.40:** Every produced value of the actionable count
+ shall carry the bound it was computed under, and every producer shall apply
+ the same bound, the same workspace scope, and the same per-operator hiding
+ rule as the list read. A produced count shall be accompanied by whether
+ further listable bundles exist beyond that bound, so the capped presentation
+ of AC-UI-NEEDS-YOU-INBOX-001.14 is representable by every producer. Given a
+ fixture of 60 listable bundles and the default limit of 50, the count before the
+ Inbox is opened and the count rendered after it is opened shall be the same
+ value and shall both present as capped. A produced count shall never be
+ applied to the badge while the list it labels is stale: where one response
+ cannot supply both the count and the rows it counts, the Inbox shall re-read
+ rather than apply the count alone.
+
+**Answering in place**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.15:** When the operator expands a row, the Inbox
+ shall present that bundle's real questions, their options, and the bundle's
+ shared context, using the same clarification answer component the task
+ session renders.
+- **AC-UI-NEEDS-YOU-INBOX-001.16:** When the operator submits an answer or a
+ rejection from an expanded row, the system shall resolve that bundle and
+ resume the owning task without navigating away from the Inbox, and shall
+ remove the resolved row from the list.
+- **AC-UI-NEEDS-YOU-INBOX-001.17:** While two callers resolve the same bundle
+ concurrently, exactly one shall be recorded as the winner. When the Inbox is
+ the losing caller, it shall remove the row and shall not present a failure.
+ It shall report the winning outcome in a transient non-error notice that
+ names the bundle and survives the row's removal, so the outcome is observable
+ after the row is gone.
+- **AC-UI-NEEDS-YOU-INBOX-001.18:** When the same resolution is submitted twice
+ for one bundle, the second submission shall not create a second resolution
+ and shall not change the recorded outcome.
+- **AC-UI-NEEDS-YOU-INBOX-001.19:** When a listed bundle stops being answerable
+ for any reason, the Inbox shall remove its row and reduce the count without a
+ manual reload. This shall hold for every exit in
+ AC-UI-NEEDS-YOU-INBOX-001.8, not only for the ones a push notification
+ announces: the row set shall converge from re-reading the bundle path, and no
+ exit shall be able to strand a row by emitting no event. A design that
+ removes the row only when a specific event arrives does not satisfy this
+ criterion.
+- **AC-UI-NEEDS-YOU-INBOX-001.35:** If a submitted resolution fails for any
+ reason other than losing to a winner or finding the bundle no longer active,
+ then the Inbox shall restore the row and the count to their pre-submission
+ values, shall keep the operator's entered answer, and shall present an error
+ that offers to submit again. A failed submission shall never leave the row
+ removed, because a removed row for an unresolved bundle hides a blocked agent
+ with no way back.
+- **AC-UI-NEEDS-YOU-INBOX-001.39:** The shared clarification answer component
+ shall report each submission's outcome to whichever surface hosts it,
+ distinguishing at least: this caller's resolution was recorded; another caller
+ had already won, with the winning outcome available to the host; the bundle is
+ no longer active; and the submission failed and may be retried.
+ AC-UI-NEEDS-YOU-INBOX-001.17 and AC-UI-NEEDS-YOU-INBOX-001.35 shall be
+ satisfied through that report, not through a second submission path: the Inbox
+ shall not post the resolution itself. Extending the component shall be
+ additive: with the task session and Quick Chat hosts
+ unchanged, their rendered behaviour and their existing resolution callback
+ shall be identical before and after, asserted by a test.
+
+**Empty and error**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.20:** When the Inbox lists no rows for the active
+ workspace, it shall render an empty state that names what it is not counting:
+ bundles in other workspaces, pending tool-permission requests,
+ parent-question records, and the operator's own snoozed or dismissed rows.
+ The trigger shall be the absence of listed rows, not the absence of
+ answerable bundles, because a dismissed or snoozed bundle remains answerable
+ under AC-UI-NEEDS-YOU-INBOX-001.22. When the operator's own dismiss or snooze
+ is hiding at least one bundle, the empty state shall say so and offer the
+ restore affordance of AC-UI-NEEDS-YOU-INBOX-001.33; when nothing is hidden it
+ shall not imply that anything is.
+- **AC-UI-NEEDS-YOU-INBOX-001.21:** If the read fails, then the Inbox shall
+ render an error state distinct from the empty state, shall not state that
+ the operator is caught up, and no count shall be rendered.
+
+**Dismiss and snooze**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.22:** When the operator dismisses or snoozes a
+ row, the underlying clarification record shall be unchanged and the bundle
+ shall remain answerable from the task session and from the external
+ question-answering tools.
+- **AC-UI-NEEDS-YOU-INBOX-001.23:** A dismissed or snoozed bundle shall be
+ excluded from both the list and the count by the same read, so the count
+ shall continue to equal the number of listed rows.
+- **AC-UI-NEEDS-YOU-INBOX-001.24:** When a snooze expires, the bundle shall
+ reappear in the list and the count, provided it is still answerable, without
+ the operator reloading or re-navigating. A snooze shall be treated as expired
+ when its expiry instant is less than or equal to server time, so an expiry
+ exactly at the current instant reappears rather than remaining hidden.
+- **AC-UI-NEEDS-YOU-INBOX-001.25:** Dismiss and snooze shall be per user and
+ idempotent: repeating the same dismiss or snooze for one bundle shall leave
+ the same single sidecar state.
+- **AC-UI-NEEDS-YOU-INBOX-001.32:** Snooze shall offer a bounded set of
+ durations of 1 hour, 4 hours, and 24 hours, shall apply 4 hours when the
+ operator selects no duration, and shall reject a duration outside that set.
+ Expiry shall be evaluated against server time.
+- **AC-UI-NEEDS-YOU-INBOX-001.33:** Dismissal shall be reversible. The Inbox
+ shall disclose how many answerable bundles in the active workspace are hidden
+ by the operator's own dismiss or snooze, both when the list is empty and when
+ it is not, and shall provide a way to restore a hidden bundle to the list. A
+ bundle blocking an agent shall never become permanently unreachable through
+ the Inbox as a result of one dismissal.
+- **AC-UI-NEEDS-YOU-INBOX-001.36:** Dismiss, snooze, and restore shall each be a
+ server-side write with an addressable contract, authorized on the workspace
+ owning the addressed bundle by the same rule as the read. Addressing a bundle
+ the caller cannot see shall produce the same non-disclosing not-found outcome
+ as AC-UI-NEEDS-YOU-INBOX-001.31, and shall write no sidecar state. Restoring
+ a bundle that is not hidden shall succeed and change nothing.
+- **AC-UI-NEEDS-YOU-INBOX-001.37:** The operator's hidden bundles shall be
+ enumerable, not merely countable: the Inbox shall be able to list the bundles
+ its own dismiss or snooze is hiding in the active workspace, with enough
+ identity to restore a chosen one. Each enumerated bundle shall carry which of
+ dismiss or snooze is hiding it and, when snoozed, when that snooze expires.
+ The enumeration shall report a workspace-wide total that is independent of its
+ own page bound, and the hidden count of AC-UI-NEEDS-YOU-INBOX-001.33 shall
+ equal that total for the same operator and workspace. A page-scoped length
+ shall not be used to satisfy this criterion: given a fixture of 60 hidden
+ bundles and a limit of 50, the total and the hidden count shall both be 60.
+- **AC-UI-NEEDS-YOU-INBOX-001.41:** A snooze expiring shall restore its bundle to
+ the count even when the Inbox has never been mounted in the current browser
+ session. Given a fixture holding one snoozed answerable bundle, with the
+ operator on any other workspace route, the sidebar count shall increase when
+ that snooze expires, without a reload and without the Inbox being opened. The
+ client shall be able to learn when the operator's earliest snooze expires from
+ a response it already reads.
+
+**Consistency with Threads**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.26:** The Inbox shall render each row's status
+ using the shared thread status vocabulary, and shall not define a second
+ status classifier.
+- **AC-UI-NEEDS-YOU-INBOX-001.27:** Given a fixture in which the only pending
+ action on every task is an answerable clarification bundle, the Inbox row set
+ and the Threads needs-action preset shall name the same tasks. Where the two
+ differ by contract, a test shall assert the difference rather than hide it:
+ a pending tool-permission request, a parent-question record, a bundle with no
+ resolvable question identifier, and a terminal session shall each appear in
+ the Threads preset and shall not appear in the Inbox.
+
+**Non-collision, copy, and coverage**
+
+- **AC-UI-NEEDS-YOU-INBOX-001.28:** The Needs-you count shall be stored under
+ its own per-workspace state key. No Needs-you code path shall write the
+ Office inbox count key.
+- **AC-UI-NEEDS-YOU-INBOX-001.29:** All Inbox copy shall be localized in the
+ five shipped locales, shall contain no Unicode em dash, and the repository
+ i18n ratchet shall pass.
+- **AC-UI-NEEDS-YOU-INBOX-001.30:** An end-to-end specification shall cover
+ listing an answerable bundle, expanding it, answering it, and observing the
+ row removed and the task resumed without navigation.
+
+## Out of scope
+
+- **Re-ask.** Cut from v1 by product decision on 2026-09-11. The Inbox answers
+ or rejects a bundle; it never re-issues one.
+- **The history bucket and the failed-task bucket.** Separate sibling
+ capabilities. v1 renders one bucket and therefore no tab strip; the variant a
+ future strip uses is recorded in the system design.
+- **Pending tool-permission requests.** They already surface in the Office
+ inbox, and admitting them here would give the Inbox two sources and break the
+ guarantee that the count and the list come from one read. The empty state
+ must name them as uncounted.
+- **Cross-workspace rows.** Workspace-scoped by product decision on
+ 2026-09-11; permissions are the barrier.
+- **Any change to the meaning of the current-turn predicate**, the bundle
+ visibility rules, or the resolution semantics. This capability consumes them
+ unchanged; a change to them is a change to the integrations contract.
+- **Backfill or notification on first enable.** Turning the flag on shall not
+ emit notifications for bundles that already exist.
+- **Retention or archival of sidecar rows.**
diff --git a/docs/specs/ui/system-design/needs-you-inbox-01.md b/docs/specs/ui/system-design/needs-you-inbox-01.md
new file mode 100644
index 0000000000..d61ebb3696
--- /dev/null
+++ b/docs/specs/ui/system-design/needs-you-inbox-01.md
@@ -0,0 +1,529 @@
+---
+status: draft
+system: ui
+requirements:
+ - REQ-UI-NEEDS-YOU-INBOX-001
+---
+# Needs-you Inbox System Design Part 1
+
+Part 2 (control flow, failure, persistence, status vocabulary, security, prior
+art) is in [`needs-you-inbox-02.md`](needs-you-inbox-02.md); Part 3 (design
+source, fidelity decisions) is in
+[`needs-you-inbox-03.md`](needs-you-inbox-03.md).
+
+## Purpose and boundaries
+
+UI owns the destination, the count, the row presentation, and the per-user
+dismiss and snooze sidecar. It consumes, and does not own:
+
+- The answerable-bundle predicate and bundle visibility rules, owned by
+ integrations (`REQ-INTEGRATIONS-EXTERNAL-QUESTION-ANSWERING-001`, labels
+ L1a-L16, D4, D6).
+- The current-turn predicate and its total order
+ (`docs/specs/current-turn-authority`, AC-3).
+- Clarification resolution semantics: first-write-wins, idempotent replay, and
+ task resume.
+
+The single most important boundary: the row set is defined by the bundle read
+path alone, never derived from session state.
+
+## Requirement mapping
+
+All rows are `REQ-UI-NEEDS-YOU-INBOX-001`; the AC suffixes map as:
+
+| ACs | Design section |
+| --- | --- |
+| .1-.4 | [Feature flag](#feature-flag) |
+| .5-.11, .31, .38 | [Data and contracts](#data-and-contracts), [Security](needs-you-inbox-02.md#security) |
+| .12-.14, .40 | [Data and contracts](#data-and-contracts), [Control flow](needs-you-inbox-02.md#control-flow) |
+| .15-.19, .34 | [Control flow](needs-you-inbox-02.md#control-flow) |
+| .20-.21, .35, .39 | [Failure and recovery](needs-you-inbox-02.md#failure-and-recovery), [Components and responsibilities](#components-and-responsibilities) |
+| .22-.25, .32-.33, .36-.37, .41 | [Persistence](needs-you-inbox-02.md#persistence), [Data and contracts](#data-and-contracts), [Control flow](needs-you-inbox-02.md#control-flow) |
+| .26-.27 | [Status vocabulary](needs-you-inbox-02.md#status-vocabulary) |
+| .28-.30 | [Components and responsibilities](#components-and-responsibilities) |
+
+## Verified input inventory
+
+Every path and line below was read at commit `20efe4855`. Nine citations the
+originating card and early review rounds got wrong are corrected here; a builder
+following the card without this list looks for files that do not exist, and C6 is
+a silent authorization bypass.
+
+1. **The bounded, workspace-scoped, cursor-paginated list query already exists**
+ (the card says it does not; that is true only of the HTTP layer).
+ `ListUnresolvedClarificationBundles`
+ (`apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go:19`)
+ already takes `WorkspaceID`, `CreatedSince`, a `(created_at, pending_id)`
+ cursor and a `Limit` in one query. New backend work is HTTP exposure plus the
+ sidecar predicate, not a new query.
+2. **Ordering is already defined and already total:** `ORDER BY b.created_at ASC,
+ b.pending_id ASC` (`:150`), matching the cursor comparison at `:65`, documented
+ as spec L6 on `models/clarification_bundle.go:45`. AC .9 restates it.
+3. **A count equal to list length already exists:** `resp.Count =
+ len(resp.Bundles)` (`mcp/handlers/question_handlers.go:225`).
+4. **`resolveThreadStatus` names two functions with incompatible return types.**
+ The card cites `thread-view-query.ts:92-93`, NON-exported, returning the string
+ union at `:15`. A second, exported one (`components/threads/thread-column.tsx:21`)
+ returns the object `ThreadStatus` (`lib/threads/thread-session-status.ts:23`).
+ AC .26 resolves to the latter family and needs no rename or re-export: the Inbox
+ calls the already-exported `resolveThreadSessionStatus`. See
+ [Status vocabulary](needs-you-inbox-02.md#status-vocabulary).
+5. **Office DOES emit a clarification-ENTITY inbox row**
+ (`office/dashboard/service_inbox.go:451-483`, `Type: "permission_request"`,
+ `EntityType: "clarification"`, from `ListPendingPermissions()`). The card's
+ substantive point survives: no Office row comes from the clarification BUNDLE
+ path. Hence permission requests are excluded here and named in the empty state.
+6. **The MCP visibility resolver cannot be reused from HTTP.**
+ `resolveBundleVisibility` is unexported and its scoped branch is gated on
+ `callerScoped(ctx)` = `resolvedByFromContext(ctx) != ""`, set only by an MCP tool
+ call, so from a browser-facing handler it takes the UNSCOPED branch and disables
+ workspace filtering. Use the identity mechanism in
+ [Security](needs-you-inbox-02.md#security). Caution: the task service has its own
+ `callerScope`/`callerSubject` (`task/service/service_access.go`) reading
+ `authn.IdentityFromContext` — same names, different package, and those are the
+ correct ones.
+7. `lib/threads/thread-session-status.ts` declares `const STATUS = {` with NO
+ `export`; only `resolveThreadSessionStatus` and `resolveThreadColumnStatus` are.
+8. `models.ClarificationBundleSummary` is `{PendingID, SessionID, TaskID,
+ CreatedAt}` — no task title.
+9. `models.ClarificationBundlePage.HasMore` is computed by the shipped query under
+ the same filters; do not re-derive truncation from cursor presence.
+
+Reused unchanged:
+
+| Contract | Location |
+| --- | --- |
+| `currentTurnAuthority` returns predicate **and** order; excludes lifecycle-only turns | `apps/backend/internal/task/repository/sqlite/turn_authority.go:74-87` |
+| Bundle list query, parent-question exclusion, missing-question-id exclusion, visibility disjunction | `clarification_bundle_query.go:109-205` |
+| Page options: default limit 50, cap 200 | `apps/backend/internal/task/models/clarification_bundle.go:19-50` |
+| Bundle messages by id | `FindMessagesByPendingID`, wired at `apps/backend/internal/backendapp/helpers.go:1902-1904` |
+| Question projection, response envelope, base64url cursor | `clarification/bundle_question.go:68-86`, `mcp/handlers/question_handlers.go:205-265` |
+| Resolution endpoint `POST /api/v1/clarification/:id/respond` | `apps/backend/internal/clarification/handlers.go:207-212` |
+| Answer component props `{pending, messages, onResolved, shortcutScopeRef, maxHeightVh}` | `apps/web/components/task/chat/clarification-panel-section.tsx:54-60` |
+| Shared status vocabulary, including `clarification` and `permission` kinds | `apps/web/lib/threads/thread-session-status.ts:9-99` |
+| Office count key, sole writer and sole reader | `office-slice.ts:258`, `office/selectors.ts:42` |
+| Sidebar chrome, row icon/hue, em-dash check, five locales | cited in the card; `app-sidebar-*`, `lib/ui/state-icons.tsx`, `scripts/check-no-em-dash-ui.mjs`, `src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}` |
+| Request identity on every HTTP route, synthetic when auth is disabled | `apps/backend/internal/auth/httpmw/middleware.go` (`Middleware`, `SyntheticIdentity`), `auth/authn.IdentityFromContext` |
+| Workspace authorization with the 404-vs-403 rule | `taskSvc.AuthorizeWorkspaceScope` / `requireWorkspaceScope`, `apps/backend/internal/task/service/service_access.go` |
+| Caller-visible workspace set | `taskSvc.ListWorkspaces` -> `filterWorkspacesForCaller`, same file |
+| Workspace-wide pending-action broadcast, fail-closed | `ws.ActionSessionPendingActionChanged` -> `hub.BroadcastToWorkspaceOrDrop`, `apps/backend/internal/gateway/websocket/task_notifications.go` |
+| Boot-state per-workspace keys and active-workspace cookie | `bootStateBuilder`, `apps/backend/internal/backendapp/boot_state.go` |
+| Page truncation flag | `models.ClarificationBundlePage.HasMore` |
+| Flag registry and profile shape | `apps/backend/internal/runtimeflags/registry.go:32-48`, `profiles.yaml:39-45` |
+
+## Feature flag
+
+Registry key `features.needsYouInbox`, env var
+`KANDEV_FEATURES_NEEDS_YOU_INBOX`, following the camelCase-to-SCREAMING_SNAKE
+convention (`features.multiTenancy` / `KANDEV_FEATURES_MULTI_TENANCY`). Profile
+defaults `prod: "false"`, `dev: "true"`, `e2e: "true"`. The e2e value is
+load-bearing: AC .30 makes an end-to-end spec mandatory and it cannot run
+against a flag that is false everywhere. Follow `/runtime-feature-flags` for the
+registry, profile and frontend completeness tests.
+
+The nav entry is gated on this flag alone. It must not be placed inside the
+`{inOffice && ...}` block, and must not read the Office feature flag.
+
+## Components and responsibilities
+
+**Backend.** New HTTP handlers in the clarification package: one read, one
+sidecar upsert, one sidecar delete, one hidden-bundle list. The read resolves
+identity and workspace access per [Security](needs-you-inbox-02.md#security) and reuses the existing
+bundle lister with the sidecar filter applied inside the same query. It adds no
+new SQL predicate for answerability.
+
+Reuse stops at the lister. These handlers do NOT call the MCP response builder
+`buildListPendingQuestionsResponse`, which emits the `QuestionStatus`
+projection; this endpoint emits durable message rows instead, for the reason in
+[Data and contracts](#data-and-contracts). The handler calls
+`ListUnresolvedClarificationBundles` then `FindMessagesByPendingID` per bundle —
+the same pair the MCP builder calls — and assembles its own envelope.
+
+**Sidecar store.** A new per-user table plus repository methods for dismiss,
+snooze and restore.
+
+**Frontend.** A route component, a bordered container of flat divided rows, a
+row component with a 32px muted icon chip and plain muted relative time, an
+expander mounting the shipped `ClarificationPanelSection`, a nav entry, a new
+store slice holding `needsYouCountByWorkspaceId`, and its selector.
+
+**One shared component IS modified, and that scope is declared here rather
+than discovered at Build (AC .39).** `ClarificationPanelSection` cannot be
+mounted entirely as-is, because its only outward signal is
+`onResolved: () => void` and `useResolveCallback` fires that solely on
+`submitState === "ok"`. A `409 not_active` becomes `"expired"` and never calls
+it; a `5xx`, a timeout or a network failure becomes `"error"` and never calls
+it; and the winner's `claimed` / `status` / `answers`, which
+`useClarificationGroup` already parses into `ClarificationRespondResult`, never
+leave the hook. So AC .17's notice has no input, AC .35's rollback has no
+trigger, and the losing-caller row removal has nothing to fire on. Two changes,
+both additive:
+
+1. **An optional outcome callback** threaded
+ `useClarificationGroup` -> `ClarificationInputOverlay` ->
+ `ClarificationPanelSection`, reporting the four outcomes AC .39 enumerates.
+ It carries values the hook already computes; nothing new is derived. See
+ [Failure and recovery](needs-you-inbox-02.md#failure-and-recovery) for the mapping.
+2. **One string externalized.** The overlay renders the literal
+ `{group.answeredCount} of {group.total} answered`
+ (`data-testid="clarification-group-progress"`) with no `t()`. That string
+ will render inside the Inbox, so AC .29 reaches it. The i18n ratchet judges
+ added and changed lines and would never have caught an untouched one, which
+ is precisely why it is named here. This is the ONLY copy change to the shared
+ component.
+
+`onResolved` keeps its exact signature and firing rule, the new callback is
+optional, and the task chat and Quick Chat hosts pass neither, so their
+behaviour is unchanged — which AC .39 requires a test to assert. The rejected
+alternative was for the Inbox to post `POST /api/v1/clarification/:id/respond`
+itself: that reads the outcome directly, and gives one bundle two posting paths
+that must stay in step forever.
+
+The count key is deliberately new: `inboxCountByWorkspaceId` has exactly one
+writer today, and a second would make the Office and Needs-you badges overwrite
+each other per workspace.
+
+v1 renders no tab strip and no page title: the layout starts at the toolbar
+with `p-6 space-y-4`, and the app top bar owns the title.
+
+## Data and contracts
+
+`GET /api/v1/clarification-inbox`
+
+Query parameters: `workspace_id` (required), `limit` (optional, default 50,
+capped at 200), `cursor` (optional, opaque).
+
+Validation (AC .38), all `400` with no rows and no sidecar effect:
+
+| Input | Rule |
+| --- | --- |
+| `workspace_id` absent or empty | reject. Never read as "the empty workspace", never defaulted to the caller's first |
+| `limit` absent | default 50 |
+| `limit` non-numeric or `<= 0` | reject, not clamped: a client sending `0` has a bug worth surfacing |
+| `limit > 200` | clamp to 200; a cap, not an error |
+| `cursor` absent | first page |
+| `cursor` present but undecodable | reject |
+
+Authorization failure is `404` per [Security](needs-you-inbox-02.md#security) (which records why
+`403` is unreachable on every endpoint here), not `400`. A
+read matching nothing returns `"bundles": []`, never `null`, and renders the
+empty state; only a non-2xx renders the error state (AC .21, .31).
+
+The workspace is a query parameter, not a path segment, on purpose:
+`/api/v1/workspaces/:x/...` already carries two different parameter names at one
+position (`:workspaceID` in the GitLab controller, `:wsId` in Office costs), and
+gin panics at registration when sibling routes disagree on a wildcard name. A
+query parameter adds no wildcard and matches the MCP tool's own `workspace_id`.
+
+Response body:
+
+```json
+{
+ "bundles": [
+ {
+ "pending_id": "...",
+ "task_id": "...",
+ "session_id": "...",
+ "session_state": "WAITING_FOR_INPUT",
+ "task_title": "...",
+ "created_at": "2026-09-03T04:54:02Z",
+ "context": "...",
+ "messages": [ { "id": "...", "metadata": { "pending_id": "...", "question_id": "...", "question_index": 0, "question": { "id": "...", "title": "...", "prompt": "...", "options": [] } } } ]
+ }
+ ],
+ "count": 1,
+ "hidden_count": 0,
+ "next_snooze_expiry": null,
+ "next_cursor": "..."
+}
+```
+
+`count` is `len(bundles)`. Because the sidecar filter runs INSIDE the bundle
+query (see [Persistence](needs-you-inbox-02.md#persistence)), every returned row is already a
+listable row, so `count == len(bundles)` holds by construction and AC .12 is
+structural rather than merely tested. The client reads the list and the badge
+from this one response and never computes either separately.
+
+**The count has exactly TWO producers, and no third (AC .40).** A number is
+easy to produce in three places and hard to keep equal in three places, so the
+set is closed here:
+
+| Producer | Bound | Truncation carried by | Rows alongside |
+| --- | --- | --- | --- |
+| This read | the request's `limit` (default 50, cap 200) | `next_cursor` present | yes, the same response |
+| Boot hydration | the SAME default limit of 50, same workspace scope, same sidecar exclusion for the boot identity | an explicit flag in the boot payload (see [Control flow](needs-you-inbox-02.md#control-flow)) | no, and none are rendered yet |
+
+Everything else that changes the row set produces NO count. In particular the
+sidecar writes return no body: they answer `204` and the client re-reads
+(below). That is what keeps AC .12's "no rendered state exists in which the
+count disagrees with the number of listed rows" true by construction instead of
+by three hand-matched computations. A third producer is the defect, not the
+optimisation: a workspace-wide count returned from a write would read 59 while
+the page it labels holds 49.
+
+Both producers are bounded the same way, so AC .14's capped presentation is
+reachable from either: 60 listable bundles read at a limit of 50 give `count`
+50 with truncation set, before the route is opened and after, and the badge
+never changes value merely because the operator visited.
+
+`next_cursor` is present exactly when `models.ClarificationBundlePage.HasMore`
+is true, which the shipped query already computes under the same filters.
+Because hidden rows are excluded before `LIMIT` rather than after, a short page
+means exhaustion and never "the rest were hidden" — which is what makes AC .11's
+truncation indicator and AC .14's capped count honest.
+
+`hidden_count` is how many answerable bundles in this workspace THIS operator's
+sidecar is hiding right now. It is workspace-wide, not page-local, so it is a
+SECOND query: a bounded `COUNT` over the same visibility and answerability
+predicates with the sidecar join inverted. No such field exists in the shipped
+tree and the MCP builder computes only a page-local `len()`, which cannot answer
+a workspace-wide question. The second query is AC .33's cost, accepted
+deliberately.
+
+`next_snooze_expiry` is the earliest `snooze_until` strictly after server time
+among the bundles THIS operator's sidecar is hiding in this workspace, or `null`
+when none is snoozed. It falls out of the same bounded query that produces
+`hidden_count` (a `MIN` beside the `COUNT`), so it costs no third round trip.
+It exists because AC .41's timer has to be schedulable from a response the
+client already reads: without it the client would have to call the hidden
+endpoint purely to learn when to ask again, and the badge would depend on a
+route the operator has not opened. A dismissal contributes no expiry, so a
+workspace with only dismissals reports `null` and schedules nothing.
+
+**Why this carries `messages` and not the MCP envelope's `questions`.** The MCP
+tool projects each bundle to `QuestionStatus`
+(`{question_id, title, prompt, status, options}`) for an agent reading data.
+This endpoint's consumer is `ClarificationPanelSection` -> `ClarificationInputOverlay`,
+which is driven by durable message rows: it reads `metadata.question`,
+`metadata.question_id`, `metadata.pending_id` and orders by
+`metadata.question_index`
+(`apps/web/components/task/chat/clarification-input-overlay.tsx:57-92`). Handing
+it the `QuestionStatus` projection would force the client to reconstruct message
+rows — a second representation of one bundle, the drift AC .26 forbids
+elsewhere. Hence reuse at the lister level, not the envelope level.
+
+Messages travel with the list, so expanding a row needs no second request and
+cannot race one.
+
+AC .10's canonical order is `metadata.question_index` ascending, tie-broken by
+`metadata.question_id` ascending. `question_index` alone is not a total order:
+the shipped overlay
+(`apps/web/components/task/chat/clarification-input-overlay.tsx`) coerces an
+absent index to `0`, so a bundle with two indexless questions has a tie that
+falls back to arrival order and is not reproducible. An index that is absent,
+negative, or not a number sorts as `0` and then by `question_id`. The server emits
+messages in this order AND rewrites each emitted `metadata.question_index` to its
+0-based rank within it. That rewrite is what makes the two surfaces agree, because
+the client does NOT preserve server order: `sortMessagesByQuestionIndex` re-sorts
+on `question_index ?? 0`, which catches only null and undefined, so a negative
+index sorts ahead of zero and a numeric string sorts as its own value, both
+contrary to AC .10. Against normalized ranks that re-sort is a no-op. Normalizing
+server-side rather than fixing the client is deliberate: changing the shared sort
+would alter what the task session and Quick Chat render, which AC .39 forbids. The
+rewrite is a response projection and writes nothing durable.
+
+Row presentation: primary text is the first question's `title` when non-empty,
+else its `prompt`, truncated; if both are empty the row falls back to the bundle
+`context`, and if that is empty too, to a localized "Question from agent"
+string. A row is never blank and never unlabelled.
+
+The envelope's bundle-level `context` is the FIRST message's `metadata.context` in
+the canonical order above when that is a non-empty string, else `""`; absent or
+non-string counts as absent, and later messages are deliberately not consulted, so
+the row's fallback text and the expanded panel cannot disagree — the overlay
+renders exactly `readSharedContext(sortedMessages[0])`. Do not reuse
+`bundleContext` (`mcp/handlers/question_handlers.go`): unexported in another
+package, and it scans in STORAGE order.
+
+Secondary text is the task title then the task identifier.
+`models.ClarificationBundleSummary` carries no title (Correction 8), so the
+handler resolves it per bundle from the task service and returns `task_title` —
+bounded by page size (at most 200 lookups), and it spares the client a second
+data source it would otherwise invent. A task lookup that FAILS, one that finds
+nothing, and an empty title all yield `task_title: ""`, a failure being logged;
+the row renders the identifier alone, so neither a deleted task nor a transient
+store error hides a question. The title is presentational and the row stays fully
+actionable without it, which is why it degrades where `messages` fails the page. Relative time derives from `created_at`.
+
+`session_state` is the owning session's `state`, resolved per bundle by the
+handler from the session service and bounded by page size exactly as
+`task_title` is. It exists for one reason: AC .26's resolver takes
+`Pick & {...}`, so `state` is a REQUIRED argument (see
+[Status vocabulary](needs-you-inbox-02.md#status-vocabulary)). Without this field the
+client would have to fabricate a value or fetch the session separately, and a
+fabricated state is a second classifier wearing a disguise. When the session
+cannot be read the field is `""`; the resolver short-circuits on
+`pending_action` before reading `state`, so the label is unaffected, and the
+row still renders.
+
+`messages` is the row's payload, not an ornament, so it does NOT degrade the way
+`task_title` and `session_state` do. Two cases, and they differ:
+
+- **The per-bundle message read FAILS.** The whole page fails and the Inbox
+ renders the read-error state (AC .21). A transient store error is not a fact
+ about that bundle, and dropping one row would hide a blocked agent behind a
+ page that looks complete. An error the operator can retry is the honest
+ outcome.
+- **The read succeeds and returns NO messages.** That bundle is omitted from the
+ page and the omission is logged with its `pending_id`. A row with no questions
+ cannot satisfy AC .15 and is not actionable, so admitting it would breach
+ AC .6's entry gate. This should be unreachable — the bundle query already
+ excludes bundles with no resolvable question identifier (AC .8) and
+ `resolveIdentity` treats a messageless bundle as not-found — but "unreachable"
+ is a claim about today's predicates, and the failure it guards against is a row
+ the operator can see and cannot answer.
+
+An omission changes only `count`, which stays `len(bundles)` and therefore stays
+equal to the rows listed (AC .12). It does not change `next_cursor`: truncation
+describes what the bounded query found, not what enrichment kept.
+
+That is also why the empty state carries a second condition: AC .20's trigger is
+the absence of listed rows AND no truncation reported. The second clause is
+AC .11's doing, not a rival trigger — it forbids showing a partial list as
+complete, and a zero-row page that reports truncation is exactly that. A response listing zero rows
+while carrying `next_cursor` means enrichment emptied a page the query had filled,
+so the Inbox says the list could not be built in full and offers the AC .21 retry
+rather than reporting the operator caught up. Nothing re-reads automatically there
+— the omissions are logged, and a retry would refetch the same page and drop the
+same rows.
+
+### Sidecar write and restore contracts
+
+AC .22-.25 and AC .32-.33 describe dismiss, snooze and restore; these are their
+contracts (AC .36). All three sit under the same new `/api/v1/clarification-inbox`
+prefix. A path parameter is safe here where it was not on `/api/v1/workspaces`:
+that prefix already carries two disagreeing wildcard names at one position and
+gin panics on that, whereas this prefix is new and every route below names the
+same `:pendingID`.
+
+**`PUT /api/v1/clarification-inbox/sidecar/:pendingID`** — dismiss or snooze.
+
+```json
+{ "state": "dismissed" | "snoozed", "snooze_duration": "1h" | "4h" | "24h" }
+```
+
+- `snooze_duration` is read only when `state` is `snoozed`; absent means `4h`
+ (AC .32). Any other value, including a well-formed `2h`, is `400`. The set is
+ closed deliberately: an open duration would let one entry hide a blocking
+ question for longer than the nine-day failure this feature exists to fix.
+- `state` absent or outside the two values is `400`.
+- Authorized on the workspace owning the addressed bundle by the same rule and
+ the same status mapping as the read (AC .36): an unseeable bundle is `404` and
+ writes nothing, and an unknown `pendingID` is `404`. **The route carries no
+ `workspace_id`, so the mechanism is NAMED here rather than left to Build:**
+ `Resolver.AuthorizeBundleAccess(ctx, pendingID)`
+ (`apps/backend/internal/clarification/resolver.go`), which resolves the
+ bundle's `task_id` from its durable messages and then applies
+ `AuthorizeTaskAccess` -> `authorizeTaskScope(ctx, taskID,
+ authz.ScopeWorkspaceRead)`. That is the READ's rule reached through the
+ bundle's task, not a second rule: it loads the task's workspace and tests the
+ same `workspaceDecision(...).CanRead()` predicate at the same scope that
+ `AuthorizeWorkspaceScope` tests for the read. Authorizing the bundle's ACTUAL
+ workspace rather than a client-named one is also how AC .31's "a
+ client-supplied workspace identifier shall not by itself grant access" holds on
+ a route that supplies none. Every rejection collapses to one `404`
+ (`ErrBundleNotFound`): an unknown `pendingID`, a parent-question record, an
+ unresolvable task and an out-of-reach workspace are indistinguishable to the
+ caller. Where a transient store error surfaces depends on WHICH step hit it, and
+ that split is reused as-is. Inside `resolveIdentity` it propagates unconverted
+ and the caller sees a server error. Inside authorization it does NOT:
+ `AuthorizeBundleAccess` rewrites ANY error from `AuthorizeTaskAccess` to
+ `ErrBundleNotFound`, and `authorizeTaskScope` returns raw errors from `GetTask`
+ and from a non-not-found `GetWorkspace`, so a transient failure there is
+ indistinguishable from a rejection and answers `404`. Build must NOT make this
+ symmetric: that resolver is shared with the shipped clarification read endpoints
+ and lies outside this feature's boundary. See
+ [Security](needs-you-inbox-02.md#security) for why `403` cannot arise here.
+- **The lookup matches ALREADY-RESOLVED bundles, and that is deliberate.**
+ `resolveIdentity` reads durable messages, which outlive resolution, so a bundle
+ dismissed and later answered elsewhere still resolves and lands in the `DELETE`
+ table's "real and visible" rows, not its "unknown" row. Persistence keeps that
+ orphan sidecar row and runs no retention job, so this is what keeps it
+ deletable. A lister-based lookup would answer `404` instead, because the bundle
+ lister is scoped to UNRESOLVED bundles — making an orphan row permanently
+ unreachable through the only endpoint that removes it.
+- Idempotent by construction: primary key `(user_id, pending_id)` makes it an
+ upsert, so repeating leaves one row (AC .25). Re-snoozing REPLACES
+ `snooze_until` with a fresh absolute instant from server time; it does not
+ extend the old one.
+- Concurrency: the primary key serializes writers, so two callers racing on one
+ bundle leave one row and the later commit wins. A `PUT` racing a `DELETE`
+ therefore ends hidden or visible, never both and never duplicated. Last-write-
+ wins is safe here because the row is per-user preference, not a claim.
+- **`204 No Content` on success. The write returns no count and no rows**, and
+ the client then re-issues the list read with the canonical parameters of
+ [Control flow](needs-you-inbox-02.md#control-flow), replacing rows, `count`,
+ `hidden_count`, `next_snooze_expiry` and `next_cursor` together from that one
+ response. Returning refreshed counts from the write instead is wrong twice
+ over: it makes the write a third producer of a number AC .40 binds to one
+ bound, and it cannot refill a truncated page — dismiss all 50 rows of a
+ 50-of-60 page and a counts-only response leaves the client holding zero rows
+ over a workspace with 10 listable bundles left, which AC .20's trigger renders
+ as caught up. The re-read returns those 10 (AC .13, AC .24). One bounded extra
+ round trip per hide changes latency, not behaviour: sidecar writes are already
+ non-optimistic and already round-tripping.
+
+**`DELETE /api/v1/clarification-inbox/sidecar/:pendingID`** — restore.
+
+- Deletes the operator's sidecar row, which is the whole of restore: the bundle
+ was never mutated (AC .22). `204`, and the client re-reads exactly as it does
+ after a `PUT`.
+- **`DELETE` resolves existence and visibility FIRST, by the same check as
+ `PUT`**, and only then decides. The three cases are distinct and must not be
+ collapsed:
+
+ | Addressed `pendingID` | Outcome |
+ | --- | --- |
+ | unknown, or in a workspace the caller cannot see | `404`, no sidecar write, no disclosure of which of the two it was |
+ | real and visible, currently hidden by this operator | `204`, row deleted |
+ | real and visible, not hidden by this operator | `204`, nothing changes (AC .36) |
+
+ The middle and last rows are the idempotency AC .36 asks for: the goal state
+ is already true, so succeeding is correct and `404` would be wrong. The first
+ row is NOT that case, and letting "unknown" fall into the `204` branch would
+ turn a bogus-id probe into an existence oracle running the wrong way: a
+ caller would learn nothing from `204` but WOULD learn from its absence. Same
+ status for all three visible outcomes is what keeps it non-disclosing.
+
+**`GET /api/v1/clarification-inbox/hidden?workspace_id=`** — enumerate hidden.
+
+- Same row shape, ordering, page bounds, validation and authorization as the
+ main read, with the sidecar predicate inverted: exactly the answerable bundles
+ this operator's dismiss or snooze is hiding. Each row additionally carries
+ `state` (`dismissed` or `snoozed`) and `snooze_until` (`null` unless snoozed),
+ because restore has to show the operator WHY a bundle is hidden and when it
+ would have come back on its own (AC .37).
+- **It reports TWO numbers, and they are not the same number.** `count` is
+ `len(bundles)`, page-scoped, exactly as on the main read so the two envelopes
+ stay one shape. `total` is the workspace-wide number of bundles this
+ operator's sidecar is hiding, produced by the SAME bounded query that
+ produces `hidden_count` on the main read, and it is what AC .37 binds to:
+ `total == hidden_count` for the same operator and workspace, at any page size.
+ An earlier draft said this endpoint's `count` equalled `hidden_count`, which
+ holds only until an operator hides more bundles than one page returns — 60
+ hidden at a limit of 50 makes those 50 and 60. Naming the workspace-wide
+ number separately is what makes AC .37 true at every page size instead of
+ only on small fixtures.
+- "Inverted" is a mode on the same option, not a second option. The field added
+ to `ListClarificationBundlesOptions` (see
+ [Persistence](needs-you-inbox-02.md#persistence)) carries a user id AND a
+ direction, `exclude` or `only`; the main read passes `exclude`, this endpoint
+ passes `only`, and everything else about the query is identical. One predicate
+ with two directions is what makes the two lists a partition of one answerable
+ set: a bundle is in the main read or in this one, never both and never
+ neither.
+- This makes AC .33's "restore a hidden bundle" constructible; `hidden_count`
+ gives the disclosure but not the identities.
+- Like the main read, the v1 client sends no `cursor` here. With 60 hidden at a
+ limit of 50 the operator sees 50 identities over a `total` of 60; restoring from
+ that page shrinks the hidden set, and the next read surfaces the rest. That is
+ the same refill argument as AC .13, and it is why `total` rather than `count` is
+ what AC .37 binds to.
+- **Empty and error.** Zero hidden bundles is `"bundles": []` with `count` and
+ `total` both `0`, and the restore surface says nothing is currently hidden
+ rather than rendering an error. A failed enumeration renders the read-error
+ state of AC .21 and offers to retry. Because `hidden_count` and this
+ enumeration are two round trips apart, a snooze expiring between them can put a
+ disclosed "3 hidden" over an enumeration of zero; the newer read wins, so the
+ restore surface replaces `hidden_count` with this response's `total`. AC .37's
+ equality is an identity within one response, not across two.
diff --git a/docs/specs/ui/system-design/needs-you-inbox-02.md b/docs/specs/ui/system-design/needs-you-inbox-02.md
new file mode 100644
index 0000000000..833dbe0f7f
--- /dev/null
+++ b/docs/specs/ui/system-design/needs-you-inbox-02.md
@@ -0,0 +1,509 @@
+---
+status: draft
+system: ui
+requirements:
+ - REQ-UI-NEEDS-YOU-INBOX-001
+---
+# Needs-you Inbox System Design Part 2
+
+Part 1 (boundaries, input inventory, flag, components, data and contracts) is in
+[`needs-you-inbox-01.md`](needs-you-inbox-01.md); Part 3 (design source,
+fidelity decisions) is in
+[`needs-you-inbox-03.md`](needs-you-inbox-03.md).
+
+## Control flow
+
+**Read.** Route mount or workspace change issues one request. The response
+populates both the row list and the count slice entry for that workspace.
+
+**The badge does not wait for the route (AC .34).** Route mount as the only
+trigger would mean the operator must open the Inbox to learn they need to —
+the Overview's own failure, and the shape Office has today (its badge is
+written solely from `apps/web/app/office/inbox/inbox-page-client.tsx`). Two
+triggers fix it, neither new machinery:
+
+1. **Boot.** `bootStateBuilder` (`apps/backend/internal/backendapp/boot_state.go`)
+ already resolves the active workspace from the `kandev-active-workspace`
+ cookie and adds per-workspace keys to the boot state map. It adds, for that
+ workspace, `{ count, has_more, next_snooze_expiry }` — NOT a bare integer.
+ It runs the SAME bounded read as the HTTP endpoint, at the same default
+ limit of 50, with the same sidecar exclusion resolved for the boot
+ identity, because AC .40 requires every producer to share one bound. A bare
+ integer cannot represent AC .14's capped badge, so 60 listable bundles would
+ render an exact 60 before the visit and a capped 50 after it, and the badge
+ would change value purely because the operator opened the page.
+ `next_snooze_expiry` travels with it so the timer in AC .41 can be armed on
+ first paint. When no active workspace resolves, or the flag is off, the key
+ is absent and no badge renders — absent is not zero, and neither renders
+ (AC .13).
+2. **The refresh triggers below**, which keep it right thereafter.
+
+**Refresh (AC .19). No event is the contract; the re-read is.** Every trigger
+below does one thing: re-read the bundle path. None of them decides what a row
+is, which is what keeps AC .6 intact — the client never reads `pending_action`
+to decide whether a row exists.
+
+That framing is load-bearing rather than pedantic, because the obvious event
+does NOT fire on every exit. `publishSessionPendingActionChanged` returns early
+unless `pendingActionProjectionChanged` is true, and that reduces to
+`previous.action != action` for the SESSION. So the enum, not the bundle, is
+what changed. Three real transitions emit nothing:
+
+- a bundle resolved and immediately replaced by another in the same session —
+ the projection stays `clarification` throughout;
+- a session going terminal, which is an AC .8 exit but not a pending-action
+ change;
+- a turn being superseded, likewise.
+
+Binding correctness to that one event would strand rows in all three. So:
+
+| Trigger | Covers | Wiring |
+| --- | --- | --- |
+| `ws.ActionSessionPendingActionChanged` | set and clear of the pending action — the common case, near-instant | none; already `hub.BroadcastToWorkspaceOrDrop` with `workspace_id` on the payload |
+| `ws.ActionSessionStateChanged` | a session becoming terminal | none; already `hub.BroadcastToWorkspace` for exactly this reason ("so the sidebar task switcher can track state changes for all tasks") |
+| WebSocket (re)connect | everything missed while disconnected | none |
+| Tab visibility regained | a backgrounded tab whose socket was throttled | none |
+| A bounded periodic re-read, at most once every 60 seconds, only while the tab is visible and a workspace is active | the residual: same-session bundle replacement, superseded turn, and any future exit nobody thought to emit an event for | none |
+
+**All five triggers are owned by the count slice, not by the Inbox route
+component**, for the same reason the snooze timer is (below): a trigger mounted
+with the route stops firing the moment the operator navigates away, and AC .34
+and AC .41 both require the badge to stay right while the Inbox is closed. The
+route subscribes to the same slice; it adds no triggers of its own.
+
+**One canonical parameter set, owned by the same slice (AC .12).** Every read in
+this design — boot, route mount, all five triggers, and the convergence re-reads
+after a settled resolution or a `204` — is issued with the SAME parameters: the
+active workspace and the default limit of 50, and NO cursor. That is what "the
+same parameters the Inbox is displaying" means wherever it appears below. Without
+one canonical set, a background trigger would write a count computed over a
+different page than the rows the route is rendering, which is precisely the
+disagreement AC .12 forbids.
+
+**The v1 client never sends `cursor`, and v1 has no forward-paging control.** The
+endpoint accepts one, so the contract is complete and the hidden endpoint shares
+the shape, but the Inbox does not use it and no AC needs it: AC .11 asks for a
+truncation INDICATOR rather than navigation, AC .14 asks for a capped count, and
+AC .13's refill works *because* the re-read is cursorless — emptying a 50-of-60
+page returns the remaining 10 instead of whatever follows a now-stale cursor. So
+`next_cursor` is consumed as a boolean, the truncation flag behind AC .11 and
+AC .14, and is never echoed back. Forward paging is not part of v1.
+
+Boot participates in the stale-response guard rather than sitting outside it:
+the boot value seeds the slice at generation zero, and the first read for that
+workspace supersedes it. So a boot payload that lands after an early read
+cannot overwrite fresher rows.
+
+The periodic read is the honest part of this table. It is the reason AC .19 can
+say "for every exit in AC .8, not only the ones a push notification announces",
+and it is bounded so it cannot become a poll: one read per minute per active
+tab, against a query already bounded to 50 rows. `session.clarification_requested`
+remains excluded as a trigger because its `TaskSessionNotificationPayload`
+carries no `workspace_id` and so cannot be filtered to the active workspace.
+
+**Snooze expiry (AC .24, AC .41).** No event fires when a snooze expires — time
+passing is not a transition anything publishes — so the client schedules one
+re-read at `next_snooze_expiry`, which the boot payload and every list response
+already carry (see [Data and contracts](needs-you-inbox-01.md#data-and-contracts)), refreshed
+on every response and cleared when the field is `null`.
+
+**The timer is owned by the count slice, not by the Inbox route component.**
+That placement is the whole of AC .41. A timer mounted with the route only runs
+while the operator is looking at the Inbox, so a bundle snoozed for four hours
+and then left alone would stay off the badge until some unrelated event
+happened to fire — reproducing, for snoozed bundles, the exact
+"you have to open the Inbox to learn you need to" failure AC .34 exists to
+remove. The count slice is live for the whole workspace session, so the badge
+recovers whether or not the route was ever mounted.
+
+**On a workspace switch the timer is cancelled and re-armed from the first
+response for the newly active workspace**, so it never fires for a workspace the
+operator has left; a new workspace whose response carries no
+`next_snooze_expiry` leaves no timer armed, which is correct, because nothing is
+snoozed there. The five refresh triggers need no equivalent: they are not
+per-workspace subscriptions, they re-read whichever workspace is active when they
+fire, and the stale-response guard drops anything that arrives for the wrong one.
+
+Expiry is evaluated server-side against server time with `snooze_until <= now`
+(see [Persistence](#persistence)), so the timer only decides WHEN to ask; it never decides
+what is hidden. A client clock running slow asks late and the row appears late.
+A client clock running fast asks early, the server returns the row still
+hidden, and the response carries the same `next_snooze_expiry` again — so the
+worst case is a small number of wasted bounded reads, not a hidden question and
+not a spin: the reschedule is floored at 5 seconds, so even a badly skewed clock
+cannot busy-loop.
+
+**There is NO local mutation. Rows and count change only when a response lands.**
+The Inbox never splices a row out of the list and never adjusts
+`needsYouCountByWorkspaceId` itself; every removal and every count change arrives
+in a list response. That is one rule for an answered bundle and a hidden one
+alike, which is what makes AC .13's "behave identically" structural rather than
+coincidental, and it is why AC .40's producer set stays closed at two: a
+client-computed decrement would be a third producer, bounded by nothing and equal
+to the rows on screen only by luck. AC .12 is then satisfied by construction,
+since the Inbox applies no change ahead of a response and no rendered state can
+disagree with itself. The rejected alternative was an immediate splice-and-
+decrement on the settled outcome. It is wrong exactly where it matters: on a
+truncated page, answering the last row of a 50-of-60 page empties the list
+locally and renders AC .20's caught-up state over a workspace with 10 bundles
+still waiting. Deferring to the response costs one bounded round trip and removes
+that state entirely. AC .35 then holds a fortiori: a submission that settles as a
+failure has nothing to restore, because nothing was removed. During the window
+between a settled outcome and the response that removes the row, the row stays
+rendered with its component still mounted in that settled state, which already
+refuses a second submit, so the deferral cannot produce a double resolution.
+
+**Stale responses (AC .38).** Reads carry a per-workspace request generation.
+The store applies a response only when its generation is the newest issued for
+the currently active workspace, and drops it otherwise. Without it, a fast
+workspace switch lets a slower earlier response replace the active list, and a
+read issued before a resolution can resurrect the row just answered. This is the
+guard this repo already applies to HTTP/WS cache races; named here so Build does
+not rediscover it.
+
+Answer: the expanded row mounts `ClarificationPanelSection` with `pending`
+true and the bundle's messages. Submission goes through the component's
+existing path to `POST /api/v1/clarification/:id/respond`, which already
+performs the resume. No resume logic is added here.
+
+**A settled resolution converges exactly the way a hide does.** Each of the three
+removing outcomes re-issues the list read with the canonical parameters above —
+the same contract a `204` from a sidecar write carries — replacing
+rows, `count`, `hidden_count`, `next_snooze_expiry` and `next_cursor` from that
+one response. This is what makes AC .13's "removing every listed row shall behave
+identically whether those rows were answered or hidden" structural rather than
+coincidental. Without it, answering the last row of a 50-of-60 page leaves the
+Inbox holding zero rows over a workspace with 10 listable bundles left, and
+AC .20's "lists no rows" trigger renders that as caught up until some later
+trigger fires. Leaning on `ws.ActionSessionPendingActionChanged` instead does not
+close it: that event does not fire when a resolved bundle is immediately replaced
+by another in the same session, because the projection stays `clarification`
+throughout. The fourth outcome re-reads nothing — it removed nothing, so there is
+nothing to converge. A re-read that itself fails is a READ failure (AC .21),
+never reported as a failed answer.
+
+The Inbox is a browser caller and is therefore not an automation principal, so
+the caller-self filter in the MCP path does not apply to it: an operator sees
+every answerable bundle in the workspace, including ones raised by the agent
+they are watching.
+
+## Failure and recovery
+
+**Four outcomes, and the Inbox must be able to tell them apart.** Nothing is
+removed until one of them arrives, so every outcome below either removes the row
+or leaves it exactly where it was. The Row and Count columns state the OUTCOME,
+not the mechanism: a removal is applied by the convergence re-read that outcome
+triggers, never by a local splice (see [Control flow](#control-flow)). The Inbox
+learns which through the optional outcome callback declared in
+[Components](needs-you-inbox-01.md#components-and-responsibilities) (AC .39); the
+values it carries are exactly the ones `useClarificationGroup` already parses into
+`ClarificationRespondResult`, so nothing new is derived and the Inbox never
+posts the resolution itself.
+
+| Outcome | Wire | Row | Count | What the operator sees |
+| --- | --- | --- | --- | --- |
+| This caller won | `200`, `claimed: true` | REMOVED | decremented | nothing extra; the row leaving IS the feedback |
+| Another caller won | `200`, `claimed: false`, with the winner's `status` and `answers` | REMOVED | decremented | a transient NON-error notice naming the bundle and the winning outcome (AC .17) |
+| Bundle no longer active | `409`, code `not_active` | REMOVED | decremented | a transient NON-error notice naming the bundle and saying it is no longer waiting |
+| Anything else | `5xx`, network, timeout, unparseable body, unrecognized status | UNCHANGED | UNCHANGED | a retryable error, with the entered answer preserved (AC .35) |
+
+**AC .35 is satisfied by never having removed the row, not by putting it back.**
+It requires a failed submission to leave the row and the count at their
+pre-submission values; because nothing is ever removed locally and this outcome
+triggers no re-read, those values are never disturbed, so the outcome it forbids
+— a removed row over an unresolved bundle — is unreachable by construction rather
+than by a rollback that could itself fail. Its other two clauses still need building: the entered answer
+survives because the shipped component stays mounted and keeps its own form
+state, and the retry is offered through that same component.
+
+**The callback fires exactly once per submission attempt**, on the transition
+into a settled state, mirroring the guard `useResolveCallback` already uses for
+`onResolved` (`last.current !== submitState`). Without that rule a settled
+`"error"` would re-fire on every render and restore the row repeatedly. It also
+inherits the hook's existing ownership fence: a callback is delivered only for
+the submission that still owns the request, so a bundle swapped under an
+in-flight submit cannot attribute one bundle's outcome to another's row.
+
+Two corrections to an earlier draft are folded in here. First, it claimed both
+of the middle rows "surface the winning outcome": they cannot. A `409` body
+carries only `error` and `code` — it is the resolver's NO-winner branch — so
+there is no winner to name, and its notice says the bundle stopped waiting
+rather than inventing who answered it. AC .17 governs the `claimed: false` row
+specifically, which is the only one that is actually a concurrent-resolution
+loss and the only one that carries a winner. Second, the whole table was
+unreachable as specified, because the shipped `onResolved` fires only on
+`submitState === "ok"` and takes no argument: the `409` and the failure rows
+never reached the host at all, so the Inbox could not tell a loss from a failure
+and had no signal it could safely remove a row on. Removing at submit instead
+was the earlier draft's answer and it is not available: the component exposes no
+submission-started signal, and AC .39 forbids the Inbox posting the resolution
+itself, so there is no moment before the settled outcome at which the Inbox
+learns a submission exists.
+
+A failed read, as opposed to a failed submission, renders an error state that is
+visually and textually distinct from the empty state, and suppresses the badge.
+Rendering "All caught up" on a failed read is the specific defect AC .21
+forbids: it is indistinguishable from success and it is the way this feature
+dies in week one.
+
+**A read that fails AFTER a successful one CLEARS the rows it was replacing.**
+The error state replaces the list, and the rows and the count are dropped in the
+SAME store update, the rule that already governs removal. Keeping the last good
+rows on screen instead is the tempting alternative and it is wrong twice: AC .21
+suppresses the badge on a failed read, so N rows would render under an absent
+count, exactly the disagreement AC .12 forbids; and rows that survived a failed
+refresh are rows the server may already have resolved, so the operator would
+expand one and submit into a `409`. What a transient blip costs is bounded and
+self-healing: the error state carries a retry for the read, and the count slice's
+triggers re-read regardless, so the list returns within at most the 60-second
+reconciling period with no operator action. A failed read never writes sidecar
+state and is never reported as a failed hide (below).
+
+Sidecar writes are never optimistic. The row moves only after the write
+succeeds, because the write is cheap and a wrongly-hidden question is expensive.
+A failed sidecar write leaves the list unchanged and shows a retryable error.
+
+**A `204` followed by a failed re-read is a READ failure, not a write failure**,
+and the two must not be conflated now that the write returns no body. The
+sidecar row is already durable at that point, so the Inbox renders the read
+error state of AC .21 and offers to retry the READ; it must not tell the
+operator the hide failed, and it must not re-issue the write, which would be
+correct but pointless since the `PUT` is an upsert and the `DELETE` is a no-op
+on an absent row. Retrying the read is always sufficient, because the server
+state the read reports is already the state the operator asked for.
+
+Snooze uses stored absolute expiry evaluated against server time, so a client
+clock skew cannot hide a row.
+
+## Persistence
+
+New table `clarification_inbox_sidecar`:
+
+| Column | Notes |
+| --- | --- |
+| `user_id` | the request identity's `UserID`; with auth disabled that is the synthetic identity's default user id, mirroring Office's `dashboardUserID` |
+| `pending_id` | The bundle |
+| `state` | `dismissed` or `snoozed` |
+| `snooze_until` | Null unless `state = snoozed` |
+| `created_at`, `updated_at` | |
+
+Primary key `(user_id, pending_id)`, which makes repeated dismiss or snooze an
+upsert and satisfies AC .25 by construction.
+
+**The filter runs inside the bundle query, before `LIMIT`.** The shipped
+`ListClarificationBundlesOptions` has no user or sidecar field, so the naive
+implementation filters the page in Go AFTER the SQL `LIMIT`. That breaks three
+things at once: hidden rows consume page slots (a limit of 50 returns 30 with 20
+still waiting); `HasMore` then describes a page the caller never saw, so AC .11's
+truncation indicator and AC .14's capped count both lie; and a page can come back
+empty while listable bundles remain, rendering the empty state over a non-empty
+workspace.
+
+So `ListClarificationBundlesOptions` gains one optional field, e.g.
+`ExcludeSidecarUserID string`. Empty leaves the query byte-for-byte as it is
+today, which is how the MCP caller keeps its behaviour. Set, the query adds a
+`LEFT JOIN` to `clarification_inbox_sidecar` on `(user_id, pending_id)` and
+excludes `dismissed` rows and `snoozed` rows with `snooze_until > :now`.
+`HasMore` and the cursor comparison are unchanged and now describe the filtered
+set, making `count == len(bundles)` structural.
+
+This is additive and inside this design's declared boundary: it changes neither
+the current-turn predicate, nor the bundle visibility rules, nor resolution
+semantics. Filtering in the client instead would let `count` include rows the
+list excludes, breaking AC .12 and AC .23.
+
+The sidecar never writes to `task_session_messages`. AC .22 is asserted by
+resolving a dismissed bundle from another surface and observing success.
+
+Snooze durations are 1h, 4h and 24h, default 4h (AC .32); the rationale for the
+closed set is at the endpoint. The value is stored as an absolute `snooze_until`
+compared against SERVER time, so a skewed client clock cannot hide a row. "Still
+hidden" is `snooze_until > now`, so an expiry equal to the current instant is
+already expired and the row is listed (AC .24) — ties resolve toward showing the
+question, the safe direction for a surface meant to stop questions going
+unseen.
+
+Dismissal is reversible (AC .33), and reversibility needs two things, not one.
+
+The DISCLOSURE is `hidden_count`, workspace-wide, so it cannot come from the
+page: a second bounded `COUNT` over the same predicates with the sidecar
+predicate inverted. An earlier draft said it arrived "without a second query",
+which was not achievable — the rows it counts are exactly the rows the main
+query excluded.
+
+**That one query answers three questions, so there is no third round trip.**
+Alongside the `COUNT` it selects `MIN(snooze_until)` over the rows whose
+`snooze_until` is strictly after server time. The main read reports the count as
+`hidden_count` and the minimum as `next_snooze_expiry`; the hidden endpoint
+reports the same count as its `total` (AC .37), which is what makes
+`total == hidden_count` an identity rather than two SQL clauses kept in step by
+hand. The hidden endpoint's own `count` stays page-scoped, like every other
+`count` in this design.
+
+The RESTORE needs identities, which a count cannot carry.
+`GET /api/v1/clarification-inbox/hidden` returns the hidden bundles so the
+operator can choose one; `DELETE .../sidecar/:pendingID` then removes the
+sidecar row and the bundle returns. Without the enumeration endpoint AC .33's
+"restore a hidden bundle" is not constructible: the UI would know how many rows
+exist and never which.
+
+Without both, one mis-click hides a blocking agent's question with no way back,
+strictly worse than the pre-Inbox status quo where the task at least appeared in
+Threads.
+
+Rows for resolved bundles are harmless: the bundle stops being answerable, so
+it leaves the list regardless. No retention job in this iteration.
+
+## Status vocabulary
+
+AC .26 resolves to the object-returning family in
+`apps/web/lib/threads/thread-session-status.ts`. The `STATUS` table in that file
+is a module-private `const` and is NOT exported; the exported entry point is
+`resolveThreadSessionStatus(input): ThreadStatus`, whose input is
+`Pick & { pending_action?, foreground_activity? }`.
+
+An Inbox row calls `resolveThreadSessionStatus` with the owning session's
+`state` — carried on the row as `session_state`, which is why that field is on
+the envelope at all, since the input type is `Pick & {...}`
+and `state` is REQUIRED, not optional — and `pending_action: "clarification"`,
+receiving
+`{ kind: "clarification", labelKey: "threads:statusQuestionFromAgent",
+hasAttention: true }` — the resolver short-circuits on `pending_action`, so the
+result does not depend on `state`. Passing `"clarification"` is not deriving the
+row from session state: the row exists because the bundle is answerable, and an
+answerable bundle is by construction a pending clarification. The row set still
+comes only from the bundle read path (AC .6); only the LABEL comes from the
+shared resolver.
+
+**No rename or re-export is required.** An earlier draft asked Build to export
+the unrelated private `resolveThreadStatus` in `thread-view-query.ts`. That is
+not needed for AC .26 and is not in scope; the two same-named functions live in
+different modules and neither is imported here. Leave them alone.
+
+The Inbox defines no status strings of its own and introduces no new hue:
+`IconMessageQuestion` with `text-yellow-500`, the pair already used for
+waiting-on-input.
+
+AC .27's agreement test is deliberately scoped. The two membership predicates
+differ by design: Threads derives from `pendingAction || taskPendingAction`
+(session state), the Inbox from the bundle path. They coincide only on the
+fixture class the AC names, and its four enumerated divergences are asserted AS
+divergences so a change that accidentally aligns them fails loudly.
+
+## Security
+
+Workspace scoping is enforced server-side from the request identity, not from
+the query parameter.
+
+**Do not reuse the MCP path's visibility resolver** (Correction 6): from an
+HTTP handler it takes the unscoped branch, sets `opts.Unscoped = true` and
+silently disables workspace filtering, the exact inverse of AC .31.
+
+The correct mechanism already exists and is identity-based:
+
+| Step | Contract |
+| --- | --- |
+| Identity is on every HTTP request | `auth/httpmw.Middleware` calls `authn.SetOnGin`; with auth disabled it injects `SyntheticIdentity()` (`UserID` = the default user, `Role` admin, `Synthetic` true) |
+| Authorize the named workspace (the two reads) | `taskSvc.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead)` (`task/service/service_access.go`) |
+| Authorize the addressed bundle (the two sidecar writes, which carry no `workspace_id`) | `Resolver.AuthorizeBundleAccess(ctx, pendingID)` -> `AuthorizeTaskAccess` -> `authorizeTaskScope(ctx, taskID, authz.ScopeWorkspaceRead)` (`clarification/resolver.go`, `task/service/service_access.go`) |
+| Resolve the visible set for the query | `taskSvc.ListWorkspaces(ctx)`, which already applies `filterWorkspacesForCaller` |
+
+`AuthorizeWorkspaceScope` also settles the status code: `requireWorkspaceScope`
+already encodes this repo's 404-vs-403 rule — `ErrWorkspaceNotFound` (404,
+non-disclosing) when the caller cannot read the workspace, `ErrForbidden` (403)
+when they can read it but lack the scope. The handler maps both straight through
+and adds nothing.
+
+**Which rejection each endpoint can actually produce (AC .31).** All four
+endpoints here authorize on `authz.ScopeWorkspaceRead`. For that scope
+specifically the 403 branch is DEAD CODE, and saying so is the point:
+`Decision.CanRead()` is defined as `d.Scopes.Has(ScopeWorkspaceRead)`, the same
+predicate `requireWorkspaceScope` then tests for the 403. A caller who fails the
+first test already got the 404; a caller who passes it cannot fail the second.
+So an out-of-reach workspace is always `404`, never `403`, on every endpoint in
+this feature.
+
+| Endpoint | Scope | Workspace reached via | Reachable rejections |
+| --- | --- | --- | --- |
+| `GET /clarification-inbox` | `ScopeWorkspaceRead` | the `workspace_id` query parameter | `400` validation, `404` out of reach |
+| `GET /clarification-inbox/hidden` | `ScopeWorkspaceRead` | the `workspace_id` query parameter | `400` validation, `404` out of reach |
+| `PUT /clarification-inbox/sidecar/:pendingID` | `ScopeWorkspaceRead` | the addressed bundle's task | `400` validation, `404` unknown or out of reach |
+| `DELETE /clarification-inbox/sidecar/:pendingID` | `ScopeWorkspaceRead` | the addressed bundle's task | `404` unknown or out of reach |
+
+**Two routes to one rule, not two rules (AC .36).** `authorizeTaskScope` loads
+the task's workspace and then runs the SAME `workspaceDecision(...)` it runs for
+a named workspace, testing `CanRead()` and the same `ScopeWorkspaceRead`. So the
+sidecar writes are authorized "by the same rule as the read"; only the way the
+workspace is identified differs, and for a route carrying no `workspace_id` the
+bundle's own task is the only honest source. The `403` branch is dead on this
+path too, for the same reason and by the same predicate. `authorizeTaskScope`
+short-circuits to allow when the caller is unscoped, matching the
+auth-disabled behaviour described below, and its own not-found
+(`ErrTaskNotFound`) is folded into `ErrBundleNotFound` by `AuthorizeBundleAccess`
+so the caller cannot tell an unknown bundle from an unreachable one.
+
+**The sidecar writes deliberately do NOT take a stronger scope.** Spec review
+suggested giving them one so AC .31's forbidden outcome would be constructible
+somewhere, and that is the wrong trade: `workspaceRoleScopes` gives
+`WorkspaceRoleViewer` exactly `{ScopeWorkspaceRead}`, so requiring anything
+stronger would stop a viewer dismissing a row from THEIR OWN sidebar. The
+sidecar is per-user preference state that mutates nothing shared and discloses
+nothing; gating it behind `ScopeTaskWrite` would degrade real behaviour to make
+a status code testable. AC .31's security guarantee is untouched by this —
+non-disclosure, server-side enforcement, and a client-supplied `workspace_id`
+granting nothing all hold. The 403 branch stays in `requireWorkspaceScope` and
+starts producing outcomes the day an endpoint here needs a scope a reaching role
+can lack; today none does.
+
+With auth disabled, `callerSubject` returns `Unscoped: true` for the synthetic
+identity and every workspace is visible. That is intended single-user behaviour,
+not a bypass: AC .31 is satisfied by having consulted identity resolution, and it
+starts constraining the moment auth is enabled, with no change here.
+
+The sidecar is keyed by user, so one user's dismissal cannot hide a row from
+another user once authentication is enabled.
+
+Agents may only enqueue themselves. This is preserved rather than added: a
+bundle is created by the agent's own session through the clarification request
+path, and the Inbox adds no way for any caller to insert a row for a session it
+does not own. The Inbox is read-plus-resolve only.
+
+## Observability
+
+Structured logs on read failure and on sidecar write failure, each carrying
+workspace id and, where relevant, `pending_id`. No new metric namespace this
+iteration: the population is small enough that logs suffice, and a counter that
+is almost always zero invites the same "learn to ignore it" failure the badge
+rule guards against.
+
+## Prior art
+
+**Leg 1, internal wiki — DID NOT RUN.** Receipt: `@henry` resolved
+`OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`,
+`QMD_WIKI_COLLECTION=wiki`. The path exists but every read returns `Operation not
+permitted` even with the sandbox disabled (macOS TCC, not the agent sandbox), and
+there is no `qmd` MCP server or CLI, so the grep fallback needed the same blocked
+filesystem. No wiki content read and none claimed; a re-run needs Full Disk Access.
+
+**Leg 2, cross-vendor `saas-kb` — DID NOT RUN.** Receipt: no `saas-kb` MCP server
+attached and no local checkout or CLI, so `search_fsm_docs` with
+`category: "ai_sdlc"` could not be called. No vendor claims cited.
+
+**Leg 3, in-repo at `20efe4855` — RAN, and is this design's basis.** Office's inbox
+(`docs/specs/office/requirements/inbox.md`) is the closest precedent and supplies
+four adopted positions: computed view not a table (AC-OFFICE-INBOX-001.2); a
+dismissal sidecar keyed by `(user_id, item_kind, item_id)`; identical inputs give
+identical output across restart; the badge uses the same aggregation as the list.
+Also read: `docs/decisions/2026-08-14-current-turn-clarification-ownership.md`,
+`docs/decisions/2026-09-05-bounded-clarification-response-path.md`.
+
+**Differently from Office:** one source rather than eight item types from five,
+because a count assembled from several sources cannot be guaranteed equal to the
+list it labels; reachable outside Office mode; and ordered oldest-first, because
+the failure being fixed is an old question nobody saw.
+
+## Related decisions
+
+- [Current-turn clarification ownership](../../../decisions/2026-08-14-current-turn-clarification-ownership.md)
+- [Bounded clarification response path](../../../decisions/2026-09-05-bounded-clarification-response-path.md)
+- [Runtime feature flags](../../../decisions/0007-runtime-feature-flags.md)
diff --git a/docs/specs/ui/system-design/needs-you-inbox-03.md b/docs/specs/ui/system-design/needs-you-inbox-03.md
new file mode 100644
index 0000000000..fe31f6a652
--- /dev/null
+++ b/docs/specs/ui/system-design/needs-you-inbox-03.md
@@ -0,0 +1,95 @@
+---
+status: draft
+system: ui
+requirements:
+ - REQ-UI-NEEDS-YOU-INBOX-001
+---
+# Needs-you Inbox System Design Part 3
+
+Part 1 (boundaries, input inventory, flag, components, data and contracts) is in
+[`needs-you-inbox-01.md`](needs-you-inbox-01.md); Part 2 (control flow, failure
+and recovery, security) is in [`needs-you-inbox-02.md`](needs-you-inbox-02.md).
+This part carries the visual and copy contract.
+
+## Design source
+
+The visual and copy contract is the KAN-41 revision-4 mockup deck, drawn
+against the shipped shell and vendored at
+[`../assets/needs-you-inbox-mockup-r4.html`](../assets/needs-you-inbox-mockup-r4.html).
+
+It is vendored rather than linked because the first implementation round
+received it as prose only. Its eleven frames were inlined into the originating
+card as host-relative `/api/attachments//content` URLs. Resolved against
+the Kandev origin, which is the only origin an agent reading that card knows,
+they 404: Kandev has its own `/api/attachments/` route over a different
+identifier space, so the path looks valid and fails without erroring. The build
+that followed satisfied every acceptance criterion in the requirements while
+diverging from the deck, because no criterion in the requirements described the
+deck.
+
+That is the failure this part exists to prevent, and it generalizes: a design
+that is not in the spec canon cannot fail review, because review checks code
+against the canon. A copy or control decision that matters is stated here, not
+only drawn.
+
+## Fidelity decisions
+
+**D1. Each row offers a control that opens its task.** The row body toggles the
+answer panel, so without a distinct control the task a question belongs to is
+unreachable from the Inbox. The deck draws it as a `Button size="sm"` on the
+right of the row, beside the actions menu; that button is withheld below the
+`sm` breakpoint, where its fixed width would squeeze the question title and put
+the row at risk of the horizontal overflow AC .30's mobile specification
+forbids. The actions menu carries the same destination at every width, so the
+task is reachable on a phone without the button.
+
+**D2. The empty state names the active workspace and does not congratulate.**
+No completion icon, no exclamation mark, and no claim of being caught up. An
+empty queue is a normal state rather than an achievement, and an unscoped
+congratulation reads as a claim about the whole instance while other workspaces
+hold unanswered questions. Naming the workspace also keeps the sentence true
+under AC .20, which already requires the empty state to name what it is not
+counting. When the active workspace cannot be resolved from client state, the
+sentence falls back to naming no workspace rather than naming the wrong one.
+
+**D3. A row whose bundle holds more than one question states how many.** A
+bundle is answered as a unit, so its size changes what answering it costs, and
+a row that hides the size understates the work. The count is of questions
+carried, not of messages: a bundle's message list can hold context messages
+that are not themselves questions, so the count filters on the presence of a
+clarification question. A single-question bundle renders no count line, because
+the line would then restate the row above it.
+
+**D4. The sidebar entry is named Inbox and uses `IconInbox`.** The deck draws
+the destination as **Inbox**; "Needs you" is the label of the first of its three
+tabs. When v1 collapsed to one bucket the tab name was promoted to the
+destination name, which put a bucket label in the sidebar and gave the entry an
+icon the deck never assigned it. The destination is the Inbox whether it renders
+one bucket or three, so adding the history and failed buckets later changes the
+tabs and not the sidebar.
+
+The one exception is Office mode. AC .1 through .3 keep this entry present
+regardless of mode, and Office renders its own `/office/inbox` entry already
+labelled Inbox, so in that mode two identically named rows would be
+indistinguishable. There, and only there, this entry falls back to "Needs you".
+The deck did not have to solve this: it assumed the kanban workspace, where
+Office's row is gated out and no collision exists.
+
+## Deck decisions this capability does not adopt
+
+Each is a recorded decision rather than an omission; all are listed in the
+requirements under "Out of scope".
+
+- **The tab strip**, and with it the deck's `variant="line"` analysis. v1
+ renders one bucket, so there is nothing to switch between.
+- **The history bucket and the failed-task bucket**, which the deck draws as
+ the second and third tabs.
+- **Permission rows** and their amber `IconShieldQuestion` treatment.
+- **Re-ask**, cut from v1 on 2026-09-11.
+- **The deck's cross-workspace empty state**, which offers to switch to a
+ workspace that has a waiting question. It depends on a per-workspace count
+ map the client does not hold for workspaces it is not in.
+
+Because the tab strip is gone, the deck's empty-state copy pointing the
+operator at "the other two tabs" has nowhere to point; D2 states the copy for
+the one-bucket world instead.