From ad54d1ce66c290c91f2dda3b852292dcfee2521c Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 22 Jul 2026 11:19:47 -0500 Subject: [PATCH 1/9] Update documentation to reflect previousSSN field as last resort --- backend/compact-connect/docs/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/compact-connect/docs/README.md b/backend/compact-connect/docs/README.md index f68b6de66..976ef8130 100644 --- a/backend/compact-connect/docs/README.md +++ b/backend/compact-connect/docs/README.md @@ -62,8 +62,8 @@ leave the field entirely empty. If some of your licenses are missing a required | middleName | Provider's middle name (optional) | String (max 100 chars) | Robert | | npi | National Provider Identifier (optional) | 10-digit number | 1234567890 | | phoneNumber | Provider's phone number (optional) | [ITU-T E.164 format](https://www.itu.int/rec/T-REC-E.164-201011-I/en) (must include country code, no spaces or dashes) | +12025550123 | -| previousSSN | The incorrect Social Security Number previously uploaded for this license (optional). Provide this along with the corrected `ssn` to fix a license record that was uploaded with the wrong SSN: the system moves the license record and any privileges purchased against it over to the provider associated with the corrected SSN. If the corrected license was the only license under the incorrect SSN, that duplicate provider account is removed and the practitioner is emailed to register again. | Format: XXX-XX-XXXX | 123-45-6789 | | suffix | Provider's name suffix (optional) | String (max 100 chars) | Jr. | +| previousSSN | The incorrect Social Security Number previously uploaded for this license (optional). **This field is a last resort** for cases where a state has already uploaded incorrect SSN information that resulted in practitioners purchasing privileges or other actions being taken against the license (e.g., encumbrances, investigations, etc.) under the incorrect SSN, and should be avoided whenever possible — see [Can we upload the same licenses multiple times? What if their information changes?](#can-we-upload-the-same-licenses-multiple-times-what-if-their-information-changes) before using it. Provide this along with the corrected `ssn` to fix a license record that was uploaded with the wrong SSN: the system moves the license record and any privileges purchased against it over to the provider associated with the corrected SSN. If the corrected license was the only license under the incorrect SSN, that duplicate provider account is removed and the practitioner is emailed to register again. | Format: XXX-XX-XXXX | 123-45-6789 | ** This field is required by compact commission rule, however, to avoid making a breaking change for states that are already integrated, the API does not enforce this rule. States are responsible for enforcing the compact rule themselves. #### Example CSV ```csv @@ -110,7 +110,9 @@ If data is not available for a required field, that particular license record ca Yes. CompactConnect is designed to automatically detect and track changes to license records over time. The Social Security Number (SSN) is the unique identifier CompactConnect uses to create and match individual practitioner accounts. When you upload a license record, CompactConnect uses the provided SSN to determine whether that practitioner already has an account in the system, creating one if not found. If the practitioner's account already exists, CompactConnect will check the differences between the existing license record and the changes uploaded by the state, and apply the changes accordingly. -Because accounts are matched on SSN, simply changing the SSN in your state's system and then uploading the corrected license will **not** update the practitioner's existing CompactConnect account. It will create a brand new, separate account under the new SSN and leave the original account (and any privileges tied to it) unchanged. If a license was previously uploaded with an incorrect SSN, use the `previousSSN` field (see the field table above) when uploading the corrected SSN so CompactConnect can migrate the practitioner's existing account instead of creating a duplicate. +Because accounts are matched on SSN, simply changing the SSN in your state's system and then uploading the corrected license will **not** update the practitioner's existing CompactConnect account. It will create a brand new, separate account under the new SSN and leave the original account (and any privileges tied to it) unchanged. + +**If your state has uploaded a license with an incorrect SSN, contact CSG support before using the `previousSSN` field.** Depending on how far the error has propagated (e.g., whether the practitioner has registered, purchased privileges, or had other actions taken against the license under the incorrect SSN, such as encumbrances or investigations), there may be alternative options for CSG support to help revert or correct the erroneous upload without migrating any accounts. The `previousSSN` field (see the field table above) should be treated as a **last resort**, used only when a state has already uploaded incorrect SSN information that resulted in the practitioner purchasing privileges or other actions being taken against the license under the incorrect SSN, and no alternative remediation is possible. When used, it causes CompactConnect to migrate the practitioner's existing account instead of creating a duplicate. > **⚠️ Verify SSNs before you upload.** The SSN is the sole identifier CompactConnect uses to match a license to a practitioner's account, and every downstream consequence of an upload (account creation, privilege eligibility, public lookup, etc.) follows from it. Uploading an incorrect SSN is not a low-risk mistake to leave unaddressed, as it silently creates or attaches records to the wrong account, fragmenting the practitioner's licensure history and leaving privileges tied to whichever account was in place at the time they were purchased. > From 51b1400c7efefa120bf4b93aea3c55fba10e5fe4 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 22 Jul 2026 12:45:19 -0500 Subject: [PATCH 2/9] Add custom metrics/alarms for partial and full migrations --- .../provider-data-v1/handlers/ingest.py | 14 ++- .../function/test_handlers/test_ingest.py | 31 ++++++- .../compact-connect/stacks/ingest_stack.py | 60 +++++++++++++ .../tests/app/test_ingest_stack.py | 87 +++++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 backend/compact-connect/tests/app/test_ingest_stack.py diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py index f8f5454d7..cbfddd8c3 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py @@ -1,8 +1,9 @@ import json from copy import deepcopy +from aws_lambda_powertools.metrics import MetricUnit from boto3.dynamodb.types import TypeSerializer -from cc_common.config import config, logger +from cc_common.config import config, logger, metrics from cc_common.data_model.provider_record_util import ProviderRecordType, ProviderRecordUtility from cc_common.data_model.schema import LicenseRecordSchema from cc_common.data_model.schema.common import ActiveInactiveStatus, UpdateCategory @@ -17,6 +18,12 @@ license_schema = LicenseIngestSchema() license_update_schema = LicenseUpdateRecordSchema() +# Custom metrics tracking how often states rely on the previousSSN last-resort correction feature, split by +# whether the correction fully migrated the practitioner (old provider had no other licenses) or only partially +# migrated them (other licenses remained on the old provider id). Each is alarmed on separately in the CDK stack. +SSN_CORRECTION_FULL_MIGRATION_METRIC = 'ssn-correction-full-migration' +SSN_CORRECTION_PARTIAL_MIGRATION_METRIC = 'ssn-correction-partial-migration' + @sqs_handler def preprocess_license_ingest(message: dict): @@ -401,6 +408,11 @@ def _perform_ssn_correction_migration( logger.info('No records to migrate for previous provider id; proceeding with normal ingest') return + if result.full_migration: + metrics.add_metric(name=SSN_CORRECTION_FULL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + else: + metrics.add_metric(name=SSN_CORRECTION_PARTIAL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + if result.full_migration and result.old_provider_registered_email is not None: _delete_old_cognito_user_and_send_reregistration_email( compact=compact, diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index d49881b97..11bead404 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -2,6 +2,7 @@ from datetime import date, datetime from unittest.mock import MagicMock, patch +from aws_lambda_powertools.metrics import MetricUnit from cc_common.data_model.update_tier_enum import UpdateTierEnum from common_test.test_constants import DEFAULT_PROVIDER_ID from moto import mock_aws @@ -1111,7 +1112,32 @@ def test_partial_migration_keeps_cognito_user_and_sends_no_email(self): self.assertTrue(self._does_old_cognito_user_exist()) self._mock_send_reregistration_email.assert_not_called() - def test_no_op_migration_still_ingests_license_normally(self): + @patch('handlers.ingest.metrics') + def test_full_migration_emits_full_migration_metric(self, mock_metrics): + self._put_old_provider_records() + self._create_old_cognito_user() + + resp = self._run_ingest_with_previous_provider_id() + self.assertEqual({'batchItemFailures': []}, resp) + + mock_metrics.add_metric.assert_called_once_with( + name='ssn-correction-full-migration', unit=MetricUnit.Count, value=1 + ) + + @patch('handlers.ingest.metrics') + def test_partial_migration_emits_partial_migration_metric(self, mock_metrics): + self._put_old_provider_records(with_second_license=True) + self._create_old_cognito_user() + + resp = self._run_ingest_with_previous_provider_id() + self.assertEqual({'batchItemFailures': []}, resp) + + mock_metrics.add_metric.assert_called_once_with( + name='ssn-correction-partial-migration', unit=MetricUnit.Count, value=1 + ) + + @patch('handlers.ingest.metrics') + def test_no_op_migration_still_ingests_license_normally(self, mock_metrics): # the previousSSN resolved to a provider id with no records at all resp = self._run_ingest_with_previous_provider_id() self.assertEqual({'batchItemFailures': []}, resp) @@ -1126,6 +1152,9 @@ def test_no_op_migration_still_ingests_license_normally(self): self._mock_send_reregistration_email.assert_not_called() + # a no-op migration is not a real correction, so neither migration metric should fire + mock_metrics.add_metric.assert_not_called() + def test_full_migration_with_unregistered_old_provider_sends_no_email(self): # the old provider never registered: no Cognito user, no registered email on the provider record self.test_data_generator.put_default_provider_record_in_provider_table(is_registered=False) diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index 52a602778..d933131e3 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -61,6 +61,8 @@ def _add_v1_ingest_chain( }, alarm_topic=persistent_stack.alarm_topic, ) + # Stored for test accessibility + self.ingest_handler = ingest_handler persistent_stack.provider_table.grant_read_write_data(ingest_handler) data_event_bus.grant_put_events_to(ingest_handler) # The SSN-correction migration deletes the old provider's Cognito account on a full migration, moves @@ -125,6 +127,8 @@ def _add_v1_ingest_chain( treat_missing_data=TreatMissingData.NOT_BREACHING, ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + self._add_ssn_correction_migration_alarms(ingest_handler, persistent_stack) + processor = QueuedLambdaProcessor( self, 'V1Ingest', @@ -168,3 +172,59 @@ def _add_v1_ingest_chain( comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, treat_missing_data=TreatMissingData.NOT_BREACHING, ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + def _add_ssn_correction_migration_alarms( + self, ingest_handler: PythonFunction, persistent_stack: ps.PersistentStack + ): + """ + Alarm whenever a state relies on the previousSSN last-resort correction feature (see + handlers/ingest.py::_perform_ssn_correction_migration), split by whether the correction fully or only + partially migrated the affected practitioner. Each metric/alarm pair uses a 24-hour period with a + threshold of 1, so devops support sees at most one notification per category (2 total) per day this + feature is used, regardless of how many corrections occurred that day. + """ + full_migration_metric = Metric( + namespace='compact-connect', + metric_name='ssn-correction-full-migration', + dimensions_map={'service': 'common'}, + statistic=Stats.SUM, + period=Duration.days(1), + ) + Alarm( + ingest_handler, + 'SsnCorrectionFullMigrationAlarm', + metric=full_migration_metric, + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + 'A state has used the previousSSN field to fully migrate a practitioner record within the ' + 'last 24 hours. This is a last-resort correction feature (see the previousSSN field ' + 'documentation) and should be rare; investigate with the reporting state to confirm the ' + 'correction was warranted and to help prevent recurring upload errors.' + ), + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + partial_migration_metric = Metric( + namespace='compact-connect', + metric_name='ssn-correction-partial-migration', + dimensions_map={'service': 'common'}, + statistic=Stats.SUM, + period=Duration.days(1), + ) + Alarm( + ingest_handler, + 'SsnCorrectionPartialMigrationAlarm', + metric=partial_migration_metric, + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + 'A state has used the previousSSN field to partially migrate a practitioner record within ' + 'the last 24 hours. This is a last-resort correction feature (see the previousSSN field ' + 'documentation) and should be rare; investigate with the reporting state to confirm the ' + 'correction was warranted and to help prevent recurring upload errors.' + ), + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) diff --git a/backend/compact-connect/tests/app/test_ingest_stack.py b/backend/compact-connect/tests/app/test_ingest_stack.py new file mode 100644 index 000000000..297957876 --- /dev/null +++ b/backend/compact-connect/tests/app/test_ingest_stack.py @@ -0,0 +1,87 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_cloudwatch import CfnAlarm + +from tests.app.base import TstAppABC + + +class TestIngestStack(TstAppABC, TestCase): + """ + Test cases for the custom metrics and alarms that track how often states rely on the previousSSN + last-resort SSN correction feature (see handlers/ingest.py::_perform_ssn_correction_migration). + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + def _get_ssn_correction_alarm_properties(self, construct_id: str) -> dict: + ingest_stack = self.app.sandbox_backend_stage.ingest_stack + ingest_template = Template.from_stack(ingest_stack) + alarms = ingest_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + + alarm_logical_id = ingest_stack.get_logical_id( + ingest_stack.ingest_handler.node.find_child(construct_id).node.default_child + ) + return self.get_resource_properties_by_logical_id(alarm_logical_id, resources=alarms) + + def test_full_migration_metric_alarm_configured(self): + """The full-migration alarm should fire on any full migration within a rolling 24-hour period.""" + alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionFullMigrationAlarm') + + self.assertEqual(alarm['Namespace'], 'compact-connect') + self.assertEqual(alarm['MetricName'], 'ssn-correction-full-migration') + self.assertEqual(alarm['Dimensions'], [{'Name': 'service', 'Value': 'common'}]) + self.assertEqual(alarm['Statistic'], 'Sum') + # 24-hour period, so at most one alert per day for this category + self.assertEqual(alarm['Period'], 86400) + self.assertEqual(alarm['EvaluationPeriods'], 1) + self.assertEqual(alarm['Threshold'], 1) + self.assertEqual(alarm['ComparisonOperator'], 'GreaterThanOrEqualToThreshold') + self.assertEqual(alarm['TreatMissingData'], 'notBreaching') + + def test_partial_migration_metric_alarm_configured(self): + """The partial-migration alarm should fire on any partial migration within a rolling 24-hour period.""" + alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionPartialMigrationAlarm') + + self.assertEqual(alarm['Namespace'], 'compact-connect') + self.assertEqual(alarm['MetricName'], 'ssn-correction-partial-migration') + self.assertEqual(alarm['Dimensions'], [{'Name': 'service', 'Value': 'common'}]) + self.assertEqual(alarm['Statistic'], 'Sum') + # 24-hour period, so at most one alert per day for this category + self.assertEqual(alarm['Period'], 86400) + self.assertEqual(alarm['EvaluationPeriods'], 1) + self.assertEqual(alarm['Threshold'], 1) + self.assertEqual(alarm['ComparisonOperator'], 'GreaterThanOrEqualToThreshold') + self.assertEqual(alarm['TreatMissingData'], 'notBreaching') + + def test_migration_alarms_notify_the_shared_alarm_topic(self): + """Both alarms should notify devops support via the shared alarm topic, at most 2 alerts/day total.""" + ingest_stack = self.app.sandbox_backend_stage.ingest_stack + ingest_template = Template.from_stack(ingest_stack) + alarms = ingest_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + + # The pre-existing V1IngestFailureAlarm already notifies the shared alarm topic; the SSN-correction + # alarms should reference that exact same imported topic + existing_alarm_logical_id = ingest_stack.get_logical_id( + ingest_stack.node.find_child('V1IngestFailureAlarm').node.default_child + ) + expected_actions = self.get_resource_properties_by_logical_id(existing_alarm_logical_id, resources=alarms)[ + 'AlarmActions' + ] + self.assertTrue(expected_actions) + + full_migration_alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionFullMigrationAlarm') + partial_migration_alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionPartialMigrationAlarm') + + self.assertEqual(expected_actions, full_migration_alarm['AlarmActions']) + self.assertEqual(expected_actions, partial_migration_alarm['AlarmActions']) From 26ecb104f24367b763f59e8186ffdbf268abb12e Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 22 Jul 2026 12:58:16 -0500 Subject: [PATCH 3/9] Add custom metric/alarm for no-op migration events There may be cases where the state enters an incorrect 'previousSSN' that doesn't match with any license records. We want to have visibility into those cases as well. --- .../provider-data-v1/handlers/ingest.py | 7 ++-- .../function/test_handlers/test_ingest.py | 6 ++-- .../compact-connect/stacks/ingest_stack.py | 32 ++++++++++++++++--- .../tests/app/test_ingest_stack.py | 19 ++++++++++- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py index cbfddd8c3..ec19f9235 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py @@ -19,10 +19,12 @@ license_update_schema = LicenseUpdateRecordSchema() # Custom metrics tracking how often states rely on the previousSSN last-resort correction feature, split by -# whether the correction fully migrated the practitioner (old provider had no other licenses) or only partially -# migrated them (other licenses remained on the old provider id). Each is alarmed on separately in the CDK stack. +# whether the correction fully migrated the practitioner (old provider had no other licenses), only partially +# migrated them (other licenses remained on the old provider id), or found nothing to migrate (spurious +# previousSSN or an already-migrated replay). Each is alarmed on separately in the CDK stack. SSN_CORRECTION_FULL_MIGRATION_METRIC = 'ssn-correction-full-migration' SSN_CORRECTION_PARTIAL_MIGRATION_METRIC = 'ssn-correction-partial-migration' +SSN_CORRECTION_NO_MIGRATION_METRIC = 'ssn-correction-no-migration' @sqs_handler @@ -406,6 +408,7 @@ def _perform_ssn_correction_migration( ) if not result.migration_performed: logger.info('No records to migrate for previous provider id; proceeding with normal ingest') + metrics.add_metric(name=SSN_CORRECTION_NO_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) return if result.full_migration: diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index 11bead404..c2b8d2b01 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -1152,8 +1152,10 @@ def test_no_op_migration_still_ingests_license_normally(self, mock_metrics): self._mock_send_reregistration_email.assert_not_called() - # a no-op migration is not a real correction, so neither migration metric should fire - mock_metrics.add_metric.assert_not_called() + # a no-op previousSSN path still emits a metric so we can detect spurious or already-migrated corrections + mock_metrics.add_metric.assert_called_once_with( + name='ssn-correction-no-migration', unit=MetricUnit.Count, value=1 + ) def test_full_migration_with_unregistered_old_provider_sends_no_email(self): # the old provider never registered: no Cognito user, no registered email on the provider record diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index d933131e3..854e17abb 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -178,10 +178,11 @@ def _add_ssn_correction_migration_alarms( ): """ Alarm whenever a state relies on the previousSSN last-resort correction feature (see - handlers/ingest.py::_perform_ssn_correction_migration), split by whether the correction fully or only - partially migrated the affected practitioner. Each metric/alarm pair uses a 24-hour period with a - threshold of 1, so devops support sees at most one notification per category (2 total) per day this - feature is used, regardless of how many corrections occurred that day. + handlers/ingest.py::_perform_ssn_correction_migration), split by whether the correction fully + migrated, partially migrated, or found nothing to migrate for the affected practitioner. Each + metric/alarm pair uses a 24-hour period with a threshold of 1, so devops support sees at most one + notification per category (3 total) per day this feature is used, regardless of how many + corrections occurred that day. """ full_migration_metric = Metric( namespace='compact-connect', @@ -228,3 +229,26 @@ def _add_ssn_correction_migration_alarms( 'correction was warranted and to help prevent recurring upload errors.' ), ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + no_migration_metric = Metric( + namespace='compact-connect', + metric_name='ssn-correction-no-migration', + dimensions_map={'service': 'common'}, + statistic=Stats.SUM, + period=Duration.days(1), + ) + Alarm( + ingest_handler, + 'SsnCorrectionNoMigrationAlarm', + metric=no_migration_metric, + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + 'A state uploaded a previousSSN that resulted in no migration within the last 24 hours ' + '(e.g. the previous SSN had no matching license records, or the correction had already ' + 'been applied). Investigate with the reporting state to confirm the previousSSN value was ' + 'correct and to help prevent recurring upload errors.' + ), + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) diff --git a/backend/compact-connect/tests/app/test_ingest_stack.py b/backend/compact-connect/tests/app/test_ingest_stack.py index 297957876..6df78df09 100644 --- a/backend/compact-connect/tests/app/test_ingest_stack.py +++ b/backend/compact-connect/tests/app/test_ingest_stack.py @@ -64,8 +64,23 @@ def test_partial_migration_metric_alarm_configured(self): self.assertEqual(alarm['ComparisonOperator'], 'GreaterThanOrEqualToThreshold') self.assertEqual(alarm['TreatMissingData'], 'notBreaching') + def test_no_migration_metric_alarm_configured(self): + """The no-migration alarm should fire when previousSSN yields no records to migrate within 24 hours.""" + alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionNoMigrationAlarm') + + self.assertEqual(alarm['Namespace'], 'compact-connect') + self.assertEqual(alarm['MetricName'], 'ssn-correction-no-migration') + self.assertEqual(alarm['Dimensions'], [{'Name': 'service', 'Value': 'common'}]) + self.assertEqual(alarm['Statistic'], 'Sum') + # 24-hour period, so at most one alert per day for this category + self.assertEqual(alarm['Period'], 86400) + self.assertEqual(alarm['EvaluationPeriods'], 1) + self.assertEqual(alarm['Threshold'], 1) + self.assertEqual(alarm['ComparisonOperator'], 'GreaterThanOrEqualToThreshold') + self.assertEqual(alarm['TreatMissingData'], 'notBreaching') + def test_migration_alarms_notify_the_shared_alarm_topic(self): - """Both alarms should notify devops support via the shared alarm topic, at most 2 alerts/day total.""" + """All three alarms should notify devops support via the shared alarm topic, at most 3 alerts/day total.""" ingest_stack = self.app.sandbox_backend_stage.ingest_stack ingest_template = Template.from_stack(ingest_stack) alarms = ingest_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) @@ -82,6 +97,8 @@ def test_migration_alarms_notify_the_shared_alarm_topic(self): full_migration_alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionFullMigrationAlarm') partial_migration_alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionPartialMigrationAlarm') + no_migration_alarm = self._get_ssn_correction_alarm_properties('SsnCorrectionNoMigrationAlarm') self.assertEqual(expected_actions, full_migration_alarm['AlarmActions']) self.assertEqual(expected_actions, partial_migration_alarm['AlarmActions']) + self.assertEqual(expected_actions, no_migration_alarm['AlarmActions']) From 0643c0e5fa8384811d512e0c07a642274a097807 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 12:59:41 -0500 Subject: [PATCH 4/9] Ignore copy events in the provider user bucket processor --- .../handlers/provider_s3_events.py | 14 ++++ .../test_handlers/test_provider_s3_events.py | 69 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py index c82aee24f..1b0a86dca 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py @@ -17,6 +17,20 @@ def process_provider_s3_events(event: dict, context: LambdaContext): # noqa: AR size = record['s3']['object']['size'] logger.info('Object', s3_url=f's3://{bucket_name}/{key}', size=size) + # "ObjectCreated:Copy" events fire when the SSN-correction migration copies a practitioner's + # documents from the old provider id's keyspace to the new one (see + # DataClient.migrate_provider_for_ssn_correction). The DynamoDB records have already been fully + # migrated by that point, so we treat this as a no-op rather than re-processing it as a fresh + # upload. + event_name = record.get('eventName', '') + if event_name.startswith('ObjectCreated:Copy'): + logger.info( + 'Ignoring S3 object copy event; records already migrated', + s3_url=f's3://{bucket_name}/{key}', + event_name=event_name, + ) + continue + # Provider objects are stored under the following keyspace prefix: # compact/{compact}/provider/{provider_id}/document-type/military-affiliations/... # we split the key to get the various parts needed to query for the record diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_s3_events.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_s3_events.py index c188c4b9f..740c96197 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_s3_events.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_s3_events.py @@ -1,5 +1,6 @@ import json from datetime import UTC, datetime, timedelta +from unittest.mock import patch from boto3.dynamodb.conditions import Key from moto import mock_aws @@ -11,6 +12,61 @@ MOCK_SSN = '123-12-1234' MOCK_MILITARY_AFFILIATION_FILE_NAME = 'military_affiliation.pdf' +# A real-world S3 "ObjectCreated:Copy" event, as emitted when the SSN-correction migration copies a +# practitioner's documents from the old provider id's keyspace to the new one (see +# DataClient.migrate_provider_for_ssn_correction). The DynamoDB records are already fully migrated by that +# point, so this event must be treated as a no-op rather than re-processed as a fresh upload. +MOCK_MILITARY_AFFILIATION_S3_COPY_EVENT = { + 'Records': [ + { + 'eventVersion': '2.5', + 'eventSource': 'aws:s3', + 'awsRegion': 'us-east-1', + 'eventTime': '2026-07-23T15:47:16.822Z', + 'eventName': 'ObjectCreated:Copy', + 'userIdentity': { + 'principalId': 'AWS:AROA3FLD54XO75CUFS6OJ:Test-IngestStack-V1IngestHandlerDDBAB1AF-b1PElCTmSnHX' + }, + 'requestParameters': {'sourceIPAddress': '44.202.75.55'}, + 'responseElements': { + 'x-amz-request-id': 'J2Y0GRMF3W65RCDA', + 'x-amz-id-2': ( + 'wf+oj1VbuoeY1Tf4CgIWTX6B8kuXxkPZh69x5GrMgKZmUwBcN5BxI+TFLX033phnfgfSa7lYWi3+zgOQTOPCPogOprAR4ABC' + ), + }, + 's3': { + 's3SchemaVersion': '1.0', + 'configurationId': 'NDJlM2YwOTUtMzc4ZC00NGUzLWI5ZDQtODY3MzM0ODZjNGE5', + 'bucket': { + 'name': 'test-persistentstack-providerusersbucket5c7b202b-sgh3k0h87td2', + 'ownerIdentity': {'principalId': 'A1D527R6C66693'}, + 'arn': 'arn:aws:s3:::test-persistentstack-providerusersbucket5c7b202b-sgh3k0h87td2', + 'awsGeneratedTags': { + 'aws:cloudformation:stack-id': ( + 'arn:aws:cloudformation:us-east-1:767398110685:stack/Test-PersistentStack/' + 'a6b4e9c0-45eb-11ef-84ee-0affdd7bca67' + ), + 'aws:cloudformation:stack-name': 'Test-PersistentStack', + 'aws:cloudformation:logical-id': 'ProviderUsersBucket5C7B202B', + }, + }, + 'object': { + 'key': ( + 'compact/coun/provider/64ddb6f5-8710-49b5-bc41-43d37e392a85/document-type/' + 'military-affiliations/2026-07-15T04%3A19%3A32%2B00%3A00/' + 'b05a9032-3eda-4006-a5a6-9de4ace8f1c7%23military_affiliation.pdf' + ), + 'size': 24457, + 'eTag': '42fbe81fd83d6fa627caade914bf4aef', + 'versionId': 'gZrjnhjYN7C6BC5_.qxpsNQX_ohvHT_N', + 'sequencer': '006A623784C006934E', + 'hasObjectAnnotation': False, + }, + }, + } + ] +} + @mock_aws class TestProviderUserBucketS3Events(TstFunction): @@ -68,6 +124,19 @@ def _get_military_affiliation_records(self): ) )['Items'] + def test_provider_user_bucket_event_handler_ignores_object_copy_events(self): + """S3 "ObjectCreated:Copy" events fire when the SSN-correction migration copies a practitioner's + documents to a new provider id's keyspace. By that point, the DynamoDB records have already been + fully migrated, so this event must be a no-op rather than being processed as a fresh upload (which + would fail since there is no initializing military affiliation record to complete).""" + from handlers.provider_s3_events import process_provider_s3_events + + with patch.object(self.config.data_client, 'complete_military_affiliation_initialization') as mock_complete: + # should not raise, even though no military affiliation record exists for this provider/key + process_provider_s3_events(MOCK_MILITARY_AFFILIATION_S3_COPY_EVENT, self.mock_context) + + mock_complete.assert_not_called() + def test_provider_user_bucket_event_handler_sets_military_affiliation_status_to_active(self): from handlers.provider_s3_events import process_provider_s3_events From 0c11e10e5849fd6944fa6022658a39f0077d9699 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 13:26:40 -0500 Subject: [PATCH 5/9] Add log to track when objects were migrated --- .../lambdas/python/common/cc_common/data_model/data_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index 6a275cfbc..303902bf6 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -3192,6 +3192,7 @@ def _move_s3_object(self, *, old_key: str, new_key: str): Key=new_key, ) self.config.s3_client.delete_object(Bucket=self.config.provider_user_bucket_name, Key=old_key) + logger.info('Moved provider document to new keyspace', old_key=old_key, new_key=new_key) except ClientError as e: logger.error( 'Failed to move provider document to the new keyspace', From 60918dcee66e0788806c68c4fbbfdd68636b5e9a Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 14:07:33 -0500 Subject: [PATCH 6/9] Update node dependency to address vulnerability --- backend/compact-connect/lambdas/nodejs/package.json | 2 +- backend/compact-connect/lambdas/nodejs/yarn.lock | 8 ++++---- backend/cosmetology-app/lambdas/nodejs/package.json | 2 +- backend/cosmetology-app/lambdas/nodejs/yarn.lock | 8 ++++---- backend/social-work-app/lambdas/nodejs/package.json | 2 +- backend/social-work-app/lambdas/nodejs/yarn.lock | 8 ++++---- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/compact-connect/lambdas/nodejs/package.json b/backend/compact-connect/lambdas/nodejs/package.json index 932562b42..1dbbcaeb4 100644 --- a/backend/compact-connect/lambdas/nodejs/package.json +++ b/backend/compact-connect/lambdas/nodejs/package.json @@ -5,7 +5,7 @@ "description": "NodeJS lambdas for CompactConnect", "resolutions": { "fast-xml-parser": "5.7.3", - "postcss": "8.5.10" + "postcss": "8.5.12" }, "scripts": { "build": "tsc", diff --git a/backend/compact-connect/lambdas/nodejs/yarn.lock b/backend/compact-connect/lambdas/nodejs/yarn.lock index 27f1df236..07e6fd8dd 100644 --- a/backend/compact-connect/lambdas/nodejs/yarn.lock +++ b/backend/compact-connect/lambdas/nodejs/yarn.lock @@ -4870,10 +4870,10 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -postcss@8.5.10, postcss@^8.3.11: - version "8.5.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" - integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== +postcss@8.5.12, postcss@^8.3.11: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" diff --git a/backend/cosmetology-app/lambdas/nodejs/package.json b/backend/cosmetology-app/lambdas/nodejs/package.json index 932562b42..1dbbcaeb4 100644 --- a/backend/cosmetology-app/lambdas/nodejs/package.json +++ b/backend/cosmetology-app/lambdas/nodejs/package.json @@ -5,7 +5,7 @@ "description": "NodeJS lambdas for CompactConnect", "resolutions": { "fast-xml-parser": "5.7.3", - "postcss": "8.5.10" + "postcss": "8.5.12" }, "scripts": { "build": "tsc", diff --git a/backend/cosmetology-app/lambdas/nodejs/yarn.lock b/backend/cosmetology-app/lambdas/nodejs/yarn.lock index 27f1df236..07e6fd8dd 100644 --- a/backend/cosmetology-app/lambdas/nodejs/yarn.lock +++ b/backend/cosmetology-app/lambdas/nodejs/yarn.lock @@ -4870,10 +4870,10 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -postcss@8.5.10, postcss@^8.3.11: - version "8.5.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" - integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== +postcss@8.5.12, postcss@^8.3.11: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" diff --git a/backend/social-work-app/lambdas/nodejs/package.json b/backend/social-work-app/lambdas/nodejs/package.json index 932562b42..1dbbcaeb4 100644 --- a/backend/social-work-app/lambdas/nodejs/package.json +++ b/backend/social-work-app/lambdas/nodejs/package.json @@ -5,7 +5,7 @@ "description": "NodeJS lambdas for CompactConnect", "resolutions": { "fast-xml-parser": "5.7.3", - "postcss": "8.5.10" + "postcss": "8.5.12" }, "scripts": { "build": "tsc", diff --git a/backend/social-work-app/lambdas/nodejs/yarn.lock b/backend/social-work-app/lambdas/nodejs/yarn.lock index 27f1df236..07e6fd8dd 100644 --- a/backend/social-work-app/lambdas/nodejs/yarn.lock +++ b/backend/social-work-app/lambdas/nodejs/yarn.lock @@ -4870,10 +4870,10 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -postcss@8.5.10, postcss@^8.3.11: - version "8.5.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" - integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== +postcss@8.5.12, postcss@^8.3.11: + version "8.5.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" + integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" From 79f56795cb622e1831d50e9019adb6236b80eb11 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 14:34:42 -0500 Subject: [PATCH 7/9] Add error handling around email notification --- .../provider-data-v1/handlers/ingest.py | 56 ++++++++++--------- .../function/test_handlers/test_ingest.py | 21 +++++++ 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py index ec19f9235..542f09394 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py @@ -396,31 +396,32 @@ def _perform_ssn_correction_migration( old Cognito user deletion and re-registration email follow here. A concurrency conflict inside the migration raises, letting SQS redeliver the message after the visibility timeout. """ - logger.info('Performing SSN correction migration', previous_provider_id=previous_provider_id) - - result = config.data_client.migrate_provider_for_ssn_correction( - compact=compact, - previous_provider_id=previous_provider_id, - new_provider_id=new_provider_id, - jurisdiction=jurisdiction, - license_type=license_type, - new_ssn_last_four=new_ssn_last_four, - ) - if not result.migration_performed: - logger.info('No records to migrate for previous provider id; proceeding with normal ingest') - metrics.add_metric(name=SSN_CORRECTION_NO_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) - return - - if result.full_migration: - metrics.add_metric(name=SSN_CORRECTION_FULL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) - else: - metrics.add_metric(name=SSN_CORRECTION_PARTIAL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + with logger.append_context_keys(previous_provider_id=previous_provider_id): + logger.info('Performing SSN correction migration') - if result.full_migration and result.old_provider_registered_email is not None: - _delete_old_cognito_user_and_send_reregistration_email( + result = config.data_client.migrate_provider_for_ssn_correction( compact=compact, - old_registered_email=result.old_provider_registered_email, + previous_provider_id=previous_provider_id, + new_provider_id=new_provider_id, + jurisdiction=jurisdiction, + license_type=license_type, + new_ssn_last_four=new_ssn_last_four, ) + if not result.migration_performed: + logger.info('No records to migrate for previous provider id; proceeding with normal ingest') + metrics.add_metric(name=SSN_CORRECTION_NO_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + return + + if result.full_migration: + metrics.add_metric(name=SSN_CORRECTION_FULL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + else: + metrics.add_metric(name=SSN_CORRECTION_PARTIAL_MIGRATION_METRIC, unit=MetricUnit.Count, value=1) + + if result.full_migration and result.old_provider_registered_email is not None: + _delete_old_cognito_user_and_send_reregistration_email( + compact=compact, + old_registered_email=result.old_provider_registered_email, + ) def _delete_old_cognito_user_and_send_reregistration_email(*, compact: str, old_registered_email: str): @@ -439,7 +440,10 @@ def _delete_old_cognito_user_and_send_reregistration_email(*, compact: str, old_ return logger.info('Deleted old Cognito user after SSN correction; sending re-registration email') - config.email_service_client.send_provider_ssn_correction_reregistration_email( - compact=compact, - provider_email=old_registered_email, - ) + try: + config.email_service_client.send_provider_ssn_correction_reregistration_email( + compact=compact, + provider_email=old_registered_email, + ) + except Exception as e: # noqa: BLE001 - best-effort notification. Should not cause batch to fail. + logger.error('Failed to send re-registration email after SSN correction migration', error=str(e)) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index c2b8d2b01..bec638cf3 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -1157,6 +1157,27 @@ def test_no_op_migration_still_ingests_license_normally(self, mock_metrics): name='ssn-correction-no-migration', unit=MetricUnit.Count, value=1 ) + def test_full_migration_still_succeeds_when_reregistration_email_fails(self): + """A re-registration email failure must not fail the ingest record: the DynamoDB/S3 migration and the + old Cognito user deletion have already completed by the time the email is sent, so retrying this record + would only redeliver a message that no longer has anything left to migrate.""" + from cc_common.exceptions import CCInternalException + + self._mock_send_reregistration_email.side_effect = CCInternalException('email service unavailable') + self._put_old_provider_records() + self._create_old_cognito_user() + + resp = self._run_ingest_with_previous_provider_id() + self.assertEqual({'batchItemFailures': []}, resp) + + self._mock_send_reregistration_email.assert_called_once_with( + compact='aslp', + provider_email=self.OLD_REGISTERED_EMAIL, + ) + # the migration itself still completed, despite the email failure + self.assertFalse(self._does_old_cognito_user_exist()) + self.assertEqual([], self._get_provider_records(self.OLD_PROVIDER_ID)) + def test_full_migration_with_unregistered_old_provider_sends_no_email(self): # the old provider never registered: no Cognito user, no registered email on the provider record self.test_data_generator.put_default_provider_record_in_provider_table(is_registered=False) From 05dd23f1d40bc0088bfc23af5243798c219b5fec Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 14:45:13 -0500 Subject: [PATCH 8/9] Remove S3 url from copy info logs --- .../common/cc_common/data_model/data_client.py | 16 ++++++++++++++-- .../handlers/provider_s3_events.py | 1 - 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index 303902bf6..c5e2dc581 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -3191,9 +3191,9 @@ def _move_s3_object(self, *, old_key: str, new_key: str): CopySource={'Bucket': self.config.provider_user_bucket_name, 'Key': old_key}, Key=new_key, ) - self.config.s3_client.delete_object(Bucket=self.config.provider_user_bucket_name, Key=old_key) - logger.info('Moved provider document to new keyspace', old_key=old_key, new_key=new_key) except ClientError as e: + # we intentionally log the keys here so technical support staff + # can recover the document if the move fails. logger.error( 'Failed to move provider document to the new keyspace', old_key=old_key, @@ -3201,6 +3201,18 @@ def _move_s3_object(self, *, old_key: str, new_key: str): error=str(e), ) + try: + self.config.s3_client.delete_object(Bucket=self.config.provider_user_bucket_name, Key=old_key) + logger.info('Moved provider document to new keyspace') + except ClientError as e: + # we intentionally log the key here so technical support staff + # can complete cleanup of the document if the delete fails. + logger.error( + 'Failed to delete provider document from the old keyspace', + old_key=old_key, + error=str(e), + ) + def _build_put_transaction_item(self, record: CCDataClass, condition: dict | None = None) -> dict: return { 'Put': { diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py index 1b0a86dca..37a845d43 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_s3_events.py @@ -26,7 +26,6 @@ def process_provider_s3_events(event: dict, context: LambdaContext): # noqa: AR if event_name.startswith('ObjectCreated:Copy'): logger.info( 'Ignoring S3 object copy event; records already migrated', - s3_url=f's3://{bucket_name}/{key}', event_name=event_name, ) continue From 9cef2f009f8277afbdcb5a629b92b6b03be25244 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 23 Jul 2026 14:47:14 -0500 Subject: [PATCH 9/9] formatting --- .../lambdas/python/common/cc_common/data_model/data_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index c5e2dc581..e8773f441 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -3192,7 +3192,7 @@ def _move_s3_object(self, *, old_key: str, new_key: str): Key=new_key, ) except ClientError as e: - # we intentionally log the keys here so technical support staff + # we intentionally log the keys here so technical support staff # can recover the document if the move fails. logger.error( 'Failed to move provider document to the new keyspace', @@ -3205,7 +3205,7 @@ def _move_s3_object(self, *, old_key: str, new_key: str): self.config.s3_client.delete_object(Bucket=self.config.provider_user_bucket_name, Key=old_key) logger.info('Moved provider document to new keyspace') except ClientError as e: - # we intentionally log the key here so technical support staff + # we intentionally log the key here so technical support staff # can complete cleanup of the document if the delete fails. logger.error( 'Failed to delete provider document from the old keyspace',