Skip to main content

Column Extensions

Column extensions let a connector add organization-specific columns to a supported Tuva Input Layer Model and retain those columns in the same-named Core table. Use a column extension when the value belongs at the same grain as that table but is not part of Tuva's standard contract. Examples include an authorization number on medical_claim, a scheduling channel on appointment, or a care navigator on patient.

Tuva Core version

This guide documents the extension-column contract in Tuva Core v1.0.0. Before using it, confirm that the connector's packages.yml resolves to Tuva Core 1.0 or a compatible later release. If you started from a Connector Template revision whose package range ends below 1.0, align that dependency before continuing; earlier Core versions use legacy extension behavior.

Column extensions do not propagate arbitrary columns through the Tuva DAG. They follow a deliberately narrow path:

connector Input Layer Model
-> Tuva Core Input Layer Wrapper
-> Tuva intermediate and Normalized relations
-> same-named Core table

This guide explains the complete contract and how to implement it in a connector dbt project.

Terminology

This guide uses the following terms consistently:

TermMeaning
Input Layer ModelThe connector-owned dbt model, such as medical_claim, that maps source data to Tuva's Input Layer contract.
Input Layer WrapperThe package-owned Tuva Core model, such as input_layer__medical_claim, that references the Input Layer Model.
Intermediate and Normalized relationsTuva-owned internal relations between an Input Layer Wrapper and a Core Model. They retain the extension prefix and are not the supported destination.
Core ModelThe package-owned final dbt model, such as core__medical_claim, that builds a Core table.
Core tableThe Warehouse Table or View built by a Core Model, such as core.medical_claim.
Extension columnA connector-defined column outside Tuva's standard Input Layer contract whose name begins with the configured extension prefix.
Extension prefixThe non-empty string configured by passthrough.prefix. It identifies extension columns. The default is x_.
Supported table pairAn Input Layer Model and same-named Core table for which Tuva supports extension columns.
Shared Core tableA Core table that contains clinical rows from the same-named Clinical Input Layer Model and claims-derived rows created from a different claims Input Layer Model.

The Input Layer reference defines Tuva's standard columns and the Core Data Model reference defines the standard Core tables.

Supported Table Pairs

Tuva supports extension columns only for the 14 table pairs below. The Input Layer Model and Core table must have the same table name.

Input domainInput Layer ModelCore tableExtension behaviorExample extension column
ClaimseligibilityeligibilityPresent on the corresponding eligibility row.x_benefit_package_code
Claimsmedical_claimmedical_claimPresent on the corresponding medical claim line.x_authorization_number
Claimspharmacy_claimpharmacy_claimPresent on the corresponding pharmacy claim line.x_prescription_origin
ClinicalappointmentappointmentPresent on the corresponding clinical row.x_scheduling_channel
ClinicalimmunizationimmunizationPresent on the corresponding clinical row.x_registry_source
Clinicallab_resultlab_resultPresent on the corresponding clinical row.x_reference_lab_name
ClinicalobservationobservationPresent on the corresponding clinical row.x_device_id
ClinicalconditionconditionPresent on clinical condition rows; null on claims-derived condition rows.x_documentation_status
ClinicalencounterencounterPresent on clinical encounter rows; null on claims-derived encounter rows.x_department_name
ClinicallocationlocationPresent on clinical location rows; null on claims-derived location rows.x_source_facility_type
ClinicalmedicationmedicationPresent on clinical medication rows; null on claims-derived medication rows.x_order_set_name
ClinicalpatientpatientPresent on clinical patient rows; null on claims-derived patient rows.x_care_navigator
ClinicalpractitionerpractitionerPresent on clinical practitioner rows; null on claims-derived practitioner rows.x_provider_group_name
ClinicalprocedureprocedurePresent on clinical procedure rows; null on claims-derived procedure rows.x_ordering_department

The last seven rows are Shared Core tables. When both claims and clinical data are enabled, Tuva combines two kinds of rows in each Shared Core table:

  • A clinical row comes from the same-named Clinical Input Layer Model and carries its extension-column values.
  • A claims-derived row is created from medical_claim, pharmacy_claim, or eligibility. It has null in extension columns belonging to the same-named Clinical Input Layer Model.

For example, x_department_name from the encounter Input Layer Model appears on clinical rows in the encounter Core table. Tuva does not copy x_department_name onto encounters derived from medical_claim. Likewise, x_care_navigator from the patient Input Layer Model appears on clinical patient rows; extension columns from eligibility do not flow into the patient Core table.

