Commit 9da537a19f for freeswitch.com

commit 9da537a19f881b67f35bd98895f5939983ccff73
Author: Dmitry Verenitsin <morbit85@gmail.com>
Date:   Tue May 26 19:33:23 2026 +0500

    [mod_sofia] Add SIP 603+ detection and passthrough control. Add unit-tests. (#3035)

    Implement SIP 603+ (ATIS-1000099) support for FCC analytics-based call blocking compliance.

    Detection:
    - Detect incoming 603+ responses by checking "Network Blocked" phrase
    and "v=analytics1;" in the `Reason` header text
    - Set `sip_603plus_reason` channel variable on both legs for CDR visibility

    Passthrough control:
    - `sip_603plus_passthrough=true`: forward 603+ phrase and Reason header
    - `sip_603plus_passthrough=false`: strip `Reason` header, send clean `603 Decline`
    - Not set: existing behavior preserved
    - Works independently of `disable_q850_reason` for selective forwarding

diff --git a/conf/vanilla/vars.xml b/conf/vanilla/vars.xml
index 54a15a2534..ae7c355f99 100644
--- a/conf/vanilla/vars.xml
+++ b/conf/vanilla/vars.xml
@@ -430,4 +430,11 @@
   <X-PRE-PROCESS cmd="set" data="video_mute_png=$${images_dir}/default-mute.png"/>
   <X-PRE-PROCESS cmd="set" data="video_no_avatar_png=$${images_dir}/default-avatar.png"/>

+  <!-- SIP 603+ call blocking passthrough control.
+       true:  Forward "Network Blocked" phrase and Reason header to the caller.
+       false: Strip Reason header, send clean 603 Decline.
+       unset: Default behavior (Reason passes through, phrase reset to "Decline").
+       Works independently of disable_q850_reason for selective 603+ forwarding. -->
+  <!--<Z-PRE-PROCESS cmd="set" data="sip_603plus_passthrough=true"/>-->
+
 </include>
diff --git a/src/mod/endpoints/mod_sofia/Makefile.am b/src/mod/endpoints/mod_sofia/Makefile.am
index 3a5d295ddb..378ba06c73 100644
--- a/src/mod/endpoints/mod_sofia/Makefile.am
+++ b/src/mod/endpoints/mod_sofia/Makefile.am
@@ -15,7 +15,7 @@ mod_sofia_la_SOURCES =
 mod_sofia_la_LIBADD = $(switch_builddir)/libfreeswitch.la libsofiamod.la
 mod_sofia_la_LDFLAGS = -avoid-version -module -no-undefined -shared $(SOFIA_SIP_LIBS) $(STIRSHAKEN_LIBS)

-noinst_PROGRAMS = test/test_sofia_funcs test/test_nuafail test/sipp-based-tests
+noinst_PROGRAMS = test/test_sofia_funcs test/test_nuafail test/sipp-based-tests test/test_603plus

 test_test_sofia_funcs_SOURCES = test/test_sofia_funcs.c
 test_test_sofia_funcs_CFLAGS = $(AM_CFLAGS) $(SOFIA_SIP_CFLAGS) $(STIRSHAKEN_CFLAGS) -DSWITCH_TEST_BASE_DIR_FOR_CONF=\"${abs_builddir}/test\" -DSWITCH_TEST_BASE_DIR_OVERRIDE=\"${abs_builddir}/test\"
@@ -25,6 +25,11 @@ endif
 test_test_sofia_funcs_LDFLAGS = $(AM_LDFLAGS) -avoid-version -no-undefined $(freeswitch_LDFLAGS) $(switch_builddir)/libfreeswitch.la $(CORE_LIBS) $(APR_LIBS) $(STIRSHAKEN_LIBS)
 test_test_sofia_funcs_LDADD = libsofiamod.la $(SOFIA_SIP_LIBS) $(STIRSHAKEN_LIBS)

+test_test_603plus_SOURCES = test/test_603plus.c
+test_test_603plus_CFLAGS = $(AM_CFLAGS) $(SOFIA_SIP_CFLAGS) -DSWITCH_TEST_BASE_DIR_FOR_CONF=\"${abs_builddir}/test\" -DSWITCH_TEST_BASE_DIR_OVERRIDE=\"${abs_builddir}/test\"
+test_test_603plus_LDFLAGS = $(AM_LDFLAGS) -avoid-version -no-undefined $(freeswitch_LDFLAGS) $(switch_builddir)/libfreeswitch.la $(CORE_LIBS) $(APR_LIBS)
+test_test_603plus_LDADD = libsofiamod.la $(SOFIA_SIP_LIBS)
+
 test_test_nuafail_SOURCES = test/test_nuafail.c
 test_test_nuafail_CFLAGS = $(AM_CFLAGS) $(SOFIA_SIP_CFLAGS) $(STIRSHAKEN_CFLAGS) -DSWITCH_TEST_BASE_DIR_FOR_CONF=\"${abs_builddir}/test\" -DSWITCH_TEST_BASE_DIR_OVERRIDE=\"${abs_builddir}/test\"
 if HAVE_STIRSHAKEN
@@ -38,13 +43,14 @@ test_sipp_based_tests_CFLAGS = $(AM_CFLAGS) $(SOFIA_SIP_CFLAGS) -DSWITCH_TEST_BA
 test_sipp_based_tests_LDFLAGS = $(AM_LDFLAGS) -avoid-version -no-undefined $(freeswitch_LDFLAGS) $(switch_builddir)/libfreeswitch.la $(CORE_LIBS) $(APR_LIBS)
 test_sipp_based_tests_LDADD = libsofiamod.la $(SOFIA_SIP_LIBS)

-TESTS = test/test_sofia_funcs.sh test/test_nuafail
+TESTS = test/test_sofia_funcs.sh test/test_nuafail test/test_603plus
 #TESTS += test/test_run_sipp.sh

 if ISMAC
 mod_sofia_la_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration
 test_test_sofia_funcs_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration
 test_test_nuafail_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration
+test_test_603plus_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration
 test_sipp_based_tests_LDFLAGS += -framework CoreFoundation -framework SystemConfiguration
 endif

diff --git a/src/mod/endpoints/mod_sofia/mod_sofia.c b/src/mod/endpoints/mod_sofia/mod_sofia.c
index 0bf07b57be..47b2d0681e 100644
--- a/src/mod/endpoints/mod_sofia/mod_sofia.c
+++ b/src/mod/endpoints/mod_sofia/mod_sofia.c
@@ -493,6 +493,7 @@ switch_status_t sofia_on_hangup(switch_core_session_t *session)
 		const char *val = NULL;
 		const char *max_forwards = switch_channel_get_variable(channel, SWITCH_MAX_FORWARDS_VARIABLE);
 		const char *call_info = switch_channel_get_variable(channel, "presence_call_info_full");
+		const char *passthrough_603plus = switch_channel_get_variable(channel, "sip_603plus_passthrough");
 		const char *session_id_header = sofia_glue_session_id_header(session, tech_pvt->profile);

 		val = switch_channel_get_variable(tech_pvt->channel, "disable_q850_reason");
@@ -512,6 +513,23 @@ switch_status_t sofia_on_hangup(switch_core_session_t *session)
 			}
 		}

