From 7fd195d4def7b067683df25c81bbaf892679c61e Mon Sep 17 00:00:00 2001 From: "C.Lee Sellers" <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:52:13 -0700 Subject: [PATCH 1/7] feat(fsma): add Facility phone for 1.1310 location descriptions (A-2190) Neo4j Facility, supplier_facilities (alembic v141), supplier API/graph sync, and the Python SDK now carry an optional business phone. Facility also exposes is_location_description_complete (name+phone+address, or GLN stand-in). Ingest-path free-text location completeness remains with #3718 (codex/food-loc-complete) to avoid overlapping that PR's fsma_validation work. Synthetic fixtures only; SEL-549/590 holds unchanged. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> --- ...cd73f8d1bb_v141_supplier_facility_phone.py | 33 +++++++++++++++++ regengine/models.py | 4 ++- services/admin/app/sqlalchemy_models.py | 1 + .../admin/app/supplier_facilities_routes.py | 5 +++ services/admin/app/supplier_funnel_routes.py | 1 + services/admin/app/supplier_graph_sync.py | 3 ++ .../admin/app/supplier_onboarding_routes.py | 5 +++ services/graph/app/models/fsma_nodes.py | 23 ++++++++++++ .../graph/tests/test_graph_models_unit.py | 36 +++++++++++++++++++ 9 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py new file mode 100644 index 000000000..09bff2b6c --- /dev/null +++ b/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py @@ -0,0 +1,33 @@ +"""v141 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). + +Revision ID: e5cd73f8d1bb +Revises: b8c1e4f2a901 +Create Date: 2026-10-31 + +Adds an optional business phone on supplier_facilities so a Facility location +description can carry name + phone + address (or a GLN stand-in). Nullable so +existing rows stay valid; UI and API accept phone on create. + +Additive. Synthetic fixtures only until SEL-590 clears. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e5cd73f8d1bb" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "supplier_facilities", + sa.Column("phone", sa.String(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("supplier_facilities", "phone") diff --git a/regengine/models.py b/regengine/models.py index d839a9d53..7e4b63529 100644 --- a/regengine/models.py +++ b/regengine/models.py @@ -90,13 +90,14 @@ class Facility: gln: str name: str address: Optional[str] = None + phone: Optional[str] = None facility_type: Optional[str] = None @classmethod def from_api(cls, payload: Dict[str, Any]) -> "Facility": """Build a ``Facility`` from a facility object in a trace response. - Trace results carry ``gln``/``name``/``address``/``facility_type`` + Trace results carry ``gln``/``name``/``address``/``phone``/``facility_type`` with no separate identifier, so ``id`` falls back to the GLN. Keys the SDK does not model are ignored. """ @@ -106,6 +107,7 @@ def from_api(cls, payload: Dict[str, Any]) -> "Facility": gln=gln, name=_first(payload, "name", default=""), address=_first(payload, "address"), + phone=_first(payload, "phone"), facility_type=_first(payload, "facility_type", "type"), ) diff --git a/services/admin/app/sqlalchemy_models.py b/services/admin/app/sqlalchemy_models.py index b0fbb9bd8..aa13e8dbc 100644 --- a/services/admin/app/sqlalchemy_models.py +++ b/services/admin/app/sqlalchemy_models.py @@ -420,6 +420,7 @@ class SupplierFacilityModel(Base): city = Column(String, nullable=False) state = Column(String, nullable=False) postal_code = Column(String, nullable=False) + phone = Column(String, nullable=True) # A-2190 / 21 CFR 1.1310 fda_registration_number = Column(String, nullable=True) roles = Column(JSONType(), nullable=False, default=list) # Hours after a CTE's event time the supplier agreed to send it. NULL: none. diff --git a/services/admin/app/supplier_facilities_routes.py b/services/admin/app/supplier_facilities_routes.py index 250f7f5c3..4c904e9bf 100644 --- a/services/admin/app/supplier_facilities_routes.py +++ b/services/admin/app/supplier_facilities_routes.py @@ -89,6 +89,7 @@ async def list_supplier_facilities( city=f.city, state=f.state, postal_code=f.postal_code, + phone=f.phone, fda_registration_number=f.fda_registration_number, roles=f.roles or [], ) @@ -115,6 +116,7 @@ async def create_supplier_facility( city=request.city.strip(), state=request.state.strip(), postal_code=request.postal_code.strip(), + phone=(request.phone.strip() if request.phone else None), fda_registration_number=(request.fda_registration_number.strip() if request.fda_registration_number else None), roles=[role.strip() for role in request.roles if role.strip()], ) @@ -129,6 +131,7 @@ async def create_supplier_facility( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], ) @@ -255,6 +258,7 @@ async def check_supplier_facility_removal( "city": facility.city, "state": facility.state, "postal_code": facility.postal_code, + "phone": facility.phone or "", }, ) @@ -390,6 +394,7 @@ async def set_facility_ftl_categories( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=categories, diff --git a/services/admin/app/supplier_funnel_routes.py b/services/admin/app/supplier_funnel_routes.py index ef428b3f9..2bd242d4d 100644 --- a/services/admin/app/supplier_funnel_routes.py +++ b/services/admin/app/supplier_funnel_routes.py @@ -598,6 +598,7 @@ async def reset_supplier_demo( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=selected_categories, diff --git a/services/admin/app/supplier_graph_sync.py b/services/admin/app/supplier_graph_sync.py index c1785d61c..5f7466d44 100644 --- a/services/admin/app/supplier_graph_sync.py +++ b/services/admin/app/supplier_graph_sync.py @@ -93,6 +93,7 @@ facility.city = $city, facility.state = $state, facility.postal_code = $postal_code, + facility.phone = $phone, facility.fda_registration_number = $fda_registration_number, facility.roles = $roles, facility.updated_at = datetime() @@ -464,6 +465,7 @@ def record_facility_ftl_scoping( fda_registration_number: Optional[str], roles: list[str], categories: list[dict[str, Any]], + phone: Optional[str] = None, db: Optional["SQLASession"] = None, ) -> None: normalized_categories = [ @@ -486,6 +488,7 @@ def record_facility_ftl_scoping( "city": city, "state": state, "postal_code": postal_code, + "phone": phone, "fda_registration_number": fda_registration_number, "roles": roles, "categories": normalized_categories, diff --git a/services/admin/app/supplier_onboarding_routes.py b/services/admin/app/supplier_onboarding_routes.py index 6a8952a43..25949504f 100644 --- a/services/admin/app/supplier_onboarding_routes.py +++ b/services/admin/app/supplier_onboarding_routes.py @@ -63,6 +63,10 @@ class SupplierFacilityCreateRequest(BaseModel): city: str = Field(min_length=2) state: str = Field(min_length=2) postal_code: str = Field(min_length=3) + phone: str | None = Field( + default=None, + description="Facility business phone (21 CFR 1.1310 location description)", + ) fda_registration_number: str | None = None roles: list[str] = Field(default_factory=list) @@ -74,6 +78,7 @@ class SupplierFacilityResponse(BaseModel): city: str state: str postal_code: str + phone: str | None = None fda_registration_number: str | None roles: list[str] diff --git a/services/graph/app/models/fsma_nodes.py b/services/graph/app/models/fsma_nodes.py index d28424008..99383e380 100644 --- a/services/graph/app/models/fsma_nodes.py +++ b/services/graph/app/models/fsma_nodes.py @@ -364,6 +364,7 @@ class Facility: city: Optional[str] = None state: Optional[str] = None country: Optional[str] = "US" + phone: Optional[str] = None # A-2190 / 21 CFR 1.1310 location description facility_type: FacilityType = FacilityType.UNKNOWN tenant_id: Optional[str] = None # Merkle hash-chain integrity @@ -384,6 +385,26 @@ def identifier(self) -> str: """Return the primary identifier (GLN preferred, then FDA reg, then name).""" return self.gln or self.fda_registration or self.name + @property + def is_location_description_complete(self) -> bool: + """Whether this facility satisfies 21 CFR 1.1310 location description. + + Complete when a GLN is present (allowed stand-in), or when business + name, phone, and a physical address (street address, or city+state) + are all present. Ingest-path location completeness for free-text KDEs + lives in shared/fsma_validation (see PR #3718); this property covers + the Neo4j Facility node itself (A-2190 fix 4). + """ + if (self.gln or "").strip(): + return True + name_ok = bool((self.name or "").strip()) + phone_ok = bool((self.phone or "").strip()) + address_ok = bool( + (self.address or "").strip() + or ((self.city or "").strip() and (self.state or "").strip()) + ) + return name_ok and phone_ok and address_ok + @property def node_properties(self) -> Dict[str, Any]: """Return properties for Neo4j node creation.""" @@ -400,6 +421,8 @@ def node_properties(self) -> Dict[str, Any]: props["state"] = self.state if self.country: props["country"] = self.country + if self.phone: + props["phone"] = self.phone props["facility_type"] = ( self.facility_type.value if isinstance(self.facility_type, FacilityType) else self.facility_type ) diff --git a/services/graph/tests/test_graph_models_unit.py b/services/graph/tests/test_graph_models_unit.py index 465b2f7c3..8e2a650d0 100644 --- a/services/graph/tests/test_graph_models_unit.py +++ b/services/graph/tests/test_graph_models_unit.py @@ -324,3 +324,39 @@ def test_response_envelope_carries_data_and_fresh_meta(): assert first.data == {"hello": "world"} assert isinstance(first.meta, ResponseMeta) assert first.meta is not second.meta + + +def test_facility_phone_serialises_into_node_properties(): + facility = Facility( + gln="G1", + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + tenant_id="tenant-a", + ) + props = facility.node_properties + assert props["phone"] == "+1-202-555-0147" + assert props["address"] == "100 Farm Rd" + + +def test_facility_location_description_complete_with_name_phone_address(): + complete = Facility( + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + ) + assert complete.is_location_description_complete is True + + missing_phone = Facility(name="Valley Fresh Farms", address="100 Farm Rd") + assert missing_phone.is_location_description_complete is False + + gln_stand_in = Facility(gln="0614141000005", name="") + assert gln_stand_in.is_location_description_complete is True + + city_state = Facility( + name="Cooler Co", + city="Salinas", + state="CA", + phone="+1-831-555-0100", + ) + assert city_state.is_location_description_complete is True From b2ca5c12514229e48cad12170dd7eef4413e4659 Mon Sep 17 00:00:00 2001 From: "C.Lee Sellers" <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:56:01 -0700 Subject: [PATCH 2/7] chore(db): supabase mirror for supplier_facilities.phone (A-2190) Keeps Supabase schema in step with alembic v141 so local/supabase checkouts see the same optional phone column. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> --- .../20261031120000_supplier_facility_phone.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 supabase/migrations/20261031120000_supplier_facility_phone.sql diff --git a/supabase/migrations/20261031120000_supplier_facility_phone.sql b/supabase/migrations/20261031120000_supplier_facility_phone.sql new file mode 100644 index 000000000..dad174254 --- /dev/null +++ b/supabase/migrations/20261031120000_supplier_facility_phone.sql @@ -0,0 +1,9 @@ +-- A-2190 / 21 CFR 1.1310: optional business phone on supplier_facilities. +-- Mirrors alembic 20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py. +-- Additive and nullable. Synthetic fixtures only until SEL-590 clears. + +ALTER TABLE public.supplier_facilities + ADD COLUMN IF NOT EXISTS phone text; + +COMMENT ON COLUMN public.supplier_facilities.phone IS + 'Facility business phone for 21 CFR 1.1310 location description (A-2190)'; From 0ce96f5ec22edc51305477c604bee4811014ad82 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:16:11 -0700 Subject: [PATCH 3/7] fix(db): rechain phone migration as v142; schema parity + Neo4j phone sync DeepSeek review on #3771: - Single alembic head: vendor #3717 v141 (08c7cf36ddbe) and set supplier_facilities.phone migration down_revision to that head as v142. - Add public.supplier_facilities.phone to expected_schema.json parity snapshot. - Pass phone= in bulk graph sync record_facility_ftl_scoping so Neo4j does not clear phone on facility FTL scoping writes. - Food Safety Lens: location complete = name+phone+address only; bare GLN flags via has_unresolved_gln (no reject / A-2170). Note: supabase mirror migration remains forward-only. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> --- ...be_v141_traceability_events_append_only.py | 198 ++++++++++++++++++ ...d73f8d1bb_v142_supplier_facility_phone.py} | 6 +- docs/schema/expected_schema.json | 4 + .../app/bulk_upload/transaction_manager.py | 1 + services/graph/app/models/fsma_nodes.py | 25 ++- .../graph/tests/test_graph_models_unit.py | 17 +- 6 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py rename alembic/versions/{20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py => 20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py} (80%) diff --git a/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py b/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py new file mode 100644 index 000000000..ea4b236c4 --- /dev/null +++ b/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py @@ -0,0 +1,198 @@ +"""v141 — fsma.traceability_events mutation policy (column allowlist trigger). + +``fsma.traceability_events`` is the canonical FSMA 204 evidence table and +the chain hash is computed over its payload columns, yet until now nothing +in the database stopped an UPDATE or DELETE on it. ``fsma.cte_events`` has +had an append-only trigger since v107; the canonical table had none, so a +``UPDATE fsma.traceability_events SET quantity = ...`` from any role with +UPDATE privilege silently broke the evidence the FDA package is built on. + +This revision installs a BEFORE UPDATE OR DELETE row trigger plus a BEFORE +TRUNCATE statement trigger that together enforce the sanctioned mutation +surface, which is exactly what the three production writers do today: + +* ``services/shared/canonical_persistence/writer.py`` marks the row an + amendment supersedes: ``status = 'superseded', amended_at = NOW()``. +* ``services/shared/rules/engine.py`` records the event-level verdict: + ``evaluation_verdict``, ``no_verdict_reason``, ``evaluated_at``, and the + evaluation error marker: ``evaluation_error``, ``evaluated_at``. + +Allowed on UPDATE: changes confined to the allowlisted columns +(``status``, ``amended_at``, ``evaluation_verdict``, ``no_verdict_reason``, +``evaluation_error``, ``evaluated_at``), and ``status`` may only move from +``active`` to ``superseded``. Any other column change, any other status +transition, every DELETE and every TRUNCATE raise ``check_violation``. + +Break-glass: ``SET LOCAL fsma.allow_mutation = 'true'`` lets the statement +through with a WARNING, the same GUC v095 and v107 use. The runtime resets +it per transaction (``services/shared/tenant_context.py``) and per pooled +connection (``services/admin/app/database.py``); the retention purge is +its only production user. + +The comparison is ``to_jsonb(NEW) - allowlist = to_jsonb(OLD) - allowlist`` +rather than a list of frozen column names, so the trigger is correct on +every catalog variant of the table (the Supabase twin types the tenant +column as text and has no ``ingested_at``) and keeps protecting columns +added later. + +Idempotent: ``CREATE OR REPLACE`` functions, ``DROP TRIGGER IF EXISTS`` +before ``CREATE TRIGGER``, ``to_regclass`` guard. No grant changes (v111 +still grants UPDATE to the runtime role; the trigger is the enforcement). + +Revision ID: 08c7cf36ddbe +Revises: b8c1e4f2a901 +Create Date: 2026-10-28 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "08c7cf36ddbe" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret +branch_labels = None +depends_on = None + +TABLE = "fsma.traceability_events" +ROW_TRIGGER = "traceability_events_mutation_policy" +TRUNCATE_TRIGGER = "traceability_events_no_truncate" +ROW_FUNCTION = "fsma.prevent_traceability_event_mutation" +TRUNCATE_FUNCTION = "fsma.prevent_traceability_event_truncate" + +# Columns the sanctioned writers may change. Keep in step with the module +# docstring and docs/engineering/SCHEMA_CHANGE_POLICY.md §5. +ALLOWLIST: tuple[str, ...] = ( + "status", + "amended_at", + "evaluation_verdict", + "no_verdict_reason", + "evaluation_error", + "evaluated_at", +) + +_ALLOWLIST_SQL = ", ".join(f"'{c}'" for c in ALLOWLIST) + +_ROW_FUNCTION_SQL = f""" +CREATE OR REPLACE FUNCTION {ROW_FUNCTION}() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + allowlist text[] := ARRAY[{_ALLOWLIST_SQL}]; + changed text[]; +BEGIN + IF current_setting('fsma.allow_mutation', true) = 'true' THEN + RAISE WARNING + 'traceability_events modification via break-glass. op=% user=% event_id=%', + TG_OP, current_user, COALESCE(NEW.event_id::text, OLD.event_id::text); + IF TG_OP = 'UPDATE' THEN + RETURN NEW; + END IF; + RETURN OLD; + END IF; + + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION + 'traceability_events is append-only (FDA 21 CFR 1.1455). DELETE rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', + OLD.event_id + USING ERRCODE = 'check_violation'; + END IF; + + -- UPDATE: every changed column must be on the allowlist. + SELECT COALESCE(array_agg(key ORDER BY key), ARRAY[]::text[]) + INTO changed + FROM ( + SELECT key FROM jsonb_each(to_jsonb(NEW) - allowlist) + EXCEPT + SELECT key FROM jsonb_each(to_jsonb(OLD) - allowlist) + UNION + SELECT n.key + FROM jsonb_each(to_jsonb(NEW) - allowlist) n + JOIN jsonb_each(to_jsonb(OLD) - allowlist) o ON o.key = n.key + WHERE n.value IS DISTINCT FROM o.value + ) diff; + IF array_length(changed, 1) IS NOT NULL THEN + RAISE EXCEPTION + 'traceability_events is append-only (FDA 21 CFR 1.1455). UPDATE rejected for event_id=%: column(s) % are not amendable. Corrections are new events that supersede the old one. Set fsma.allow_mutation=true for authorized break-glass.', + OLD.event_id, array_to_string(changed, ', ') + USING ERRCODE = 'check_violation'; + END IF; + + -- status may only move active -> superseded (an amendment landed). + -- IS NOT TRUE, not NOT (...): the Supabase twin leaves status nullable, + -- and a NULL on either side would turn NOT (...) into NULL and let the + -- change through. + IF NEW.status IS DISTINCT FROM OLD.status + AND (OLD.status = 'active' AND NEW.status = 'superseded') IS NOT TRUE THEN + RAISE EXCEPTION + 'traceability_events status may only move from active to superseded; % -> % rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', + OLD.status, NEW.status, OLD.event_id + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; +""" + +_TRUNCATE_FUNCTION_SQL = f""" +CREATE OR REPLACE FUNCTION {TRUNCATE_FUNCTION}() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF current_setting('fsma.allow_mutation', true) = 'true' THEN + RAISE WARNING 'traceability_events TRUNCATE via break-glass. user=%', current_user; + RETURN NULL; + END IF; + RAISE EXCEPTION + 'traceability_events is append-only (FDA 21 CFR 1.1455). TRUNCATE rejected. Set fsma.allow_mutation=true for authorized break-glass.' + USING ERRCODE = 'check_violation'; +END; +$$; +""" + +_TRIGGERS_SQL = f""" +DO $$ +BEGIN + IF to_regclass('{TABLE}') IS NULL THEN + RETURN; + END IF; + DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; + CREATE TRIGGER {ROW_TRIGGER} + BEFORE UPDATE OR DELETE ON {TABLE} + FOR EACH ROW EXECUTE FUNCTION {ROW_FUNCTION}(); + DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; + CREATE TRIGGER {TRUNCATE_TRIGGER} + BEFORE TRUNCATE ON {TABLE} + FOR EACH STATEMENT EXECUTE FUNCTION {TRUNCATE_FUNCTION}(); +END $$; +""" + +_DOWNGRADE_SQL = f""" +DO $$ +BEGIN + IF to_regclass('{TABLE}') IS NOT NULL THEN + DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; + DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; + END IF; +END $$; +DROP FUNCTION IF EXISTS {ROW_FUNCTION}(); +DROP FUNCTION IF EXISTS {TRUNCATE_FUNCTION}(); +""" + + +# CREATE/DROP TRIGGER takes SHARE ROW EXCLUSIVE on the table and would queue +# behind a long ingest or export transaction, holding every later writer +# behind it. Bound the wait (same 10s as v134) so the migration fails fast +# and can be retried instead of stalling production. Alembic runs each +# path in one transaction, so SET LOCAL covers every statement below. +_LOCK_TIMEOUT_SQL = "SET LOCAL lock_timeout = '10s'" + + +def upgrade() -> None: + op.execute(_LOCK_TIMEOUT_SQL) + op.execute(_ROW_FUNCTION_SQL) + op.execute(_TRUNCATE_FUNCTION_SQL) + op.execute(_TRIGGERS_SQL) + + +def downgrade() -> None: + op.execute(_LOCK_TIMEOUT_SQL) + op.execute(_DOWNGRADE_SQL) diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py similarity index 80% rename from alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py rename to alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py index 09bff2b6c..ca88139b8 100644 --- a/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py +++ b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py @@ -1,7 +1,7 @@ -"""v141 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). +"""v142 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). Revision ID: e5cd73f8d1bb -Revises: b8c1e4f2a901 +Revises: 08c7cf36ddbe (#3717 v141 traceability_events append-only) Create Date: 2026-10-31 Adds an optional business phone on supplier_facilities so a Facility location @@ -17,7 +17,7 @@ from alembic import op revision: str = "e5cd73f8d1bb" # pragma: allowlist secret -down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "08c7cf36ddbe" # pragma: allowlist secret branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/docs/schema/expected_schema.json b/docs/schema/expected_schema.json index 7803847ca..f7c6aa8c5 100644 --- a/docs/schema/expected_schema.json +++ b/docs/schema/expected_schema.json @@ -7074,6 +7074,10 @@ "nullable": false, "type": "text" }, + "phone": { + "nullable": true, + "type": "text" + }, "postal_code": { "nullable": false, "type": "text" diff --git a/services/admin/app/bulk_upload/transaction_manager.py b/services/admin/app/bulk_upload/transaction_manager.py index 370ba4459..90a5ad319 100644 --- a/services/admin/app/bulk_upload/transaction_manager.py +++ b/services/admin/app/bulk_upload/transaction_manager.py @@ -534,6 +534,7 @@ def resolve_facility_by_name(name: str) -> SupplierFacilityModel: city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=categories, diff --git a/services/graph/app/models/fsma_nodes.py b/services/graph/app/models/fsma_nodes.py index 99383e380..59326d567 100644 --- a/services/graph/app/models/fsma_nodes.py +++ b/services/graph/app/models/fsma_nodes.py @@ -389,14 +389,15 @@ def identifier(self) -> str: def is_location_description_complete(self) -> bool: """Whether this facility satisfies 21 CFR 1.1310 location description. - Complete when a GLN is present (allowed stand-in), or when business - name, phone, and a physical address (street address, or city+state) - are all present. Ingest-path location completeness for free-text KDEs - lives in shared/fsma_validation (see PR #3718); this property covers - the Neo4j Facility node itself (A-2190 fix 4). + Complete only with business name + phone + physical address + (street address, or city+state). A GLN is not a stand-in by itself: + it counts only when the location master row has already resolved to + those three fields on this Facility node. A bare / unresolved GLN + leaves the description incomplete (flag; ingest must not reject — + A-2170). A TLC source reference is never a stand-in on Facility + (it only substitutes for the TLC source location field on an event + at ingest). See Food Safety Lens answers for A-2190 / PR #3771. """ - if (self.gln or "").strip(): - return True name_ok = bool((self.name or "").strip()) phone_ok = bool((self.phone or "").strip()) address_ok = bool( @@ -405,6 +406,16 @@ def is_location_description_complete(self) -> bool: ) return name_ok and phone_ok and address_ok + @property + def has_unresolved_gln(self) -> bool: + """True when a GLN is stored but name+phone+address are incomplete. + + Flag for operators / export; does not reject the record (A-2170). + """ + if not (self.gln or "").strip(): + return False + return not self.is_location_description_complete + @property def node_properties(self) -> Dict[str, Any]: """Return properties for Neo4j node creation.""" diff --git a/services/graph/tests/test_graph_models_unit.py b/services/graph/tests/test_graph_models_unit.py index 8e2a650d0..5ea7ed450 100644 --- a/services/graph/tests/test_graph_models_unit.py +++ b/services/graph/tests/test_graph_models_unit.py @@ -346,12 +346,25 @@ def test_facility_location_description_complete_with_name_phone_address(): phone="+1-202-555-0147", ) assert complete.is_location_description_complete is True + assert complete.has_unresolved_gln is False missing_phone = Facility(name="Valley Fresh Farms", address="100 Farm Rd") assert missing_phone.is_location_description_complete is False - gln_stand_in = Facility(gln="0614141000005", name="") - assert gln_stand_in.is_location_description_complete is True + # Bare / unresolved GLN is incomplete (Food Safety Lens A-2190): flag only. + bare_gln = Facility(gln="0614141000005", name="") + assert bare_gln.is_location_description_complete is False + assert bare_gln.has_unresolved_gln is True + + # GLN counts only once the master row carries name + phone + address. + resolved_gln = Facility( + gln="0614141000005", + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + ) + assert resolved_gln.is_location_description_complete is True + assert resolved_gln.has_unresolved_gln is False city_state = Facility( name="Cooler Co", From f8b7e6816a9355b0f703fc5fb881f699ddef865e Mon Sep 17 00:00:00 2001 From: "C.Lee Sellers" <131207452+PetrefiedThunder@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:07:35 -0700 Subject: [PATCH 4/7] fix(db): drop vendored v141 append-only; rechain phone as v141 from v140 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3771 previously vendored #3717's fsma.traceability_events append-only trigger as v141 and bumped the phone ADD COLUMN to v142. Live DB suite then hit CheckViolation on intentional tamper UPDATEs — caused by the append-only policy, not by phone backfill (phone never UPDATEs that table). Remove the vendored append-only migration and rechain supplier_facilities.phone as v141 revising v140 (b8c1e4f2a901). Append-only stays on #3717. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> --- ...be_v141_traceability_events_append_only.py | 198 ------------------ ...d73f8d1bb_v141_supplier_facility_phone.py} | 11 +- 2 files changed, 8 insertions(+), 201 deletions(-) delete mode 100644 alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py rename alembic/versions/{20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py => 20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py} (62%) diff --git a/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py b/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py deleted file mode 100644 index ea4b236c4..000000000 --- a/alembic/versions/20261028_08c7cf36ddbe_v141_traceability_events_append_only.py +++ /dev/null @@ -1,198 +0,0 @@ -"""v141 — fsma.traceability_events mutation policy (column allowlist trigger). - -``fsma.traceability_events`` is the canonical FSMA 204 evidence table and -the chain hash is computed over its payload columns, yet until now nothing -in the database stopped an UPDATE or DELETE on it. ``fsma.cte_events`` has -had an append-only trigger since v107; the canonical table had none, so a -``UPDATE fsma.traceability_events SET quantity = ...`` from any role with -UPDATE privilege silently broke the evidence the FDA package is built on. - -This revision installs a BEFORE UPDATE OR DELETE row trigger plus a BEFORE -TRUNCATE statement trigger that together enforce the sanctioned mutation -surface, which is exactly what the three production writers do today: - -* ``services/shared/canonical_persistence/writer.py`` marks the row an - amendment supersedes: ``status = 'superseded', amended_at = NOW()``. -* ``services/shared/rules/engine.py`` records the event-level verdict: - ``evaluation_verdict``, ``no_verdict_reason``, ``evaluated_at``, and the - evaluation error marker: ``evaluation_error``, ``evaluated_at``. - -Allowed on UPDATE: changes confined to the allowlisted columns -(``status``, ``amended_at``, ``evaluation_verdict``, ``no_verdict_reason``, -``evaluation_error``, ``evaluated_at``), and ``status`` may only move from -``active`` to ``superseded``. Any other column change, any other status -transition, every DELETE and every TRUNCATE raise ``check_violation``. - -Break-glass: ``SET LOCAL fsma.allow_mutation = 'true'`` lets the statement -through with a WARNING, the same GUC v095 and v107 use. The runtime resets -it per transaction (``services/shared/tenant_context.py``) and per pooled -connection (``services/admin/app/database.py``); the retention purge is -its only production user. - -The comparison is ``to_jsonb(NEW) - allowlist = to_jsonb(OLD) - allowlist`` -rather than a list of frozen column names, so the trigger is correct on -every catalog variant of the table (the Supabase twin types the tenant -column as text and has no ``ingested_at``) and keeps protecting columns -added later. - -Idempotent: ``CREATE OR REPLACE`` functions, ``DROP TRIGGER IF EXISTS`` -before ``CREATE TRIGGER``, ``to_regclass`` guard. No grant changes (v111 -still grants UPDATE to the runtime role; the trigger is the enforcement). - -Revision ID: 08c7cf36ddbe -Revises: b8c1e4f2a901 -Create Date: 2026-10-28 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "08c7cf36ddbe" # pragma: allowlist secret -down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret -branch_labels = None -depends_on = None - -TABLE = "fsma.traceability_events" -ROW_TRIGGER = "traceability_events_mutation_policy" -TRUNCATE_TRIGGER = "traceability_events_no_truncate" -ROW_FUNCTION = "fsma.prevent_traceability_event_mutation" -TRUNCATE_FUNCTION = "fsma.prevent_traceability_event_truncate" - -# Columns the sanctioned writers may change. Keep in step with the module -# docstring and docs/engineering/SCHEMA_CHANGE_POLICY.md §5. -ALLOWLIST: tuple[str, ...] = ( - "status", - "amended_at", - "evaluation_verdict", - "no_verdict_reason", - "evaluation_error", - "evaluated_at", -) - -_ALLOWLIST_SQL = ", ".join(f"'{c}'" for c in ALLOWLIST) - -_ROW_FUNCTION_SQL = f""" -CREATE OR REPLACE FUNCTION {ROW_FUNCTION}() -RETURNS TRIGGER LANGUAGE plpgsql AS $$ -DECLARE - allowlist text[] := ARRAY[{_ALLOWLIST_SQL}]; - changed text[]; -BEGIN - IF current_setting('fsma.allow_mutation', true) = 'true' THEN - RAISE WARNING - 'traceability_events modification via break-glass. op=% user=% event_id=%', - TG_OP, current_user, COALESCE(NEW.event_id::text, OLD.event_id::text); - IF TG_OP = 'UPDATE' THEN - RETURN NEW; - END IF; - RETURN OLD; - END IF; - - IF TG_OP = 'DELETE' THEN - RAISE EXCEPTION - 'traceability_events is append-only (FDA 21 CFR 1.1455). DELETE rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', - OLD.event_id - USING ERRCODE = 'check_violation'; - END IF; - - -- UPDATE: every changed column must be on the allowlist. - SELECT COALESCE(array_agg(key ORDER BY key), ARRAY[]::text[]) - INTO changed - FROM ( - SELECT key FROM jsonb_each(to_jsonb(NEW) - allowlist) - EXCEPT - SELECT key FROM jsonb_each(to_jsonb(OLD) - allowlist) - UNION - SELECT n.key - FROM jsonb_each(to_jsonb(NEW) - allowlist) n - JOIN jsonb_each(to_jsonb(OLD) - allowlist) o ON o.key = n.key - WHERE n.value IS DISTINCT FROM o.value - ) diff; - IF array_length(changed, 1) IS NOT NULL THEN - RAISE EXCEPTION - 'traceability_events is append-only (FDA 21 CFR 1.1455). UPDATE rejected for event_id=%: column(s) % are not amendable. Corrections are new events that supersede the old one. Set fsma.allow_mutation=true for authorized break-glass.', - OLD.event_id, array_to_string(changed, ', ') - USING ERRCODE = 'check_violation'; - END IF; - - -- status may only move active -> superseded (an amendment landed). - -- IS NOT TRUE, not NOT (...): the Supabase twin leaves status nullable, - -- and a NULL on either side would turn NOT (...) into NULL and let the - -- change through. - IF NEW.status IS DISTINCT FROM OLD.status - AND (OLD.status = 'active' AND NEW.status = 'superseded') IS NOT TRUE THEN - RAISE EXCEPTION - 'traceability_events status may only move from active to superseded; % -> % rejected for event_id=%. Set fsma.allow_mutation=true for authorized break-glass.', - OLD.status, NEW.status, OLD.event_id - USING ERRCODE = 'check_violation'; - END IF; - - RETURN NEW; -END; -$$; -""" - -_TRUNCATE_FUNCTION_SQL = f""" -CREATE OR REPLACE FUNCTION {TRUNCATE_FUNCTION}() -RETURNS TRIGGER LANGUAGE plpgsql AS $$ -BEGIN - IF current_setting('fsma.allow_mutation', true) = 'true' THEN - RAISE WARNING 'traceability_events TRUNCATE via break-glass. user=%', current_user; - RETURN NULL; - END IF; - RAISE EXCEPTION - 'traceability_events is append-only (FDA 21 CFR 1.1455). TRUNCATE rejected. Set fsma.allow_mutation=true for authorized break-glass.' - USING ERRCODE = 'check_violation'; -END; -$$; -""" - -_TRIGGERS_SQL = f""" -DO $$ -BEGIN - IF to_regclass('{TABLE}') IS NULL THEN - RETURN; - END IF; - DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; - CREATE TRIGGER {ROW_TRIGGER} - BEFORE UPDATE OR DELETE ON {TABLE} - FOR EACH ROW EXECUTE FUNCTION {ROW_FUNCTION}(); - DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; - CREATE TRIGGER {TRUNCATE_TRIGGER} - BEFORE TRUNCATE ON {TABLE} - FOR EACH STATEMENT EXECUTE FUNCTION {TRUNCATE_FUNCTION}(); -END $$; -""" - -_DOWNGRADE_SQL = f""" -DO $$ -BEGIN - IF to_regclass('{TABLE}') IS NOT NULL THEN - DROP TRIGGER IF EXISTS {ROW_TRIGGER} ON {TABLE}; - DROP TRIGGER IF EXISTS {TRUNCATE_TRIGGER} ON {TABLE}; - END IF; -END $$; -DROP FUNCTION IF EXISTS {ROW_FUNCTION}(); -DROP FUNCTION IF EXISTS {TRUNCATE_FUNCTION}(); -""" - - -# CREATE/DROP TRIGGER takes SHARE ROW EXCLUSIVE on the table and would queue -# behind a long ingest or export transaction, holding every later writer -# behind it. Bound the wait (same 10s as v134) so the migration fails fast -# and can be retried instead of stalling production. Alembic runs each -# path in one transaction, so SET LOCAL covers every statement below. -_LOCK_TIMEOUT_SQL = "SET LOCAL lock_timeout = '10s'" - - -def upgrade() -> None: - op.execute(_LOCK_TIMEOUT_SQL) - op.execute(_ROW_FUNCTION_SQL) - op.execute(_TRUNCATE_FUNCTION_SQL) - op.execute(_TRIGGERS_SQL) - - -def downgrade() -> None: - op.execute(_LOCK_TIMEOUT_SQL) - op.execute(_DOWNGRADE_SQL) diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py similarity index 62% rename from alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py rename to alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py index ca88139b8..0e0b59c96 100644 --- a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py +++ b/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py @@ -1,7 +1,7 @@ -"""v142 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). +"""v141 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). Revision ID: e5cd73f8d1bb -Revises: 08c7cf36ddbe (#3717 v141 traceability_events append-only) +Revises: b8c1e4f2a901 (v140 partner_file_storage_retention) Create Date: 2026-10-31 Adds an optional business phone on supplier_facilities so a Facility location @@ -9,6 +9,11 @@ existing rows stay valid; UI and API accept phone on create. Additive. Synthetic fixtures only until SEL-590 clears. + +Note: Do NOT vendor #3717's fsma.traceability_events append-only trigger here. +That migration belongs on its own PR; shipping it as a side-effect of the phone +column caused Live DB suite CheckViolation failures on intentional tamper +UPDATEs (append-only policy), which phone ADD COLUMN never touches. """ from typing import Sequence, Union @@ -17,7 +22,7 @@ from alembic import op revision: str = "e5cd73f8d1bb" # pragma: allowlist secret -down_revision: Union[str, Sequence[str], None] = "08c7cf36ddbe" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 59df0db47c1bbb645264049a5088c5c5526e692d Mon Sep 17 00:00:00 2001 From: "C.Lee Sellers" <131207452+PetrefiedThunder@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:07:23 -0800 Subject: [PATCH 5/7] fix(A-2190): rechain supplier_facilities.phone migration to v142 after v141 append-only (08c7cf36ddbe) Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> --- ...1031_e5cd73f8d1bb_v142_supplier_facility_phone.py} | 11 ++++++----- .../20261031120000_supplier_facility_phone.sql | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) rename alembic/versions/{20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py => 20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py} (71%) diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py similarity index 71% rename from alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py rename to alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py index 0e0b59c96..9d76c9ddb 100644 --- a/alembic/versions/20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py +++ b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py @@ -1,7 +1,7 @@ -"""v141 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). +"""v142 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). Revision ID: e5cd73f8d1bb -Revises: b8c1e4f2a901 (v140 partner_file_storage_retention) +Revises: 08c7cf36ddbe (v141 traceability_events_append_only) Create Date: 2026-10-31 Adds an optional business phone on supplier_facilities so a Facility location @@ -10,8 +10,9 @@ Additive. Synthetic fixtures only until SEL-590 clears. -Note: Do NOT vendor #3717's fsma.traceability_events append-only trigger here. -That migration belongs on its own PR; shipping it as a side-effect of the phone +Note: #3717's fsma.traceability_events append-only trigger landed on main as +v141 (08c7cf36ddbe); this migration chains after it and does not vendor it. +Earlier, shipping it as a side-effect of the phone column caused Live DB suite CheckViolation failures on intentional tamper UPDATEs (append-only policy), which phone ADD COLUMN never touches. """ @@ -22,7 +23,7 @@ from alembic import op revision: str = "e5cd73f8d1bb" # pragma: allowlist secret -down_revision: Union[str, Sequence[str], None] = "b8c1e4f2a901" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "08c7cf36ddbe" # pragma: allowlist secret branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/supabase/migrations/20261031120000_supplier_facility_phone.sql b/supabase/migrations/20261031120000_supplier_facility_phone.sql index dad174254..a40a1d5cc 100644 --- a/supabase/migrations/20261031120000_supplier_facility_phone.sql +++ b/supabase/migrations/20261031120000_supplier_facility_phone.sql @@ -1,5 +1,5 @@ -- A-2190 / 21 CFR 1.1310: optional business phone on supplier_facilities. --- Mirrors alembic 20261031_e5cd73f8d1bb_v141_supplier_facility_phone.py. +-- Mirrors alembic 20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py. -- Additive and nullable. Synthetic fixtures only until SEL-590 clears. ALTER TABLE public.supplier_facilities From 8100dc53f63f277ac04806b045731c3fa0e9ef1b Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:58:22 -0800 Subject: [PATCH 6/7] fix(schema): store supplier_facilities.phone as Text Align Alembic v142 and SupplierFacilityModel with the expected_schema and Supabase mirror (phone text) so schema parity sees TEXT, not VARCHAR. Signed-off-by: C.Lee Sellers <131207452+PetrefiedThunder@users.noreply.github.com> Signed-off-by: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> --- .../20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py | 2 +- services/admin/app/sqlalchemy_models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py index 9d76c9ddb..70562a6aa 100644 --- a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py +++ b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py @@ -31,7 +31,7 @@ def upgrade() -> None: op.add_column( "supplier_facilities", - sa.Column("phone", sa.String(), nullable=True), + sa.Column("phone", sa.Text(), nullable=True), ) diff --git a/services/admin/app/sqlalchemy_models.py b/services/admin/app/sqlalchemy_models.py index aa13e8dbc..6067ee07e 100644 --- a/services/admin/app/sqlalchemy_models.py +++ b/services/admin/app/sqlalchemy_models.py @@ -420,7 +420,7 @@ class SupplierFacilityModel(Base): city = Column(String, nullable=False) state = Column(String, nullable=False) postal_code = Column(String, nullable=False) - phone = Column(String, nullable=True) # A-2190 / 21 CFR 1.1310 + phone = Column(Text, nullable=True) # A-2190 / 21 CFR 1.1310 fda_registration_number = Column(String, nullable=True) roles = Column(JSONType(), nullable=False, default=list) # Hours after a CTE's event time the supplier agreed to send it. NULL: none. From 739cac72f7e8360cb09d39b71c4f7c6d0a26e62b Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:53:05 -0800 Subject: [PATCH 7/7] fix(fsma): bind $phone in the bulk-upload facility outbox row FACILITY_FTL_SCOPING_QUERY reads $phone, but the durable bulk-upload outbox params omitted it, so Neo4j would reject the replayed write. Adds a test that every $param in the Cypher is bound. Keeps Facility's positional order by moving phone after facility_type, and notes that the v142 downgrade deletes stored phones. Co-Authored-By: Claude Opus 5.5 --- ...cd73f8d1bb_v142_supplier_facility_phone.py | 2 + regengine/models.py | 2 +- .../app/bulk_upload/transaction_manager.py | 1 + .../test_bulk_upload_graph_sync_wiring.py | 48 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py index 70562a6aa..8e29b6e33 100644 --- a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py +++ b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py @@ -36,4 +36,6 @@ def upgrade() -> None: def downgrade() -> None: + # Destructive: deletes every stored facility phone. Back up + # supplier_facilities.phone first if those values must be kept. op.drop_column("supplier_facilities", "phone") diff --git a/regengine/models.py b/regengine/models.py index 7e4b63529..72ee27844 100644 --- a/regengine/models.py +++ b/regengine/models.py @@ -90,8 +90,8 @@ class Facility: gln: str name: str address: Optional[str] = None - phone: Optional[str] = None facility_type: Optional[str] = None + phone: Optional[str] = None @classmethod def from_api(cls, payload: Dict[str, Any]) -> "Facility": diff --git a/services/admin/app/bulk_upload/transaction_manager.py b/services/admin/app/bulk_upload/transaction_manager.py index 90a5ad319..f28221879 100644 --- a/services/admin/app/bulk_upload/transaction_manager.py +++ b/services/admin/app/bulk_upload/transaction_manager.py @@ -493,6 +493,7 @@ def resolve_facility_by_name(name: str) -> SupplierFacilityModel: "city": facility.city, "state": facility.state, "postal_code": facility.postal_code, + "phone": facility.phone, "fda_registration_number": facility.fda_registration_number, "roles": facility.roles or [], "categories": normalized_categories, diff --git a/services/admin/tests/test_bulk_upload_graph_sync_wiring.py b/services/admin/tests/test_bulk_upload_graph_sync_wiring.py index ac1608883..b80ee4754 100644 --- a/services/admin/tests/test_bulk_upload_graph_sync_wiring.py +++ b/services/admin/tests/test_bulk_upload_graph_sync_wiring.py @@ -467,3 +467,51 @@ def _boom(*_args: Any, **_kwargs: Any) -> None: if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +def test_facility_scoping_outbox_row_binds_every_cypher_parameter( + db_session: Session, + monkeypatch: pytest.MonkeyPatch, +): + """A replayed outbox row must supply every ``$param`` the Cypher reads. + + ``FACILITY_FTL_SCOPING_QUERY`` gained ``$phone``. Neo4j rejects a query + with an unbound parameter, so a row without it can never drain. + """ + import re + + import app.bulk_upload.transaction_manager as tx_manager + from app.supplier_graph_sync import FACILITY_FTL_SCOPING_QUERY + + monkeypatch.setattr(tx_manager.supplier_graph_sync, "record_cte_event", lambda **_kwargs: None) + monkeypatch.setattr( + tx_manager.supplier_graph_sync, + "record_facility_ftl_scoping", + lambda **_kwargs: None, + ) + + current_user = db_session.get(UserModel, TEST_USER_ID) + assert current_user is not None + + payload = _build_payload(event_count=1) + payload["ftl_scopes"] = [ + { + "facility_name": "Bulk Test Packhouse", + "category_id": next(iter(tx_manager.FTL_CATEGORY_LOOKUP)), + } + ] + tx_manager.execute_bulk_commit( + db_session, + tenant_id=TEST_TENANT_ID, + current_user=current_user, + normalized_payload=payload, + ) + + rows = [r for r in _outbox_rows(db_session) if r["operation"] == "facility_ftl_scoping"] + assert rows, "expected a facility_ftl_scoping outbox row" + + required = set(re.findall(r"\$([A-Za-z_][A-Za-z0-9_]*)", FACILITY_FTL_SCOPING_QUERY)) + assert "phone" in required + for row in rows: + params = row["params"] if isinstance(row["params"], dict) else json.loads(row["params"]) + assert required <= set(params), f"unbound: {sorted(required - set(params))}"