diff --git a/backend/alembic/versions/0ec213a5ffde_add_incognito_record_mode_to_chat_.py b/backend/alembic/versions/0ec213a5ffde_add_incognito_record_mode_to_chat_.py new file mode 100644 index 00000000000..a792b59760a --- /dev/null +++ b/backend/alembic/versions/0ec213a5ffde_add_incognito_record_mode_to_chat_.py @@ -0,0 +1,29 @@ +"""add incognito_record_mode to chat_session + +Revision ID: 0ec213a5ffde +Revises: a44c4ebac3d6 +Create Date: 2026-08-07 13:05:59.971436 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0ec213a5ffde" +down_revision = "a44c4ebac3d6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Unbounded VARCHAR, as reasoning_effort_override on this table already + # does, so adding a longer mode stays a code change. + op.add_column( + "chat_session", + sa.Column("incognito_record_mode", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("chat_session", "incognito_record_mode") diff --git a/backend/onyx/db/enums.py b/backend/onyx/db/enums.py index f1f2ae85305..3f863bdb78a 100644 --- a/backend/onyx/db/enums.py +++ b/backend/onyx/db/enums.py @@ -712,3 +712,71 @@ class SSOProviderType(str, PyEnum): GOOGLE_OAUTH = "GOOGLE_OAUTH" OIDC = "OIDC" SAML = "SAML" + + +class IncognitoRecordMode(str, PyEnum): + """What a workspace retains from an incognito chat. + + Both modes keep the chat out of the owner's own history and refuse memory + writes. The mode governs what else the workspace may record. Every mode + meters usage, since token rate limits read those rows and incognito must + never become a quota-evasion route. Feature-off is deliberately not a + member: disabling incognito is an admin setting, never a mode pinned on a + session. + + Values differ from member names here, so the column storing this passes + ``values_callable`` to persist the value rather than the default name. + """ + + # Content persists as an ordinary chat, hidden only from the owner's own + # surfaces (history, search, project lists). + FULL_HISTORY = "full_history" + # No conversation content is written to Postgres. Usage is still metered. + USAGE_ONLY = "usage_only" + + @classmethod + def from_context_value(cls, value: str | None) -> "IncognitoRecordMode | None": + """None outside incognito. Unknown values fail closed to USAGE_ONLY.""" + if value is None: + return None + try: + return cls(value) + except ValueError: + return cls.USAGE_ONLY + + @property + def persists_content(self) -> bool: + """Whether conversation content may be written to chat_message rows. + + Content-free modes still write the rows, with empty text and real + token counts. False means content is never written, not + written-and-hidden: deletion is not atomic across WAL, replicas, + and backups. + """ + return self is IncognitoRecordMode.FULL_HISTORY + + @property + def emits_external_traces(self) -> bool: + """Whether spans may reach external trace processors. + + Redacting a payload still sends a request to a destination the trust + boundary denies, so suppression is total rather than scrubbed. Internal + spans always run: the usage ledger is a tracing processor consuming + them, so metering needs no egress. + """ + return self is IncognitoRecordMode.FULL_HISTORY + + @property + def fires_hooks(self) -> bool: + """Whether the query-processing hook may run. + + The hook ships the raw query and the user's email to a customer- + configured endpoint before persistence, which the trust boundary denies + for anything but a fully-recorded chat. + """ + return self is IncognitoRecordMode.FULL_HISTORY + + +def record_mode_persists_content(mode: IncognitoRecordMode | None) -> bool: + """None is an ordinary chat, which always persists content.""" + return mode is None or mode.persists_content diff --git a/backend/onyx/db/models.py b/backend/onyx/db/models.py index 33063ad48af..1561d9ebf35 100644 --- a/backend/onyx/db/models.py +++ b/backend/onyx/db/models.py @@ -83,6 +83,7 @@ HierarchyNodeType, HookFailStrategy, HookPoint, + IncognitoRecordMode, IndexingMode, IndexingStatus, IndexModelStatus, @@ -3067,6 +3068,18 @@ class ChatSession(Base): description: Mapped[str | None] = mapped_column(Text, nullable=True) # This chat created by OnyxBot onyxbot_flow: Mapped[bool] = mapped_column(Boolean, default=False) + # Pinned at creation, so a later setting change cannot alter a live + # session. NULL is an ordinary chat and records normally. Of the incognito + # modes only FULL_HISTORY writes conversation content into messages. + incognito_record_mode: Mapped[IncognitoRecordMode | None] = mapped_column( + Enum( + IncognitoRecordMode, + native_enum=False, + values_callable=lambda x: [e.value for e in x], + ), + nullable=True, + default=None, + ) # Only ever set to True if system is set to not hard-delete chats deleted: Mapped[bool] = mapped_column(Boolean, default=False) # controls whether or not this conversation is viewable by others