patient also retains its existing claims-precedence rule. When claims and clinical inputs contain the same person_id and data_source, the claims-derived Core patient row is retained and the overlapping clinical patient row is excluded. Patient extension columns on that excluded clinical row are not copied to the claims-derived row, so they are null in the retained Core patient row.

location and practitioner use the opposite precedence for an exact-key overlap. When a clinical and claims-derived row share (location_id, data_source) or (practitioner_id, data_source), Tuva retains the clinical row and excludes the overlapping claims-derived row. The retained clinical row carries its extension values. Claims-derived rows without an exact clinical match remain in Core and have null in the clinical extension columns; the same identifier in another data_source remains a separate row.

In a claims-only project, the Clinical domain is not enabled, so Tuva does not build a clinical path or add its extension columns to Shared Core tables. In a clinical-only or claims-and-clinical project, Tuva discovers extension columns from the enabled Clinical Input Layer Models.

Unsupported Tables

The following tables are intentionally outside the extension-column contract:

TableWhy it is unsupported
member_monthIt is derived from eligibility spans at a different grain. Multiple eligibility rows can contribute to one member month, so there is no generic rule for choosing an extension value.
costIt is a derived member-month aggregation and has no same-named Input Layer Model.
utilizationIt is a derived member-month aggregation and has no same-named Input Layer Model.
person_id_crosswalkIt is a derived identity crosswalk and has no same-named Input Layer Model.
provider_attributionIt is an Input Layer Model consumed by Claims Preprocessing, but there is no same-named Core table.

Adding an extension column to one of these inputs does not make it available in an unsupported output. If an analytical output needs such a value, create a downstream model with an explicit join or aggregation rule. For example, enriching member_month from eligibility requires a documented rule for overlapping eligibility records and conflicting values; a generic extension column cannot supply that rule.

The supported contract ends at the same-named Core table. Tuva does not promise to propagate extension columns into Claims Preprocessing outputs, standalone packages, or other downstream tables. A downstream model can select an extension column explicitly, but that dependency belongs to the project that owns the downstream model.

Configure Prefix and Strip

Configure column extensions once under vars: in the connector's dbt_project.yml:

vars:
passthrough:
prefix: "x_"
strip: false
ConfigurationRequired typeDefaultBehavior
passthroughYAML mapping{}Contains only the optional prefix and strip keys. Unknown keys fail compilation.
passthrough.prefixNon-empty string"x_"Identifies extension columns using a case-insensitive prefix match.
passthrough.stripYAML booleanfalseControls the extension-column name in the final Core table.

Use the YAML booleans true or false without quotation marks. For example, use strip: true, not strip: "true".

The configuration is global. Every supported Input Layer Model in the dbt project must use the same extension prefix.

Keep the Prefix

With the default strip: false, the extension column keeps its name in every layer:

LayerColumn name
Input Layer Modelx_authorization_number
Input Layer Wrapperx_authorization_number
Intermediate and Normalized relationsx_authorization_number
Core tablex_authorization_number

Keeping the prefix makes extension columns easy to identify and reduces the chance of colliding with a standard Tuva column.

Strip the Prefix in Core

With strip: true, Tuva preserves the prefix through the Input Layer Wrapper and every intermediate and Normalized relation, then removes it exactly once in the final Core table:

LayerColumn name
Input Layer Modelx_authorization_number
Input Layer Wrapperx_authorization_number
Intermediate and Normalized relationsx_authorization_number
Core tableauthorization_number

Do not remove the prefix in connector SQL when strip: true. Tuva needs the prefixed name to identify the extension column before it reaches Core.

Implement Extension Columns in a Connector

1. Confirm the Table and Grain

Choose one of the 14 supported table pairs. The value must describe the same row represented by the Input Layer Model.

Good examples include:

  • an authorization number that belongs to one medical_claim line;
  • a scheduling channel that belongs to one appointment;
  • a department name that belongs to one clinical encounter; and
  • a care navigator that belongs to one clinical patient record.

Do not place a value on a convenient input table merely because you want it in a different Core table. For example, adding x_care_navigator to eligibility does not send it to patient or member_month.

2. Add the Column to the Input Layer Model

Start with the connector's existing mapping path and add the extension where source casting occurs. In a Connector Template revision aligned with Tuva Core 1.0, that means adding it in staging and letting the thin final model carry it forward. Keep every standard column required by the Input Layer contract.

