A 320-template hospital doesn't have a templates problem. It has a schema-drift problem.
Clínica Alemana de Santiago is one of Chile's largest private hospitals. Inside its EHR, doctors use more than 320 medical templates — forms for hospitalization, surgery, oncology follow-up, home care, every clinical workflow has its own. Each template has dozens of variables that change as protocols evolve.
The legacy system was a constellation of PL/SQL stored procedures
— one per template, each one a PRC_PLANT_* with thousands of hardcoded
IF-ELSIF branches mapping vertical key-value rows to relational columns.
When a doctor added a new variable in production, the procedure either failed silently
or dropped the data on the floor.
Every change required engineering intervention. The data team became a bottleneck for clinical operations.
The cost wasn't typing — it was understanding. A new template took ≈1 month per developer, not because they wrote code daily, but because they had to decode what each variable meant, how the template rendered, how the data travelled from Oracle to the report. Domain knowledge lived in the heads of senior staff, not in code.
The mock template (the form layout) is now the only artefact a developer needs. The framework infers the rest from metadata. Onboarding a template: ≈1 hour. Same engine, same SQL, same DDL — only the metadata changes. The understanding is encoded once, in the data, not re-decoded per template.
One engine, N templates. The schema is data, not code.
The bet was simple: treat the template structure as metadata in BigQuery,
not as columns in physical tables. A single ingestion engine reads the metadata,
normalizes names to snake_case, and emits idempotent SQL deltas — the
same engine handles 320 templates without a single line of template-specific code.
Variable values live as json_general objects per module/group, leveraging
BigQuery's native PARSE_JSON, JSON_KEYS and JSON_TYPE.
This means one physical table supports all templates without ever altering
the schema. Adding a new template is a data event, not a deploy.
From a doctor's click to a Looker dashboard — six hops, zero manual mappings.
Plantilla 329 has 67 fields spread across 5 clinical modules. The doctor fills
the form in the EHR, Oracle stores it vertically, the framework normalizes it
to JSON, BigQuery flattens it to a view, and Looker renders it. At no point
does a human write IF variable_id = 41892 THEN nombre_antibiotico_3.
The metadata drives everything.
template 329
vertical rows
via 01_variables
reconciles
+ json_general
w/ JSON_VALUE
dashboard
Template 329 · Home Hospitalization HOSDOM
Treatment / Follow-up · 67 fields · 5 clinical modules
Reconciliation isn't a SQL JOIN. It's a 3-level fallback that tolerates schema drift.
The engine — plantilla_ingestor_v5.py — compares the new Oracle JSON
against the current BigQuery state and decides, per field, whether it's new,
updated, deactivated, or unchanged. The hard part isn't the
comparison; it's that the catalog has been written by multiple versions of
the script over time. UUID vs MD5 IDs, stopword stripping that changed,
module names that drifted (datos_paciente vs datos_de_paciente).
A naive join produces 87 false positives where there are 35 real changes. The fix: a three-level resolver that tries id_registro first, falls back to natural key, and finally to drift-tolerant matching.
Resolver levels
- Level 1 — id_registro exact match When BQ was loaded with the same MD5 algorithm. Hash hits → record is identical.
-
Level 2 — natural key (module + field)
When BQ has UUIDs from
GENERATE_UUID(). Same module + same field name → same conceptual record. - Level 3 — drift-tolerant match When module names changed across script versions. Strip stopwords, compare. Catches the 52 false renames a naive matcher misses.
Immutability guard
-
aplicar_inmutabilidad()
Before reconciliation, force new field names to match
their historical names in BQ (matched by Oracle
variable_id). This protects 200+ existing dashboards from cosmetic name changes. -
Soft delete only
Fields are never removed from
catalogo_campos— they're flaggedactivo = FALSEwith a termination date. History stays queryable. -
Idempotent by design
Running the same JSON twice produces identical SQL.
id_registrois deterministic MD5 — no auto-increments, no UUIDs in the engine's output.
What a one-level join would say happened — mostly false noise from schema-version drift.
Real changes plus 52 silent renames preserved. Over half the apparent diff was version drift, not Oracle drift.
INSERTs, UPDATEs and soft-deletes — fully idempotent. Re-runnable until the end of time.
From Oracle key-value rows to idempotent BigQuery SQL — the actual transformation.
What follows is a real run on plantilla 329 (Hospital Home Care). On the left, the JSON exported from Oracle — vertical, flat, semantically opaque without context. On the right, what the engine produces — categorized, hashed, idempotent SQL ready to merge into BigQuery.
Nothing was hand-edited between input and output. The same engine handles all 320 templates with this exact contract.
Note on language:
Production code is in Spanish (hospital standard). Field names like
plan_tratamiento and nombre_antibiotico_3 translate
to treatment_plan and antibiotic_name_3. The framework
preserves whatever naming convention Oracle uses — no translation layer.
The view writes itself. JSON_KEYS reads the catalog, generates the DDL.
The final view for plantilla 329 has 67 flattened columns.
Each one is a JSON_VALUE extraction from json_general.
Without the framework, those 67 columns would have been 67 hardcoded
IF-ELSIF branches in PL/SQL. With the framework, they're
generated automatically by reading the JSON structure that's
already stored in BigQuery.
This is reverse engineering as a design pattern. The schema doesn't dictate the data — the data dictates the schema. When Oracle adds a field, it appears in the JSON. The DDL constructor reads the JSON keys, writes the new column, and rebuilds the view. No human touches the SQL.
The constructor runs once per template whenever the catalog changes.
It reads the actual JSON stored in BigQuery, discovers the
structure with JSON_KEYS, and writes the DDL. The analyst
copies the output, pastes it into BigQuery, and the view rebuilds.
This is the opposite of schema-first design. The data defines the schema. When Oracle evolves, the JSON evolves, the constructor re-reads it, and the DDL regenerates. Zero migrations, zero ALTER TABLE, zero manual column mapping.
The wizard turns 8 flags into 7 questions. Anyone runs it.
Clinical analysts run this — not engineers. The original argparse CLI required memorizing 8 flags and remembering output paths. The wizard asks one question at a time, validates each answer in place, and confirms the plan before executing. Output goes to the script's folder, period — no "where do you want this?" prompt that shifts cognitive load to the operator.
What a single weekly run actually moves through the catalog.
The naive matcher would have reported 87 new fields and 79 deletions for plantilla 329 — a panic-inducing diff. The 3-level resolver tells the truth: 35 genuinely new fields, 52 silent renames preserved, 34 actual soft-deletes. Most of the apparent churn was schema drift between script versions, not Oracle drift.
1,258 lines of generated SQL
Without the framework, this run would have been a one-month engineering ticket. With the framework, it's the output of one Python invocation. Re-runnable. Idempotent. Auditable.
Every run produces an Excel for human validation. Color-coded. Sortable. Auditable.
Before the SQL ever touches BigQuery, the analyst opens the auto-generated Excel and reviews what's about to change. Green is new, amber is updated, red is soft-delete. The collisions tab flags fields whose name appears in more than one module — the engine resolves them with deterministic prefixes, but the human gets the final say.
The cycle closes itself. Friday's CDC is the next Monday's worklist.
The Friday CDC is a Scheduled Query that compares the current catalog against the
previous week's snapshot and writes detected changes into registro_plantillas_cdc_log
with state PENDIENTE. The dashboard shows it. The next Monday, the analyst
downloads the state for those exact templates — no need to know which templates need
attention, the CDC tells them.
The orchestrator generates the closing SQL itself — every run produces
a cierre_cdc.sql that flips the processed records to
PROCESADO_INGESTA. Without that file, the next CDC re-detects the same
changes as pending. The cycle's self-closing is what makes the system operational
at scale.
What CDC tracks
- NUEVA_VARIABLENew field added in Oracle
- BAJA_VARIABLEField removed from Oracle (soft-delete in BQ)
- CAMBIO_MODULOField moved between sections
- CAMBIO_NOMBREField renamed in Oracle
- CAMBIO_COORDENADAS_ORDENLayout/ordering change
GCP data governance
-
Labels at DDL time
Every
CREATE TABLEships withdg-layer,dg-source,dg-domain,dg-has-piianddg-data-classification. No retroactive tagging. -
TRF / CNS layering
Transformation tables in
ds_trf_datalake_nucleo, analytics views inds_cns_datalake_nucleo. Strict separation prevents reporting from coupling to upstream changes. -
Partitioning & clustering
Snapshot partitioned by
fecha_snapshot, CDC log clustered by(plantilla_id, tipo_cambio, estado_revision)for cheap Looker queries.
The orchestrator is a wizard. The output is a folder. The audit is a JSONL.
The original argparse CLI required the operator to remember 8 flags.
That was wrong for the user — clinical analysts run this, not engineers.
run_plantilla.py now opens an interactive wizard:
step-by-step questions, smart defaults, validation in-place. No flags, no manual,
no decisions about output paths.
Every execution writes to a deterministic folder structure
({id}/ for individual runs, bloque_{date}/ for batches),
logs to a single ejecuciones.jsonl audit file with SHA-256 hashes of
every input, and ends by printing the next 4 manual steps in execution order.
Validate · Validate · Reconcile · Generate SQL · Generate DDL · Excel · Audit · Close CDC. Each one fail-fast, idempotent.
delta.sql · ddl_vista.sql · revision.xlsx · cierre_cdc.sql · run.log. All timestamped, all in one folder, all idempotent.
Every run appends a record with SHA-256, counts, timestamp and final state. No execution is ever lost.
The decisions I'd defend in any interview — and the ones I had to rethink.
Decisions I owned
-
Metadata-driven over schema-per-template.
Refused to add columns. JSON-native in BigQuery
means 320 templates share
onephysical table. Adding template 321 is zero infra work. -
Soft delete, never hard delete.
catalogo_campos_plantillais immutable. Fields removed in Oracle stay in the catalog withactivo = FALSE. Historical reports keep working forever. -
CDC drives the worklist, not the calendar.
The Monday queue isn't "all 320 templates" — it's
only the ones with
PENDIENTEchanges. Operator sees a clean worklist, not a haystack. -
Wizard over CLI flags.
Removed argparse for the operator-facing scripts.
Clinical analysts shouldn't memorize
--bq-campos --bq-valores. Step-by-step questions, smart defaults, exit on Ctrl+C. - Output folder = script folder. No "where do you want to save this?" prompt. The output is always next to the script. Predictable for the user, scriptable for downstream automation.
Decisions I had to rethink
-
Initial 1-level reconciliation.
First version compared by
id_registroonly. It failed on the first plantilla in production because BQ had been seeded withGENERATE_UUID(). Had to add 2 fallback levels, then a 3rd for stopword drift. -
A separate "deletions" table.
Considered backing up every soft-delete to a dedicated
audit table. Rejected — it duplicated data already living in three places
(catalog, snapshot, CDC log). Built a
v_bajasview instead. - Dry-run as a wizard option. Originally a question in the operator wizard. Wrong abstraction — operators want to run, not simulate. Removed entirely; dry-run is a developer-only flag now.
-
Manual ID list per week.
First version of the batch required typing the IDs.
Replaced with
--ids-desde-cdc: read pending from the CDC log directly. Removed an entire class of operator error.