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.
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:
| Term | Meaning |
|---|---|
| Input Layer Model | The connector-owned dbt model, such as medical_claim, that maps source data to Tuva's Input Layer contract. |
| Input Layer Wrapper | The package-owned Tuva Core model, such as input_layer__medical_claim, that references the Input Layer Model. |
| Intermediate and Normalized relations | Tuva-owned internal relations between an Input Layer Wrapper and a Core Model. They retain the extension prefix and are not the supported destination. |
| Core Model | The package-owned final dbt model, such as core__medical_claim, that builds a Core table. |
| Core table | The Warehouse Table or View built by a Core Model, such as core.medical_claim. |
| Extension column | A connector-defined column outside Tuva's standard Input Layer contract whose name begins with the configured extension prefix. |
| Extension prefix | The non-empty string configured by passthrough.prefix. It identifies extension columns. The default is x_. |
| Supported table pair | An Input Layer Model and same-named Core table for which Tuva supports extension columns. |
| Shared Core table | A 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 domain | Input Layer Model | Core table | Extension behavior | Example extension column |
|---|---|---|---|---|
| Claims | eligibility | eligibility | Present on the corresponding eligibility row. | x_benefit_package_code |
| Claims | medical_claim | medical_claim | Present on the corresponding medical claim line. | x_authorization_number |
| Claims | pharmacy_claim | pharmacy_claim | Present on the corresponding pharmacy claim line. | x_prescription_origin |
| Clinical | appointment | appointment | Present on the corresponding clinical row. | x_scheduling_channel |
| Clinical | immunization | immunization | Present on the corresponding clinical row. | x_registry_source |
| Clinical | lab_result | lab_result | Present on the corresponding clinical row. | x_reference_lab_name |
| Clinical | observation | observation | Present on the corresponding clinical row. | x_device_id |
| Clinical | condition | condition | Present on clinical condition rows; null on claims-derived condition rows. | x_documentation_status |
| Clinical | encounter | encounter | Present on clinical encounter rows; null on claims-derived encounter rows. | x_department_name |
| Clinical | location | location | Present on clinical location rows; null on claims-derived location rows. | x_source_facility_type |
| Clinical | medication | medication | Present on clinical medication rows; null on claims-derived medication rows. | x_order_set_name |
| Clinical | patient | patient | Present on clinical patient rows; null on claims-derived patient rows. | x_care_navigator |
| Clinical | practitioner | practitioner | Present on clinical practitioner rows; null on claims-derived practitioner rows. | x_provider_group_name |
| Clinical | procedure | procedure | Present 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, oreligibility. 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:
| Table | Why it is unsupported |
|---|---|
member_month | It 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. |
cost | It is a derived member-month aggregation and has no same-named Input Layer Model. |
utilization | It is a derived member-month aggregation and has no same-named Input Layer Model. |
person_id_crosswalk | It is a derived identity crosswalk and has no same-named Input Layer Model. |
provider_attribution | It 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
| Configuration | Required type | Default | Behavior |
|---|---|---|---|
passthrough | YAML mapping | {} | Contains only the optional prefix and strip keys. Unknown keys fail compilation. |
passthrough.prefix | Non-empty string | "x_" | Identifies extension columns using a case-insensitive prefix match. |
passthrough.strip | YAML boolean | false | Controls 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:
| Layer | Column name |
|---|---|
| Input Layer Model | x_authorization_number |
| Input Layer Wrapper | x_authorization_number |
| Intermediate and Normalized relations | x_authorization_number |
| Core table | x_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:
| Layer | Column name |
|---|---|
| Input Layer Model | x_authorization_number |
| Input Layer Wrapper | x_authorization_number |
| Intermediate and Normalized relations | x_authorization_number |
| Core table | authorization_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_claimline; - 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
patientrecord.
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:
- The Input Layer Model SQL selects the extension column with the expected name and value.
- The Warehouse Table or View built by the Input Layer Wrapper contains the prefixed extension column.
- The Core table contains the expected final name based on
passthrough.strip. - Known rows retain the expected value and data type.
- For a Shared Core table, clinical rows contain the value and claims-derived rows contain null.
- 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 origin | x_department_name |
|---|---|
Clinical encounter Input Layer Model | Value from the corresponding clinical encounter row |
Claims-derived encounter created from medical_claim | Null |
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
| Layer | Column name |
|---|---|
| Input Layer Model | custom_prescription_origin |
| Input Layer Wrapper | custom_prescription_origin |
| Intermediate and Normalized relations | custom_prescription_origin |
| Core table | prescription_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:
passthroughmust be a YAML mapping.passthroughaccepts only the optionalprefixandstripkeys; any other key is invalid.passthrough.prefixmust be a non-empty string.passthrough.stripmust be the YAML booleantrueorfalse.- 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:
- The Input Layer Model and Core table form one of the 14 supported table pairs.
- The column name begins with the configured prefix using a case-insensitive comparison.
- The relevant domain is enabled with
claims_enabledorclinical_enabled. - The built connector-owned Input Layer Model contains the extension column.
- The built Tuva Core Input Layer Wrapper contains the extension column.
- You rebuilt both relations with
tag:input_layerwhen every connector model is tagged, as in a Connector Template revision aligned with Tuva Core 1.0, or with+tag:input_layeror an explicit connector selector when they are not. - You performed a full refresh after adding the column.
- 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
- Choose one of the 14 supported table pairs.
- Confirm that the value belongs at the Input Layer Model's grain.
- Add and explicitly cast the extension column in the connector's Input Layer Model.
- Name it with the configured extension prefix.
- Configure only
passthrough.prefixandpassthrough.strip, using a YAML mapping, non-empty string, and boolean. Unknownpassthroughkeys fail compilation. - Build the connector Input Layer Models and Tuva Core Wrappers with
tag:input_layerwhen every connector model is tagged, as in a Connector Template revision aligned with Tuva Core 1.0, or use+tag:input_layeror explicit connector paths when they are not. - Build the affected Core Model with a full refresh.
- Validate the final name, value, type, and null behavior.
- For Shared Core tables, confirm that retained claims-derived rows have null extension values.
- Document and test the extension column in the connector repository.