diff --git a/backend/src/apis/app_api/admin/costs/models.py b/backend/src/apis/app_api/admin/costs/models.py
index 8f9d5afa1..7d3da566a 100644
--- a/backend/src/apis/app_api/admin/costs/models.py
+++ b/backend/src/apis/app_api/admin/costs/models.py
@@ -463,6 +463,44 @@ class ToolCensusEntry(BaseModel):
errors: int = 0
+class FeedbackCounts(BaseModel):
+ """Thumbs on one bucket of calls: ``up`` / ``down`` are counts of live
+ feedback rows, never the text of anything."""
+ model_config = ConfigDict(populate_by_name=True)
+
+ up: int = 0
+ down: int = 0
+
+
+class FeedbackByTurnClass(BaseModel):
+ """Feedback split by the call's document turn class (document-context
+ offload spec §6.1): *full* (``hasDocuments``), *retrieved*
+ (``documentReads.pages > 0``), *digestOnly* (``documentDigests > 0``),
+ else *none* — in that precedence, since a retrieving call still holds
+ the digest. ``n`` per class is ``up + down``; the down-thumb rate is
+ ``down / n``."""
+ model_config = ConfigDict(populate_by_name=True)
+
+ full: FeedbackCounts = Field(default_factory=FeedbackCounts)
+ digest_only: FeedbackCounts = Field(default_factory=FeedbackCounts, alias="digestOnly")
+ retrieved: FeedbackCounts = Field(default_factory=FeedbackCounts)
+ none: FeedbackCounts = Field(default_factory=FeedbackCounts)
+
+
+class FeedbackProfile(BaseModel):
+ """The outcome signal joined to the session's cost rows. ``byTurnClass``
+ is ``None`` when no cost row carries the turn-class fields (they arrive
+ with #1137; rows written before it have none) — "not tracked", not zero.
+ ``unjoined`` counts thumbs whose message has no cost row at all (the row
+ expired, or the call was never recorded)."""
+ model_config = ConfigDict(populate_by_name=True)
+
+ up: int = 0
+ down: int = 0
+ by_turn_class: Optional[FeedbackByTurnClass] = Field(None, alias="byTurnClass")
+ unjoined: int = 0
+
+
class DataCoverage(BaseModel):
"""Which optional signals this session actually has, so the UI can say
"not tracked" instead of rendering an honest-looking zero."""
@@ -475,6 +513,8 @@ class DataCoverage(BaseModel):
prefix_tokens: bool = Field(False, alias="prefixTokens")
window_trim: bool = Field(False, alias="windowTrim")
compaction_events: bool = Field(False, alias="compactionEvents")
+ # Any F# row, or a session rollup written while diagnostics were on.
+ feedback: bool = False
documents: bool = False
@@ -518,6 +558,8 @@ class SessionProfile(BaseModel):
)
# The summary's token size at the most recent compaction decision.
last_summary_tokens: Optional[int] = Field(None, alias="lastSummaryTokens")
+ # Thumbs up/down joined to the cost rows by (sessionId, messageId).
+ feedback: FeedbackProfile = Field(default_factory=FeedbackProfile)
# Document lifecycle across the session's calls: how many calls ran with
# the full document inline vs. a digest only (the digest-vs-full turn
# shares), the largest estimated document footprint seen, and what
diff --git a/backend/src/apis/app_api/admin/costs/service.py b/backend/src/apis/app_api/admin/costs/service.py
index 8b2efef4b..bf4a5eb60 100644
--- a/backend/src/apis/app_api/admin/costs/service.py
+++ b/backend/src/apis/app_api/admin/costs/service.py
@@ -26,6 +26,9 @@
AttachmentProfile,
ContextTrajectoryPoint,
DataCoverage,
+ FeedbackByTurnClass,
+ FeedbackCounts,
+ FeedbackProfile,
FingerprintChanges,
SessionDiagnosis,
SessionProfile,
@@ -78,6 +81,84 @@ def _record_cost(record: Dict[str, Any]) -> Optional[float]:
return _as_float(raw)
+def _turn_class(record: Dict[str, Any]) -> Optional[str]:
+ """Turn class of one ``C#`` row from its document-context fields (spec
+ §6.1): ``full`` (``hasDocuments``), ``retrieved`` (``documentReads.pages
+ > 0``), ``digestOnly`` (``documentDigests > 0``), else ``none``.
+
+ Precedence is full > retrieved > digestOnly: a call that pulled pages back
+ still holds the digest, so testing the digest first would leave the
+ retrieved arm — the one the quality gate is about — permanently empty.
+ ``None`` when the row carries none of the fields (written before #1137
+ or with diagnostics off), so the caller says "not tracked", not "none".
+ """
+ has_documents = record.get("hasDocuments")
+ digests = record.get("documentDigests")
+ reads = record.get("documentReads")
+ if has_documents is None and digests is None and reads is None:
+ return None
+ if has_documents:
+ return "full"
+ pages = _as_int(reads.get("pages")) if isinstance(reads, dict) else _as_int(reads)
+ if (pages or 0) > 0:
+ return "retrieved"
+ if (_as_int(digests) or 0) > 0:
+ return "digestOnly"
+ return "none"
+
+
+def _join_feedback(
+ records: List[Dict[str, Any]],
+ feedback_rows: List[Dict[str, Any]],
+) -> FeedbackProfile:
+ """Join ``F#`` rows to ``C#`` rows on ``messageId`` and bucket by turn
+ class. Pure; the profile's numbers, never any content. Explicit thumbs
+ only (``signal`` absent or ``"explicit"``)."""
+ by_message: Dict[int, Dict[str, Any]] = {}
+ for record in records:
+ message_id = _as_int(record.get("messageId"))
+ if message_id is not None:
+ # The last call of a multi-call turn is the one the user thumbed;
+ # rows share a messageId only across the turn's tool round trips
+ # and later rows have the fuller context, so last write wins.
+ by_message[message_id] = record
+
+ any_turn_class = any(_turn_class(r) is not None for r in records)
+ buckets = FeedbackByTurnClass() if any_turn_class else None
+ profile = FeedbackProfile()
+ for row in feedback_rows:
+ # Explicit thumbs only — implicit signals (spec §10) share the row
+ # family but answer a different question and must never be summed in.
+ if row.get("signal") not in (None, "explicit"):
+ continue
+ value = _as_int(row.get("value"))
+ if value not in (1, -1):
+ continue
+ if value == 1:
+ profile.up += 1
+ else:
+ profile.down += 1
+ message_id = _as_int(row.get("messageId"))
+ record = by_message.get(message_id) if message_id is not None else None
+ if record is None:
+ profile.unjoined += 1
+ continue
+ if buckets is None:
+ continue
+ klass = _turn_class(record) or "none"
+ bucket: FeedbackCounts = {
+ "full": buckets.full,
+ "digestOnly": buckets.digest_only,
+ "retrieved": buckets.retrieved,
+ }.get(klass, buckets.none)
+ if value == 1:
+ bucket.up += 1
+ else:
+ bucket.down += 1
+ profile.by_turn_class = buckets
+ return profile
+
+
def _context_tokens(record: Dict[str, Any]) -> int:
"""True context occupancy of one call: uncached input + cached prefix +
newly cached tokens (Bedrock reports the three disjointly)."""
@@ -1036,6 +1117,20 @@ async def _attachment_profile(self, session_id: str) -> AttachmentProfile:
digest_tokens=digest_tokens,
)
+ async def _feedback_rows(self, session_id: str) -> List[Dict[str, Any]]:
+ """The session's ``F#`` rows. Best-effort: a storage fork without the
+ reader, or a transient error, yields none — the profile then falls
+ back to the session rollups and reports coverage honestly."""
+ reader = getattr(self.storage, "get_session_feedback_rows", None)
+ if reader is None:
+ return []
+ try:
+ rows = await reader(session_id)
+ except Exception as e: # noqa: BLE001 - feedback is one signal of several
+ logger.debug("Feedback rows unavailable for session: %s", e)
+ return []
+ return list(rows or [])
+
async def get_session_profile(self, session_id: str) -> Optional[SessionProfile]:
"""The content-free diagnostic profile of one conversation, or ``None``
when the session has no metadata row.
@@ -1052,6 +1147,7 @@ async def get_session_profile(self, session_id: str) -> Optional[SessionProfile]
records = await self.storage.get_session_cost_records(session_id)
attachments = await self._attachment_profile(session_id)
+ feedback_rows = await self._feedback_rows(session_id)
user_period_cost = (
await self._user_period_cost(user_id, self._get_current_period())
if user_id else None
@@ -1192,6 +1288,15 @@ async def get_session_profile(self, session_id: str) -> Optional[SessionProfile]
facts.tool_call_count = sum(e.calls for e in census.values())
facts.tool_error_count = sum(e.errors for e in census.values())
+ # Outcome signal: thumbs joined to the calls they rate. The rows are
+ # authoritative when present; the session rollups cover thumbs whose
+ # rows expired (they share the C# TTL, so this is rare).
+ feedback = _join_feedback(records, feedback_rows)
+ if not feedback_rows:
+ feedback.up = _as_int(row.get("thumbsUp")) or 0
+ feedback.down = _as_int(row.get("thumbsDown")) or 0
+ feedback_tracked = bool(feedback_rows) or row.get("thumbsUp") is not None
+
findings = run_diagnoses(facts)
summary = self._session_summary(row, findings, share)
if any_census and summary.tool_call_count is None:
@@ -1229,8 +1334,10 @@ async def get_session_profile(self, session_id: str) -> Optional[SessionProfile]
compaction_events=(
any_compaction_events or row.get("compactionAppliedCount") is not None
),
+ feedback=feedback_tracked,
documents=any_documents or row.get("fullDocumentCalls") is not None,
),
+ feedback=feedback,
prefix_tokens=prefix_tokens,
window_trim_calls=window_trim_calls,
window_removed_messages=last_removed,
diff --git a/backend/src/apis/app_api/messages/models.py b/backend/src/apis/app_api/messages/models.py
index 44c0f4723..95d4fc006 100644
--- a/backend/src/apis/app_api/messages/models.py
+++ b/backend/src/apis/app_api/messages/models.py
@@ -117,6 +117,38 @@ class Citation(BaseModel):
text: str = Field(..., description="Relevant text excerpt from the document")
+#: Reason codes a down-thumb may carry — the six buckets of
+#: ``docs/specs/response-feedback.md`` §6, each of which routes to an
+#: evaluator or an ops signal. A closed enum, never free text: the row is
+#: content-free by construction so it can sit beside the ``C#`` cost row and
+#: be read by the admin profile without reading the conversation. The spec's
+#: "something else → free text" is deliberately not here; that hand-off is
+#: the existing Agent report dialog (spec §3), which already has moderation.
+FEEDBACK_REASONS = ("wrong", "instructions", "length", "tool_failed", "outdated", "other")
+FeedbackReason = Literal["wrong", "instructions", "length", "tool_failed", "outdated", "other"]
+
+
+class MessageFeedback(BaseModel):
+ """One user's thumb on one assistant message (``F#`` row, see
+ ``apis.shared.sessions.metadata``). ``value`` is +1 (up) or -1 (down);
+ ``reason`` is an optional code from ``FEEDBACK_REASONS``."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
+ reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")
+
+
+class MessageFeedbackRequest(BaseModel):
+ """Body of ``PUT /sessions/{id}/messages/{messageId}/feedback``."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
+ reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+
+
class MessageMetadata(BaseModel):
"""Metadata associated with a single message"""
@@ -129,8 +161,10 @@ class MessageMetadata(BaseModel):
cost: Optional[Union[float, Dict[str, float]]] = Field(None, description="Cost for this message — either a total float (legacy) or a breakdown dict with total, inputCost, outputCost, cacheReadCost, cacheWriteCost")
citations: Optional[List[Dict[str, str]]] = Field(None, description="RAG citations for this message (stored as dicts for flexible JSON storage)")
display_text: Optional[str] = Field(None, alias="displayText", description="Original user message text before RAG augmentation (for clean UI display)")
- # Note: Feedback will be added in future implementation
- # feedback: Optional[Feedback] = None
+ # One user's thumb on this message, merged from the ``F#`` row on read
+ # (see ``apis.shared.sessions.metadata``). Content-free: a ±1, a timestamp
+ # and an optional reason code.
+ feedback: Optional[MessageFeedback] = Field(None, description="User thumbs up/down on this assistant message")
class Message(BaseModel):
diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py
index 47c01fee4..5d069c9bd 100644
--- a/backend/src/apis/app_api/sessions/routes.py
+++ b/backend/src/apis/app_api/sessions/routes.py
@@ -3,7 +3,7 @@
Provides endpoints for managing session metadata.
"""
-from fastapi import APIRouter, HTTPException, Depends, Query, Response, BackgroundTasks, status
+from fastapi import APIRouter, HTTPException, Depends, Path, Query, Response, BackgroundTasks, status
from typing import Optional
import logging
from apis.shared.sessions.models import (
@@ -18,7 +18,14 @@
BulkDeleteSessionsRequest,
BulkDeleteSessionsResponse,
BulkDeleteSessionResult,
- MessagesListResponse
+ MessagesListResponse,
+ MessageFeedback,
+ MessageFeedbackRequest,
+)
+from apis.shared.sessions.feedback import (
+ SessionNotOwned,
+ delete_message_feedback,
+ put_message_feedback,
)
from apis.shared.sessions.messages import get_messages
from apis.shared.sessions.metadata import (
@@ -35,7 +42,7 @@
from apis.app_api.shares.service import get_share_service
from apis.app_api.artifacts.service import get_artifact_share_service
from apis.shared.auth.dependencies import get_current_user_from_session
-from apis.shared.feature_flags import mid_turn_steering_enabled
+from apis.shared.feature_flags import response_feedback_enabled, mid_turn_steering_enabled
from apis.shared.auth.models import User
from apis.shared.system_prompts.service import get_system_prompts_service
@@ -662,6 +669,80 @@ async def get_session_messages_endpoint(
)
+def _require_message_feedback() -> None:
+ """404 while ``RESPONSE_FEEDBACK_ENABLED=false`` — the surface does not exist."""
+ if not response_feedback_enabled():
+ raise HTTPException(status_code=404, detail="Not found")
+
+
+@router.put(
+ "/{session_id}/messages/{message_id}/feedback",
+ response_model=MessageFeedback,
+ response_model_by_alias=True,
+ response_model_exclude_none=True,
+)
+async def put_message_feedback_endpoint(
+ session_id: str,
+ message_id: int = Path(..., ge=0, description="0-based message index"),
+ body: MessageFeedbackRequest = ...,
+ current_user: User = Depends(get_current_user_from_session),
+):
+ """Thumb an assistant message up (+1) or down (-1), optionally with a
+ reason code. Idempotent per (user, message): a second click replaces the
+ first. Content-free by construction — the body is a closed enum, so no
+ text can be stored (``apis.shared.sessions.feedback``).
+
+ ``message_id`` is the message's 0-based index in the conversation — the
+ trailing number of the SPA's ``msg-{sessionId}-{index}`` id, and the
+ ``messageId`` the message's cost row carries.
+ """
+ _require_message_feedback()
+ try:
+ return await put_message_feedback(
+ session_id=session_id,
+ user_id=current_user.user_id,
+ message_id=message_id,
+ value=body.value,
+ reason=body.reason,
+ )
+ except SessionNotOwned:
+ raise HTTPException(status_code=404, detail=f"Session not found: {session_id}")
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
+ except RuntimeError as e:
+ # No metadata table configured (local dev without DynamoDB).
+ logger.warning("Message feedback unavailable: %s", scrub_log(str(e)))
+ raise HTTPException(status_code=503, detail="Message feedback is not available")
+ except Exception:
+ logger.error("Error storing message feedback", exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to store message feedback")
+
+
+@router.delete("/{session_id}/messages/{message_id}/feedback", status_code=204)
+async def delete_message_feedback_endpoint(
+ session_id: str,
+ message_id: int = Path(..., ge=0, description="0-based message index"),
+ current_user: User = Depends(get_current_user_from_session),
+):
+ """Withdraw this user's thumb on a message. 204 whether or not one existed."""
+ _require_message_feedback()
+ try:
+ await delete_message_feedback(
+ session_id=session_id,
+ user_id=current_user.user_id,
+ message_id=message_id,
+ )
+ except SessionNotOwned:
+ raise HTTPException(status_code=404, detail=f"Session not found: {session_id}")
+ except RuntimeError as e:
+ logger.warning("Message feedback unavailable: %s", scrub_log(str(e)))
+ raise HTTPException(status_code=503, detail="Message feedback is not available")
+ except Exception:
+ logger.error("Error deleting message feedback", exc_info=True)
+ raise HTTPException(status_code=500, detail="Failed to delete message feedback")
+ return Response(status_code=204)
+
+
@router.post("/{session_id}/interrupt", status_code=204)
async def signal_turn_interrupted_endpoint(
session_id: str,
diff --git a/backend/src/apis/shared/feature_flags.py b/backend/src/apis/shared/feature_flags.py
index 5bde2c2f0..41a1305c9 100644
--- a/backend/src/apis/shared/feature_flags.py
+++ b/backend/src/apis/shared/feature_flags.py
@@ -368,6 +368,25 @@ def ask_user_question_enabled() -> bool:
return os.environ.get("ASK_USER_QUESTION_ENABLED", "").strip().lower() != "false"
+def response_feedback_enabled() -> bool:
+ """Whether users can thumb an assistant message up or down.
+
+ Covers the ``PUT`` / ``DELETE /sessions/{id}/messages/{messageId}/feedback``
+ routes, the ``feedback`` field merged into ``GET /sessions/{id}/messages``,
+ and the ``thumbsUp`` / ``thumbsDown`` session rollups. **Default ON with a
+ kill switch** (house style, mirroring ``cost_diagnostics_enabled``): unset
+ or empty resolves to enabled; only the literal ``"false"`` (case-
+ insensitive) disables. While off the write routes 404 and the read merge
+ is skipped; rows already written stay in the table. Name and default per
+ ``docs/specs/response-feedback.md`` §5.
+
+ The signal is content-free by construction (a ±1, a timestamp and an
+ optional reason *code* from a fixed enum — never free text), which is
+ what lets it join the ``C#`` cost row's turn class on the admin session
+ profile without the profile ever reading the conversation. See
+ ``docs/specs/document-context-offload.md`` §5 row 7 / §6.1.
+ """
+ return os.environ.get("RESPONSE_FEEDBACK_ENABLED", "").strip().lower() != "false"
def attachment_turn_guard_enabled() -> bool:
"""Whether a turn's attachments are held to the per-message file count and
the aggregate inline-bytes budget before the message is built.
diff --git a/backend/src/apis/shared/observability/content_policy.py b/backend/src/apis/shared/observability/content_policy.py
index d5f5213bc..69d83aa57 100644
--- a/backend/src/apis/shared/observability/content_policy.py
+++ b/backend/src/apis/shared/observability/content_policy.py
@@ -134,6 +134,10 @@ def is_content_bearing(path: str) -> bool:
"compactionAppliedCount",
"compactionForcedCount",
"compactionFloorUnreachableCount",
+ # Message-feedback rollups (live F# rows written while diagnostics were
+ # on; see `apis.shared.sessions.feedback`)
+ "thumbsUp",
+ "thumbsDown",
# Document lifecycle rollups (per-call document fields summed; see
# `apis.shared.sessions.metadata.DOCUMENT_ROLLUP_ATTRS`)
"fullDocumentCalls",
@@ -198,10 +202,24 @@ def is_content_bearing(path: str) -> bool:
"digest.tokens",
)
+#: F# rows for the session profile's feedback join. A thumb is a ±1, an
+#: optional reason *code*, a `signal` discriminator (explicit / implicit,
+#: response-feedback spec §10) and a timestamp — never text, by the request
+#: model's closed enum (`apis.shared.sessions.models.FEEDBACK_REASONS`).
+FEEDBACK_ROW_PROJECTION: Tuple[str, ...] = (
+ "sessionId",
+ "messageId",
+ "value",
+ "reason",
+ "signal",
+ "updatedAt",
+)
+
ALL_PROJECTIONS: Dict[str, Tuple[str, ...]] = {
"SESSION_ROW_PROJECTION": SESSION_ROW_PROJECTION,
"CALL_ROW_PROJECTION": CALL_ROW_PROJECTION,
"FILE_ROW_PROJECTION": FILE_ROW_PROJECTION,
+ "FEEDBACK_ROW_PROJECTION": FEEDBACK_ROW_PROJECTION,
}
diff --git a/backend/src/apis/shared/sessions/feedback.py b/backend/src/apis/shared/sessions/feedback.py
new file mode 100644
index 000000000..1400b1639
--- /dev/null
+++ b/backend/src/apis/shared/sessions/feedback.py
@@ -0,0 +1,239 @@
+"""Message feedback (thumbs up / down) storage — the ``F#`` row family.
+
+The outcome signal the cost work has been missing (document-context-offload
+spec §5 row 7, §6.1 "the outcome signal"; compaction thresholds spec §7.2).
+One row per ``(user, session, message)``, content-free by construction: a
+``value`` of +1 / -1, a timestamp and an optional reason *code* from
+:data:`FEEDBACK_REASONS`. No free text can be stored here — the request
+model's ``reason`` is a closed ``Literal`` and this module never accepts a
+string outside the tuple.
+
+Schema (``sessions-metadata`` table, beside the ``C#`` / ``D#`` rows — see the
+row-family summary in ``apis.shared.sessions.metadata``)::
+
+ PK: USER#{user_id}
+ SK: F#{session_id}#{message_id}
+ GSI_PK: SESSION#{session_id} (SessionLookupIndex)
+ GSI_SK: F#{message_id}
+ sessionId, messageId, userId, value, reason?, signal, updatedAt, ttl
+
+``signal`` is ``"explicit"`` for a thumb. ``docs/specs/response-feedback.md``
+§10 adds implicit signals (copy, continue, edit-and-resend, abandonment) to
+this same row family under ``signal: "implicit"``; every reader here filters
+to explicit rows so that phase needs no backfill and the two are never summed.
+
+``messageId`` is the same 0-based index the ``C#`` cost row carries for the
+assistant message, so an admin read joins feedback to the call's turn class
+(``hasDocuments`` / ``documentDigests`` / ``documentReads``, #1137) on
+``(sessionId, messageId)`` with no second lookup. The SK is deterministic, so
+a second thumb on the same message *replaces* the first (``put_item``), and a
+``DELETE`` removes it — one thumb per (user, message) by key design.
+
+Session-row rollups: ``thumbsUp`` / ``thumbsDown`` are ``ADD``ed on the ``S#``
+row like ``toolCallCount``, only while ``COST_DIAGNOSTICS_ENABLED`` (an absent
+attribute reads "not tracked", never 0). A replace adjusts both counters so
+the rollup always equals the count of live ``F#`` rows written while the
+diagnostics were on.
+
+Everything is gated by :func:`apis.shared.feature_flags.response_feedback_enabled`
+at the route; this module is deliberately flag-free so a backfill or a test
+can drive it directly.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, Optional
+
+from .models import FEEDBACK_REASONS, MessageFeedback
+from .preview import is_preview_session
+
+logger = logging.getLogger(__name__)
+
+#: Retention, matching the ``C#`` cost rows the feedback joins to.
+FEEDBACK_TTL_DAYS = 365
+
+
+class SessionNotOwned(Exception):
+ """The session has no metadata row for this user (missing or another user's)."""
+
+
+def _table_name() -> str:
+ name = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME")
+ if not name:
+ raise RuntimeError("DYNAMODB_SESSIONS_METADATA_TABLE_NAME environment variable is required")
+ return name
+
+
+def _table():
+ import boto3
+
+ return boto3.resource("dynamodb").Table(_table_name())
+
+
+def feedback_sk(session_id: str, message_id: int) -> str:
+ return f"F#{session_id}#{message_id}"
+
+
+def _keys(user_id: str, session_id: str, message_id: int) -> Dict[str, str]:
+ return {"PK": f"USER#{user_id}", "SK": feedback_sk(session_id, message_id)}
+
+
+def _to_model(item: Dict[str, Any]) -> MessageFeedback:
+ return MessageFeedback(
+ value=int(item.get("value", 0)),
+ reason=item.get("reason") if item.get("reason") in FEEDBACK_REASONS else None,
+ updated_at=str(item.get("updatedAt", "")),
+ )
+
+
+async def _owned_session_sk(session_id: str, user_id: str, table) -> str:
+ """The session row's SK, or raise :class:`SessionNotOwned`."""
+ from .metadata import _get_session_by_gsi
+
+ existing = await _get_session_by_gsi(session_id, user_id, table)
+ if not existing or not existing.get("SK"):
+ raise SessionNotOwned(session_id)
+ return existing["SK"]
+
+
+def _bump_rollups(table, user_id: str, session_sk: str, *, up: int, down: int) -> None:
+ """``ADD thumbsUp :up, thumbsDown :down`` on the session row, only while
+ the content-free diagnostics are on. Best-effort: a failed bump never
+ fails the user's click — the rows stay authoritative and the profile
+ prefers them when present."""
+ from apis.shared.feature_flags import cost_diagnostics_enabled
+
+ if not cost_diagnostics_enabled():
+ return
+ try:
+ table.update_item(
+ Key={"PK": f"USER#{user_id}", "SK": session_sk},
+ UpdateExpression="ADD thumbsUp :up, thumbsDown :down",
+ ExpressionAttributeValues={":up": int(up), ":down": int(down)},
+ )
+ except Exception as e: # noqa: BLE001 - rollup drift is tolerable, a lost click is not
+ logger.debug("feedback rollup bump failed for %s: %s", session_sk, e)
+
+
+async def put_message_feedback(
+ session_id: str,
+ user_id: str,
+ message_id: int,
+ value: int,
+ reason: Optional[str] = None,
+) -> MessageFeedback:
+ """Write (or replace) this user's thumb on one message.
+
+ Raises :class:`SessionNotOwned` when the session is not this user's, and
+ ``ValueError`` on a value outside ``{1, -1}`` or a reason outside
+ :data:`FEEDBACK_REASONS` — the route's request model already rejects
+ both, this is the storage layer refusing to become a text field.
+ """
+ if value not in (1, -1):
+ raise ValueError("feedback value must be 1 or -1")
+ if reason is not None and reason not in FEEDBACK_REASONS:
+ raise ValueError("feedback reason must be one of the fixed codes")
+ if is_preview_session(session_id):
+ # Preview sessions persist nothing; echo the thumb so the UI is consistent.
+ return MessageFeedback(value=value, reason=reason, updated_at=_now())
+
+ table = _table()
+ session_sk = await _owned_session_sk(session_id, user_id, table)
+
+ now = _now()
+ ttl = int((datetime.now(timezone.utc) + timedelta(days=FEEDBACK_TTL_DAYS)).timestamp())
+ item: Dict[str, Any] = {
+ **_keys(user_id, session_id, message_id),
+ "GSI_PK": f"SESSION#{session_id}",
+ "GSI_SK": f"F#{message_id}",
+ "sessionId": session_id,
+ "messageId": int(message_id),
+ "userId": user_id,
+ "value": int(value),
+ "signal": "explicit",
+ "updatedAt": now,
+ "ttl": ttl,
+ }
+ if reason:
+ item["reason"] = reason
+
+ # ReturnValues=ALL_OLD tells us what a replace is replacing, so the
+ # session rollups move by the delta rather than double-counting.
+ response = table.put_item(Item=item, ReturnValues="ALL_OLD")
+ previous = response.get("Attributes") or {}
+ previous_value = int(previous.get("value", 0)) if previous else 0
+
+ up = (1 if value == 1 else 0) - (1 if previous_value == 1 else 0)
+ down = (1 if value == -1 else 0) - (1 if previous_value == -1 else 0)
+ if up or down:
+ _bump_rollups(table, user_id, session_sk, up=up, down=down)
+
+ logger.info("👍 feedback stored for message %s in session %s", message_id, session_id)
+ return _to_model(item)
+
+
+async def delete_message_feedback(session_id: str, user_id: str, message_id: int) -> bool:
+ """Remove this user's thumb on one message. Returns whether a row existed."""
+ if is_preview_session(session_id):
+ return False
+ table = _table()
+ session_sk = await _owned_session_sk(session_id, user_id, table)
+
+ response = table.delete_item(Key=_keys(user_id, session_id, message_id), ReturnValues="ALL_OLD")
+ previous = response.get("Attributes") or {}
+ if not previous:
+ return False
+ previous_value = int(previous.get("value", 0))
+ _bump_rollups(
+ table, user_id, session_sk,
+ up=-1 if previous_value == 1 else 0,
+ down=-1 if previous_value == -1 else 0,
+ )
+ return True
+
+
+def query_session_feedback(table, session_id: str, user_id: Optional[str] = None) -> Dict[str, MessageFeedback]:
+ """All ``F#`` rows for a session via ``SessionLookupIndex``, keyed by
+ message id (as ``str``, matching the metadata index). With ``user_id``
+ set, rows belonging to another user are dropped (the SPA read); the
+ admin reader passes ``None`` and gets every user's thumb."""
+ from boto3.dynamodb.conditions import Key
+
+ out: Dict[str, MessageFeedback] = {}
+ last_key = None
+ while True:
+ kwargs: Dict[str, Any] = {
+ "IndexName": "SessionLookupIndex",
+ "KeyConditionExpression": Key("GSI_PK").eq(f"SESSION#{session_id}") & Key("GSI_SK").begins_with("F#"),
+ }
+ if last_key:
+ kwargs["ExclusiveStartKey"] = last_key
+ response = table.query(**kwargs)
+ for item in response.get("Items", []):
+ if user_id is not None and item.get("userId") != user_id:
+ continue
+ if not is_explicit(item):
+ continue
+ raw_id = item.get("messageId")
+ try:
+ message_id = str(int(raw_id))
+ except (TypeError, ValueError):
+ continue
+ out[message_id] = _to_model(item)
+ last_key = response.get("LastEvaluatedKey")
+ if not last_key:
+ break
+ return out
+
+
+def is_explicit(row: Dict[str, Any]) -> bool:
+ """A thumb, as opposed to a §10 implicit signal. Rows written before the
+ discriminator existed carry none and are explicit by construction."""
+ return row.get("signal") in (None, "explicit")
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py
index 63714f9a9..ad1dc1645 100644
--- a/backend/src/apis/shared/sessions/metadata.py
+++ b/backend/src/apis/shared/sessions/metadata.py
@@ -5,6 +5,22 @@
Architecture:
- Cloud: Stores metadata in DynamoDB table specified by DYNAMODB_SESSIONS_METADATA_TABLE_NAME
+
+Row families on the ``sessions-metadata`` table (PK = ``USER#{user_id}``; all
+carry ``GSI_PK = SESSION#{session_id}`` so ``SessionLookupIndex`` lists one
+session's rows by prefix):
+
+ S#{session_id} session row (rollups, preferences, compaction state)
+ C#{timestamp}#{uuid} one model call's cost/usage record; ``messageId`` = the
+ assistant message's 0-based index (``_store_message_metadata_cloud``)
+ D#{session_id}#{message_id} the user's original prompt text for display (``store_user_display_text``)
+ F#{session_id}#{message_id} the user's thumb on an assistant message — value ±1, optional
+ reason code, timestamp; content-free (``apis.shared.sessions.feedback``).
+ Same ``messageId`` as the ``C#`` row, so feedback joins the call's
+ turn class on ``(sessionId, messageId)`` in one lookup.
+
+``GSI_SK`` is ``META`` / ``C#{timestamp}`` / ``D#{message_id}`` / ``F#{message_id}``
+respectively. Only ``C#`` / ``D#`` / ``F#`` rows carry a ``ttl``.
"""
import logging
@@ -2161,6 +2177,20 @@ async def _get_all_message_metadata_cloud(session_id: str, user_id: str, table_n
metadata_index[message_id] = {"displayText": display_text}
logger.debug(f"🔗 Merged displayText for user message {message_id}")
+ # Merge this user's thumbs (F# rows) so a reload restores the SPA's
+ # pressed state. Skipped while the feature is off — the rows stay.
+ from apis.shared.feature_flags import response_feedback_enabled
+
+ if response_feedback_enabled():
+ from .feedback import query_session_feedback
+
+ try:
+ for message_id, feedback in query_session_feedback(table, session_id, user_id).items():
+ entry = metadata_index.setdefault(message_id, {})
+ entry["feedback"] = feedback.model_dump(by_alias=True, exclude_none=True)
+ except Exception as e: # noqa: BLE001 - feedback is a UI enhancement, never block history
+ logger.warning(f"Failed to merge message feedback: {e}")
+
logger.info(f"📋 Metadata keys: {sorted(metadata_index.keys())}")
return metadata_index
diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py
index 82dc611b0..ae3c3d9d5 100644
--- a/backend/src/apis/shared/sessions/models.py
+++ b/backend/src/apis/shared/sessions/models.py
@@ -642,6 +642,38 @@ class Citation(BaseModel):
text: str = Field(..., description="Relevant text excerpt from the document")
+#: Reason codes a down-thumb may carry — the six buckets of
+#: ``docs/specs/response-feedback.md`` §6, each of which routes to an
+#: evaluator or an ops signal. A closed enum, never free text: the row is
+#: content-free by construction so it can sit beside the ``C#`` cost row and
+#: be read by the admin profile without reading the conversation. The spec's
+#: "something else → free text" is deliberately not here; that hand-off is
+#: the existing Agent report dialog (spec §3), which already has moderation.
+FEEDBACK_REASONS = ("wrong", "instructions", "length", "tool_failed", "outdated", "other")
+FeedbackReason = Literal["wrong", "instructions", "length", "tool_failed", "outdated", "other"]
+
+
+class MessageFeedback(BaseModel):
+ """One user's thumb on one assistant message (``F#`` row, see
+ ``apis.shared.sessions.metadata``). ``value`` is +1 (up) or -1 (down);
+ ``reason`` is an optional code from ``FEEDBACK_REASONS``."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
+ reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+ updated_at: str = Field(..., alias="updatedAt", description="ISO timestamp of the latest thumb")
+
+
+class MessageFeedbackRequest(BaseModel):
+ """Body of ``PUT /sessions/{id}/messages/{messageId}/feedback``."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ value: Literal[1, -1] = Field(..., description="+1 for thumbs up, -1 for thumbs down")
+ reason: Optional[FeedbackReason] = Field(None, description="Optional reason code (never free text)")
+
+
class MessageMetadata(BaseModel):
"""Metadata associated with a single message"""
@@ -654,8 +686,10 @@ class MessageMetadata(BaseModel):
cost: Optional[Union[float, Dict[str, float]]] = Field(None, description="Cost for this message — either a total float (legacy) or a breakdown dict with total, inputCost, outputCost, cacheReadCost, cacheWriteCost")
citations: Optional[List[Dict[str, str]]] = Field(None, description="RAG citations for this message (stored as dicts for flexible JSON storage)")
display_text: Optional[str] = Field(None, alias="displayText", description="Original user message text before RAG augmentation (for clean UI display)")
- # Note: Feedback will be added in future implementation
- # feedback: Optional[Feedback] = None
+ # One user's thumb on this message, merged from the ``F#`` row on read
+ # (see ``apis.shared.sessions.metadata``). Content-free: a ±1, a timestamp
+ # and an optional reason code.
+ feedback: Optional[MessageFeedback] = Field(None, description="User thumbs up/down on this assistant message")
class Message(BaseModel):
diff --git a/backend/src/apis/shared/storage/dynamodb_storage.py b/backend/src/apis/shared/storage/dynamodb_storage.py
index b010640ad..6b03ba1ff 100644
--- a/backend/src/apis/shared/storage/dynamodb_storage.py
+++ b/backend/src/apis/shared/storage/dynamodb_storage.py
@@ -337,6 +337,49 @@ async def get_session_cost_records(
except ClientError as e:
raise Exception(f"Failed to get session cost records: {e}")
+ async def get_session_feedback_rows(
+ self,
+ session_id: str,
+ ) -> List[Dict[str, Any]]:
+ """All ``F#`` message-feedback rows for a session, any user — admin
+ scope, content-free by projection (``FEEDBACK_ROW_PROJECTION``).
+ Each row: ``messageId`` (the assistant message's index, the same key
+ the ``C#`` row carries), ``value`` ±1, optional ``reason`` code,
+ ``updatedAt``. Empty when the session has no thumbs.
+ """
+ from boto3.dynamodb.conditions import Key
+ from apis.shared.observability.content_policy import (
+ FEEDBACK_ROW_PROJECTION,
+ build_projection,
+ strip_content,
+ )
+
+ projection, names = build_projection(FEEDBACK_ROW_PROJECTION)
+ try:
+ items: List[Dict[str, Any]] = []
+ last_evaluated_key = None
+ while True:
+ query_kwargs = {
+ "IndexName": "SessionLookupIndex",
+ "KeyConditionExpression": (
+ Key("GSI_PK").eq(f"SESSION#{session_id}")
+ & Key("GSI_SK").begins_with("F#")
+ ),
+ "ProjectionExpression": projection,
+ "ExpressionAttributeNames": names,
+ }
+ if last_evaluated_key:
+ query_kwargs["ExclusiveStartKey"] = last_evaluated_key
+ response = self.sessions_metadata_table.query(**query_kwargs)
+ items.extend(response.get("Items", []))
+ last_evaluated_key = response.get("LastEvaluatedKey")
+ if not last_evaluated_key:
+ break
+ except ClientError as e:
+ raise Exception(f"Failed to get session feedback rows: {e}")
+
+ return [strip_content(self._convert_decimal_to_float(item)) for item in items]
+
async def get_session_diagnostic_row(
self,
session_id: str,
diff --git a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
index 4eeda6a46..db5c794b4 100644
--- a/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
+++ b/backend/tests/apis/app_api/admin/costs/test_drilldown_routes.py
@@ -105,8 +105,10 @@ def test_session_profile_returns_200():
assert body["dataCoverage"] == {
"toolCensus": False, "compactionCount": False, "fingerprints": False, "cost": False,
"prefixTokens": False, "windowTrim": False, "compactionEvents": False,
+ "feedback": False,
"documents": False,
}
+ assert body["feedback"] == {"up": 0, "down": 0, "byTurnClass": None, "unjoined": 0}
service.get_session_profile.assert_awaited_once_with("s1")
diff --git a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
index ea7f6ebf7..530b81316 100644
--- a/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
+++ b/backend/tests/apis/app_api/admin/costs/test_session_profile_service.py
@@ -254,3 +254,98 @@ async def test_session_row_counters_alone_mark_compaction_events_as_tracked():
p = await _service(_row(compactionAppliedCount=0), [_call(0)]).get_session_profile("s1")
assert p.data_coverage.compaction_events
assert p.session.compaction_applied_count == 0
+
+
+# ── feedback join (document-context offload PR-7) ───────────────────────────
+
+
+def _feedback(message_id, value, reason=None):
+ row = {"sessionId": "s1", "messageId": message_id, "value": value, "updatedAt": "2026-09-16T00:00:00Z"}
+ if reason:
+ row["reason"] = reason
+ return row
+
+
+def _service_with_feedback(row, records, feedback):
+ service = _service(row, records)
+ service.storage.get_session_feedback_rows = AsyncMock(return_value=feedback)
+ return service
+
+
+@pytest.mark.asyncio
+async def test_feedback_joins_the_turn_class_when_the_rows_carry_it():
+ records = [
+ _call(0), # attach turn: full document inline
+ _call(1), # follow-up: digest only
+ _call(2), # follow-up that read pages back
+ _call(3), # no documents at all
+ ]
+ records[0]["hasDocuments"] = True
+ records[1]["hasDocuments"] = False
+ records[1]["documentDigests"] = 1
+ records[2]["hasDocuments"] = False
+ records[2]["documentDigests"] = 1
+ records[2]["documentReads"] = {"calls": 1, "pages": 4, "bytes": 1000}
+ records[3]["hasDocuments"] = False
+ records[3]["documentDigests"] = 0
+ feedback = [
+ _feedback(0, 1),
+ _feedback(1, -1, "wrong"),
+ _feedback(2, 1),
+ _feedback(3, -1, "tool_failed"),
+ _feedback(9, -1), # no cost row for this message
+ ]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+
+ assert (p.feedback.up, p.feedback.down, p.feedback.unjoined) == (2, 3, 1)
+ by = p.feedback.by_turn_class
+ assert by is not None
+ assert (by.full.up, by.full.down) == (1, 0)
+ assert (by.digest_only.up, by.digest_only.down) == (0, 1)
+ assert (by.retrieved.up, by.retrieved.down) == (1, 0)
+ assert (by.none.up, by.none.down) == (0, 1)
+ assert p.data_coverage.feedback is True
+ # Wire shape the SPA reads.
+ wire = p.model_dump(by_alias=True)["feedback"]
+ assert wire["byTurnClass"]["digestOnly"] == {"up": 0, "down": 1}
+
+
+@pytest.mark.asyncio
+async def test_feedback_counts_without_turn_class_when_rows_predate_1137():
+ records = [_call(0), _call(1)] # no hasDocuments / documentDigests / documentReads
+ feedback = [_feedback(0, 1), _feedback(1, -1, "instructions")]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+ assert (p.feedback.up, p.feedback.down) == (1, 1)
+ assert p.feedback.by_turn_class is None, "turn class is 'not tracked', not 'none'"
+ assert p.feedback.unjoined == 0
+ assert p.data_coverage.feedback is True
+
+
+@pytest.mark.asyncio
+async def test_no_feedback_rows_falls_back_to_rollups_and_coverage_is_honest():
+ p = await _service_with_feedback(_row(), [_call(0)], []).get_session_profile("s1")
+ assert (p.feedback.up, p.feedback.down) == (0, 0)
+ assert p.data_coverage.feedback is False
+
+ p = await _service_with_feedback(_row(thumbsUp=2, thumbsDown=1), [_call(0)], []).get_session_profile("s1")
+ assert (p.feedback.up, p.feedback.down) == (2, 1)
+ assert p.data_coverage.feedback is True
+ assert p.feedback.by_turn_class is None
+
+
+@pytest.mark.asyncio
+async def test_implicit_signal_rows_are_never_summed_into_the_thumb_counts():
+ records = [_call(0)]
+ records[0]["hasDocuments"] = True
+ feedback = [_feedback(0, 1), {**_feedback(0, -1), "signal": "implicit"}, {**_feedback(0, -1), "signal": "explicit"}]
+ p = await _service_with_feedback(_row(), records, feedback).get_session_profile("s1")
+ assert (p.feedback.up, p.feedback.down) == (1, 1)
+ assert (p.feedback.by_turn_class.full.up, p.feedback.by_turn_class.full.down) == (1, 1)
+
+
+@pytest.mark.asyncio
+async def test_feedback_reader_failure_never_breaks_the_profile():
+ service = _service(_row(), [_call(0)])
+ service.storage.get_session_feedback_rows = AsyncMock(side_effect=RuntimeError("boom"))
+ p = await service.get_session_profile("s1")
+ assert p is not None and p.feedback.up == 0 and p.data_coverage.feedback is False
diff --git a/backend/tests/apis/app_api/test_message_feedback_routes.py b/backend/tests/apis/app_api/test_message_feedback_routes.py
new file mode 100644
index 000000000..f2b7ed395
--- /dev/null
+++ b/backend/tests/apis/app_api/test_message_feedback_routes.py
@@ -0,0 +1,92 @@
+"""Routes for the thumbs signal:
+
+- PUT /sessions/{id}/messages/{messageId}/feedback → 200 with the stored thumb
+- DELETE /sessions/{id}/messages/{messageId}/feedback → 204
+- 404 for another user's session, 404 while RESPONSE_FEEDBACK_ENABLED=false,
+ 422 for anything but ±1 / a fixed reason code, 503 with no table.
+
+Auth is the cookie-aware `get_current_user_from_session` (SPA-facing route);
+storage is swapped through the module's own function names.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from apis.app_api.sessions import routes as session_routes
+from apis.shared.auth.dependencies import get_current_user_from_session
+from apis.shared.auth.models import User
+from apis.shared.sessions.feedback import SessionNotOwned
+from apis.shared.sessions.models import MessageFeedback
+
+
+def _user() -> User:
+ return User(user_id="user-1", email="u@example.com", name="U", roles=["default"], raw_token="tok")
+
+
+def _client(monkeypatch, put=None, delete=None) -> TestClient:
+ monkeypatch.delenv("RESPONSE_FEEDBACK_ENABLED", raising=False)
+ monkeypatch.setattr(session_routes, "put_message_feedback", put or AsyncMock(
+ return_value=MessageFeedback(value=1, updated_at="2026-09-16T00:00:00Z")
+ ))
+ monkeypatch.setattr(session_routes, "delete_message_feedback", delete or AsyncMock(return_value=True))
+ app = FastAPI()
+ app.include_router(session_routes.router)
+ app.dependency_overrides[get_current_user_from_session] = _user
+ return TestClient(app)
+
+
+def test_put_stores_and_echoes_the_thumb(monkeypatch):
+ put = AsyncMock(return_value=MessageFeedback(value=-1, reason="instructions", updated_at="2026-09-16T00:00:00Z"))
+ client = _client(monkeypatch, put=put)
+
+ resp = client.put("/sessions/s1/messages/3/feedback", json={"value": -1, "reason": "instructions"})
+
+ assert resp.status_code == 200
+ assert resp.json() == {"value": -1, "reason": "instructions", "updatedAt": "2026-09-16T00:00:00Z"}
+ put.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3, value=-1, reason="instructions")
+
+
+def test_put_rejects_free_text_and_out_of_range_values(monkeypatch):
+ client = _client(monkeypatch)
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 1, "reason": "it lied to me"}).status_code == 422
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 0}).status_code == 422
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 5}).status_code == 422
+ assert client.put("/sessions/s1/messages/three/feedback", json={"value": 1}).status_code == 422
+ assert client.put("/sessions/s1/messages/-1/feedback", json={"value": 1}).status_code == 422
+
+
+def test_delete_returns_204(monkeypatch):
+ delete = AsyncMock(return_value=True)
+ client = _client(monkeypatch, delete=delete)
+ resp = client.delete("/sessions/s1/messages/3/feedback")
+ assert resp.status_code == 204
+ delete.assert_awaited_once_with(session_id="s1", user_id="user-1", message_id=3)
+
+
+def test_another_users_session_is_404(monkeypatch):
+ client = _client(
+ monkeypatch,
+ put=AsyncMock(side_effect=SessionNotOwned("s1")),
+ delete=AsyncMock(side_effect=SessionNotOwned("s1")),
+ )
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 1}).status_code == 404
+ assert client.delete("/sessions/s1/messages/3/feedback").status_code == 404
+
+
+def test_kill_switch_hides_the_surface(monkeypatch):
+ put = AsyncMock()
+ client = _client(monkeypatch, put=put)
+ monkeypatch.setenv("RESPONSE_FEEDBACK_ENABLED", "false")
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 1}).status_code == 404
+ assert client.delete("/sessions/s1/messages/3/feedback").status_code == 404
+ put.assert_not_awaited()
+
+
+def test_missing_table_is_503_not_500(monkeypatch):
+ client = _client(monkeypatch, put=AsyncMock(side_effect=RuntimeError("DYNAMODB_SESSIONS_METADATA_TABLE_NAME")))
+ assert client.put("/sessions/s1/messages/3/feedback", json={"value": 1}).status_code == 503
diff --git a/backend/tests/costs/test_content_free_projections.py b/backend/tests/costs/test_content_free_projections.py
index 454a45835..ae86d1389 100644
--- a/backend/tests/costs/test_content_free_projections.py
+++ b/backend/tests/costs/test_content_free_projections.py
@@ -177,3 +177,27 @@ async def test_deleted_sessions_are_listed_on_request_and_stay_content_free(stor
assert by_id["gone"]["status"] == "deleted"
assert by_id["gone"]["totalCost"] == 9
assert "title" not in by_id["gone"]
+
+
+@pytest.mark.asyncio
+async def test_feedback_rows_come_back_content_free_and_keyed_to_the_call(storage):
+ _seed(storage)
+ storage.sessions_metadata_table.put_item(Item={
+ "PK": f"USER#{USER_ID}", "SK": f"F#{SESSION_ID}#3",
+ "GSI_PK": f"SESSION#{SESSION_ID}", "GSI_SK": "F#3",
+ "sessionId": SESSION_ID, "messageId": Decimal(3), "userId": USER_ID,
+ "value": Decimal(-1), "reason": "wrong", "signal": "explicit", "updatedAt": "2026-09-16T00:00:00Z",
+ "ttl": Decimal(1_800_000_000),
+ # A stray content-bearing attribute must never leave the reader even
+ # if something wrote one (the writer cannot, but the reader is the guard).
+ "displayText": "SECRET",
+ })
+ rows = await storage.get_session_feedback_rows(SESSION_ID)
+ assert len(rows) == 1
+ row = rows[0]
+ assert content_bearing_paths(row) == []
+ assert row == {"sessionId": SESSION_ID, "messageId": 3, "value": -1, "reason": "wrong", "signal": "explicit", "updatedAt": "2026-09-16T00:00:00Z"}
+ # Joins the C# row on messageId.
+ records = await storage.get_session_cost_records(SESSION_ID)
+ assert records[0]["messageId"] == row["messageId"]
+ assert await storage.get_session_feedback_rows("no-such-session") == []
diff --git a/backend/tests/shared/test_message_feedback.py b/backend/tests/shared/test_message_feedback.py
new file mode 100644
index 000000000..c4ea26c51
--- /dev/null
+++ b/backend/tests/shared/test_message_feedback.py
@@ -0,0 +1,206 @@
+"""The ``F#`` message-feedback row family (``apis.shared.sessions.feedback``)
+against a real (moto) sessions-metadata table.
+
+Pins the key shape that makes the admin join a one-key lookup, replace-not-
+append semantics, the session rollups' deltas, ownership, the content-free
+guard, and the read-side merge into the messages-list metadata index.
+"""
+
+from __future__ import annotations
+
+import boto3
+import pytest
+from boto3.dynamodb.conditions import Key
+
+from apis.shared.sessions import feedback as fb
+from apis.shared.sessions import metadata as md
+
+SESSION = "sess-fb-1"
+OWNER = "user-owner"
+OTHER = "user-other"
+
+
+def _table():
+ return boto3.resource("dynamodb", region_name="us-east-1").Table("test-sessions-metadata")
+
+
+def _seed_session(user_id=OWNER, session_id=SESSION):
+ _table().put_item(Item={
+ "PK": f"USER#{user_id}",
+ "SK": f"S#{session_id}",
+ "GSI_PK": f"SESSION#{session_id}",
+ "GSI_SK": "META",
+ "sessionId": session_id,
+ "userId": user_id,
+ "status": "active",
+ "messageCount": 4,
+ })
+
+
+def _session_row(user_id=OWNER, session_id=SESSION):
+ return _table().get_item(Key={"PK": f"USER#{user_id}", "SK": f"S#{session_id}"}).get("Item") or {}
+
+
+def _feedback_items(session_id=SESSION):
+ resp = _table().query(
+ IndexName="SessionLookupIndex",
+ KeyConditionExpression=Key("GSI_PK").eq(f"SESSION#{session_id}") & Key("GSI_SK").begins_with("F#"),
+ )
+ return resp.get("Items", [])
+
+
+@pytest.fixture()
+def table(sessions_metadata_table, monkeypatch):
+ monkeypatch.delenv("COST_DIAGNOSTICS_ENABLED", raising=False)
+ _seed_session()
+ return _table()
+
+
+@pytest.mark.asyncio
+async def test_put_writes_one_row_keyed_beside_the_cost_row(table):
+ result = await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ assert result.value == 1 and result.reason is None and result.updated_at
+
+ items = _feedback_items()
+ assert len(items) == 1
+ row = items[0]
+ assert row["PK"] == f"USER#{OWNER}"
+ assert row["SK"] == f"F#{SESSION}#3"
+ assert row["GSI_PK"] == f"SESSION#{SESSION}" and row["GSI_SK"] == "F#3"
+ assert int(row["messageId"]) == 3 and row["sessionId"] == SESSION
+ assert int(row["value"]) == 1
+ assert "reason" not in row
+ assert "ttl" in row
+ # Content-free: nothing on the row but ids, a number, a timestamp and keys.
+ assert row["signal"] == "explicit"
+ assert set(row) <= {"PK", "SK", "GSI_PK", "GSI_SK", "sessionId", "messageId", "userId", "value", "signal", "updatedAt", "ttl"}
+
+
+@pytest.mark.asyncio
+async def test_second_thumb_replaces_the_first_and_rollups_follow(table):
+ await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ assert _session_row()["thumbsUp"] == 1 and _session_row()["thumbsDown"] == 0
+
+ result = await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="instructions")
+ assert result.value == -1 and result.reason == "instructions"
+
+ items = _feedback_items()
+ assert len(items) == 1, "a second click replaces, never appends"
+ assert int(items[0]["value"]) == -1 and items[0]["reason"] == "instructions"
+ row = _session_row()
+ assert row["thumbsUp"] == 0 and row["thumbsDown"] == 1
+
+ # Same value again: no rollup movement, reason updates in place.
+ await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong")
+ row = _session_row()
+ assert row["thumbsUp"] == 0 and row["thumbsDown"] == 1
+ assert _feedback_items()[0]["reason"] == "wrong"
+
+
+@pytest.mark.asyncio
+async def test_delete_removes_the_row_and_decrements(table):
+ await fb.put_message_feedback(SESSION, OWNER, 1, -1, reason="tool_failed")
+ await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ assert await fb.delete_message_feedback(SESSION, OWNER, 1) is True
+ assert [int(i["messageId"]) for i in _feedback_items()] == [3]
+ row = _session_row()
+ assert row["thumbsUp"] == 1 and row["thumbsDown"] == 0
+ # Deleting what isn't there is a no-op, not an error.
+ assert await fb.delete_message_feedback(SESSION, OWNER, 1) is False
+ assert _session_row()["thumbsDown"] == 0
+
+
+@pytest.mark.asyncio
+async def test_rollups_are_not_written_while_diagnostics_are_off(table, monkeypatch):
+ monkeypatch.setenv("COST_DIAGNOSTICS_ENABLED", "false")
+ await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ row = _session_row()
+ assert "thumbsUp" not in row and "thumbsDown" not in row, "absent reads 'not tracked', never 0"
+ assert len(_feedback_items()) == 1, "the row itself is still written"
+
+
+@pytest.mark.asyncio
+async def test_another_users_session_is_not_found(table):
+ with pytest.raises(fb.SessionNotOwned):
+ await fb.put_message_feedback(SESSION, OTHER, 3, 1)
+ with pytest.raises(fb.SessionNotOwned):
+ await fb.delete_message_feedback(SESSION, OTHER, 3)
+ assert _feedback_items() == []
+
+
+@pytest.mark.asyncio
+async def test_storage_refuses_free_text_and_bad_values(table):
+ with pytest.raises(ValueError):
+ await fb.put_message_feedback(SESSION, OWNER, 3, 1, reason="it was rude to me")
+ with pytest.raises(ValueError):
+ await fb.put_message_feedback(SESSION, OWNER, 3, 2)
+ assert _feedback_items() == []
+
+
+@pytest.mark.asyncio
+async def test_preview_sessions_echo_without_persisting(table):
+ result = await fb.put_message_feedback("preview-abc", OWNER, 0, 1)
+ assert result.value == 1
+ assert _feedback_items("preview-abc") == []
+
+
+@pytest.mark.asyncio
+async def test_query_filters_by_user_unless_admin(table):
+ _seed_session(OTHER, "sess-shared")
+ _seed_session(OWNER, "sess-shared")
+ await fb.put_message_feedback("sess-shared", OWNER, 1, 1)
+ await fb.put_message_feedback("sess-shared", OTHER, 1, -1)
+
+ mine = fb.query_session_feedback(table, "sess-shared", OWNER)
+ assert {k: v.value for k, v in mine.items()} == {"1": 1}
+ everyone = fb.query_session_feedback(table, "sess-shared", None)
+ assert len(everyone) == 1 # keyed by message id; the admin reader returns rows, this map is per message
+
+
+@pytest.mark.asyncio
+async def test_implicit_signal_rows_share_the_family_but_never_read_as_thumbs(table):
+ """Spec §10: implicit signals land in the same F# family under
+ signal="implicit"; every thumb reader skips them and never sums the two."""
+ await fb.put_message_feedback(SESSION, OWNER, 3, 1)
+ table.put_item(Item={
+ "PK": f"USER#{OWNER}", "SK": f"F#{SESSION}#5", "GSI_PK": f"SESSION#{SESSION}", "GSI_SK": "F#5",
+ "sessionId": SESSION, "messageId": 5, "userId": OWNER, "value": 1, "signal": "implicit",
+ "updatedAt": "2026-09-16T00:00:00Z",
+ })
+ assert set(fb.query_session_feedback(table, SESSION, OWNER)) == {"3"}
+ index = await md.get_all_message_metadata(SESSION, OWNER)
+ assert "5" not in index
+ # A row written before the discriminator existed is explicit.
+ assert fb.is_explicit({"value": 1}) and not fb.is_explicit({"value": 1, "signal": "implicit"})
+
+
+@pytest.mark.asyncio
+async def test_messages_list_metadata_index_carries_feedback(table, monkeypatch):
+ """The read path the SPA uses on reload: F# rows merge into the metadata
+ index by message id, beside (or in place of) the cost record."""
+ table.put_item(Item={
+ "PK": f"USER#{OWNER}", "SK": "C#2026-09-16T00:00:00Z#u1",
+ "GSI_PK": f"SESSION#{SESSION}", "GSI_SK": "C#2026-09-16T00:00:00Z",
+ "sessionId": SESSION, "messageId": 3, "userId": OWNER,
+ "timestamp": "2026-09-16T00:00:00Z", "cost": {"total": 1},
+ })
+ await fb.put_message_feedback(SESSION, OWNER, 3, -1, reason="wrong")
+ await fb.put_message_feedback(SESSION, OWNER, 5, 1)
+
+ index = await md.get_all_message_metadata(SESSION, OWNER)
+ assert index["3"]["cost"] == {"total": 1}
+ assert index["3"]["feedback"] == {"value": -1, "reason": "wrong", "updatedAt": index["3"]["feedback"]["updatedAt"]}
+ assert index["5"] == {"feedback": {"value": 1, "updatedAt": index["5"]["feedback"]["updatedAt"]}}
+
+ monkeypatch.setenv("RESPONSE_FEEDBACK_ENABLED", "false")
+ index = await md.get_all_message_metadata(SESSION, OWNER)
+ assert "feedback" not in index["3"] and "5" not in index
+
+
+@pytest.mark.asyncio
+async def test_message_metadata_model_round_trips_feedback(table):
+ from apis.shared.sessions.models import MessageMetadata
+
+ meta = MessageMetadata(**{"cost": 0.1, "feedback": {"value": 1, "updatedAt": "2026-09-16T00:00:00Z"}})
+ assert meta.feedback is not None and meta.feedback.value == 1
+ assert meta.model_dump(by_alias=True, exclude_none=True)["feedback"] == {"value": 1, "updatedAt": "2026-09-16T00:00:00Z"}
diff --git a/docs/specs/response-feedback.md b/docs/specs/response-feedback.md
index 8768952fa..b8aa02693 100644
--- a/docs/specs/response-feedback.md
+++ b/docs/specs/response-feedback.md
@@ -1,7 +1,9 @@
# Response feedback
-**Status:** PROPOSED — no code. Written 2026-09-04 from the "how would we
-benefit?" conversation.
+**Status:** PARTIALLY BUILT — capture and the read model shipped in PR #1142
+(2026-09-16, as document-context-offload PR-7); the consequence (§11 PR-1's
+retry-with-correction), implicit signals and eval sampling are not built.
+See §13. Written 2026-09-04 from the "how would we benefit?" conversation.
**Refs:** `docs/specs/agentcore-evaluations-spike-findings.md` (the eval
harness this feeds), `docs/specs/mid-turn-steering.md` (the injection path
Phase 1 reuses), `docs/specs/agent-marketplace.md` D15 (the *other* feedback
@@ -320,3 +322,39 @@ if PR-3 proves the signal is real.
than copying whichever neighbour is read first. Agent Designer previews are
exactly where an author would want to thumb their own work, but that data
must never reach fleet aggregates.
+
+## 13. Built — PR #1142 (2026-09-16)
+
+Shipped as the outcome signal of `document-context-offload.md` §5 row 7, so
+it was built against that spec's content-free rule first and reconciled with
+this one after. What landed, and the decisions it took on this spec's open
+points:
+
+- **Storage exactly per §5**: `F#{session_id}#{message_id}` on
+ `sessions-metadata`, `GSI_SK F#{message_id}`, idempotent upsert, overwrite
+ in place, never on the message object (`apis/shared/sessions/feedback.py`).
+ Rows carry `signal: "explicit"` from day one so §10's implicit rows can
+ join the family without a backfill; every thumb reader filters on it, so
+ the two are never summed (§10's rule, enforced).
+- **Routes** `PUT`/`DELETE /sessions/{id}/messages/{message_id}/feedback` on
+ app-api (PUT rather than POST: the write is an upsert). Flag
+ `RESPONSE_FEEDBACK_ENABLED`, default on with a kill switch.
+- **Reason set = §6's six buckets as codes** (`wrong`, `instructions`,
+ `length`, `tool_failed`, `outdated`, `other`). **Free text is not stored,
+ and this is a decision, not an omission**: the row sits beside the `C#`
+ cost row and is read by the content-free admin profile, whose test walks
+ the projection against the denylist. "Something else → free text" is
+ served by the §3 hand-off to the Agent report dialog, which already has
+ moderation and a scope. Revisit only together with §8 rule 1.
+- **Read model**: the session profile (`GET /admin/costs/sessions/{id}/profile`)
+ joins thumbs to the call's document turn class with `n` per bucket
+ (§9's "every response carries its n"); `dataCoverage.feedback` says when
+ nothing is tracked. The other §7 attribution axes (model, compaction,
+ `agentSwitched`, skills) are further buckets in the same join loop.
+- **Open question 4 settled**: preview sessions echo the thumb and persist
+ nothing, matching the `D#` write. Preview data never reaches aggregates.
+- **Not built**: retry-with-correction (§11 PR-1's consequence — open
+ question 1 still stands), implicit signals (PR-2), eval sampling (PR-4),
+ author/marketplace surfaces (PR-5), the report-dialog escape hatch in the
+ reason row.
+
diff --git a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
index 212f36ca8..104f54696 100644
--- a/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
+++ b/frontend/ai.client/src/app/admin/costs/models/admin-cost.models.ts
@@ -416,6 +416,29 @@ export interface DataCoverage {
windowTrim?: boolean;
compactionEvents?: boolean;
documents?: boolean;
+ /** Any thumbs row, or a session rollup written while diagnostics were on. */
+ feedback?: boolean;
+}
+
+/** Thumbs on one bucket of calls — counts, never content. */
+export interface FeedbackCounts {
+ up: number;
+ down: number;
+}
+
+/** Turn classes from the document-context offload spec §6.1. */
+export type TurnClass = 'full' | 'digestOnly' | 'retrieved' | 'none';
+
+/**
+ * The outcome signal joined to the cost rows. `byTurnClass` is null when no
+ * cost row carries the turn-class fields (they arrive with offload PR-1);
+ * that is "not tracked", not zero. `unjoined` thumbs have no cost row.
+ */
+export interface FeedbackProfile {
+ up: number;
+ down: number;
+ byTurnClass?: Record
healthy ≈ 0.1
+Feedback
+ @if (profile.dataCoverage.feedback && profile.feedback; as feedback) { + += 50 + ? 'text-state-danger-600 dark:text-state-danger-400' + : 'text-gray-900 dark:text-white' + " + > + {{ feedbackDownRate() != null ? feedbackDownRate() + '% down' : '—' }} +
++ {{ feedback.up }} up · {{ feedback.down }} down + @if (feedbackTurnClassLine(); as byClass) { + · {{ byClass }} + } @else { + · turn class not tracked + } +
+ } @else { +—
+not tracked
+ } +