Skip to content

Add Sensitive Tier destination table pipeline with configurable participant eligibility filtering and suppression - #30

Open
gloria-trivitt wants to merge 67 commits into
mainfrom
stg_add_filter_step
Open

Add Sensitive Tier destination table pipeline with configurable participant eligibility filtering and suppression#30
gloria-trivitt wants to merge 67 commits into
mainfrom
stg_add_filter_step

Conversation

@gloria-trivitt

@gloria-trivitt gloria-trivitt commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR builds the pipeline that produces the Sensitive Tier destination table(s) and their suppression summaries. Participant eligibility/suppression is applied per destination table via a classification_filter config flag. Tables that do not need consent-based filtering can omit it and skip that part of the pipeline entirely.

The workflow performs the following steps:

  1. Creates a participant status parquet of the CleanConnect table.
  2. Determines participant eligibility based on specific criteria (consent withdrawal, HIPAA revocation, data destruction requests). Determines survey data cutoffs and EHR data cutoff dates.
  3. Creates a BigQuery table to hold the participant eligibility information (the classification table). Only verified participants are included in this table.
  4. Creates a query that will perform the following actions, where applicable, to generate the Sensitive Tier table:
    • Base table row filtering, configured per destination table via a filter_profile. A table can use filter_profile: "no_filters" to skip this step. Connect_ID IS NOT NULL is always enforced automatically, regardless of the selected profile.
    • Column subsetting
    • If the destination table's config includes a classification_filter:
      • Filter for participants in groups 1, 2, 3A, and 3B. Group 4 cases are excluded.
      • Suppress data in groups 3A and 3B where the data submission date is at or after the determined cutoff
      • Suppress data for columns that cannot be determined to have a previously mapped secondary source in constants.py
    • If the destination table's config does not include a classification_filter, none of the three items above are applied; the table is built as a plain, filtered (per filter_profile) subset of its base table(s), and columns that would otherwise be suppressed pass through unchanged.
    • A configuration diagnostics report (JSON) is also written to GCS, capturing any configured columns not found in their source tables, duplicate column selections that were skipped, and columns whose concept ID did not match any known suppression rule (and were therefore set to NULL by default).
  5. If the destination table has a classification_filter configured, creates a suppression summary table to explain why a column was suppressed. Tables without a classification_filter skip this step entirely; there is no suppression to summarize. Note: the summary does not specify if a column was already NULL to begin with or did not exist in the source table.
    • A rollup report (JSON) of suppression counts by column and by case is also written to GCS.

…ransfer them to a new dataset table, and apply an initial hardcoded filter via a CTE.
- Generate SQL programmatically using CTE-based filtering
- Replace hardcoded filters with configurable filter_profiles
- Support dynamic base and join table selection via JSON config
- Enforce non-null Connect_ID sourced from base table
- Add schema validation and missing column reporting
- Export generated SQL and reports to GCS
- Improve logging and error handling across the pipeline
Prevents destroy=No/withdraw=Yes/Revoke=No rows from being misclassified as
DATA_QUALITY_EXCLUSION when they should be UNDEFINED. Updates synthetic
participant TEST_EXCLUSION_WITHDRAW_NO_TS to include revoke=Yes so the test
actually covers this clause.
Previously, render_subset_expression emitted a full CASE/WHEN block
for every censored output column, repeating the same status/timestamp/
cutoff condition once per column that shared a gate (e.g. the same
module2 condition was duplicated 3x, menstrual 6x, etc.). This made
the generated SQL large and repetitive, and meant BigQuery had to
re-evaluate identical boolean logic multiple times per row.

This commit moves that logic into the CTE as a set of precomputed
boolean flags (one per unique status_col), built by the new
build_unique_eligibility_rules() helper. Each censored column now
just references its flag via IF(flag, col, NULL) instead of
restating the full condition.

build_unique_eligibility_rules() dedupes MODULE_CENSOR_RULES by
status_col (since some CIDs, e.g. the two menstrual survey CIDs,
legitimately share one gate) and raises ValueError if two CIDs
share a status_col but disagree on completion_ts/cutoff, to catch
config mistakes that would otherwise silently miscompute
eligibility for consent-sensitive columns.

