From fd5ef806d65dbf7fac81c1e033b35cb2c6bc524e Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:39:01 -0400 Subject: [PATCH] feat(studio): enable Assistant tracing for High Compliance projects (#50759) Assistant chats from High Compliance projects now flow to Braintrust like any other project. The constraint that required suppressing them no longer applies, see AI-1241 for the details. `isTracingAllowed` now takes only the project region to maintain EU exclusion. Traces also carry an `isHighComplianceProject` metadata field, so the project's status at the time of the trace is recorded rather than looked up later against a setting customers can toggle. To verify, see [this sample trace](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs?r=afabbdcc-aa89-446e-aa52-78aaa90d44a4&v=Production&s=afabbdcc-aa89-446e-aa52-78aaa90d44a4&tvt=trace) from a High Compliance project on staging which indicates that tracing is now enabled for these projects and that it carries metadata showing the high compliance status. | High Compliance project setting | `isHighComplianceProject` metadata | |--------|--------| | CleanShot 2026-09-22 at 5 14 58
PM@2x | CleanShot 2026-09-22 at 5 17 40
PM@2x | Closes AI-1241 ## Summary by CodeRabbit * **Changes** * AI project compliance information is now represented by a unified high-compliance project status. * AI response tracing is now determined by project region: tracing remains disabled for EU and unknown regions, while known non-EU regions are eligible. * AI feedback and SQL generation now use the updated compliance and regional handling. * **Tests** * Updated coverage to reflect the revised compliance and tracing behavior. --- apps/studio/lib/ai/ai-details.test.ts | 68 ++++--------------- apps/studio/lib/ai/ai-details.ts | 36 ++++------ apps/studio/lib/ai/braintrust-logger.test.ts | 67 ++---------------- apps/studio/lib/ai/braintrust-logger.ts | 20 +----- .../lib/ai/generate-assistant-response.ts | 3 + apps/studio/lib/api/generate-v4.test.ts | 2 - apps/studio/lib/api/rate.test.ts | 2 - apps/studio/pages/api/ai/feedback/rate.ts | 10 +-- apps/studio/pages/api/ai/sql/generate-v4.ts | 13 ++-- 9 files changed, 46 insertions(+), 175 deletions(-) diff --git a/apps/studio/lib/ai/ai-details.test.ts b/apps/studio/lib/ai/ai-details.test.ts index 5589621f098..3aa84f64346 100644 --- a/apps/studio/lib/ai/ai-details.test.ts +++ b/apps/studio/lib/ai/ai-details.test.ts @@ -10,10 +10,6 @@ vi.mock('@/data/projects/project-detail-query', () => ({ getProjectDetail: vi.fn(), })) -vi.mock('@/data/subscriptions/org-subscription-query', () => ({ - getOrgSubscription: vi.fn(), -})) - vi.mock('@/data/config/project-settings-v2-query', () => ({ getProjectSettings: vi.fn(), })) @@ -22,10 +18,6 @@ vi.mock('@/hooks/misc/useOrgOptedIntoAi', () => ({ getAiOptInLevel: vi.fn(), })) -vi.mock('@/components/interfaces/Billing/Subscription/Subscription.utils', () => ({ - subscriptionHasHipaaAddon: vi.fn(), -})) - vi.mock('@/data/entitlements/entitlements-query', () => ({ checkEntitlement: vi.fn(), })) @@ -37,29 +29,22 @@ const PROJECT_REF = 'test-project' describe('getAIDetails', () => { let mockGetOrganizations: ReturnType - let mockGetOrgSubscription: ReturnType let mockGetProjectDetail: ReturnType let mockGetProjectSettings: ReturnType let mockGetAiOptInLevel: ReturnType - let mockSubscriptionHasHipaaAddon: ReturnType let mockCheckEntitlement: ReturnType beforeEach(async () => { const orgsQuery = await import('@/data/organizations/organizations-query') - const subscriptionQuery = await import('@/data/subscriptions/org-subscription-query') const projectQuery = await import('@/data/projects/project-detail-query') const settingsQuery = await import('@/data/config/project-settings-v2-query') const aiHook = await import('@/hooks/misc/useOrgOptedIntoAi') - const subscriptionUtils = - await import('@/components/interfaces/Billing/Subscription/Subscription.utils') const entitlementsQuery = await import('@/data/entitlements/entitlements-query') mockGetOrganizations = vi.mocked(orgsQuery.getOrganizations) - mockGetOrgSubscription = vi.mocked(subscriptionQuery.getOrgSubscription) mockGetProjectDetail = vi.mocked(projectQuery.getProjectDetail) mockGetProjectSettings = vi.mocked(settingsQuery.getProjectSettings) mockGetAiOptInLevel = vi.mocked(aiHook.getAiOptInLevel) - mockSubscriptionHasHipaaAddon = vi.mocked(subscriptionUtils.subscriptionHasHipaaAddon) mockCheckEntitlement = vi.mocked(entitlementsQuery.checkEntitlement) mockGetOrganizations.mockResolvedValue([ @@ -71,8 +56,6 @@ describe('getAIDetails', () => { organization_id: 1, }) mockGetProjectSettings.mockResolvedValue({ is_sensitive: false }) - mockGetOrgSubscription.mockResolvedValue({ addons: [] }) - mockSubscriptionHasHipaaAddon.mockReturnValue(false) mockCheckEntitlement.mockResolvedValue({ hasAccess: false }) mockGetAiOptInLevel.mockReturnValue('schema') }) @@ -87,15 +70,26 @@ describe('getAIDetails', () => { expect(result).toEqual({ aiOptInLevel: 'schema', hasAccessToAdvanceModel: false, - hasHipaaAddon: false, orgId: 1, orgSlug: ORG_SLUG, planId: 'pro', region: 'us-east-1', - isSensitive: false, + isHighComplianceProject: false, }) }) + it('flags a High Compliance project', async () => { + mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) + + const result = await getAIDetails({ + orgSlug: ORG_SLUG, + projectRef: PROJECT_REF, + authorization: AUTH, + }) + + expect(result.isHighComplianceProject).toBe(true) + }) + it('calls getAiOptInLevel with the matched org opt_in_tags', async () => { const opt_in_tags = ['AI_SQL_GENERATOR_OPT_IN'] mockGetOrganizations.mockResolvedValue([ @@ -144,7 +138,6 @@ describe('getAIDetails', () => { await getAIDetails({ orgSlug: ORG_SLUG, projectRef: PROJECT_REF, authorization: AUTH }) expect(mockGetOrganizations).toHaveBeenCalledWith({ headers: HEADERS }) - expect(mockGetOrgSubscription).toHaveBeenCalledWith({ orgSlug: ORG_SLUG }, undefined, HEADERS) expect(mockCheckEntitlement).toHaveBeenCalledWith( ORG_SLUG, 'assistant.advance_model', @@ -192,16 +185,14 @@ describe('getAIDetails', () => { expect(result.planId).toBeUndefined() }) - it('leaves hasHipaaAddon undefined so tracing checks fail closed', async () => { - mockSubscriptionHasHipaaAddon.mockReturnValue(false) - + it('leaves the region undefined so tracing checks fail closed', async () => { const result = await getAIDetails({ orgSlug: ORG_SLUG, projectRef: PROJECT_REF, authorization: AUTH, }) - expect(result.hasHipaaAddon).toBeUndefined() + expect(result.region).toBeUndefined() }) }) @@ -231,33 +222,4 @@ describe('getAIDetails', () => { expect(result.aiOptInLevel).toBe('disabled') expect(result.region).toBeUndefined() }) - - it('keeps the opt-in level for a sensitive project in a HIPAA org', async () => { - mockSubscriptionHasHipaaAddon.mockReturnValue(true) - mockGetAiOptInLevel.mockReturnValue('schema_and_log_and_data') - mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) - - const result = await getAIDetails({ - orgSlug: ORG_SLUG, - projectRef: PROJECT_REF, - authorization: AUTH, - }) - - expect(result.aiOptInLevel).toBe('schema_and_log_and_data') - expect(result.hasHipaaAddon).toBe(true) - expect(result.isSensitive).toBe(true) - }) - - it('keeps the opt-in level for a sensitive project outside a HIPAA org', async () => { - mockSubscriptionHasHipaaAddon.mockReturnValue(false) - mockGetProjectSettings.mockResolvedValue({ is_sensitive: true }) - - const result = await getAIDetails({ - orgSlug: ORG_SLUG, - projectRef: PROJECT_REF, - authorization: AUTH, - }) - - expect(result.aiOptInLevel).toBe('schema') - }) }) diff --git a/apps/studio/lib/ai/ai-details.ts b/apps/studio/lib/ai/ai-details.ts index 63db823ff32..1ba7101aa83 100644 --- a/apps/studio/lib/ai/ai-details.ts +++ b/apps/studio/lib/ai/ai-details.ts @@ -1,20 +1,18 @@ -import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { getProjectSettings } from '@/data/config/project-settings-v2-query' import { checkEntitlement } from '@/data/entitlements/entitlements-query' import { getOrganizations } from '@/data/organizations/organizations-query' import { getProjectDetail } from '@/data/projects/project-detail-query' -import { getOrgSubscription } from '@/data/subscriptions/org-subscription-query' import { getAiOptInLevel, type AiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi' export type AIDetails = { aiOptInLevel: AiOptInLevel hasAccessToAdvanceModel: boolean - hasHipaaAddon: boolean | undefined orgId: number | undefined orgSlug: string | undefined planId: string | undefined region: string | undefined - isSensitive: boolean | null | undefined + /** "High Compliance" in the dashboard, `is_sensitive` in the platform API. */ + isHighComplianceProject: boolean | undefined } // Resolves the AI opt-in level, model access and tracing inputs for one org/project pair. @@ -34,19 +32,17 @@ export const getAIDetails = async ({ ...(authorization && { Authorization: authorization }), } - const [organizations, subscription, advanceModelAccess, project, projectSettings] = - await Promise.all([ - getOrganizations({ headers }), - getOrgSubscription({ orgSlug }, undefined, headers), - checkEntitlement(orgSlug, 'assistant.advance_model', undefined, headers), - // skipWake: only organization_id and region are needed, neither requires a running project - getProjectDetail({ ref: projectRef, skipWake: true }, undefined, headers), - getProjectSettings({ projectRef }, undefined, headers), - ]) + const [organizations, advanceModelAccess, project, projectSettings] = await Promise.all([ + getOrganizations({ headers }), + checkEntitlement(orgSlug, 'assistant.advance_model', undefined, headers), + // skipWake: only organization_id and region are needed, neither requires a running project + getProjectDetail({ ref: projectRef, skipWake: true }, undefined, headers), + getProjectSettings({ projectRef }, undefined, headers), + ]) const selectedOrg = organizations.find((org) => org.slug === orgSlug) const region = project?.region - const isSensitive = projectSettings?.is_sensitive + const isHighComplianceProject = projectSettings?.is_sensitive ?? undefined const isProjectInOrg = selectedOrg !== undefined && project?.organization_id === selectedOrg.id @@ -54,26 +50,22 @@ export const getAIDetails = async ({ return { aiOptInLevel: 'disabled', hasAccessToAdvanceModel: false, - // Undefined rather than false so isTracingAllowed fails closed - hasHipaaAddon: undefined, orgId: undefined, orgSlug: undefined, planId: undefined, - region, - isSensitive, + // Undefined rather than the real region so isTracingAllowed fails closed + region: undefined, + isHighComplianceProject: undefined, } } - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - return { aiOptInLevel: getAiOptInLevel(selectedOrg.opt_in_tags), hasAccessToAdvanceModel: advanceModelAccess.hasAccess, - hasHipaaAddon, orgId: selectedOrg.id, orgSlug: selectedOrg.slug, planId: selectedOrg.plan.id, region, - isSensitive, + isHighComplianceProject, } } diff --git a/apps/studio/lib/ai/braintrust-logger.test.ts b/apps/studio/lib/ai/braintrust-logger.test.ts index ac76344fe9f..4f087b60212 100644 --- a/apps/studio/lib/ai/braintrust-logger.test.ts +++ b/apps/studio/lib/ai/braintrust-logger.test.ts @@ -2,71 +2,18 @@ import { describe, expect, it } from 'vitest' import { isTracingAllowed } from './braintrust-logger' -const baseAllowed = { - orgHasHipaaAddon: false, - projectIsSensitive: false, - projectRegion: 'us-east-1', -} - describe('isTracingAllowed', () => { - it('allows tracing when all flags are explicitly off/non-EU', () => { - expect(isTracingAllowed(baseAllowed)).toBe(true) - }) - - it('disallows tracing when HIPAA addon is active and project is sensitive', () => { - expect( - isTracingAllowed({ ...baseAllowed, orgHasHipaaAddon: true, projectIsSensitive: true }) - ).toBe(false) - }) - - it('allows tracing when HIPAA addon is active but project is not sensitive', () => { - expect( - isTracingAllowed({ ...baseAllowed, orgHasHipaaAddon: true, projectIsSensitive: false }) - ).toBe(true) - }) - - it('allows tracing when project is sensitive but no HIPAA addon', () => { - expect( - isTracingAllowed({ ...baseAllowed, orgHasHipaaAddon: false, projectIsSensitive: true }) - ).toBe(true) + it('allows tracing for non-EU regions', () => { + expect(isTracingAllowed({ projectRegion: 'us-east-1' })).toBe(true) + expect(isTracingAllowed({ projectRegion: 'ap-southeast-1' })).toBe(true) }) it('disallows tracing for EU regions', () => { - expect(isTracingAllowed({ ...baseAllowed, projectRegion: 'eu-west-1' })).toBe(false) - expect(isTracingAllowed({ ...baseAllowed, projectRegion: 'eu-central-1' })).toBe(false) + expect(isTracingAllowed({ projectRegion: 'eu-west-1' })).toBe(false) + expect(isTracingAllowed({ projectRegion: 'eu-central-1' })).toBe(false) }) - it('allows tracing for non-EU regions', () => { - expect(isTracingAllowed({ ...baseAllowed, projectRegion: 'ap-southeast-1' })).toBe(true) - }) - - it('allows tracing when HIPAA addon is false and is_sensitive is null (DB default)', () => { - expect(isTracingAllowed({ ...baseAllowed, projectIsSensitive: null })).toBe(true) - }) - - it('disallows tracing when HIPAA addon is unknown and is_sensitive is null', () => { - expect( - isTracingAllowed({ ...baseAllowed, orgHasHipaaAddon: undefined, projectIsSensitive: null }) - ).toBe(false) - }) - - it('disallows tracing when flags are undefined (unknown = restricted)', () => { - expect( - isTracingAllowed({ - orgHasHipaaAddon: undefined, - projectIsSensitive: undefined, - projectRegion: undefined, - }) - ).toBe(false) - expect(isTracingAllowed({ ...baseAllowed, projectRegion: undefined })).toBe(false) - expect(isTracingAllowed({ ...baseAllowed, orgHasHipaaAddon: undefined })).toBe(false) - // projectIsSensitive unknown only matters when orgHasHipaaAddon is also unknown - expect( - isTracingAllowed({ - ...baseAllowed, - orgHasHipaaAddon: undefined, - projectIsSensitive: undefined, - }) - ).toBe(false) + it('disallows tracing when the region is unknown', () => { + expect(isTracingAllowed({ projectRegion: undefined })).toBe(false) }) }) diff --git a/apps/studio/lib/ai/braintrust-logger.ts b/apps/studio/lib/ai/braintrust-logger.ts index 00ef10da3a8..ad0d57781a8 100644 --- a/apps/studio/lib/ai/braintrust-logger.ts +++ b/apps/studio/lib/ai/braintrust-logger.ts @@ -13,25 +13,9 @@ if (IS_TRACING_ENABLED) { }) } -// Checks compliance flags for tracing and returns true only when all checks pass. -// Defaults to disabling tracing when states are unknown. -export function isTracingAllowed({ - orgHasHipaaAddon, - projectIsSensitive, - projectRegion, -}: { - orgHasHipaaAddon: boolean | undefined - projectIsSensitive: boolean | null | undefined - projectRegion: string | undefined -}) { - // Disable tracing for EU (or unknown) regions +// Checks that the project is outside the EU. An unknown region fails closed. +export function isTracingAllowed({ projectRegion }: { projectRegion: string | undefined }) { if (projectRegion === undefined || projectRegion.startsWith('eu-')) return false - // Disable tracing for orgs with an unknown HIPAA addon state - if (orgHasHipaaAddon === undefined) return false - - // Disable tracing for projects within a HIPAA-enabled org that are sensitive (or unknown sensitivity) - if (orgHasHipaaAddon && projectIsSensitive !== false) return false - return true } diff --git a/apps/studio/lib/ai/generate-assistant-response.ts b/apps/studio/lib/ai/generate-assistant-response.ts index ccb02cc2866..18eac67cfee 100644 --- a/apps/studio/lib/ai/generate-assistant-response.ts +++ b/apps/studio/lib/ai/generate-assistant-response.ts @@ -41,6 +41,7 @@ export async function generateAssistantResponse({ orgId, orgSlug, planId, + isHighComplianceProject, includesLogsSnippets, isExplorerEnabled, systemProviderOptions, @@ -63,6 +64,7 @@ export async function generateAssistantResponse({ orgId?: number orgSlug?: string planId?: string + isHighComplianceProject?: boolean /** Whether any user message in the conversation attached a logs (ClickHouse) query. */ includesLogsSnippets?: boolean isExplorerEnabled?: boolean @@ -175,6 +177,7 @@ export async function generateAssistantResponse({ orgId, orgSlug, planId, + isHighComplianceProject, requestedModel, gitBranch: process.env.VERCEL_GIT_COMMIT_REF, environment: process.env.NEXT_PUBLIC_ENVIRONMENT, diff --git a/apps/studio/lib/api/generate-v4.test.ts b/apps/studio/lib/api/generate-v4.test.ts index 8fc0b1dd949..1b82828975d 100644 --- a/apps/studio/lib/api/generate-v4.test.ts +++ b/apps/studio/lib/api/generate-v4.test.ts @@ -14,9 +14,7 @@ vi.mock('@/lib/ai/ai-details', () => ({ getAIDetails: vi.fn().mockResolvedValue({ aiOptInLevel: 'schema_and_log_and_data', hasAccessToAdvanceModel: true, - hasHipaaAddon: false, region: 'us-east-1', - isSensitive: false, }), })) diff --git a/apps/studio/lib/api/rate.test.ts b/apps/studio/lib/api/rate.test.ts index 03ea9968868..5d76ac7b8f2 100644 --- a/apps/studio/lib/api/rate.test.ts +++ b/apps/studio/lib/api/rate.test.ts @@ -13,9 +13,7 @@ vi.mock('@/lib/ai/ai-details', () => ({ getAIDetails: vi.fn().mockResolvedValue({ aiOptInLevel: 'schema_and_log_and_data', hasAccessToAdvanceModel: true, - hasHipaaAddon: false, region: 'us-east-1', - isSensitive: false, }), })) diff --git a/apps/studio/pages/api/ai/feedback/rate.ts b/apps/studio/pages/api/ai/feedback/rate.ts index 8483d4de15d..20e591c1ec0 100644 --- a/apps/studio/pages/api/ai/feedback/rate.ts +++ b/apps/studio/pages/api/ai/feedback/rate.ts @@ -55,8 +55,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { const { rating, messages: rawMessages, projectRef, orgSlug, reason, spanId } = data let aiOptInLevel: AiOptInLevel = 'disabled' - let orgHasHipaaAddon: boolean | undefined - let projectIsSensitive: boolean | null | undefined let projectRegion: string | undefined if (!IS_PLATFORM) { @@ -68,8 +66,6 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { const aiDetails = await getAIDetails({ orgSlug, projectRef, authorization }) aiOptInLevel = aiDetails.aiOptInLevel - orgHasHipaaAddon = aiDetails.hasHipaaAddon - projectIsSensitive = aiDetails.isSensitive projectRegion = aiDetails.region } catch (error) { return res.status(400).json({ @@ -131,11 +127,7 @@ Instructions: }) // Log feedback to Braintrust if tracing is enabled and span ID is available - if ( - IS_TRACING_ENABLED && - isTracingAllowed({ orgHasHipaaAddon, projectIsSensitive, projectRegion }) && - spanId - ) { + if (IS_TRACING_ENABLED && isTracingAllowed({ projectRegion }) && spanId) { try { const logger = currentLogger() logger?.logFeedback({ diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index b68ba949bc3..e163fd7f66c 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -121,9 +121,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw let aiOptInLevel: AiOptInLevel = 'disabled' let hasAccessToAdvanceModel = false - let orgHasHipaaAddon: boolean | undefined - let projectIsSensitive: boolean | null | undefined let projectRegion: string | undefined + let isHighComplianceProject: boolean | undefined let orgId: number | undefined let orgSlug: string | undefined let planId: string | undefined @@ -139,12 +138,11 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw aiOptInLevel = aiDetails.aiOptInLevel hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel - orgHasHipaaAddon = aiDetails.hasHipaaAddon orgId = aiDetails.orgId orgSlug = aiDetails.orgSlug planId = aiDetails.planId - projectIsSensitive = aiDetails.isSensitive projectRegion = aiDetails.region + isHighComplianceProject = aiDetails.isHighComplianceProject } catch (error) { return res.status(400).json({ error: 'There was an error fetching your organization details', @@ -227,16 +225,13 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw projectRef, chatId, chatName, - allowTracing: isTracingAllowed({ - orgHasHipaaAddon, - projectIsSensitive, - projectRegion, - }), + allowTracing: isTracingAllowed({ projectRegion }), supportMode, userId, orgId, orgSlug, planId, + isHighComplianceProject, includesLogsSnippets, isExplorerEnabled: explorerEnabled, requestedModel,