+		/* 603+ (ATIS-1000099) Reason header override — applied after standard reason construction.
+		 *
+		 * passthrough=true:  Restore 603+ Reason even if disable_q850_reason suppressed it.
+		 *                    Allows selective forwarding of 603+ while suppressing other Reason headers.
+		 * passthrough=false: Strip Reason header entirely — send clean 603 Decline with no Reason. */
+		if (passthrough_603plus) {
+			const char *reason_603plus = switch_channel_get_variable(channel, "sip_603plus_reason");
+
+			if (!zstr(reason_603plus)) {
+				if (switch_true(passthrough_603plus)) {
+					reason = switch_core_session_sprintf(session, "%s", reason_603plus);
+				} else if (switch_false(passthrough_603plus)) {
+					reason = switch_core_session_sprintf(session, "");
+				}
+			}
+		}
+
 		if (switch_channel_test_flag(channel, CF_INTERCEPT) || cause == SWITCH_CAUSE_PICKED_OFF || cause == SWITCH_CAUSE_LOSE_RACE) {
 			switch_channel_set_variable(channel, "call_completed_elsewhere", "true");
 		}
@@ -557,6 +575,11 @@ switch_status_t sofia_on_hangup(switch_core_session_t *session)
 				if (tech_pvt->respond_phrase) {
 					//phrase = su_strdup(nua_handle_home(tech_pvt->nh), tech_pvt->respond_phrase);
 					phrase = tech_pvt->respond_phrase;
+				} else if (sip_cause == 603
+						   && !zstr(reason)
+						   && switch_true(passthrough_603plus)
+						   && !zstr(switch_channel_get_variable(channel, "sip_603plus_reason"))) {
+					phrase = "Network Blocked";
 				} else {
 					phrase = sip_status_phrase(sip_cause);
 				}