Verified equivalent output against the synthetic dataset via
bidirectional EXCEPT DISTINCT between the old and new generated
tables (zero rows either direction).
…nfer INT64, so all columns in the generated table have a consistent type
…ons, so generated SQL has deterministic column order across runs
…DEFINED), setting ehr_cutoff to withdraw_ts since there is no ambiguity window when the two timestamps are equal
…avior and exercise the cutoff boundary across four modules
- Rename _build_censorship_case_block -> _build_reason_cte_block; the block
  now keys on status_col instead of column_name, so CIDs that share a
  status_col (e.g. the two menstrual survey CIDs) collapse to a single
  reason block instead of generating identical CASE logic twice.
- Join deduped reasons back to every output column via a new
  column_status_map CTE, so each column (including duplicate CIDs) still
  gets its own row in the final result.
- Pass classification_table as a real parameter to _build_reason_cte_block
  instead of templating it via a placeholder + string.replace().
- Reword reason messages: quote status values, add "(empty string)"
  annotation, distinguish NULL vs "too late" as separate sub-reasons.
Extract render_eligibility_condition() as the single source of truth for
"is {status_col} eligible" used both to build the *_eligible flags in
build_subset_query's CTE and to gate the censored/not-censored branch in
_build_reason_cte_block. Previously these were two hand-written copies of
the same condition that could silently drift apart; now a change to one
always propagates to the other.
- build_censorship_summary_sql: own the full CREATE OR REPLACE TABLE
  (...) statement, including header comment, instead of returning a
  bare SELECT for transforms.py to wrap
- build_censorship_summary_sql: add output_table param, use it in
  "/* Censorship summary query for {output_table} -> {destination_table} */"
  header comment
- create_censorship_summary_table (transforms.py): drop the local
  CREATE TABLE wrapper and pass output_table/destination_table through
  to build_censorship_summary_sql directly, matching the pattern used
  by destination_table_builder.build_destination_table_query
…bles without a classification_filter, instead of generating invalid SQL
@gloria-trivitt

gloria-trivitt commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Current scope: required changes

These edits are applicable to the pr2-transformation repo and potentially the pr2-orchestrator repo.

  • Remove bio_cutoff because it is undetermined and currently unused in the code.
  • Add a comment in constants.py and classification.py explaining why BUM, BU, and Mouthwash are commented out.
    • Commit
  • Rename caseconsent_group throughout the repo.
  • Rename classificationeligibility throughout the repo.
    • Update module/file names, imports, functions, variables, configuration, tests, and documentation.
    • Rename the classification table to a more appropriate name. Consider publication_eligibility, delivery_eligibility, orpr2_eligibility.
  • Rename censorsuppress throughout the repo.
    • Update module/file names, imports, functions, variables, configuration, tests, and documentation.
  • In reference/destination_config.json, use classification_filter: {} to indicate that the filter is not in use.
  • Save a copy of destination_config and the synthetic-data scripts as test fixtures.
    • Ensure the test copies are stored in the appropriate test-fixture location.
    • Do not persist test copies in the production location.
  • Update documentation that provides a detailed explanation of the different case / consent_group values.
  • Create an issue documenting the current behavior when classification_filter is not used to create a destination table.
    • Document what happens when an unrecognized CID is identified.
    • Document what happens when an unrecognized non-CID (such as token) is identified.
    • Capture the current behavior rather than changing it as part of this work.

Edits specific to pr2-orchestrator only

  • Change the DAG name to pr2-mvp-pipeline.
  • Write out the full mapping explicitly in the constants.py.
    • Remove _build_destination_mappings, which creates the mapping.
    • Stop generating the mapping by referencing destination_config.json from the pr2-transformation repo.

Future notes and improvements

These items are noted for future consideration but should not be implemented as part of the current work.

  • Consider using Pydantic to validate JSON configuration.
  • Review pytest test-fixture organization and best practices.
  • Determine best practices for running test tables:
    • Should test tables be run all the way through the Orchestrator process?
  • Consider replacing integer-based consent-group identifiers with more reader-friendly names.
    • Proposed names:
      • Case 1 → eligible or no-restrictions
      • Case 2 → only-revoke-hipaa

…M, BU, and Mouthwash modules and timestamps are commented out
…cross classify_participants, the destination table query builder, the censorship summary SQL, Flask endpoints, and destination_config.json (allowed_cases -> allowed_consent_groups)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant