From 5c5416dd101b6ce21d96f6c6bbfa85a1933fc057 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:34 -0700 Subject: [PATCH 1/4] feat(suppliers): track data gap time to fix per supplier Open one gap event per flagged missing KDE or failed CTE validation, close it only when a passing record for the same lot and CTE arrives, and store a receipt hash of the closing record. Adds a per supplier view with open gaps, median time to fix, oldest open gap and a 24 hour at risk flag citing 21 CFR 1.1455(c)(1). Migration included but not applied. Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- docs/security/route-security-manifest.json | 17 +- .../components/gap-time-to-fix.test.tsx | 71 +++++ .../pages/suppliers-metrics.test.tsx | 1 + .../pages/suppliers-removal.test.tsx | 1 + .../dashboard/suppliers/gap-time-to-fix.tsx | 81 +++++ frontend/src/app/dashboard/suppliers/page.tsx | 19 ++ frontend/src/lib/api-client.ts | 6 + frontend/src/types/api.ts | 16 + .../admin/app/supplier_compliance_routes.py | 52 ++- services/ingestion/app/webhook_compat.py | 37 ++- .../ingestion/app/webhook_router_v2/routes.py | 59 ++++ services/shared/data_gap_tracking.py | 300 ++++++++++++++++++ .../shared/tests/test_data_gap_tracking.py | 260 +++++++++++++++ .../20261024060000_data_gap_time_to_fix.sql | 96 ++++++ 14 files changed, 1012 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/components/gap-time-to-fix.test.tsx create mode 100644 frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx create mode 100644 services/shared/data_gap_tracking.py create mode 100644 services/shared/tests/test_data_gap_tracking.py create mode 100644 supabase/migrations/20261024060000_data_gap_time_to_fix.sql diff --git a/docs/security/route-security-manifest.json b/docs/security/route-security-manifest.json index 6b7a7828d..a4794c99f 100644 --- a/docs/security/route-security-manifest.json +++ b/docs/security/route-security-manifest.json @@ -3021,6 +3021,19 @@ "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", "test_adapter": "tenant_dual_session_csrf" }, + { + "operation": "admin:GET:/v1/supplier/gap-time-to-fix", + "owner": "admin", + "method": "GET", + "path": "/v1/supplier/gap-time-to-fix", + "classification": "tenant-scoped", + "authentication": "bearer-token-or-opaque-session-cookie", + "required_permissions": [ + "authenticated" + ], + "storage_boundary": "admin-postgres:jwt-tenant-or-session-membership-tenant", + "test_adapter": "tenant_dual_session_csrf" + }, { "operation": "admin:GET:/v1/supplier/gaps", "owner": "admin", @@ -4905,13 +4918,13 @@ } ], "summary": { - "enabled_operation_count": 254, + "enabled_operation_count": 255, "disabled_operation_count": 180, "disabled_unique_path_count": 163, "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 201, + "tenant-scoped": 202, "workload-only": 7 } } diff --git a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx new file mode 100644 index 000000000..0c53b0d3c --- /dev/null +++ b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { GapTimeToFix } from '@/app/dashboard/suppliers/gap-time-to-fix'; +import type { SupplierGapTimeToFix } from '@/types/api'; + +const NOW = Date.UTC(2026, 0, 2); +const HOUR_MS = 3_600_000; + +const summary = (overrides: Partial): SupplierGapTimeToFix => ({ + supplier_key: 'k', + open_gap_count: 0, + median_time_to_fix_seconds: null, + oldest_open_gap_opened_at: null, + at_risk: false, + has_data: true, + ...overrides, +}); + +describe('GapTimeToFix', () => { + it('shows No data when the supplier has no gap history', () => { + render(); + expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + }); + + it('shows No data when the summary reports has_data false', () => { + render(); + expect(screen.getByText('Gap time to fix: No data')).toBeInTheDocument(); + }); + + it('cites 21 CFR 1.1455(c)(1) in the empty state', () => { + render(); + expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( + 'href', + 'https://www.ecfr.gov/current/title-21/section-1.1455', + ); + }); + + it('says unavailable instead of No data when the request failed', () => { + render(); + expect(screen.getByText('Gap time to fix unavailable')).toBeInTheDocument(); + expect(screen.queryByText(/No data/)).not.toBeInTheDocument(); + }); + + it('renders open count, median, oldest open gap, and the at risk badge', () => { + render( + , + ); + expect(screen.getByText('2 open gaps')).toBeInTheDocument(); + expect(screen.getByText('Median time to fix: 1 h 30 min')).toBeInTheDocument(); + expect(screen.getByText('Oldest open gap: 1 d 1 h')).toBeInTheDocument(); + expect(screen.getByText('At risk: gap open over 24 hours')).toBeInTheDocument(); + }); + + it('says No fixes yet when no gap has closed and hides the badge when not at risk', () => { + render(); + expect(screen.getByText('1 open gap')).toBeInTheDocument(); + expect(screen.getByText('Median time to fix: No fixes yet')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/NaN|undefined/); + }); +}); diff --git a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx index 8e09da6a4..9e468b2b9 100644 --- a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx @@ -22,6 +22,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: mocks.score, getSupplierComplianceGaps: mocks.gaps, listSupplierTLCs: mocks.tlcs, + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), }, })); diff --git a/frontend/src/__tests__/pages/suppliers-removal.test.tsx b/frontend/src/__tests__/pages/suppliers-removal.test.tsx index 60ce32178..54b1c423a 100644 --- a/frontend/src/__tests__/pages/suppliers-removal.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-removal.test.tsx @@ -24,6 +24,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: () => Promise.resolve(null), getSupplierComplianceGaps: () => Promise.resolve({ gaps: [] }), listSupplierTLCs: () => Promise.resolve({ items: [] }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), checkFacilityRemoval: mocks.checkRemoval, deleteSupplierFacility: mocks.deleteFacility, }, diff --git a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx new file mode 100644 index 000000000..5d65d6dac --- /dev/null +++ b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx @@ -0,0 +1,81 @@ +import { ExternalLink } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import type { SupplierGapTimeToFix } from '@/types/api'; + +const RECORDS_REQUEST_URL = 'https://www.ecfr.gov/current/title-21/section-1.1455'; + +/** Whole seconds to a short duration, largest two units. */ +function formatDuration(totalSeconds: number): string { + if (totalSeconds < 60) return 'under 1 min'; + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + if (days > 0) return hours > 0 ? `${days} d ${hours} h` : `${days} d`; + if (hours > 0) return minutes > 0 ? `${hours} h ${minutes} min` : `${hours} h`; + return `${minutes} min`; +} + +function openFor(openedAt: string | null, now: number): string | null { + if (!openedAt) return null; + const opened = Date.parse(openedAt); + if (Number.isNaN(opened)) return null; + return formatDuration(Math.max(0, (now - opened) / 1000)); +} + +interface GapTimeToFixProps { + summary: SupplierGapTimeToFix | undefined; + /** True when the time to fix request failed. */ + unavailable: boolean; + now: number; +} + +/** Per facility data gap time to fix: open gaps, median fix time, oldest open gap. */ +export function GapTimeToFix({ summary, unavailable, now }: GapTimeToFixProps) { + if (unavailable) { + return

Gap time to fix unavailable

; + } + + const citation = ( + + 21 CFR 1.1455(c)(1) + + ); + + if (!summary || !summary.has_data) { + return ( +
+ Gap time to fix: No data + {citation} +
+ ); + } + + const median = summary.median_time_to_fix_seconds; + const oldest = openFor(summary.oldest_open_gap_opened_at, now); + + return ( +
+ + {summary.open_gap_count} open gap{summary.open_gap_count !== 1 ? 's' : ''} + + Median time to fix: {median === null ? 'No fixes yet' : formatDuration(median)} + {oldest !== null && Oldest open gap: {oldest}} + {summary.at_risk && ( + + At risk: gap open over 24 hours + + )} + {citation} +
+ ); +} diff --git a/frontend/src/app/dashboard/suppliers/page.tsx b/frontend/src/app/dashboard/suppliers/page.tsx index 22bf62a40..f204284c2 100644 --- a/frontend/src/app/dashboard/suppliers/page.tsx +++ b/frontend/src/app/dashboard/suppliers/page.tsx @@ -48,7 +48,9 @@ import type { PortalLink, IntegrationProfile, FacilityRemovalCheck, + SupplierGapTimeToFix, } from '@/types/api'; +import { GapTimeToFix } from './gap-time-to-fix'; import { NO_COMPLIANCE_DATA, countOrNull, @@ -140,6 +142,8 @@ export default function SupplierDashboardPage() { const [error, setError] = useState(null); const [workbenchSummary, setWorkbenchSummary] = useState(null); const [now, setNow] = useState(Date.now); + const [gapTimeToFix, setGapTimeToFix] = useState>({}); + const [gapTimeToFixUnavailable, setGapTimeToFixUnavailable] = useState(false); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); @@ -226,6 +230,16 @@ export default function SupplierDashboardPage() { .then((res) => setIntegrationProfiles(res.profiles || [])) .catch(() => setIntegrationProfiles([])); + apiClient.getSupplierGapTimeToFix() + .then((res) => { + setGapTimeToFix(Object.fromEntries(res.suppliers.map((s) => [s.supplier_key, s]))); + setGapTimeToFixUnavailable(false); + }) + .catch(() => { + setGapTimeToFix({}); + setGapTimeToFixUnavailable(true); + }); + if (tenantId) { fetchWorkbenchReadinessSummary(tenantId) .then(setWorkbenchSummary) @@ -865,6 +879,11 @@ export default function SupplierDashboardPage() { )} + {comp.gaps.length > 0 && (
    {comp.gaps.map((gap, index) => { diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 01988f06a..fd02334d6 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -28,6 +28,7 @@ import type { SupplierCTEEventResponse, SupplierComplianceGapsResponse, SupplierComplianceScore, + SupplierGapTimeToFixResponse, SupplierBulkUploadCommitResponse, SupplierBulkUploadParseResponse, SupplierBulkUploadStatusResponse, @@ -871,6 +872,11 @@ class APIClient { return data; } + async getSupplierGapTimeToFix(): Promise { + const { data } = await this.adminClient.get('/v1/supplier/gap-time-to-fix'); + return data; + } + async getSupplierFDAExportPreview( facilityId?: string, limit: number = 25, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d830d4189..52846cbf0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -490,6 +490,22 @@ export interface SupplierComplianceGapsResponse { evaluated_at: string; } +export interface SupplierGapTimeToFix { + /** Supplier facility id. */ + supplier_key: string; + open_gap_count: number; + median_time_to_fix_seconds: number | null; + oldest_open_gap_opened_at: string | null; + at_risk: boolean; + has_data: boolean; +} + +export interface SupplierGapTimeToFixResponse { + suppliers: SupplierGapTimeToFix[]; + at_risk_threshold_seconds: number; + evaluated_at: string; +} + export interface SupplierFDAExportRow { event_id: string; tlc_code: string; diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index f17fed22d..7527994b6 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -13,12 +13,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from shared.data_gap_tracking import AT_RISK_THRESHOLD, fetch_supplier_summaries +from sqlalchemy import select from sqlalchemy.orm import Session from .database import get_session from .dependencies import get_current_user from .models import TenantContext -from .sqlalchemy_models import UserModel +from .sqlalchemy_models import SupplierFacilityModel, UserModel from .supplier_onboarding_routes import ( SupplierComplianceGap, SupplierComplianceGapsResponse, @@ -116,6 +119,53 @@ async def get_compliance_gaps( ) +class SupplierGapTimeToFix(BaseModel): + supplier_key: str + open_gap_count: int + median_time_to_fix_seconds: float | None + oldest_open_gap_opened_at: str | None + at_risk: bool + has_data: bool + + +class SupplierGapTimeToFixResponse(BaseModel): + suppliers: list[SupplierGapTimeToFix] + at_risk_threshold_seconds: int + evaluated_at: str + + +@router.get("/gap-time-to-fix", response_model=SupplierGapTimeToFixResponse) +async def get_gap_time_to_fix( + current_user: UserModel = Depends(get_current_user), + db: Session = Depends(get_session), +) -> SupplierGapTimeToFixResponse: + """Per facility data gap time to fix for the current supplier's facilities. + + Scoped to the tenant and to facilities owned by the calling user; RLS on + ``public.data_gap_events`` enforces the tenant boundary as well. + """ + tenant_id = TenantContext.get_tenant_context(db) + if not tenant_id: + raise HTTPException(status_code=400, detail="Tenant context required") + + facility_ids = [ + str(facility_id) + for facility_id in db.scalars( + select(SupplierFacilityModel.id).where( + SupplierFacilityModel.tenant_id == tenant_id, + SupplierFacilityModel.supplier_user_id == current_user.id, + ) + ).all() + ] + now = datetime.now(timezone.utc) + summaries = fetch_supplier_summaries(db, str(tenant_id), facility_ids, now) + return SupplierGapTimeToFixResponse( + suppliers=[SupplierGapTimeToFix(supplier_key=key, **summary) for key, summary in summaries.items()], + at_risk_threshold_seconds=int(AT_RISK_THRESHOLD.total_seconds()), + evaluated_at=now.isoformat(), + ) + + @router.get("/export/fda-records/preview", response_model=SupplierFDAExportPreviewResponse) async def preview_fda_records_export( facility_id: str | None = None, diff --git a/services/ingestion/app/webhook_compat.py b/services/ingestion/app/webhook_compat.py index 308f42e4b..71cb3964b 100644 --- a/services/ingestion/app/webhook_compat.py +++ b/services/ingestion/app/webhook_compat.py @@ -25,6 +25,12 @@ from fastapi import Depends, Header, HTTPException, Request from shared.api_key_store import APIKeyResponse from shared.auth import APIKey, require_api_key +from shared.data_gap_tracking import ( + ValidationOutcome, + gap_from_rule_reason, + gaps_from_kde_errors, + record_outcomes, +) from shared.database import get_db_session as _get_db_session from shared.permissions import has_permission from shared.tenant_context import TenantContext, resolve_tenant_context @@ -297,13 +303,15 @@ async def ingest_events( accepted = 0 rejected = 0 seen_in_batch: set[str] = set() + # Data gap time to fix: written after the ingest commit, fail-open. + gap_outcomes: list[ValidationOutcome] = [] try: if source_file is not None: # The receipt is written first so a run exists even if every row # is rejected; the same transaction carries run, blob and events. run_id = _open_ingestion_run(db_session, tenant_id, payload.source, source_file) - for event in payload.events: + for event_index, event in enumerate(payload.events): dedup_key = _batch_dedup_key(event) if dedup_key in seen_in_batch: results.append( @@ -329,6 +337,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=gaps_from_kde_errors(errors), + ) + ) continue alerts = _generate_alerts(event) @@ -457,6 +473,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=(gap_from_rule_reason(reject_reason),), + ) + ) continue # Accepted path. Savepoint commits the primary CTE write. @@ -472,6 +496,15 @@ async def ingest_events( ) ) accepted += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_index, + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) _publish_graph_sync(store_result.event_id, event, tenant_id) # Non-blocking failures → exception queue. Separate @@ -522,6 +555,8 @@ async def ingest_events( ) if db_session: db_session.commit() + # After the commit so closes reference durable records; never raises. + record_outcomes(db_session, tenant_id, gap_outcomes) except Exception: if db_session: db_session.rollback() diff --git a/services/ingestion/app/webhook_router_v2/routes.py b/services/ingestion/app/webhook_router_v2/routes.py index 03c7eee36..bb8902e7c 100644 --- a/services/ingestion/app/webhook_router_v2/routes.py +++ b/services/ingestion/app/webhook_router_v2/routes.py @@ -20,6 +20,12 @@ from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from shared.data_gap_tracking import ( + ValidationOutcome, + gap_from_rule_reason, + gaps_from_kde_errors, + record_outcomes, +) from shared.database import get_db_session from shared.idempotency import IdempotencyDependency from sqlalchemy.exc import SQLAlchemyError @@ -141,6 +147,11 @@ async def ingest_events( # Batch deduplication — detect identical events within the same payload seen_in_batch: set[str] = set() + # Data gap time to fix: validation outcomes queued during the request and + # written after the ingest commit (fail-open, never blocks ingest). + gap_outcomes: list[ValidationOutcome] = [] + event_order = {id(e): i for i, e in enumerate(payload.events)} + # Get persistence layer from injected db_session (Depends(get_db_session)) persistence = None if db_session is None: @@ -215,6 +226,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=gaps_from_kde_errors(errors), + ) + ) continue # Generate alerts @@ -250,6 +269,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(_event)], + lot_code=_event.traceability_lot_code, + cte_type=_event.cte_type.value, + gaps=(gap_from_rule_reason(_reason),), + ) + ) continue _filtered.append((_event, _alerts)) pre_eval_summaries.append(_summary) @@ -292,6 +319,7 @@ async def ingest_events( # recorded above. _pre_batch_results_len = len(results) _pre_batch_accepted = accepted + _pre_batch_gap_outcomes_len = len(gap_outcomes) try: store_results = persistence.store_events_batch( @@ -314,6 +342,15 @@ async def ingest_events( accepted += 1 # Billed below, only after db_session.commit() succeeds. billable_event_ids.append(store_result.event_id) + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) # Tenant-configured obligation-CTE rules (#2901). Runs # after the store because it keys its fsma.compliance_alerts @@ -385,6 +422,7 @@ async def ingest_events( # for the same physical label -- bypassing the # UNIQUE(tenant_id, cte_event_id) idempotency guard entirely. del results[_pre_batch_results_len:] + del gap_outcomes[_pre_batch_gap_outcomes_len:] accepted = _pre_batch_accepted billable_event_ids.clear() # Fall back to per-event persistence @@ -407,6 +445,14 @@ async def ingest_events( ) ) rejected += 1 + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + gaps=(gap_from_rule_reason(_fb_reason),), + ) + ) continue # Each event gets its own savepoint (same pattern as the # webhook_compat v1 path) so one event's storage failure @@ -467,6 +513,15 @@ async def ingest_events( # releases the nested savepoint; it does not make # this row durable against a later outer rollback. billable_event_ids.append(store_result.event_id) + gap_outcomes.append( + ValidationOutcome( + order=event_order[id(event)], + lot_code=event.traceability_lot_code, + cte_type=event.cte_type.value, + closing_record_id=store_result.event_id, + closing_record_hash=store_result.sha256_hash, + ) + ) _pkg._publish_graph_sync(store_result.event_id, event, tenant_id) try: @@ -524,6 +579,10 @@ async def ingest_events( if db_session: db_session.commit() + # Data gap time to fix. After the commit so closes reference durable + # records; record_outcomes catches and logs its own errors. + record_outcomes(db_session, tenant_id, gap_outcomes) + except Exception as e: if db_session: db_session.rollback() diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py new file mode 100644 index 000000000..a09ffb67c --- /dev/null +++ b/services/shared/data_gap_tracking.py @@ -0,0 +1,300 @@ +"""Data gap time to fix. + +A *gap* is a validation failure on a traceability lot: a required KDE missing +from a CTE record (``missing_kde``) or a CTE record that failed rule +validation (``cte_validation_failed``). Gaps live in ``public.data_gap_events`` +(``supabase/migrations/20261024060000_data_gap_time_to_fix.sql``). + +State transitions, per (tenant, lot_code, cte_type): + +* a record that fails validation opens one gap per failure, idempotently + (``ON CONFLICT DO NOTHING`` on the partial unique index over open rows), and + never closes anything; +* a record that passes validation and is persisted closes every open gap for + that lot and CTE, stamping the closing record id and its canonical SHA-256 + (the persisted ledger hash) as the receipt. + +Gap logging is observability. It must never block or roll back ingest, so the +writer catches every error, logs it, and returns. +""" + +from __future__ import annotations + +import logging +import re +import statistics +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any, Iterable, Optional, Sequence + +from sqlalchemy import text + +logger = logging.getLogger("data-gap-tracking") + +GAP_KIND_MISSING_KDE = "missing_kde" +GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" +UNASSIGNED_SUPPLIER_KEY = "unassigned" + +# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request. +AT_RISK_THRESHOLD = timedelta(hours=24) + +_MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") +_HARVESTER_PHONE_ERROR = "Harvester business phone must be a non-empty string" +_RECEIPT_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class Gap: + gap_kind: str + gap_key: str + + +@dataclass(frozen=True) +class ValidationOutcome: + """One record's validation result, queued for the gap writer. + + ``order`` is the record's position in the request so a fail then pass for + the same lot and CTE in one batch is applied in arrival order. + """ + + order: int + lot_code: str + cte_type: str + gaps: tuple[Gap, ...] = field(default_factory=tuple) + closing_record_id: Optional[str] = None + closing_record_hash: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Pure functions +# --------------------------------------------------------------------------- + + +def gaps_from_kde_errors(errors: Iterable[str]) -> tuple[Gap, ...]: + """Map ``_validate_event_kdes`` messages to missing KDE gaps. + + An unrecognised message still opens a gap (keyed on the message) so a new + validator message is never silently dropped from time to fix. + """ + gaps: list[Gap] = [] + for error in errors: + match = _MISSING_KDE_RE.search(error) + if match: + key = match.group(1) + elif error == _HARVESTER_PHONE_ERROR: + key = "harvester_business_phone" + else: + key = error.strip() + gap = Gap(GAP_KIND_MISSING_KDE, key) + if key and gap not in gaps: + gaps.append(gap) + return tuple(gaps) + + +def gap_from_rule_reason(reason: Optional[str]) -> Gap: + """Map a rules pre-eval reject reason (``": "``) to a gap keyed on rule id.""" + rule_id = (reason or "").split(":", 1)[0].strip() + return Gap(GAP_KIND_CTE_VALIDATION_FAILED, rule_id or "rule_violation") + + +def gap_identity( + tenant_id: str, + supplier_key: str, + lot_code: str, + cte_type: str, + gap: Gap, +) -> tuple[str, str, str, str, str, str]: + """The open gap idempotency key, column for column with ``uq_data_gap_events_open``.""" + return ( + str(tenant_id).strip().lower(), + supplier_key.strip(), + lot_code.strip(), + cte_type.strip().lower(), + gap.gap_kind, + gap.gap_key.strip(), + ) + + +def decide_transition(gaps: Sequence[Gap]) -> str: + """``"open"`` when the record failed validation, ``"close"`` only when it passed.""" + return "open" if gaps else "close" + + +def is_receipt_hash(value: Optional[str]) -> bool: + """True for a lowercase SHA-256 hex digest, the only accepted closing receipt.""" + return bool(value) and _RECEIPT_HASH_RE.match(value) is not None + + +def median_seconds(durations: Sequence[float]) -> Optional[float]: + """Median of fix durations in seconds; None when nothing has been fixed yet.""" + if not durations: + return None + return float(statistics.median(durations)) + + +def oldest_opened_at(opened_ats: Sequence[datetime]) -> Optional[datetime]: + return min(opened_ats) if opened_ats else None + + +def is_at_risk( + open_opened_ats: Sequence[datetime], + now: datetime, + threshold: timedelta = AT_RISK_THRESHOLD, +) -> bool: + """True when any open gap has been open strictly longer than ``threshold``.""" + return any(now - opened > threshold for opened in open_opened_ats) + + +def summarize_supplier( + open_opened_ats: Sequence[datetime], + fix_durations_seconds: Sequence[float], + now: datetime, +) -> dict[str, Any]: + oldest = oldest_opened_at(open_opened_ats) + return { + "open_gap_count": len(open_opened_ats), + "median_time_to_fix_seconds": median_seconds(fix_durations_seconds), + "oldest_open_gap_opened_at": oldest.isoformat() if oldest else None, + "at_risk": is_at_risk(open_opened_ats, now), + "has_data": bool(open_opened_ats) or bool(fix_durations_seconds), + } + + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +_OPEN_GAP_SQL = text( + """ + INSERT INTO public.data_gap_events + (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + SELECT + CAST(:tid AS uuid), + COALESCE( + (SELECT l.facility_id::text + FROM public.supplier_traceability_lots l + WHERE l.tenant_id = CAST(:tid AS uuid) + AND l.tlc_code = :lot + LIMIT 1), + :unassigned + ), + :lot, :cte, :kind, :key + ON CONFLICT (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + WHERE closed_at IS NULL + DO NOTHING + """ +) + +_CLOSE_GAPS_SQL = text( + """ + UPDATE public.data_gap_events + SET closed_at = GREATEST(now(), opened_at), + closing_record_id = CAST(:rid AS uuid), + closing_record_hash = :rhash + WHERE tenant_id = CAST(:tid AS uuid) + AND lot_code = :lot + AND cte_type = :cte + AND closed_at IS NULL + """ +) + + +def _apply_outcome(db_session: Any, tenant_id: str, outcome: ValidationOutcome) -> None: + lot_code = outcome.lot_code.strip() + cte_type = outcome.cte_type.strip().lower() + if decide_transition(outcome.gaps) == "open": + for gap in outcome.gaps: + db_session.execute( + _OPEN_GAP_SQL, + { + "tid": tenant_id, + "lot": lot_code, + "cte": cte_type, + "kind": gap.gap_kind, + "key": gap.gap_key.strip(), + "unassigned": UNASSIGNED_SUPPLIER_KEY, + }, + ) + return + if not outcome.closing_record_id or not is_receipt_hash(outcome.closing_record_hash): + # No receipt, no close: a gap must never close without proof. + logger.warning("data_gap_close_skipped_missing_receipt tenant=%s", tenant_id) + return + db_session.execute( + _CLOSE_GAPS_SQL, + { + "tid": tenant_id, + "lot": lot_code, + "cte": cte_type, + "rid": outcome.closing_record_id, + "rhash": outcome.closing_record_hash, + }, + ) + + +def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[ValidationOutcome]) -> None: + """Apply queued outcomes in arrival order, then commit. Never raises. + + Call after the ingest transaction has committed: closes then reference + durable records, and a failure here cannot roll back accepted events. + Each outcome gets its own savepoint so one bad row does not drop the rest. + """ + if db_session is None or not outcomes: + return + try: + for outcome in sorted(outcomes, key=lambda o: o.order): + savepoint = db_session.begin_nested() + try: + _apply_outcome(db_session, tenant_id, outcome) + savepoint.commit() + except Exception as exc: # noqa: BLE001 -- gap logging is fail-open + savepoint.rollback() + logger.warning( + "data_gap_write_failed tenant=%s error_type=%s error=%s", + tenant_id, + type(exc).__name__, + exc, + ) + db_session.commit() + except Exception as exc: # noqa: BLE001 -- gap logging must never block ingest + logger.warning( + "data_gap_record_failed tenant=%s error_type=%s error=%s", + tenant_id, + type(exc).__name__, + exc, + ) + try: + db_session.rollback() + except Exception: # noqa: BLE001 + logger.warning("data_gap_rollback_failed tenant=%s", tenant_id) + + +_SUMMARY_ROWS_SQL = text( + """ + SELECT supplier_key, + opened_at, + EXTRACT(EPOCH FROM (closed_at - opened_at)) AS fix_seconds + FROM public.data_gap_events + WHERE tenant_id = CAST(:tid AS uuid) + AND supplier_key = ANY(:keys) + """ +) + + +def fetch_supplier_summaries( + db_session: Any, + tenant_id: str, + supplier_keys: Sequence[str], + now: datetime, +) -> dict[str, dict[str, Any]]: + """Per supplier time to fix summary for ``supplier_keys`` (every key is present).""" + open_by_key: dict[str, list[datetime]] = {key: [] for key in supplier_keys} + fixed_by_key: dict[str, list[float]] = {key: [] for key in supplier_keys} + if supplier_keys: + rows = db_session.execute(_SUMMARY_ROWS_SQL, {"tid": tenant_id, "keys": list(supplier_keys)}).fetchall() + for supplier_key, opened_at, fix_seconds in rows: + if fix_seconds is None: + open_by_key[supplier_key].append(opened_at) + else: + fixed_by_key[supplier_key].append(float(fix_seconds)) + return {key: summarize_supplier(open_by_key[key], fixed_by_key[key], now) for key in supplier_keys} diff --git a/services/shared/tests/test_data_gap_tracking.py b/services/shared/tests/test_data_gap_tracking.py new file mode 100644 index 000000000..f4bdee5a2 --- /dev/null +++ b/services/shared/tests/test_data_gap_tracking.py @@ -0,0 +1,260 @@ +"""Data gap time to fix: pure logic and writer state transitions. + +No database. The writer runs against a recording fake session so the tests +check which statements a validation outcome produces, not their effect. +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone + +from shared.data_gap_tracking import ( + AT_RISK_THRESHOLD, + GAP_KIND_CTE_VALIDATION_FAILED, + GAP_KIND_MISSING_KDE, + Gap, + ValidationOutcome, + decide_transition, + gap_from_rule_reason, + gap_identity, + gaps_from_kde_errors, + is_at_risk, + is_receipt_hash, + median_seconds, + oldest_opened_at, + record_outcomes, + summarize_supplier, +) + +NOW = datetime(2026, 1, 2, tzinfo=timezone.utc) +RECEIPT = hashlib.sha256(b"x").hexdigest() + + +# --------------------------------------------------------------------------- +# Median / oldest / at risk +# --------------------------------------------------------------------------- + + +def test_median_empty_is_none(): + assert median_seconds([]) is None + + +def test_median_odd_count(): + assert median_seconds([30.0, 10.0, 20.0]) == 20.0 + + +def test_median_even_count_averages_middle_pair(): + # Matches percentile_cont(0.5): interpolates between the middle values. + assert median_seconds([1.0, 2.0, 3.0, 10.0]) == 2.5 + + +def test_oldest_opened_at(): + assert oldest_opened_at([]) is None + assert oldest_opened_at([NOW, NOW - timedelta(hours=1)]) == NOW - timedelta(hours=1) + + +def test_at_risk_threshold_is_24_hours(): + assert AT_RISK_THRESHOLD == timedelta(hours=24) + + +def test_gap_open_exactly_24_hours_is_not_at_risk(): + assert is_at_risk([NOW - timedelta(hours=24)], NOW) is False + + +def test_gap_open_just_over_24_hours_is_at_risk(): + assert is_at_risk([NOW - timedelta(hours=24, microseconds=1)], NOW) is True + + +def test_no_open_gaps_is_not_at_risk(): + assert is_at_risk([], NOW) is False + + +def test_summary_without_history_has_no_data(): + assert summarize_supplier([], [], NOW) == { + "open_gap_count": 0, + "median_time_to_fix_seconds": None, + "oldest_open_gap_opened_at": None, + "at_risk": False, + "has_data": False, + } + + +def test_summary_with_only_closed_gaps_has_data(): + result = summarize_supplier([], [60.0], NOW) + assert result["has_data"] is True + assert result["open_gap_count"] == 0 + assert result["median_time_to_fix_seconds"] == 60.0 + + +def test_summary_with_open_gaps(): + opened = NOW - timedelta(hours=30) + result = summarize_supplier([opened, NOW], [], NOW) + assert result["open_gap_count"] == 2 + assert result["oldest_open_gap_opened_at"] == opened.isoformat() + assert result["at_risk"] is True + + +# --------------------------------------------------------------------------- +# Gap extraction and idempotency key +# --------------------------------------------------------------------------- + + +def test_missing_kde_errors_map_to_kde_names(): + errors = ["Missing required KDE 'a' for b CTE", "Missing required KDE 'c' for b CTE"] + assert gaps_from_kde_errors(errors) == (Gap(GAP_KIND_MISSING_KDE, "a"), Gap(GAP_KIND_MISSING_KDE, "c")) + + +def test_harvester_phone_error_maps_to_kde_name(): + assert gaps_from_kde_errors(["Harvester business phone must be a non-empty string"]) == ( + Gap(GAP_KIND_MISSING_KDE, "harvester_business_phone"), + ) + + +def test_duplicate_kde_errors_collapse(): + errors = ["Missing required KDE 'a' for b CTE"] * 2 + assert gaps_from_kde_errors(errors) == (Gap(GAP_KIND_MISSING_KDE, "a"),) + + +def test_unrecognised_error_still_opens_a_gap(): + assert gaps_from_kde_errors(["z"]) == (Gap(GAP_KIND_MISSING_KDE, "z"),) + + +def test_rule_reason_keys_on_rule_id(): + assert gap_from_rule_reason("r1: why") == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "r1") + + +def test_rule_reason_without_id_falls_back(): + assert gap_from_rule_reason(None) == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "rule_violation") + assert gap_from_rule_reason("") == Gap(GAP_KIND_CTE_VALIDATION_FAILED, "rule_violation") + + +def test_gap_identity_is_deterministic_and_normalized(): + gap = Gap(GAP_KIND_MISSING_KDE, "a") + first = gap_identity("T", " s ", " l ", "Shipping", gap) + assert first == gap_identity("t", "s", "l", "shipping", gap) + assert first == ("t", "s", "l", "shipping", GAP_KIND_MISSING_KDE, "a") + + +def test_gap_identity_differs_by_kind_and_key(): + base = gap_identity("t", "s", "l", "c", Gap(GAP_KIND_MISSING_KDE, "a")) + assert base != gap_identity("t", "s", "l", "c", Gap(GAP_KIND_CTE_VALIDATION_FAILED, "a")) + assert base != gap_identity("t", "s", "l", "c", Gap(GAP_KIND_MISSING_KDE, "b")) + + +# --------------------------------------------------------------------------- +# State transitions +# --------------------------------------------------------------------------- + + +def test_failing_record_opens_and_passing_record_closes(): + assert decide_transition([Gap(GAP_KIND_MISSING_KDE, "a")]) == "open" + assert decide_transition([]) == "close" + + +def test_receipt_hash_validation(): + assert is_receipt_hash(RECEIPT) is True + assert is_receipt_hash(RECEIPT.upper()) is False + assert is_receipt_hash(RECEIPT[:-1]) is False + assert is_receipt_hash(None) is False + assert is_receipt_hash("") is False + + +class _Savepoint: + def __init__(self, log: list[str]): + self._log = log + + def commit(self): + self._log.append("savepoint_commit") + + def rollback(self): + self._log.append("savepoint_rollback") + + +class _RecordingSession: + def __init__(self, fail_on: str | None = None): + self.log: list[str] = [] + self.statements: list[tuple[str, dict]] = [] + self._fail_on = fail_on + + def begin_nested(self): + return _Savepoint(self.log) + + def execute(self, statement, params): + sql = str(statement).strip().split()[0].upper() + if self._fail_on == sql: + raise RuntimeError("boom") + self.statements.append((sql, params)) + + def commit(self): + self.log.append("commit") + + def rollback(self): + self.log.append("rollback") + + +def _kinds(session: _RecordingSession) -> list[str]: + return [sql for sql, _ in session.statements] + + +def test_failing_outcome_only_inserts_and_never_closes(): + session = _RecordingSession() + outcome = ValidationOutcome( + order=0, + lot_code="l", + cte_type="c", + gaps=(Gap(GAP_KIND_MISSING_KDE, "a"), Gap(GAP_KIND_MISSING_KDE, "b")), + ) + record_outcomes(session, "t", [outcome]) + assert _kinds(session) == ["INSERT", "INSERT"] + assert session.log[-1] == "commit" + + +def test_passing_outcome_closes_with_unmodified_receipt(): + session = _RecordingSession() + outcome = ValidationOutcome(order=0, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + record_outcomes(session, "t", [outcome]) + assert _kinds(session) == ["UPDATE"] + params = session.statements[0][1] + assert params["rhash"] == RECEIPT + assert params["rid"] == "r" + assert (params["lot"], params["cte"]) == ("l", "c") + + +def test_passing_outcome_without_receipt_does_not_close(): + session = _RecordingSession() + record_outcomes(session, "t", [ValidationOutcome(order=0, lot_code="l", cte_type="c", closing_record_id="r")]) + assert _kinds(session) == [] + + +def test_outcomes_apply_in_arrival_order(): + session = _RecordingSession() + passed = ValidationOutcome(order=1, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + record_outcomes(session, "t", [passed, failed]) + assert _kinds(session) == ["INSERT", "UPDATE"] + + +def test_write_errors_are_swallowed_per_outcome(): + session = _RecordingSession(fail_on="INSERT") + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + passed = ValidationOutcome(order=1, lot_code="l", cte_type="c", closing_record_id="r", closing_record_hash=RECEIPT) + record_outcomes(session, "t", [failed, passed]) + assert "savepoint_rollback" in session.log + assert _kinds(session) == ["UPDATE"] + assert session.log[-1] == "commit" + + +def test_commit_failure_never_raises(): + class _CommitFails(_RecordingSession): + def commit(self): + raise RuntimeError("boom") + + session = _CommitFails() + failed = ValidationOutcome(order=0, lot_code="l", cte_type="c", gaps=(Gap(GAP_KIND_MISSING_KDE, "a"),)) + record_outcomes(session, "t", [failed]) + assert session.log[-1] == "rollback" + + +def test_no_session_is_a_no_op(): + record_outcomes(None, "t", [ValidationOutcome(order=0, lot_code="l", cte_type="c")]) diff --git a/supabase/migrations/20261024060000_data_gap_time_to_fix.sql b/supabase/migrations/20261024060000_data_gap_time_to_fix.sql new file mode 100644 index 000000000..da6c181a2 --- /dev/null +++ b/supabase/migrations/20261024060000_data_gap_time_to_fix.sql @@ -0,0 +1,96 @@ +-- Data gap time to fix. +-- +-- One row per data gap on a traceability lot: a required KDE missing from a +-- CTE record, or a CTE record that failed rule validation. A gap opens when a +-- record for (lot_code, cte_type) fails validation and closes when a later +-- record for the same lot_code and cte_type passes. The closing record's id and +-- its canonical SHA-256 (the persisted ledger hash) are kept as the receipt. +-- +-- supplier_key is the supplier facility id (public.supplier_traceability_lots +-- maps each tenant TLC to one facility) or 'unassigned' when the lot is not +-- registered to a facility. +-- +-- Idempotent: safe to re-run. + +create table if not exists public.data_gap_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null, + supplier_key text not null, + lot_code text not null, + cte_type text not null, + gap_kind text not null, + gap_key text not null, + opened_at timestamptz not null default now(), + closed_at timestamptz, + closing_record_id uuid, + closing_record_hash text, + constraint data_gap_events_gap_kind_check + check (gap_kind in ('missing_kde', 'cte_validation_failed')), + -- An open gap carries no receipt; a closed gap always carries one. + constraint data_gap_events_close_receipt_check + check ( + (closed_at is null and closing_record_id is null and closing_record_hash is null) + or ( + closed_at is not null + and closed_at >= opened_at + and closing_record_hash ~ '^[0-9a-f]{64}$' + ) + ) +); + +-- Exactly one open row per gap identity. Closed rows are history and may repeat. +create unique index if not exists uq_data_gap_events_open + on public.data_gap_events (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + where closed_at is null; + +-- Close path: every open gap for a tenant lot and CTE, regardless of supplier. +create index if not exists idx_data_gap_events_open_lot + on public.data_gap_events (tenant_id, lot_code, cte_type) + where closed_at is null; + +-- Read path: per supplier summary. +create index if not exists idx_data_gap_events_supplier + on public.data_gap_events (tenant_id, supplier_key); + +alter table public.data_gap_events enable row level security; +alter table public.data_gap_events force row level security; + +-- CASE form, not OR: public.get_tenant_context() raises when app.tenant_id is +-- unset and Postgres does not order OR operands, so the sysadmin arm is tested +-- first (same predicate shape as alembic v096 and v134). +drop policy if exists tenant_isolation_data_gap_events on public.data_gap_events; +create policy tenant_isolation_data_gap_events on public.data_gap_events + for all + using ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ) + with check ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ); + +-- Runtime role allowlist (alembic v111): new tables are inaccessible to the +-- app role until a migration names them. No DELETE: gap history is evidence. +-- Not exposed through the Supabase Data API; the backend reads and writes it. +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'anon') then + revoke all on public.data_gap_events from anon; + end if; + if exists (select 1 from pg_roles where rolname = 'authenticated') then + revoke all on public.data_gap_events from authenticated; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine') then + grant select, insert, update on public.data_gap_events to regengine; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine_sysadmin') then + grant select on public.data_gap_events to regengine_sysadmin; + end if; +end $$; From 50b49e5cd3caba447286f31770ce62f8a062fd4e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:43:55 -0700 Subject: [PATCH 2/4] feat(alembic): add v135 data_gap_events revision and redact gap write errors Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- ..._41f96588d87c_v135_data_gap_time_to_fix.py | 135 ++++++++++++++++++ services/shared/data_gap_tracking.py | 9 +- 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py diff --git a/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py b/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py new file mode 100644 index 000000000..a72445c1f --- /dev/null +++ b/alembic/versions/20261024_41f96588d87c_v135_data_gap_time_to_fix.py @@ -0,0 +1,135 @@ +"""Data gap time to fix: public.data_gap_events. + +Mirrors supabase/migrations/20261024060000_data_gap_time_to_fix.sql exactly: +same table, columns, constraints, indexes, RLS policy and grants. One row per +data gap on a traceability lot (a missing KDE or a failed CTE validation). A +partial unique index allows exactly one open row per gap identity. A closed +gap carries the closing record id and its SHA-256 as the receipt. + +Additive and idempotent (IF NOT EXISTS, drop-if-exists-then-create policy, +role-guarded grants), so the plain SQL file can run ahead of it. Downgrade +drops only what this revision created. + +Revision ID: 41f96588d87c +Revises: 1faec7148e78 +Create Date: 2026-10-24 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "41f96588d87c" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "1faec7148e78" # pragma: allowlist secret +branch_labels = None +depends_on = None + +_UPGRADE_STATEMENTS: tuple[str, ...] = ( + """ +create table if not exists public.data_gap_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null, + supplier_key text not null, + lot_code text not null, + cte_type text not null, + gap_kind text not null, + gap_key text not null, + opened_at timestamptz not null default now(), + closed_at timestamptz, + closing_record_id uuid, + closing_record_hash text, + constraint data_gap_events_gap_kind_check + check (gap_kind in ('missing_kde', 'cte_validation_failed')), + -- An open gap carries no receipt; a closed gap always carries one. + constraint data_gap_events_close_receipt_check + check ( + (closed_at is null and closing_record_id is null and closing_record_hash is null) + or ( + closed_at is not null + and closed_at >= opened_at + and closing_record_hash ~ '^[0-9a-f]{64}$' + ) + ) +); +""", + """ +-- Exactly one open row per gap identity. Closed rows are history and may repeat. +create unique index if not exists uq_data_gap_events_open + on public.data_gap_events (tenant_id, supplier_key, lot_code, cte_type, gap_kind, gap_key) + where closed_at is null; +""", + """ +-- Close path: every open gap for a tenant lot and CTE, regardless of supplier. +create index if not exists idx_data_gap_events_open_lot + on public.data_gap_events (tenant_id, lot_code, cte_type) + where closed_at is null; +""", + """ +-- Read path: per supplier summary. +create index if not exists idx_data_gap_events_supplier + on public.data_gap_events (tenant_id, supplier_key); +""", + """ +alter table public.data_gap_events enable row level security; +""", + """ +alter table public.data_gap_events force row level security; +""", + """ +-- CASE form, not OR: public.get_tenant_context() raises when app.tenant_id is +-- unset and Postgres does not order OR operands, so the sysadmin arm is tested +-- first (same predicate shape as alembic v096 and v134). +drop policy if exists tenant_isolation_data_gap_events on public.data_gap_events; +""", + """ +create policy tenant_isolation_data_gap_events on public.data_gap_events + for all + using ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ) + with check ( + case + when (select current_setting('regengine.is_sysadmin', true)) = 'true' + and current_user = 'regengine_sysadmin' then true + else tenant_id = (select public.get_tenant_context()) + end + ); +""", + """ +-- Runtime role allowlist (alembic v111): new tables are inaccessible to the +-- app role until a migration names them. No DELETE: gap history is evidence. +-- Not exposed through the Supabase Data API; the backend reads and writes it. +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'anon') then + revoke all on public.data_gap_events from anon; + end if; + if exists (select 1 from pg_roles where rolname = 'authenticated') then + revoke all on public.data_gap_events from authenticated; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine') then + grant select, insert, update on public.data_gap_events to regengine; + end if; + if exists (select 1 from pg_roles where rolname = 'regengine_sysadmin') then + grant select on public.data_gap_events to regengine_sysadmin; + end if; +end $$; +""", +) + + +def upgrade() -> None: + for statement in _UPGRADE_STATEMENTS: + op.execute(statement) + + +def downgrade() -> None: + op.execute("DROP POLICY IF EXISTS tenant_isolation_data_gap_events ON public.data_gap_events") + op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_supplier") + op.execute("DROP INDEX IF EXISTS public.idx_data_gap_events_open_lot") + op.execute("DROP INDEX IF EXISTS public.uq_data_gap_events_open") + op.execute("DROP TABLE IF EXISTS public.data_gap_events") diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py index a09ffb67c..7cd3af568 100644 --- a/services/shared/data_gap_tracking.py +++ b/services/shared/data_gap_tracking.py @@ -35,7 +35,8 @@ GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" UNASSIGNED_SUPPLIER_KEY = "unassigned" -# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request. +# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request, or +# within a reasonable time FDA agrees to. This threshold measures gap age only. AT_RISK_THRESHOLD = timedelta(hours=24) _MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") @@ -250,18 +251,16 @@ def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[Validati except Exception as exc: # noqa: BLE001 -- gap logging is fail-open savepoint.rollback() logger.warning( - "data_gap_write_failed tenant=%s error_type=%s error=%s", + "data_gap_write_failed tenant=%s error_type=%s", tenant_id, type(exc).__name__, - exc, ) db_session.commit() except Exception as exc: # noqa: BLE001 -- gap logging must never block ingest logger.warning( - "data_gap_record_failed tenant=%s error_type=%s error=%s", + "data_gap_record_failed tenant=%s error_type=%s", tenant_id, type(exc).__name__, - exc, ) try: db_session.rollback() From 2bdca3f5e5861c73356c2dafe1364905b8f99725 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:47:20 -0700 Subject: [PATCH 3/4] feat(suppliers): flag gaps open at a records request response due time Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../components/gap-time-to-fix.test.tsx | 28 ++++++++- .../pages/suppliers-metrics.test.tsx | 2 +- .../pages/suppliers-removal.test.tsx | 2 +- .../dashboard/suppliers/gap-time-to-fix.tsx | 16 ++++-- frontend/src/types/api.ts | 6 +- .../admin/app/supplier_compliance_routes.py | 8 ++- services/shared/data_gap_tracking.py | 57 ++++++++++++++++--- .../shared/tests/test_data_gap_tracking.py | 47 +++++++++++---- 8 files changed, 136 insertions(+), 30 deletions(-) diff --git a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx index 0c53b0d3c..3be6f22f3 100644 --- a/frontend/src/__tests__/components/gap-time-to-fix.test.tsx +++ b/frontend/src/__tests__/components/gap-time-to-fix.test.tsx @@ -12,6 +12,8 @@ const summary = (overrides: Partial): SupplierGapTimeToFix median_time_to_fix_seconds: null, oldest_open_gap_opened_at: null, at_risk: false, + at_risk_gap_count: 0, + past_internal_target: false, has_data: true, ...overrides, }); @@ -42,7 +44,7 @@ describe('GapTimeToFix', () => { expect(screen.queryByText(/No data/)).not.toBeInTheDocument(); }); - it('renders open count, median, oldest open gap, and the at risk badge', () => { + it('renders open count, median, oldest open gap, and the request due badge with its citation', () => { render( { median_time_to_fix_seconds: 5_400, oldest_open_gap_opened_at: new Date(NOW - 25 * HOUR_MS).toISOString(), at_risk: true, + at_risk_gap_count: 1, + past_internal_target: true, })} unavailable={false} now={NOW} @@ -58,7 +62,27 @@ describe('GapTimeToFix', () => { expect(screen.getByText('2 open gaps')).toBeInTheDocument(); expect(screen.getByText('Median time to fix: 1 h 30 min')).toBeInTheDocument(); expect(screen.getByText('Oldest open gap: 1 d 1 h')).toBeInTheDocument(); - expect(screen.getByText('At risk: gap open over 24 hours')).toBeInTheDocument(); + expect(screen.getByText('At risk: open when FDA records response is due')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /21 CFR 1\.1455\(c\)\(1\)/ })).toHaveAttribute( + 'href', + 'https://www.ecfr.gov/current/title-21/section-1.1455', + ); + expect(screen.getByText('Internal target: fixed within 24 hours of flag')).toBeInTheDocument(); + expect(screen.getByText('Past internal target')).toBeInTheDocument(); + expect(screen.queryByText(/over 24 hours/)).not.toBeInTheDocument(); + }); + + it('shows the internal target without a regulatory citation when not at risk', () => { + render( + , + ); + expect(screen.getByText('Past internal target')).toBeInTheDocument(); + expect(screen.queryByText(/At risk/)).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /1\.1455/ })).not.toBeInTheDocument(); }); it('says No fixes yet when no gap has closed and hides the badge when not at risk', () => { diff --git a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx index 9e468b2b9..2eaf22e2a 100644 --- a/frontend/src/__tests__/pages/suppliers-metrics.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-metrics.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: mocks.score, getSupplierComplianceGaps: mocks.gaps, listSupplierTLCs: mocks.tlcs, - getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), }, })); diff --git a/frontend/src/__tests__/pages/suppliers-removal.test.tsx b/frontend/src/__tests__/pages/suppliers-removal.test.tsx index 54b1c423a..7f91a6133 100644 --- a/frontend/src/__tests__/pages/suppliers-removal.test.tsx +++ b/frontend/src/__tests__/pages/suppliers-removal.test.tsx @@ -24,7 +24,7 @@ vi.mock('@/lib/api-client', () => ({ getSupplierComplianceScore: () => Promise.resolve(null), getSupplierComplianceGaps: () => Promise.resolve({ gaps: [] }), listSupplierTLCs: () => Promise.resolve({ items: [] }), - getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], at_risk_threshold_seconds: 86400, evaluated_at: '' }), + getSupplierGapTimeToFix: () => Promise.resolve({ suppliers: [], internal_target_seconds: 86400, evaluated_at: '' }), checkFacilityRemoval: mocks.checkRemoval, deleteSupplierFacility: mocks.deleteFacility, }, diff --git a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx index 5d65d6dac..6469ca36e 100644 --- a/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx +++ b/frontend/src/app/dashboard/suppliers/gap-time-to-fix.tsx @@ -70,12 +70,20 @@ export function GapTimeToFix({ summary, unavailable, now }: GapTimeToFixProps) { Median time to fix: {median === null ? 'No fixes yet' : formatDuration(median)} {oldest !== null && Oldest open gap: {oldest}} - {summary.at_risk && ( - - At risk: gap open over 24 hours + Internal target: fixed within 24 hours of flag + {summary.past_internal_target && ( + + Past internal target )} - {citation} + {summary.at_risk && ( + + + At risk: open when FDA records response is due + + {citation} + + )} ); } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 52846cbf0..8535e29e7 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -496,13 +496,17 @@ export interface SupplierGapTimeToFix { open_gap_count: number; median_time_to_fix_seconds: number | null; oldest_open_gap_opened_at: string | null; + /** A gap on a lot named in an unanswered records request is open at its response_due_at. */ at_risk: boolean; + at_risk_gap_count: number; + /** Internal target only: an open gap is older than 24 hours. Not a regulatory deadline. */ + past_internal_target: boolean; has_data: boolean; } export interface SupplierGapTimeToFixResponse { suppliers: SupplierGapTimeToFix[]; - at_risk_threshold_seconds: number; + internal_target_seconds: number; evaluated_at: string; } diff --git a/services/admin/app/supplier_compliance_routes.py b/services/admin/app/supplier_compliance_routes.py index 7527994b6..c0ea74d4e 100644 --- a/services/admin/app/supplier_compliance_routes.py +++ b/services/admin/app/supplier_compliance_routes.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel -from shared.data_gap_tracking import AT_RISK_THRESHOLD, fetch_supplier_summaries +from shared.data_gap_tracking import INTERNAL_TARGET, fetch_supplier_summaries from sqlalchemy import select from sqlalchemy.orm import Session @@ -125,12 +125,14 @@ class SupplierGapTimeToFix(BaseModel): median_time_to_fix_seconds: float | None oldest_open_gap_opened_at: str | None at_risk: bool + at_risk_gap_count: int + past_internal_target: bool has_data: bool class SupplierGapTimeToFixResponse(BaseModel): suppliers: list[SupplierGapTimeToFix] - at_risk_threshold_seconds: int + internal_target_seconds: int evaluated_at: str @@ -161,7 +163,7 @@ async def get_gap_time_to_fix( summaries = fetch_supplier_summaries(db, str(tenant_id), facility_ids, now) return SupplierGapTimeToFixResponse( suppliers=[SupplierGapTimeToFix(supplier_key=key, **summary) for key, summary in summaries.items()], - at_risk_threshold_seconds=int(AT_RISK_THRESHOLD.total_seconds()), + internal_target_seconds=int(INTERNAL_TARGET.total_seconds()), evaluated_at=now.isoformat(), ) diff --git a/services/shared/data_gap_tracking.py b/services/shared/data_gap_tracking.py index 7cd3af568..fe585ab48 100644 --- a/services/shared/data_gap_tracking.py +++ b/services/shared/data_gap_tracking.py @@ -35,9 +35,12 @@ GAP_KIND_CTE_VALIDATION_FAILED = "cte_validation_failed" UNASSIGNED_SUPPLIER_KEY = "unassigned" -# 21 CFR 1.1455(c)(1): records must reach FDA within 24 hours of a request, or -# within a reasonable time FDA agrees to. This threshold measures gap age only. -AT_RISK_THRESHOLD = timedelta(hours=24) +# Internal target only, not a regulatory deadline: fix a gap within 24 hours of +# flagging it. Regulatory risk is computed against request_cases.response_due_at. +INTERNAL_TARGET = timedelta(hours=24) + +# Request cases that have not yet been answered. +CLOSED_REQUEST_STATUSES = ("submitted", "amended") _MISSING_KDE_RE = re.compile(r"Missing required KDE '([^']+)'") _HARVESTER_PHONE_ERROR = "Harvester business phone must be a non-empty string" @@ -137,26 +140,34 @@ def oldest_opened_at(opened_ats: Sequence[datetime]) -> Optional[datetime]: return min(opened_ats) if opened_ats else None -def is_at_risk( +def is_past_internal_target( open_opened_ats: Sequence[datetime], now: datetime, - threshold: timedelta = AT_RISK_THRESHOLD, + threshold: timedelta = INTERNAL_TARGET, ) -> bool: - """True when any open gap has been open strictly longer than ``threshold``.""" + """True when any open gap has been open strictly longer than the internal target.""" return any(now - opened > threshold for opened in open_opened_ats) +def is_open_at(opened_at: datetime, closed_at: datetime | None, due_at: datetime) -> bool: + """True when a gap is open at ``due_at`` (opened by then, not closed by then).""" + return opened_at <= due_at and (closed_at is None or closed_at > due_at) + + def summarize_supplier( open_opened_ats: Sequence[datetime], fix_durations_seconds: Sequence[float], now: datetime, + at_risk_gap_count: int = 0, ) -> dict[str, Any]: oldest = oldest_opened_at(open_opened_ats) return { "open_gap_count": len(open_opened_ats), "median_time_to_fix_seconds": median_seconds(fix_durations_seconds), "oldest_open_gap_opened_at": oldest.isoformat() if oldest else None, - "at_risk": is_at_risk(open_opened_ats, now), + "at_risk": at_risk_gap_count > 0, + "at_risk_gap_count": at_risk_gap_count, + "past_internal_target": is_past_internal_target(open_opened_ats, now), "has_data": bool(open_opened_ats) or bool(fix_durations_seconds), } @@ -280,6 +291,27 @@ def record_outcomes(db_session: Any, tenant_id: str, outcomes: Sequence[Validati ) +# A gap is at risk when its lot is listed on an unanswered request case and the +# gap is still open at that case's response_due_at (21 CFR 1.1455(c)(1)). The +# same predicate is in is_open_at; a closed gap counts only when it closed after +# the due time. +_AT_RISK_SQL = text( + """ + SELECT g.supplier_key, COUNT(DISTINCT g.id) AS at_risk_gaps + FROM public.data_gap_events g + JOIN fsma.request_cases r + ON r.tenant_id = g.tenant_id + AND g.lot_code = ANY(r.affected_lots) + WHERE g.tenant_id = CAST(:tid AS uuid) + AND g.supplier_key = ANY(:keys) + AND r.package_status <> ALL(:closed_statuses) + AND g.opened_at <= r.response_due_at + AND (g.closed_at IS NULL OR g.closed_at > r.response_due_at) + GROUP BY g.supplier_key + """ +) + + def fetch_supplier_summaries( db_session: Any, tenant_id: str, @@ -289,6 +321,7 @@ def fetch_supplier_summaries( """Per supplier time to fix summary for ``supplier_keys`` (every key is present).""" open_by_key: dict[str, list[datetime]] = {key: [] for key in supplier_keys} fixed_by_key: dict[str, list[float]] = {key: [] for key in supplier_keys} + at_risk_by_key: dict[str, int] = {key: 0 for key in supplier_keys} if supplier_keys: rows = db_session.execute(_SUMMARY_ROWS_SQL, {"tid": tenant_id, "keys": list(supplier_keys)}).fetchall() for supplier_key, opened_at, fix_seconds in rows: @@ -296,4 +329,12 @@ def fetch_supplier_summaries( open_by_key[supplier_key].append(opened_at) else: fixed_by_key[supplier_key].append(float(fix_seconds)) - return {key: summarize_supplier(open_by_key[key], fixed_by_key[key], now) for key in supplier_keys} + at_risk_rows = db_session.execute( + _AT_RISK_SQL, + {"tid": tenant_id, "keys": list(supplier_keys), "closed_statuses": list(CLOSED_REQUEST_STATUSES)}, + ).fetchall() + for supplier_key, count in at_risk_rows: + at_risk_by_key[supplier_key] = int(count) + return { + key: summarize_supplier(open_by_key[key], fixed_by_key[key], now, at_risk_by_key[key]) for key in supplier_keys + } diff --git a/services/shared/tests/test_data_gap_tracking.py b/services/shared/tests/test_data_gap_tracking.py index f4bdee5a2..4abc458d6 100644 --- a/services/shared/tests/test_data_gap_tracking.py +++ b/services/shared/tests/test_data_gap_tracking.py @@ -10,16 +10,17 @@ from datetime import datetime, timedelta, timezone from shared.data_gap_tracking import ( - AT_RISK_THRESHOLD, GAP_KIND_CTE_VALIDATION_FAILED, GAP_KIND_MISSING_KDE, + INTERNAL_TARGET, Gap, ValidationOutcome, decide_transition, gap_from_rule_reason, gap_identity, gaps_from_kde_errors, - is_at_risk, + is_open_at, + is_past_internal_target, is_receipt_hash, median_seconds, oldest_opened_at, @@ -54,20 +55,36 @@ def test_oldest_opened_at(): assert oldest_opened_at([NOW, NOW - timedelta(hours=1)]) == NOW - timedelta(hours=1) -def test_at_risk_threshold_is_24_hours(): - assert AT_RISK_THRESHOLD == timedelta(hours=24) +def test_internal_target_is_24_hours(): + assert INTERNAL_TARGET == timedelta(hours=24) -def test_gap_open_exactly_24_hours_is_not_at_risk(): - assert is_at_risk([NOW - timedelta(hours=24)], NOW) is False +def test_gap_open_exactly_24_hours_is_not_past_internal_target(): + assert is_past_internal_target([NOW - timedelta(hours=24)], NOW) is False -def test_gap_open_just_over_24_hours_is_at_risk(): - assert is_at_risk([NOW - timedelta(hours=24, microseconds=1)], NOW) is True +def test_gap_open_just_over_24_hours_is_past_internal_target(): + assert is_past_internal_target([NOW - timedelta(hours=24, microseconds=1)], NOW) is True -def test_no_open_gaps_is_not_at_risk(): - assert is_at_risk([], NOW) is False +def test_no_open_gaps_is_not_past_internal_target(): + assert is_past_internal_target([], NOW) is False + + +def test_gap_still_open_at_due_time_is_open_at(): + assert is_open_at(NOW - timedelta(hours=2), None, NOW) is True + + +def test_gap_closed_after_due_time_is_open_at(): + assert is_open_at(NOW - timedelta(hours=2), NOW + timedelta(seconds=1), NOW) is True + + +def test_gap_closed_exactly_at_due_time_is_not_open_at(): + assert is_open_at(NOW - timedelta(hours=2), NOW, NOW) is False + + +def test_gap_opened_after_due_time_is_not_open_at(): + assert is_open_at(NOW + timedelta(seconds=1), None, NOW) is False def test_summary_without_history_has_no_data(): @@ -76,6 +93,8 @@ def test_summary_without_history_has_no_data(): "median_time_to_fix_seconds": None, "oldest_open_gap_opened_at": None, "at_risk": False, + "at_risk_gap_count": 0, + "past_internal_target": False, "has_data": False, } @@ -92,7 +111,15 @@ def test_summary_with_open_gaps(): result = summarize_supplier([opened, NOW], [], NOW) assert result["open_gap_count"] == 2 assert result["oldest_open_gap_opened_at"] == opened.isoformat() + assert result["past_internal_target"] is True + assert result["at_risk"] is False + + +def test_summary_at_risk_comes_from_request_due_count_not_gap_age(): + result = summarize_supplier([NOW], [], NOW, at_risk_gap_count=2) assert result["at_risk"] is True + assert result["at_risk_gap_count"] == 2 + assert result["past_internal_target"] is False # --------------------------------------------------------------------------- From bd5bd6ebd6df527baa10fbd7c493e02399330409 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:23:15 -0700 Subject: [PATCH 4/4] test(gap-time-to-fix): count new supplier route in manifest and isolate ingest commit from gap writes Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- services/ingestion/tests/test_webhook_compat_1342.py | 11 +++++++++++ tests/security/test_route_security_manifest.py | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/services/ingestion/tests/test_webhook_compat_1342.py b/services/ingestion/tests/test_webhook_compat_1342.py index 92cd09cad..0e1172a8c 100644 --- a/services/ingestion/tests/test_webhook_compat_1342.py +++ b/services/ingestion/tests/test_webhook_compat_1342.py @@ -189,6 +189,15 @@ def _publish(event_id, event, tenant_id): monkeypatch.setattr(wc, "_publish_graph_sync", _publish) + # Data gap writes run in their own transaction after the ingest commit; + # stubbed here so commit_count measures only the ingest transaction. + captured["gap_record_calls"] = [] + + def _record_outcomes(db_session, tenant_id, outcomes): + captured["gap_record_calls"].append((tenant_id, len(outcomes), db_session.commit_count if db_session else None)) + + monkeypatch.setattr(wc, "record_outcomes", _record_outcomes) + # _get_db_session generator factory on wc def _db_gen_factory(): def _gen(): @@ -519,6 +528,8 @@ def test_happy_path_single_event_accepted(self, monkeypatch): # Savepoint committed, session committed once at the end assert any(a == ("commit", "sp-0") for a in sess.actions) assert sess.commit_count == 1 + # Gap outcomes recorded once per accepted event, after the ingest commit + assert cap["gap_record_calls"] == [("t", len(payload.events), sess.commit_count)] # Graph sync, canonical path, rules + exc queue all fire in happy path assert cap["publish_graph_calls"] == [("ev-1", "t")] assert cap["normalize_calls"] and cap["normalize_calls"][0][1] == "t" diff --git a/tests/security/test_route_security_manifest.py b/tests/security/test_route_security_manifest.py index c7fea970a..a34cfbce0 100644 --- a/tests/security/test_route_security_manifest.py +++ b/tests/security/test_route_security_manifest.py @@ -70,7 +70,9 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): # +1 global-sysadmin: admin:PATCH:/admin/organizations/{org_id} (rename). # +2 tenant-scoped (SEL-421, #3492): admin:GET:/v1/supplier/facilities/{facility_id}/removal-check # and admin:DELETE:/v1/supplier/facilities/{facility_id}, owner-only, tenant-filtered. - "enabled_operation_count": 254, + # +1 tenant-scoped (#3572): admin:GET:/v1/supplier/gap-time-to-fix, read-only, + # tenant-filtered data gap time to fix summary. + "enabled_operation_count": 255, # 178/161 includes graph:GET:/api/v1/provisions/by-request: it is added # to the graph v1_router AFTER router.include_router(v1_router), so the # eager route copies of fastapi < 0.137 (under which the previous @@ -94,7 +96,7 @@ def test_checked_in_manifest_matches_live_route_and_dependency_graph(manifest): "enabled_by_classification": { "global-sysadmin": 16, "public": 30, - "tenant-scoped": 201, + "tenant-scoped": 202, "workload-only": 7, }, } @@ -187,7 +189,8 @@ def test_dual_session_routes_record_cookie_tenancy_and_csrf_adapter(manifest): dual = [item for item in manifest["enabled_operations"] if item["test_adapter"] == "tenant_dual_session_csrf"] mutations = [item for item in dual if item["method"] in {"POST", "PUT", "PATCH", "DELETE"}] - assert len(dual) == 51 + # 52 includes admin:GET:/v1/supplier/gap-time-to-fix (#3572), a read, so mutations stay 23. + assert len(dual) == 52 assert len(mutations) == 23 assert all(item["authentication"] == "bearer-token-or-opaque-session-cookie" for item in dual) assert all(item["storage_boundary"] == "admin-postgres:jwt-tenant-or-session-membership-tenant" for item in dual)