The following two files form a complete example compatible with a Connector Template revision aligned with Tuva Core 1.0. Add and cast the extension in staging, then keep the final model as the template's thin select * projection. This example assumes the raw medical_claim relation is already Tuva-shaped: it exposes every required standard medical_claim column under its Tuva name, plus authorization_number. If the raw relation uses different names or shapes, map every required standard column explicitly instead of treating this select * example as a generic raw-claims mapping.

-- models/staging/stg_medical_claim.sql

{{ config(
enabled = the_tuva_project.tuva_boolean_var('claims_enabled', false)
)
}}

select
medical_claim.*
, cast(medical_claim.authorization_number as {{ dbt.type_string() }}) as x_authorization_number
from {{ source('source_input', 'medical_claim') }} as medical_claim
-- models/final/medical_claim.sql

{{ config(
enabled = the_tuva_project.tuva_boolean_var('claims_enabled', false)
)
}}

select *
from {{ ref('stg_medical_claim') }}

Tuva does not standardize the meaning, values, data type, or nullability of an extension column. Cast it to the intended warehouse type in the connector so the Input Layer Model exposes a stable contract. Use the native boolean configuration shown above; the_tuva_project.tuva_boolean_var rejects string-valued switches instead of coercing them.

The next block is a select-list excerpt, not a complete patient model. Add these expressions to the connector's existing model while retaining every standard patient column:

select
-- Existing standard patient columns
person_id
, patient_id
, cast(care_navigator as {{ dbt.type_string() }}) as x_care_navigator
, cast(risk_score as {{ dbt.type_numeric() }}) as x_risk_score
, data_source
from {{ ref('stg_patient') }}

If one Input Layer Model unions multiple source queries, every branch must expose the extension column with a compatible type. Use a typed null when a source does not provide the value:

The following focused union excerpt omits other required patient columns. Retain the complete standard projection in both branches of the real connector model:

select
person_id
, patient_id
, cast(care_navigator as {{ dbt.type_string() }}) as x_care_navigator
, data_source
from {{ ref('stg_ehr_patient') }}

union all

select
person_id
, patient_id
, cast(null as {{ dbt.type_string() }}) as x_care_navigator
, data_source
from {{ ref('stg_adt_patient') }}

3. Configure the Project

The defaults require no configuration. A column named x_authorization_number is discovered automatically and remains x_authorization_number in Core.

Set the variables explicitly when you want the project contract visible in dbt_project.yml:

vars:
claims_enabled: true
clinical_enabled: false
passthrough:
prefix: "x_"
strip: false

Do not call Tuva's internal select_extension_columns macro from the connector. The connector's responsibility is to publish correctly named columns from its Input Layer Models; Tuva Core discovers and selects them.

4. Rebuild in Two Steps

Tuva discovers extension columns by inspecting the built Input Layer Wrapper. First build both the connector-owned Input Layer Models and the Tuva Core Input Layer Wrappers, then build the rest of the project:

dbt build --select tag:input_layer --full-refresh
dbt build --full-refresh

A Connector Template revision aligned with Tuva Core 1.0 applies +tags: [input_layer] at the connector project root, so the focused command selects its staging and final models as well as the Tuva Core Wrappers. Confirm that setting remains under the renamed connector project key in dbt_project.yml:

models:
your_connector_name:
+tags: [input_layer]

For an older template-derived or custom connector that does not tag all connector models, either use the ancestor-inclusive selector +tag:input_layer or name the connector paths explicitly. Both forms ensure that connector relations are refreshed before Tuva Core inspects the Wrappers:

dbt build --select +tag:input_layer --full-refresh
dbt build --full-refresh
dbt build --select path:models/staging path:models/final tag:input_layer --full-refresh
dbt build --full-refresh

For a focused development cycle, the second command can select the affected Core Model after its upstream relations already exist:

dbt build --select +core__medical_claim --full-refresh

Use a full refresh when adding, removing, or renaming an extension column. Incremental warehouse behavior can otherwise leave the prior physical schema in place.

5. Validate the Result

Validate all of the following:

  1. The Input Layer Model SQL selects the extension column with the expected name and value.
  2. The Warehouse Table or View built by the Input Layer Wrapper contains the prefixed extension column.
  3. The Core table contains the expected final name based on passthrough.strip.
  4. Known rows retain the expected value and data type.
  5. For a Shared Core table, clinical rows contain the value and claims-derived rows contain null.
  6. Downstream models reference the final Core name, not the prefixed intermediate name when strip: true.