diff --git a/src/mod/endpoints/mod_sofia/sofia.c b/src/mod/endpoints/mod_sofia/sofia.c
index 62b4963e78..8d82e9d3fd 100644
--- a/src/mod/endpoints/mod_sofia/sofia.c
+++ b/src/mod/endpoints/mod_sofia/sofia.c
@@ -6658,9 +6658,23 @@ static void sofia_handle_sip_r_invite(switch_core_session_t *session, int status
 				switch_channel_set_variable(channel, "sip_reason", reason_header);
 				switch_channel_set_variable_partner(channel, "sip_reason", reason_header);
 			}
+
+			/* 603+ (ATIS-1000099) detection: clear stale state from serial forking, then check */
+			switch_channel_set_variable(channel, "sip_603plus_reason", NULL);
+			switch_channel_set_variable_partner(channel, "sip_603plus_reason", NULL);
+
+			if (status == 603 && phrase && !strcasecmp(phrase, "Network Blocked")
+				&& sip->sip_reason && sip->sip_reason->re_text
+				&& !strncmp(sip->sip_reason->re_text, "\"v=analytics1;", 14)
+				&& !zstr(reason_header)) {
+
+				switch_channel_set_variable(channel, "sip_603plus_reason", reason_header);
+				switch_channel_set_variable_partner(channel, "sip_603plus_reason", reason_header);
+			}
 		} else {
 			switch_channel_set_variable_partner(channel, "sip_invite_failure_status", NULL);
 			switch_channel_set_variable_partner(channel, "sip_invite_failure_phrase", NULL);
+			switch_channel_set_variable_partner(channel, "sip_603plus_reason", NULL);
 		}

 		if (status >= 400 && sip->sip_reason && sip->sip_reason->re_protocol && (!strcasecmp(sip->sip_reason->re_protocol, "Q.850")
diff --git a/src/mod/endpoints/mod_sofia/test/conf/freeswitch.xml b/src/mod/endpoints/mod_sofia/test/conf/freeswitch.xml
index fa626af93e..6a2959918e 100644
--- a/src/mod/endpoints/mod_sofia/test/conf/freeswitch.xml
+++ b/src/mod/endpoints/mod_sofia/test/conf/freeswitch.xml
@@ -140,6 +140,90 @@
           <action application="park" data=""/>
         </condition>
       </extension>
+      <!-- 603+ (ATIS-1000099) detection tests -->
+      <extension name="603plus_detect_valid_sip">
+        <condition field="destination_number" expression="^\+15553336050$">
+          <action application="set" data="sip_reason=SIP;cause=603;text=&quot;v=analytics1;url=https://example.com/redress&quot;;location=TN"/>
+          <action application="respond" data="603 Network Blocked"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_valid_q850">
+        <condition field="destination_number" expression="^\+15553336051$">
+          <action application="set" data="sip_reason=Q.850;cause=21;text=&quot;v=analytics1;url=https://example.com/redress&quot;;location=LN"/>
+          <action application="respond" data="603 Network Blocked"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_wrong_phrase">
+        <condition field="destination_number" expression="^\+15553336052$">
+          <action application="set" data="sip_reason=SIP;cause=603;text=&quot;v=analytics1;url=https://example.com/redress&quot;;location=TN"/>
+          <action application="respond" data="603 Decline"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_no_analytics">
+        <condition field="destination_number" expression="^\+15553336053$">
+          <action application="set" data="sip_reason=Q.850;cause=21;text=&quot;Call Rejected&quot;"/>
+          <action application="respond" data="603 Network Blocked"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_no_reason">
+        <condition field="destination_number" expression="^\+15553336054$">
+          <action application="set" data="disable_q850_reason=true"/>
+          <action application="respond" data="603 Network Blocked"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_non_603">
+        <condition field="destination_number" expression="^\+15553336055$">
+          <action application="set" data="sip_reason=SIP;cause=603;text=&quot;v=analytics1;url=https://example.com/redress&quot;;location=TN"/>
+          <action application="respond" data="486 Busy Here"/>
+        </condition>
+      </extension>
+      <extension name="603plus_detect_after_180">
+        <condition field="destination_number" expression="^\+15553336056$">
+          <action application="set" data="sip_reason=SIP;cause=603;text=&quot;v=analytics1;url=https://example.com/redress&quot;;location=TN"/>
+          <action application="ring_ready"/>
+          <action application="sleep" data="500"/>
+          <action application="respond" data="603 Network Blocked"/>
+        </condition>
+      </extension>
+      <!-- 603+ passthrough tests: bridge to 603+ target, vary passthrough setting -->
+      <extension name="603plus_passthrough_true">
+        <condition field="destination_number" expression="^\+15553336060$">
+          <action application="set" data="sip_603plus_passthrough=true"/>
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
+      <extension name="603plus_passthrough_false">
+        <condition field="destination_number" expression="^\+15553336061$">
+          <action application="set" data="sip_603plus_passthrough=false"/>
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
+      <extension name="603plus_passthrough_default">
+        <condition field="destination_number" expression="^\+15553336062$">
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
+      <!-- 603+ passthrough + disable_q850_reason combination tests -->
+      <extension name="603plus_disable_reason_passthrough_true">
+        <condition field="destination_number" expression="^\+15553336063$">
+          <action application="set" data="disable_q850_reason=true"/>
+          <action application="set" data="sip_603plus_passthrough=true"/>
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
+      <extension name="603plus_disable_reason_passthrough_false">
+        <condition field="destination_number" expression="^\+15553336064$">
+          <action application="set" data="disable_q850_reason=true"/>
+          <action application="set" data="sip_603plus_passthrough=false"/>
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
+      <extension name="603plus_disable_reason_passthrough_default">
+        <condition field="destination_number" expression="^\+15553336065$">
+          <action application="set" data="disable_q850_reason=true"/>
+          <action application="bridge" data="sofia/gateway/test/+15553336050"/>
+        </condition>
+      </extension>
     </context>
   </section>
 </document>
diff --git a/src/mod/endpoints/mod_sofia/test/test_603plus.c b/src/mod/endpoints/mod_sofia/test/test_603plus.c
new file mode 100644
index 0000000000..658881c884
--- /dev/null
+++ b/src/mod/endpoints/mod_sofia/test/test_603plus.c
@@ -0,0 +1,490 @@
+/*
+ * FreeSWITCH Modular Media Switching Software Library / Soft-Switch Application
+ * Copyright (C) 2005-2026, Anthony Minessale II <anthm@freeswitch.org>
+ *
+ * Version: MPL 1.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is FreeSWITCH Modular Media Switching Software Library / Soft-Switch Application
+ *
+ * The Initial Developer of the Original Code is
+ * Anthony Minessale II <anthm@freeswitch.org>
+ * Portions created by the Initial Developer are Copyright (C)
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *
+ * Dmitry Verenitsin <dmitry.verenitsin@signalwire.com>
+ *
+ *
+ * test_603plus.c -- Tests for SIP 603+ (ATIS-1000099) detection and passthrough
+ *
+ * Detection requires BOTH:
+ *   1. SIP status 603 with phrase "Network Blocked" (case-insensitive)
+ *   2. Reason header text starts with "v=analytics1;" (ATIS version AVP)
+ *
+ * Test approach: originate via loopback gateway (same FS instance).
+ * The responding extension sends a crafted 603 with/without Reason header.
+ * We bind to CHANNEL_HANGUP_COMPLETE to capture sip_603plus_reason from
+ * the outbound leg before it is destroyed.
+ *
+ * Passthrough tests use a bridge scenario: originate -> middle extension
+ * (sets passthrough) -> bridges to 603+ target. The originate leg receives
+ * the response FROM the middle box, letting us verify what was actually sent.
+ */
+
+#include <switch.h>
+#include <test/switch_test.h>
+
+/* Event capture state */
+
+static struct {
+	char sip_603plus_reason[1024];
+	char sip_invite_failure_phrase[256];
+	char sip_reason[1024];
+	switch_bool_t received;
+} capture;
+
+static void reset_capture(void)
+{
+	memset(capture.sip_603plus_reason, 0, sizeof(capture.sip_603plus_reason));
+	memset(capture.sip_invite_failure_phrase, 0, sizeof(capture.sip_invite_failure_phrase));
+	memset(capture.sip_reason, 0, sizeof(capture.sip_reason));
+	capture.received = SWITCH_FALSE;
+}
+
+static void on_hangup_complete(switch_event_t *event)
+{
+	const char *direction, *val;
+
+	/* Only capture from outbound legs (the originating call, not the responder).
+	 * In bridge tests, multiple outbound legs hang up (bridge B-leg, then originate O-leg).
+	 * Reset on every outbound event so the last one (O-leg) wins cleanly. */
+	direction = switch_event_get_header(event, "Call-Direction");
+	if (zstr(direction) || strcmp(direction, "outbound")) return;
+
+	reset_capture();
+
+	val = switch_event_get_header(event, "variable_sip_603plus_reason");
+	if (!zstr(val)) {
+		switch_snprintf(capture.sip_603plus_reason, sizeof(capture.sip_603plus_reason), "%s", val);
+	}
+
+	val = switch_event_get_header(event, "variable_sip_invite_failure_phrase");
+	if (!zstr(val)) {
+		switch_snprintf(capture.sip_invite_failure_phrase, sizeof(capture.sip_invite_failure_phrase), "%s", val);
+	}
+
+	val = switch_event_get_header(event, "variable_sip_reason");
+	if (!zstr(val)) {
+		switch_snprintf(capture.sip_reason, sizeof(capture.sip_reason), "%s", val);
+	}
+
+	capture.received = SWITCH_TRUE;
+}
+
+static void originate_and_wait(const char *dest, switch_call_cause_t *cause)
+{
+	switch_core_session_t *session = NULL;
+
+	switch_ivr_originate(NULL, &session, cause,
+		dest, 2, NULL, NULL, NULL, NULL, NULL, SOF_NONE, NULL, NULL);
+
+	if (session) {
+		switch_channel_hangup(switch_core_session_get_channel(session), SWITCH_CAUSE_NORMAL_CLEARING);
+		switch_core_session_rwunlock(session);
+	}
+
+	/* Let event dispatch thread deliver CHANNEL_HANGUP_COMPLETE */
+	switch_yield(1000000);
+}
+
+/* Test suite */
+
+FST_CORE_EX_BEGIN("./conf", SCF_VG | SCF_USE_SQL)
+{
+FST_MODULE_BEGIN(mod_sofia, sofia)
+{
+	FST_SETUP_BEGIN()
+	{
+	}
+	FST_SETUP_END()
+
+	FST_TEARDOWN_BEGIN()
+	{
+	}
+	FST_TEARDOWN_END()
+
+	/* Detection: positive cases */
+
+	FST_TEST_BEGIN(detect_valid_603plus_sip)
+	{
+		/*
+		 * Extension +15553336050 sends:
+		 *   603 Network Blocked
+		 *   Reason: SIP;cause=603;text="v=analytics1;url=https://example.com/redress";location=TN
+		 *
+		 * Both conditions met -> sip_603plus_reason MUST be set.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336050", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(!zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must be set for valid 603+");
+		fst_xcheck(!!strstr(capture.sip_603plus_reason, "v=analytics1"), "sip_603plus_reason must contain v=analytics1");
+		fst_xcheck(!strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked"), "Failure phrase must be 'Network Blocked'");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(detect_valid_603plus_q850)
+	{
+		/*
+		 * Extension +15553336051 sends:
+		 *   603 Network Blocked
+		 *   Reason: Q.850;cause=21;text="v=analytics1;url=https://example.com/redress";location=LN
+		 *
+		 * Q.850 protocol is equally valid per ATIS-1000099 section 4.1.1.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336051", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(!zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must be set for Q.850 ATIS Reason");
+		fst_xcheck(!!strstr(capture.sip_603plus_reason, "v=analytics1"), "sip_603plus_reason must contain v=analytics1");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(detect_603plus_after_180)
+	{
+		/*
+		 * Extension +15553336056 sends 180 Ringing, waits 500ms, then:
+		 *   603 Network Blocked
+		 *   Reason: SIP;cause=603;text="v=analytics1;url=https://example.com/redress";location=TN
+		 *
+		 * Detection must work after provisional responses.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336056", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(!zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must be set after 180+603");
+		fst_xcheck(!!strstr(capture.sip_603plus_reason, "v=analytics1"), "sip_603plus_reason must contain v=analytics1");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	/* Detection: negative cases */
+
+	FST_TEST_BEGIN(detect_wrong_phrase)
+	{
+		/*
+		 * Extension +15553336052 sends:
+		 *   603 Decline            <- wrong phrase
+		 *   Reason: SIP;cause=603;text="v=analytics1;url=https://example.com/redress";location=TN
+		 *
+		 * Phrase is "Decline", not "Network Blocked". Detection must NOT fire.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336052", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must NOT be set when phrase is 'Decline'");
+		/* sip_reason should still be set (existing behavior for any Reason header) */
+		fst_xcheck(!zstr_buf(capture.sip_reason), "sip_reason should be set regardless of phrase");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(detect_no_analytics_in_reason)
+	{
+		/*
+		 * Extension +15553336053 sends:
+		 *   603 Network Blocked
+		 *   Reason: Q.850;cause=21;text="Call Rejected"   <- no v=analytics1
+		 *
+		 * Reason header lacks v=analytics1. Detection must NOT fire.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336053", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must NOT be set without v=analytics1");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(detect_no_reason_header)
+	{
+		/*
+		 * Extension +15553336054 sends:
+		 *   603 Network Blocked
+		 *   (no Reason header -- disable_q850_reason=true suppresses it)
+		 *
+		 * No Reason header -> sip->sip_reason is NULL. Detection must NOT fire.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336054", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must NOT be set without Reason header");
+		fst_xcheck(zstr_buf(capture.sip_reason), "sip_reason should not be set when Reason header is suppressed");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(detect_non_603_status)
+	{
+		/*
+		 * Extension +15553336055 sends:
+		 *   486 Busy Here           <- not 603
+		 *   Reason: SIP;cause=603;text="v=analytics1;url=https://example.com/redress";location=TN
+		 *
+		 * Status code is 486, not 603. Detection must NOT fire.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336055", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_USER_BUSY, "Expected USER_BUSY for 486");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(zstr_buf(capture.sip_603plus_reason), "sip_603plus_reason must NOT be set for non-603 status");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	/*
+	 * Passthrough behavior.
+	 *
+	 * Bridge scenario: originate -> middle extension (sets passthrough) -> bridges to 603+ target.
+	 * The originate leg receives the response FROM the middle box. We capture its
+	 * sip_invite_failure_phrase and sip_reason to verify what was actually sent.
+	 */
+
+	FST_TEST_BEGIN(passthrough_true)
+	{
+		/*
+		 * Extension +15553336060 sets sip_603plus_passthrough=true, bridges to 603+ target.
+		 * The middle box should forward both "Network Blocked" phrase and ATIS Reason header.
+		 * Our originate leg should see a valid 603+.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336060", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(!strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked"),
+			"passthrough=true must preserve 'Network Blocked' phrase");
+		fst_xcheck(!zstr_buf(capture.sip_603plus_reason),
+			"passthrough=true must result in valid 603+ on originate leg");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(passthrough_false)
+	{
+		/*
+		 * Extension +15553336061 sets sip_603plus_passthrough=false, bridges to 603+ target.
+		 * The middle box should strip the ATIS Reason and use default phrase "Decline".
+		 * Our originate leg should NOT see a 603+.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336061", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked") != 0,
+			"passthrough=false must NOT send 'Network Blocked' phrase");
+		fst_xcheck(zstr_buf(capture.sip_603plus_reason),
+			"passthrough=false must strip ATIS Reason (no 603+ on originate leg)");
+		fst_xcheck(zstr_buf(capture.sip_reason),
+			"passthrough=false must suppress Reason header entirely");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(passthrough_default)
+	{
+		/*
+		 * Extension +15553336062 does NOT set sip_603plus_passthrough, bridges to 603+ target.
+		 * Default: phrase is "Decline" (existing behavior), but ATIS Reason leaks through.
+		 * This is the backward-compatible state.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336062", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked") != 0,
+			"default passthrough must NOT change phrase (stays 'Decline')");
+		/* ATIS Reason leaks through via sip_reason -- this is existing behavior */
+		fst_xcheck(!zstr_buf(capture.sip_reason),
+			"default passthrough: sip_reason should still be set (existing behavior)");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	/*
+	 * disable_q850_reason + passthrough combinations.
+	 *
+	 * Tests that disable_q850_reason and sip_603plus_passthrough work independently.
+	 * disable_q850_reason suppresses standard Reason headers;
+	 * sip_603plus_passthrough controls 603+ ATIS Reason forwarding.
+	 */
+
+	FST_TEST_BEGIN(disable_reason_passthrough_true)
+	{
+		/*
+		 * Extension +15553336063: disable_q850_reason=true + sip_603plus_passthrough=true.
+		 * Standard Reason suppressed, but ATIS 603+ Reason restored.
+		 * The customer use case: suppress all Reason headers except FCC-required 603+.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336063", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(!strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked"),
+			"disable_q850+passthrough=true must preserve 'Network Blocked' phrase");
+		fst_xcheck(!zstr_buf(capture.sip_603plus_reason),
+			"disable_q850+passthrough=true must restore ATIS Reason");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(disable_reason_passthrough_false)
+	{
+		/*
+		 * Extension +15553336064: disable_q850_reason=true + sip_603plus_passthrough=false.
+		 * Both suppress -- no Reason header at all.
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336064", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked") != 0,
+			"disable_q850+passthrough=false must NOT send 'Network Blocked' phrase");
+		fst_xcheck(zstr_buf(capture.sip_reason),
+			"disable_q850+passthrough=false must suppress Reason header entirely");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+	FST_TEST_BEGIN(disable_reason_passthrough_default)
+	{
+		/*
+		 * Extension +15553336065: disable_q850_reason=true, passthrough not set.
+		 * disable_q850_reason suppresses everything, passthrough not set = no override.
+		 * No Reason header, phrase is "Decline".
+		 */
+		switch_call_cause_t cause;
+
+		switch_event_bind("test_603plus", SWITCH_EVENT_CHANNEL_HANGUP_COMPLETE,
+			SWITCH_EVENT_SUBCLASS_ANY, on_hangup_complete, NULL);
+
+		reset_capture();
+		originate_and_wait("sofia/gateway/test/+15553336065", &cause);
+
+		fst_xcheck(cause == SWITCH_CAUSE_CALL_REJECTED, "Expected CALL_REJECTED for 603");
+		fst_xcheck(capture.received == SWITCH_TRUE, "Should have received outbound hangup event");
+		fst_xcheck(strcasecmp(capture.sip_invite_failure_phrase, "Network Blocked") != 0,
+			"disable_q850+default must NOT send 'Network Blocked' phrase");
+		fst_xcheck(zstr_buf(capture.sip_reason),
+			"disable_q850+default must suppress Reason header");
+
+		switch_event_unbind_callback(on_hangup_complete);
+	}
+	FST_TEST_END()
+
+}
+FST_MODULE_END()
+}
+FST_CORE_END()