A focused DuckDB query for the default configuration can look like this. Replace <core_schema> with the built Core schema; use your warehouse's row-limit syntax if it does not support limit:

select
claim_id,
claim_line_number,
data_source,
x_authorization_number
from <core_schema>.medical_claim
where x_authorization_number is not null
limit 100;

For a shared encounter table, encounter_source_type distinguishes clinical rows from claims-derived rows:

select
encounter_source_type,
count(*) as row_count,
count(x_department_name) as rows_with_department_name
from <core_schema>.encounter
group by encounter_source_type
order by encounter_source_type;

rows_with_department_name can be greater than zero for encounter_source_type = 'clinical'. It must be zero for encounter_source_type = 'claim'.

Worked Examples

Direct Claims Table With the Default Prefix

Connector select-list excerpt; retain every other standard eligibility column in the complete model:

select
-- Standard eligibility columns
person_id
, member_id
, enrollment_start_date
, enrollment_end_date
, benefit_package_code as x_benefit_package_code
, data_source
from {{ ref('stg_eligibility') }}

Configuration:

vars:
passthrough:
prefix: "x_"
strip: false

Result: core.eligibility.x_benefit_package_code contains the value from the corresponding eligibility row. It does not appear in core.patient or core.member_month.

Clinical-Only Table

Connector select-list excerpt; retain every other standard appointment column in the complete model:

select
-- Standard appointment columns
appointment_id
, person_id
, start_datetime
, scheduling_channel as x_scheduling_channel
, data_source
from {{ ref('stg_appointment') }}

Result: core.appointment.x_scheduling_channel contains the value from the corresponding clinical appointment row.

Shared Core Table

Connector select-list excerpt; retain every other standard encounter column in the complete model:

select
-- Standard encounter columns
encounter_id
, person_id
, encounter_start_date
, department_name as x_department_name
, data_source
from {{ ref('stg_encounter') }}

With both claims_enabled: true and clinical_enabled: true, the Core result follows this rule:

Core row originx_department_name
Clinical encounter Input Layer ModelValue from the corresponding clinical encounter row
Claims-derived encounter created from medical_claimNull

Tuva does not infer a department for the claims-derived encounter or join the clinical value onto it.

Custom Prefix

Use a custom prefix when x_ conflicts with an established connector naming convention:

vars:
passthrough:
prefix: "custom_"
strip: false

Connector select-list excerpt; retain every other standard pharmacy-claim column in the complete model:

select
-- Standard pharmacy_claim columns
claim_id
, claim_line_number
, prescription_origin as custom_prescription_origin
, data_source
from {{ ref('stg_pharmacy_claim') }}

Result: the extension column is named custom_prescription_origin in core.pharmacy_claim. A column named x_prescription_origin is not treated as an extension because the configured prefix is custom_.

Strip a Custom Prefix

vars:
passthrough:
prefix: "custom_"
strip: true
LayerColumn name
Input Layer Modelcustom_prescription_origin
Input Layer Wrappercustom_prescription_origin
Intermediate and Normalized relationscustom_prescription_origin
Core tableprescription_origin

Unsupported Derived Output

This focused connector excerpt does not create a member-month extension. Retain every other standard eligibility column in the complete model:

-- models/final/eligibility.sql
select
-- Standard eligibility columns
person_id
, member_id
, product_segment as x_product_segment
, data_source
from {{ ref('stg_eligibility') }}

The value is supported in core.eligibility. It is not added to core.member_month, because member month is a derived output at a different grain. To add product segment to a downstream member-month model, define how to resolve multiple or overlapping eligibility rows and implement that rule outside the generic extension-column feature.

Names, Collisions, and Compiler Errors

Tuva validates the extension-column configuration and output names during compilation. Invalid configuration fails with a compiler error instead of producing an ambiguous Core schema.

The rules are:

  • passthrough must be a YAML mapping.
  • passthrough accepts only the optional prefix and strip keys; any other key is invalid.
  • passthrough.prefix must be a non-empty string.
  • passthrough.strip must be the YAML boolean true or false.
  • Prefix matching is case-insensitive.
  • Stripping the prefix must leave a non-empty output name.
  • The final extension output name must not collide, case-insensitively, with a standard Core column.
  • Two extension columns must not produce the same final output name, compared case-insensitively.
  • A prefix that is broad enough to capture standard fields is invalid when those fields collide with fixed Core output columns.

For example, this configuration fails when x_person_id reaches core.patient because stripping would create a second person_id column:

vars:
passthrough:
prefix: "x_"
strip: true
select
person_id
, source_person_id as x_person_id -- Invalid final name: person_id
, data_source
from {{ ref('stg_patient') }}

These configurations are also invalid:

# passthrough must be a mapping
vars:
passthrough: true
# passthrough accepts only prefix and strip
vars:
passthrough:
prefix: "x_"
strip: false
mode: "strict"
# prefix must not be empty
vars:
passthrough:
prefix: ""
strip: false
# strip must be a boolean, not a string
vars:
passthrough:
prefix: "x_"
strip: "true"

Tuva adapter-quotes detected extension identifiers and final aliases. Even so, use portable snake_case names composed of clear words, and avoid reserved words, punctuation, whitespace, and names that differ only by letter case. Tuva Core supports multiple warehouses, and a name that works in one adapter may be inconvenient or invalid in another tool downstream.

Changes and Upgrades

Treat an extension column as part of your connector's public contract:

  • document its grain, meaning, type, and null behavior;
  • add a connector test for important values or accepted values;
  • notify downstream users before renaming or removing it;
  • use a full refresh after any schema change; and
  • test the connector against the Tuva Core version it pins before upgrading.

Changing passthrough.prefix changes which columns Tuva recognizes. Changing passthrough.strip changes the names exposed by every supported Core table. Either change can break downstream SQL, dashboards, semantic models, and standalone dbt packages that reference extension columns.

When upgrading from Tuva Core 0.18.0 to 1.0 with passthrough.strip: true, update downstream references for two additional output-name changes even if the project configuration does not change. Core appointment and Core patient preserved the prefix in 0.18.0; Core 1.0 strips it consistently at the final supported table. For example, core.appointment.x_scheduling_channel becomes core.appointment.scheduling_channel, and a retained clinical core.patient.x_care_navigator becomes core.patient.care_navigator. Fully refresh the Input Layer and Core tables before validating the new names. Eligibility extensions that previously reached Core patient are removed rather than renamed.

Troubleshooting

The Column Exists in the Connector but Not in Core

Check these conditions in order:

  1. The Input Layer Model and Core table form one of the 14 supported table pairs.
  2. The column name begins with the configured prefix using a case-insensitive comparison.
  3. The relevant domain is enabled with claims_enabled or clinical_enabled.
  4. The built connector-owned Input Layer Model contains the extension column.
  5. The built Tuva Core Input Layer Wrapper contains the extension column.
  6. You rebuilt both relations with tag:input_layer when every connector model is tagged, as in a Connector Template revision aligned with Tuva Core 1.0, or with +tag:input_layer or an explicit connector selector when they are not.
  7. You performed a full refresh after adding the column.
  8. You are checking the prefixed or stripped Core name that matches passthrough.strip.

The Column Is Null on Some Shared Core Rows

This is expected for claims-derived rows. An extension column from a Clinical Input Layer Model is populated only on rows sourced from that same model. Tuva does not copy it onto claims-derived rows.

The Column Appears in the Input Layer With an Unexpected Type

Cast the value explicitly in the connector's Input Layer Model. Tuva carries the extension column but does not define or normalize its data type.

Compilation Reports a Name Collision

Rename the extension column, choose a narrower prefix, or keep the prefix with strip: false. Do not suppress the error: it prevents an extension column from replacing or duplicating a standard Core column.

A Downstream Model Cannot Find the Column After Configuration Changed

Check whether passthrough.strip or passthrough.prefix changed. Rebuild the Input Layer and Core in two steps, then update downstream references to the final Core name.

Implementation Checklist

  1. Choose one of the 14 supported table pairs.
  2. Confirm that the value belongs at the Input Layer Model's grain.
  3. Add and explicitly cast the extension column in the connector's Input Layer Model.
  4. Name it with the configured extension prefix.
  5. Configure only passthrough.prefix and passthrough.strip, using a YAML mapping, non-empty string, and boolean. Unknown passthrough keys fail compilation.
  6. Build the connector Input Layer Models and Tuva Core Wrappers with tag:input_layer when every connector model is tagged, as in a Connector Template revision aligned with Tuva Core 1.0, or use +tag:input_layer or explicit connector paths when they are not.
  7. Build the affected Core Model with a full refresh.
  8. Validate the final name, value, type, and null behavior.
  9. For Shared Core tables, confirm that retained claims-derived rows have null extension values.
  10. Document and test the extension column in the connector repository.