+51,746 lines looks daunting. It isn't: 81% is lockfiles, and most of the rest is documentation, generated config, and nine copies of the same CI workflow. Toggle the view — the “real change” scale is what a reviewer actually faces.
Nine customers whose EHR/PM exports drop delimited files into the shared SFTP landing bucket — the non-EDI remainder after HL7/X12 feeds were carved off. Before this branch, none of them were ingested; the landing bucket (external, expiring) was the only copy.
The feeds share almost nothing byte-for-byte. Three delimiters (including the 3-byte ^|^ token), four encodings, three quoting regimes, NUL-byte sentinels, ragged rows, and five different drop-folder layouts. Worse, the inventory that described them — hand-transcribed once — was wrong four times: every ^|^ feed was labeled “pipe”, and jewishboard’s UTF-16 was labeled “mixed pipe/tab” (its tab bytes 0x09 0x00 read as ASCII). Three cp1252 feeds were declared utf-8 and would have mojibake’d silently.
That failure mode — a human transcribes format facts, the facts are wrong, bronze corrupts silently — is what the library is designed against. Every byte-level fact below is now either sniffed from the bytes, pinned explicitly, or asserted as a drift guard (§05).
| customer | source | delimiter | quoting | true encoding | ragged rows | layout wrinkle |
|---|---|---|---|---|---|---|
heatoneye | nextgen | | | per-table " | cp1252 — corrected | reconstruct + quarantine | Flow/ vs Archive/ subdirs; 2 ragged feeds |
mneye | nextgen | ^|^ | none | cp1252 — corrected | — | clean daily feed, 78 landed_at= partitions |
ushp | nextgen | | | " RFC4180 | utf-8 (genuine) | pad (1 row) | 3 practice subdirs unioned by */ mask |
middletownmedical | ecw | ^|^ | none | utf-8 (ASCII) | pad | manually-named drop folders; 3.3 GB backfill |
sunlife | ecw | ^|^ | none | utf-8 (ASCII) | — | first customer migrated to the rebuilt core |
ccp | experity | | | none | utf-8 (ASCII) | — | daily drop is a rolling open-balance window |
paa | phygen | , | " + embedded \n | utf-8 + cp1252 × 2 — corrected | — | per-file mixed encodings; glob traps |
jewishboard | netsmart | TAB | none | utf-16 LE (BOM) — corrected | — | as-of dates in filenames drift vs landed_at |
southsoundradiology | ris | | | " minimal | cp1252 — corrected | pad | ~35 intra-day full snapshots per weekday |
“corrected” = the probe contradicted the declared/inventory label; all five would have been silent-corruption bugs. Every fact here was confirmed by PHI-authorized probes (structure/counts only) and is recorded per-customer in DATA_NOTES.md.
One shape serves all nine customers. The landing bucket is treated as external and ephemeral; bronze raw — an append-only parquet log — is the durable replay source of truth. Everything downstream is a pure function of it.
NextGen, eCW, Experity, Netsmart, RIS, Phygen — nightly/weekly SFTP drops.
external & ephemeral — can expire or stop; never the replay source.
s3://…-datalake-landing/customer/<prefix>
one dlt asset per table, from defs.yaml
list files → incremental cursor
(modification_date: each file once)
per file:
assert / sniff format
→ transcode to utf-8
→ strip NUL sentinels
→ ragged policy
→ DuckDB parse, all-VARCHAR
→ + provenance columns
append-only parquet log of every drop ever landed. Immutable; the durable replay source.
…/bronze/<source>/raw/<table>/ · all-VARCHAR
+ _source_path, _s3_modified
dbt-duckdb dedup: latest row per PK, still source-shaped.
mneye + heatoneye today
current state + in-band tombstones (planned; dispatches on deletes).
DuckDB bulk-swap into shadow Import*_dagster tables.
mneye + heatoneye today
source entities → our canonical entities; typed, conformed.
customer/layer/source/step/table — mneye/bronze/nextgen/raw/charges materializes to s3://…/bronze/nextgen/raw/charges/, and the dbt clean step is the sibling …/nextgen/clean/charges. Key and S3 layout can never disagree, and consumers depend on the step (raw vs clean vs frontier), never inferring trust from the layer color.The whole byte path is one dlt transformer wrapping one tested function. Per file: read the bytes, resolve/check the format, decode to all-VARCHAR Arrow, attach provenance. No second code path, no live-only branch.
def _reader(
resolve_fmt, extractions, capture_s3_modified: bool,
sink: "RejectSink | None" = None, asserts: Optional[dict] = None,
):
"""The one dlt transformer body: per file, resolve a :class:`CsvFormat` (fixed
or sniffed), decode through the tested core, and optionally attach the S3
``modification_date`` as ``_s3_modified``. When a ``sink`` is given, quarantined
rows are recorded into it (per file) instead of vanishing silently. ``asserts``
is the **fixed-mode** drift guard (the sniff path asserts inside ``resolve_fmt``)."""
@dlt.transformer
def _read(items):
for item in items:
data = item.read_bytes()
if asserts:
check_asserts(data[:65536], asserts)
path = _path_of(item)
rejects: list[str] | None = [] if sink is not None else None
table = process_file(data, path, resolve_fmt(data), extractions, rejects=rejects)
if sink is not None and rejects:
sink.record(path, rejects)
if capture_s3_modified:
table = apply_file_metadata(
table, {"modification_date": item.get("modification_date")}, _S3_MODIFIED
)
yield table
return _readInside process_file → decode(), the file goes through four moves. The first two are unconditional; the third is the per-feed policy dial; the fourth is DuckDB (chosen because it natively parses the 3-byte ^|^delimiter, which pyarrow.csv cannot).
def decode(data: bytes, fmt: CsvFormat, *, rejects: list | None = None):
"""Parse ``data`` into a pyarrow Table of all-VARCHAR columns named from the
header. ``fmt`` carries the delimiter (possibly multi-byte), quote char (or
None), source encoding, NUL-scrub flag, and ragged-row policy. For
``ragged="quarantine"``, when a ``rejects`` list is given the raw rejected lines
are appended to it (visible, not silently dropped)."""
import os
import tempfile
import duckdb
data = _to_utf8(data, fmt.encoding)
if fmt.strip_nul and b"\x00" in data:
data = data.replace(b"\x00", b"")
if fmt.ragged == "reconstruct":
text = data.decode("utf-8")
header = text.split("\n", 1)[0].rstrip("\r")
width = header.count(fmt.delimiter) + 1
data = _reconstruct(text, fmt.delimiter, width).encode("utf-8")
elif fmt.ragged == "quarantine":
data, rejected = _quarantine_filter(data, fmt)
if rejects is not None:
rejects.extend(rejected)
# "error": no flags -> DuckDB raises on a ragged row. "reconstruct"/"quarantine":
# the data was made rectangular above, so a plain parse suffices.
kwargs = _csv_kwargs(fmt)
tmp = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
try:
tmp.write(data)
tmp.close()
conn = duckdb.connect()
try:
return conn.read_csv(tmp.name, **kwargs).to_arrow_table()
finally:
conn.close()
finally:
os.unlink(tmp.name)Rows whose field count disagrees with the header are where feeds silently corrupt. CsvFormat.ragged makes the policy explicit per feed — and two of the four policies were driven in by one customer (heatoneye) whose feed had both failure shapes at once.
default A ragged row is a fault: DuckDB raises, the asset run fails, a human looks. Right for feeds the probe showed are rectangular (mneye, sunlife, ccp, jewishboard, paa) — a ragged row there means the feed changed shape.
visit_id|cpt|dos|amount 70001|76700|2026-05-01|120.00 70002|76856|2026-05-02 ← 3 fields vs header 4 → CSV Error, run fails
ssr · ushp · middletown Short rows are NULL-filled to header width (DuckDB’s null_padding). Right when the probe shows rare, benign short rows — trailing columns simply absent.
visit_id|cpt|dos|amount 70001|76700|2026-05-01|120.00 70002|76856|2026-05-02 → 70002|76856|2026-05-02|NULL
heatoneye appointments An unquoted free-text field contains a newline, splitting one logical record across physical lines. Reconstruct accumulates lines until the delimiter count reaches header width, flattening the spurious newline to a space.
appt_id|status|note|amount 70003|booked|patient called will reschedule|40.00 → 70003|booked|patient called will reschedule|40.00
def _reconstruct(text: str, delimiter: str, width: int) -> str:
"""Rejoin records split by an unquoted embedded newline: accumulate physical
lines until the delimiter count reaches the header width, then that's one
logical record. The spurious newline is flattened to a space. (Recovers the
embedded-newline case; an *unescaped delimiter* over-count is ambiguous and is
left to the ``quarantine`` policy.)"""
lines = [ln.rstrip("\r") for ln in text.split("\n")]
if lines and lines[-1] == "":
lines.pop()
if not lines:
return text
need = width - 1 # delimiters per complete record
out = [lines[0]]
buf = ""
for line in lines[1:]:
buf = line if buf == "" else f"{buf} {line}"
if buf.count(delimiter) >= need:
out.append(buf)
buf = ""
if buf:
out.append(buf) # trailing incomplete record -> DuckDB will flag it
return "\n".join(out) + "\n"heatoneye patient_insurance An unescaped delimiter inside a field over-counts — reassembling it would be a guess. Quarantine keeps the rectangular rows, sidelines the bad ones visibly: a per-run RejectSink surfaces counts + a bounded sample as Dagster asset metadata, and the run logs a warning. Never silently dropped.
member_id|plan|copay|amount 88010|PPO|20.00|150.00 88011|PPO|copay|waived|10.00 ← 5 fields → rejected, surfaced in run metadata
def _quarantine_filter(data: bytes, fmt: CsvFormat) -> tuple[bytes, list[str]]:
"""Split off rows whose field count != the header's. Returns (clean bytes with
header + good rows, list of rejected raw lines) — so DuckDB then parses a
rectangular file and the bad rows are *surfaced*, never silently dropped."""
lines = data.decode("utf-8").split("\n")
out: list[str] = []
rejects: list[str] = []
width: int | None = None
for raw in lines:
s = raw.rstrip("\r")
if width is None: # header
width = _field_count(s, fmt)
out.append(s)
continue
if s == "":
continue
if _field_count(s, fmt) == width:
out.append(s)
else:
rejects.append(s)
return ("\n".join(out) + "\n").encode("utf-8"), rejectsfilesystem-common was rebuilt test-first after eight customers were built against the original — the redesign encodes what the buildout kept tripping on. A pure, dlt-free core (90 unit tests, no S3 needed) with a thin Dagster/dlt adapter on top.
| module | loc | responsibility | replaces |
|---|---|---|---|
core/format.py | 65 | CsvFormat — one frozen value object for every byte fact + the override() cascade | six positional booleans threaded through six signatures |
core/sniff.py | 75 | sniff(head) → CsvFormat: BOM/encoding, delimiter, quote, NUL from the bytes | hand-transcribed inventory labels (wrong 4×) |
core/decode.py | 133 | the one decoder: bytes + CsvFormat → all-VARCHAR Arrow (§04) | two divergent CSV paths, one never unit-tested |
core/spec.py | 82 | TableSpec/SourceSpec — pk / semantics / deletes / soft_delete as data | prose defs.yaml headers usable by nothing |
core/provenance.py | 73 | declared Extractions → real columns; _source_path, _s3_modified | a magic filename column re-regexed in every silver model |
core/select.py | 30 | narrow_to_latest — pin a key=* glob to the newest partition | the all/latest/incremental enum |
core/pipeline.py | 61 | process_file (decode+provenance) · reconcile_format / check_asserts (drift) | a parse path first exercised in production |
reader.py | 125 | the dlt transformer + RejectSink + config→spec mapping (dlt, no dagster) | — |
component.py | 392 | the Dagster component: YAML → assets, validation, key scheme, quarantine metadata | the legacy FilesystemDltComponent (sgp still on it) |
The defining change. Primary keys, snapshot/delta/rolling semantics, the soft-delete column, how deletes manifest — knowledge that used to live in a 100-line YAML comment header now travels as structured fields that silver reads (dedup key, tombstone filter), docs read, and bronze validates. Note deletes is deliberately orthogonal to semantics: a feed can be a physical snapshot whose rows age out, so absence ≠ deletion (§07).
_SELECTIONS = ("all", "latest_partition", "incremental")
_SEMANTICS = ("snapshot", "delta", "rolling")
# How a deletion in the source becomes a tombstone in the frontier. Orthogonal to
# ``semantics`` (the physical shape): a full *snapshot* can still be ``in_band`` if
# rows age out of a window rather than being deleted (SSR), so absence is unreliable.
# - ``none`` don't derive tombstones (default — never manufacture deletes).
# - ``absence`` present-in-an-earlier-snapshot-but-absent-from-the-latest = deleted.
# ONLY sound for a *bounded* full snapshot (a true master/dimension
# export); the frontier must gate it behind a completeness fence.
# - ``in_band`` the source carries the delete itself (a flag/status column, named
# by ``soft_delete``); absence means "aged out", NOT deleted.
_DELETES = ("none", "absence", "in_band")
@dataclass
class TableSpec:
"""One logical table. ``file_mask`` is a glob; the remaining fields are the
structured metadata that silver consumes."""
name: str
file_mask: str
selection: str = "incremental"
selection_key: str = "landed_at"
pk: list[str] = field(default_factory=list)
semantics: Optional[str] = None
soft_delete: Optional[str] = None
deletes: str = "none"
provenance: list[Extraction] = field(default_factory=list)One frozen value object per feed, with a customer→table cascade. The subtlety: override() applies only the keys passed, so quote=None — a genuinely unquoted feed, which five customers are — survives the cascade instead of being confused with “inherit”. A TDD round-trip caught exactly that overload in the original design.
@dataclass(frozen=True)
class CsvFormat:
"""How to turn a delimited file's bytes into rows. Bronze always lands every
column as VARCHAR (typing is a silver concern), so there is deliberately no
column/dtype machinery here — only the bytes-to-rows facts.
delimiter: str = ","
quote: Optional[str] = '"'
encoding: str = "utf-8"
header: bool = True
strip_nul: bool = False
ragged: RaggedPolicy = "error"
def override(self, **fields) -> "CsvFormat":
"""Return a copy with the provided fields replaced. **Omission means
inherit** — a per-table override passes only the keys it changes — while a
passed value (including ``None`` for ``quote``, i.e. "no quoting", or
``False`` for a bool) is applied. This avoids overloading ``None`` to mean
both "inherit" and "no quoting", which a quote-less feed genuinely needs."""
known = {f.name for f in dataclasses.fields(self)}
unknown = set(fields) - known
if unknown:
raise ValueError(f"unknown CsvFormat field(s): {sorted(unknown)}")
return replace(self, **fields)Born from the inventory being wrong on every ^|^ feed and on UTF-16. The sniffer reads a file’s head and infers the byte facts; config asserts pin any fact so a feed that silently changes shape fails loudly instead of corrupting bronze. The two compose into three modes: sniff & parse, declare & parse, and — the default all nine customers use — declare & parse, sniff only to check (a per-file drift guard that never trusts the sniff to parse). One rule: only assert sniff-visible facts. cp1252 has no BOM and sniffs as utf-8, so it is pinned(encoding: cp1252), never asserted.
def sniff(head: bytes) -> CsvFormat:
"""Infer a :class:`CsvFormat` from a file's leading bytes (header + a few rows).
- encoding: from the BOM (UTF-16 LE/BE, UTF-8 BOM), else UTF-8.
- delimiter: a multi-byte token (``^|^``) if present in the header, else the
single-char candidate that occurs most in the header line (tab/pipe/semicolon/
comma), defaulting to comma.
- quote: ``"`` if a quote byte appears anywhere in the head, else ``None``.
- strip_nul: True if a ``U+0000`` (post-decode) field sentinel appears.
"""
encoding, text = _decode_head(head)
header = _first_line(text)
delimiter = ","
for token in _MULTI_DELIMS:
if token in header:
delimiter = token
break
else:
counts = {d: header.count(d) for d in _SINGLE_DELIMS}
best = max(_SINGLE_DELIMS, key=lambda d: counts[d])
if counts[best] > 0:
delimiter = best
quote = '"' if '"' in text else None
strip_nul = "\x00" in text
return CsvFormat(
delimiter=delimiter,
quote=quote,
encoding=encoding,
strip_nul=strip_nul,
)def _assert_facts(fmt: CsvFormat, asserts: dict | None) -> None:
for key, expected in (asserts or {}).items():
actual = getattr(fmt, key)
if actual != expected:
raise FormatAssertionError(
f"format drift: {key} is {actual!r} but config asserts {expected!r}"
)
def reconcile_format(
head: bytes, *, override: dict | None = None, asserts: dict | None = None
) -> CsvFormat:
"""Infer the format from ``head``, apply config ``override`` (None values
inherit the sniff), then verify every key in ``asserts`` matches — raising
:class:`FormatAssertionError` on mismatch so feed drift fails loudly instead of
silently corrupting bronze."""
fmt = sniff(head).override(**(override or {}))
_assert_facts(fmt, asserts)
return fmt
def check_asserts(head: bytes, asserts: dict | None) -> None:
"""Drift guard for a **fixed-format** feed: sniff ``head`` and verify the asserted
facts hold, raising :class:`FormatAssertionError` on mismatch. The sniff is used
*only* to check, never to parse — so a feed can keep an explicitly-declared format
(e.g. cp1252, which has no BOM and sniffs as utf-8) yet still fail loudly if the
bytes drift. Corollary: only assert facts the sniff can actually see (delimiter,
quote, BOM-based encodings) — asserting an unsniffable encoding would false-alarm."""
if asserts:
_assert_facts(sniff(head), asserts)The legacy failure mode for bad rows was silence. The rebuilt reader records every rejected line into a per-run sink, bounded so a pathological file can’t blow up run metadata; the component merges the counts + sample onto the asset’s materialization (§04).
class RejectSink:
"""Accumulates quarantined (malformed) rows across a load so the component can
surface them as Dagster metadata, rather than ``ragged="quarantine"`` dropping
them with no trace. One sink per asset run; the reader records into it per file.
``total``/``files`` are the row and file counts; ``sample`` holds up to
``sample_limit`` ``"<path>: <raw line>"`` strings for a glance at what was
rejected (bounded so a pathological file can't blow up run metadata)."""
def __init__(self, sample_limit: int = 20):
self.sample_limit = sample_limit
self.total = 0
self.files = 0
self.sample: list[str] = []
def record(self, path: str, rejected: list[str]) -> None:
if not rejected:
return
self.total += len(rejected)
self.files += 1
room = self.sample_limit - len(self.sample)
if room > 0:
self.sample.extend(f"{path}: {line}" for line in rejected[:room])The component maps Resolvable YAML onto the core and builds one dlt asset per table. Two details worth review attention. First, _validate(): the run path dispatches on string equality, so before this guard a typo like selection: incrmental would have silently flipped an append-only raw log to replace — destroying the replay history invariant. Validation routes the YAML through the same build_source_spec seam the core tests exercise, so config and spec can’t drift apart.
def _validate(self) -> None:
"""Fail at definition-build time on config the run path would misread.
The run path dispatches on string equality (``selection == "incremental"``
decides append vs replace), so an unvalidated typo like ``incrmental`` would
silently flip an append-only raw log to ``replace``. The enums (``selection``,
``semantics``/``deletes``, ``in_band`` requires ``soft_delete``, duplicate
table names) are enforced by mapping the config through the same
``build_source_spec`` seam the core tests exercise, so YAML and
:class:`~filesystem_common.core.spec.TableSpec` can't drift apart silently."""
known_facts = {f.name for f in dataclass_fields(CsvFormat)}
unknown = set(self.format.asserts) - known_facts
if unknown:
raise ValueError(
f"format.asserts keys {sorted(unknown)} are not CsvFormat facts "
f"({sorted(known_facts)})"
)
self.format.to_core() # validates ragged + non-empty delimiterSecond, the seams to dlt: each table becomes a filesystem | reader source with an incremental cursor on S3 modification_date (each file ingested exactly once), and the translation stamps the 5-part key that mirrors the S3 layout.
def _make_source(
self, t: TableConfig, *, mask: Optional[str] = None, sink: Optional[RejectSink] = None
):
creds = _aws_credentials(self.credentials)
incremental = t.selection == "incremental"
uri, name = self.uri, t.name
file_mask = t.file_mask if mask is None else mask
reader = self._build_reader(t, sink)
@dlt.source(name=name)
def _src():
files = filesystem(bucket_url=uri, file_glob=file_mask, credentials=creds)
if incremental:
files.apply_hints(incremental=dlt.sources.incremental("modification_date"))
return (files | reader).with_name(name)
return _src()def _make_translation(customer_id: str, source: str, layer: str):
"""Key each bronze table ``customer/layer/source/raw/table`` (e.g.
``mneye/bronze/nextgen/raw/charges``) — layer-first with an explicit ``raw``
step, mirroring the S3 layout ``{customer-bucket}/{layer}/{source}/raw/{table}``.
Group ``{customer}_{layer}_{source}`` (the dbt cleanup writes the sibling
``clean`` step under the same ``{layer}/{source}/``); ``customer``/``source``/
``layer``/``step`` are tags."""
def _translate(spec: AssetSpec, data: DltResourceTranslatorData) -> AssetSpec:
table = spec.key.path[-1]
return spec.replace_attributes(
key=AssetKey([customer_id, layer, source, "raw", table]),
group_name=f"{customer_id}_{layer}_{source}",
tags={"customer": customer_id, "source": source, "layer": layer, "step": "raw"},
)
return _translateAnd on the run path, execute() is where quarantine becomes observable — the sink is complete once dlt finishes extraction, so its counts are merged onto the MaterializeResult:
creds = self.credentials or None
mask = self._resolve_mask(table_config, creds)
disposition = "append" if table_config.selection == "incremental" else "replace"
sink = RejectSink()
# dlt fully extracts (running our reader, populating the sink) before the
# resource yields its MaterializeResult, so the sink is complete by then —
# merge the quarantine counts onto the result instead of dropping them.
for result in dlt_pipeline_resource.run(
context=context,
dlt_source=self._make_source(table_config, mask=mask, sink=sink),
dlt_pipeline=self._make_pipeline(table_config.name),
loader_file_format="parquet",
write_disposition=disposition,
):
if sink.total and isinstance(result, dg.MaterializeResult):
result = result._replace(
metadata={**(result.metadata or {}), **_reject_metadata(sink)}
)
yield result
if sink.total:
context.log.warning(
"%s: quarantined %d malformed row(s) across %d file(s); "
"see asset metadata for a sample",
table_config.name, sink.total, sink.files,
)The per-customer decisions in §07 all trace back to four principles (pipelines/docs/bronze-principles.md) — worth internalizing before reading any defs.yaml, because they explain the choices that look odd at first (reference tables appended daily, 35 intra-day snapshots all kept, no SCD-2 anywhere).
Raw is an immutable append-only log of every drop. Current state is a pure, re-runnable function of it — replay = filter raw to _s3_modified ≤ Tand re-derive. No SCD-2, no table format; history lives in the log.
The SFTP landing bucket is external and ephemeral — it can expire or stop. The moment a drop lands, raw captures it; replayability is defined against raw, never against landing.
Where the source carries deletes (a flag column), the frontier exposes tombstones. Where it doesn’t, absence in a rolling/windowed feed means aged out, not deleted. The per-table deletes field exists precisely so this is never guessed.
raw → clean → frontier are all faithful to what the source asserts — source column names, deliberately all-VARCHAR. Mapping onto ourcanonical entities is the bronze→silver boundary.
Given a complete raw log, deriving “current state + tombstones” is one of three reductions — and the choice is data, dispatched on each table’s semantics + deletes:
| shape | reduction | customers | why |
|---|---|---|---|
| A | snapshot-reduce — latest row per PK; tombstone from the in-band flag | mneye · heatoneye · ushp · middletown · sunlife · paa · ssr | each drop is a full snapshot; absence = aged out, ignored |
| B | window-accumulate — latest per PK across all history; a key keeps its last-known state after leaving the window | ccp | open-balance AR/ERA window churns both ways; a closed row isn’t deleted |
| C | ledger-accumulate — union all drops, dedup by transaction key, keep reversals; current = the net | jewishboard | reversals are sign-flipped new rows linked via JOIN_TO_* — overwrite would erase them |
Cross-cutting fact: not one of the nine customers uses deletes: absence. Every feed either rolls or carries deletes in-band — which is exactly why the field is explicit.
Nine customers, five families. Within a family the defs.yaml files are near-clones — review one, skim the rest. Across families, each one pushed a different capability into the library; the tabs tell that story.
Family A — the “MDClarity standard export” (NextGen)
The founding family. heatoneye was the pilot; mneye and ushp share its 13-table schema, the rolling-window transaction tables, and the NextGen soft-delete scheme (delete_ind on operational tables, DeleteInd on masters — casing carries meaning).
The instructive part is where the siblings diverge: heatoneye is bare-| with two ragged feeds, mneye is ^|^ and cp1252, ushp is genuinely UTF-8 with three practice subdirs per drop. A family shares semantics, not bytes — which is why format is per-customer config and why the probe re-checks every sibling instead of cloning (“do not blindly clone an encoding across the family”).
# mneye — NextGen Family A, but with the ^|^ token and a cp1252 fix
format:
delimiter: "^|^"
quote: ""
encoding: cp1252 # FIX: probe found windows-1252 bytes; utf-8 mis-decodes them
strip_nul: true
asserts:
delimiter: "^|^" # drift guard: sniff-visible; a feed that changes shape fails loudly
tables:
- name: appointments
file_mask: "landing/source=sftp/landed_at=*/ExportAppointments*"
selection: incremental
pk: [appt_id]
semantics: rolling
deletes: in_band # also cancel_ind/appt_status/workflow_status
soft_delete: delete_ind
- name: patient
# [!I] excludes ExportPatientInsurance* (a bare ExportPatient* glob swallows
# those files too and silently widens this table's schema with foreign rows)
file_mask: "landing/source=sftp/landed_at=*/ExportPatient[!I]*"
selection: incremental
pk: [PersonNumber] # best-effort (the clean model's dedup key)
semantics: rolling
deletes: none # no flag# heatoneye — same NextGen schema as mneye, but bare "|" and two ragged feeds
- name: adjustments
file_mask: "landing/source=sftp/landed_at=*/TempExportAdjustments*"
selection: incremental
format_override: { quote: "\"" }
pk: [RowID] # best-effort
semantics: rolling
deletes: none # no delete flag; TransactionStatus candidate (values unknown)
- name: appointments
file_mask: "landing/source=sftp/landed_at=*/Flow/ExportAppointments*"
selection: incremental
format_override: { ragged: reconstruct } # unquoted embedded newlines -> rejoin to header width
pk: [appt_id] # best-effort (the clean model's dedup key)
semantics: rolling
deletes: in_band
soft_delete: delete_ind # Y/N; also cancel_ind / appt_status / workflow_status
- name: patient_insurance
file_mask: "landing/source=sftp/landed_at=*/Flow/ExportPatientInsurance*"
selection: incremental
format_override: { ragged: quarantine } # unescaped-pipe over-count -> visible rejects
pk: [PersonNumber, person_payer_id] # best-effort composite — confirm vs source DDL
semantics: rolling
deletes: in_band
soft_delete: delete_ind# ushp — third NextGen sibling: genuine UTF-8 (do NOT clone cp1252 across the
# family) and THREE practice subdirs per drop, unioned by a */ wildcard.
format:
delimiter: "|"
quote: "\"" # RFC4180 quoting — quoted embedded newlines parse cleanly
encoding: utf-8 # genuine multibyte utf-8 (U+2022 bullet in PatientInsurance)
strip_nul: true
asserts:
delimiter: "|"
tables:
- name: adjustments
# */ unions the MDClarity / Stuart101124 / USHP126051 practice subdirs;
# _source_path records which subdir each row came from, for a silver split
file_mask: "landing/source=sftp/landed_at=*/*/TempExportAdjustments.txt"
selection: incremental
pk: [RowID] # confirmed by probe (1.000 unique, 0 null)
semantics: rolling
deletes: none # only TransactionStatus (candidate, unconfirmed)ragged: reconstruct and ragged: quarantine + the visible RejectSink (heatoneye had both failure shapes at once).latest_partition/replace kept only the newest snapshot and silently discarded dimension history — all five reference tables now append, and the dbt clean step reduces to the latest landed_at.ExportPatient[!I]*, because a bare glob swallows ExportPatientInsurance and silently widens the patient table with foreign rows.Family B — eClinicalWorks (the ^|^ lineage)
Same export lineage, opposite landing discipline. sunlife is the tidy one — clean daily landed_at= partitions — and became the first customer migrated onto the rebuilt core. middletown is the chaotic one: drop folders are manually named (MDClarity_5-Junlanded June 9; future-dated empty placeholders exist), three folder layouts coexist, a legacy bare-| baseline sits next to the ^|^ feeds, and one drop was an 18M-row / 3.3 GB historical backfill.
middletown is why _s3_modified is captured on every row: S3 server write time is strictly ordered across drops when folder names lie. It is also the accepted memory ceiling — strip_nul must mutate the bytes, which forces whole-file buffering, so replaying the backfill needs task memory sized to the largest file.
# middletownmedical — eClinicalWorks: manually-named drop folders (not sortable),
# three coexisting folder layouts, and a bare-"|" legacy baseline to keep OUT.
format:
delimiter: "^|^"
quote: ""
encoding: utf-8
strip_nul: true
asserts:
delimiter: "^|^" # drift guard: a bare-"|" file (e.g. the excluded legacy
# baseline) fails loudly instead of parsing as one column
tables:
- name: charge_items
# ** matches both the flat MDClarity_<D>-<Mon>/ and nested NN-Jun/MDClarity_NN-Jun/
# layouts; anchoring on MDClarity_* keeps the bare-pipe MDClarity/ baseline out
file_mask: "source=ecw/**/MDClarity_*/db_nymidm_ExportChargeItems.txt"
selection: incremental
pk: [ItemID]
semantics: rolling # rows leave and re-enter the window
deletes: in_band # absence != deletion; tombstone from the flag
soft_delete: deleteFlag# sunlife — same ECW ^|^ lineage as middletown; the first customer migrated
# onto the rebuilt core. Watch the per-table delete-flag casing drift.
format:
delimiter: "^|^"
quote: "" # unquoted feed
encoding: utf-8 # genuine utf-8 (pure ASCII per probe) — explicit, not defaulted
strip_nul: true
asserts:
delimiter: "^|^"
tables:
- name: charge_items
file_mask: "landing/source=sftp/landed_at=*/ExportChargeItems.txt"
selection: incremental
pk: [ItemID]
semantics: rolling
deletes: in_band
soft_delete: deleteFlag
- name: visit_codes
file_mask: "landing/source=sftp/landed_at=*/ExportVisitCodes.txt"
selection: incremental
pk: [CodeID]
semantics: snapshot
deletes: in_band
soft_delete: Active # INVERSE polarity: Active=N means inactive/deleted_s3_modified as a first-class, per-row ordering column (never trust a folder name).| file reaching a ^|^ reader parses as one column with zero delimiter tokens — ragged never fires — so the assert is the only loud failure available.null_padding (ragged: pad) wiring, and the documented buffering ceiling.deleteFlag / DeleteFlag / delflag / inactive / inverse-polarity Active — five spellings across two customers, each recorded in soft_delete.Family C — Experity AR/ERA (the rolling window)
The semantics teacher. ccp’s daily Export*Table.txt files look like full snapshots but are a churning open-balance window: rows leave when balances close and re-enter when they reopen, skewing dropped rows toward zero-balance. So disappearance never means deletion, deletes: absence is never valid, and the frontier must keep a key’s last-known state after it leaves the window — the window-accumulate reduction exists because of this feed.
It also set the PK-honesty convention: eight tables lead with Practice, a tenant discriminator, not a row id. Rather than key on a guess, pk is left unset and the real composite is documented in DATA_NOTES.md for confirmation against source DDL.
# ccp — Experity AR/ERA: each daily drop is a ROLLING OPEN-BALANCE WINDOW, not a
# snapshot. Rows leave as balances close — disappearance is NEVER a deletion.
format:
delimiter: "|"
quote: ""
encoding: utf-8
strip_nul: true
asserts:
delimiter: "|"
tables:
# Practice-led composite PK (Practice + per-row id) — pk left UNSET, not guessed
- name: ar_detail
file_mask: "landing/source=sftp/landed_at=*/ExportARDetailTable.txt"
selection: incremental
semantics: rolling # open-balance window; disappearance != deletion
deletes: in_band # reversals signaled in-band, not physically removed
soft_delete: Reverse_Flag # also ManualReverse
- name: insurance
file_mask: "landing/source=sftp/landed_at=*/ExportInsuranceTable.txt"
selection: incremental
pk: [InsuranceId]
semantics: snapshot # reference
deletes: nonesemantics / deletes orthogonality — physical shape and delete channel are independent axes.pk is a statement, not an omission.Family D — Netsmart (UTF-16, the ledger)
The format odd-one-out. Every file is UTF-16 LE with a BOM — the inventory’s “mixed pipe/tab” label was its tab bytes (0x09 0x00) read as ASCII. The encoding: knob transcodes to UTF-8 first; the BOM makes utf-16 sniff-visible, so this is the one feed that assertsits encoding as a drift guard.
Semantically it’s two populations: billing tables are a rolling recent-activity window; dimension tables are true weekly deltas(only new/changed rows — no weekly file is ever complete). And deletion is simply not a concept in the feed: reversals arrive as newsign-flipped rows linked via JOIN_TO_* keys. Overwriting latest-per-PK would erase the reversal — hence the ledger-accumulate frontier, where “current” is the net of the ledger.
# jewishboard — Netsmart, the encoding odd-one-out: UTF-16 LE with a BOM on every
# file. The inventory's "mixed pipe/tab" label was UTF-16 tab bytes read as ASCII.
format:
delimiter: "\t"
quote: ""
encoding: utf-16 # CORRECT AS-IS: UTF-16 LE + BOM; do not change
strip_nul: true # strips the all-U+0000 sentinels left after transcoding
asserts:
delimiter: "\t" # drift guards: both sniff-visible (the BOM pins utf-16),
encoding: utf-16 # so a feed that changes shape or encoding fails loudly
tables:
- name: patient_data
file_mask: "landing/source=sftp/landed_at=*/JB_MD_C_Patient_Data*.txt"
selection: incremental
pk: [patientid] # CORRECTED: episode_number is NOT unique
semantics: delta # dims ship only new/changed rows each week
deletes: none
- name: billing_tx_history
file_mask: "landing/source=sftp/landed_at=*/JB_MD_C_billing_tx_history*.txt"
selection: incremental
# reversal signal (NOT a delete): service_status_value. Reversals are NEW ROWS
# linked via JOIN_TO_* keys — "deletion" is not a concept in this feed.
semantics: rolling
deletes: noneencoding knob itself (utf-16 → utf-8 transcode before parsing), plus post-transcode NUL stripping.semantics: delta — the third snapshot shape, forcing accumulate-forever dims.patientid, not the repeating episode_number the schema suggests.Family E — scheduling snapshots (the future that ages out)
Both feeds snapshot the future schedule, so every row eventually leaves the file simply by the date passing — the purest illustration of why absence must never be a tombstone. SSR lands a fresh full snapshot every ~30 minutes (~35/day); raw ingests all of them — replayability lives in raw, reduction lives in the frontier, and no reduce-at-extraction shortcut is taken.
paa adds the messiest format story: ten heterogeneous comma-CSVs where two files are secretly cp1252 inside a utf-8 customer — the case that justifies format_override being per-table. Its cancel signal is leaky (some future cases vanish with no cancelled status), which still doesn’t license absence-tombstoning — it becomes a soft “absent-from-window” hint in the frontier, nothing more.
# southsoundradiology — ~35 intra-day FULL snapshots per weekday. Raw ingests ALL
# of them (replayability); the frontier reduces to latest-state-per-VisitNumber.
format:
delimiter: "|"
quote: "\""
encoding: cp1252 # PINNED: probe found windows-1252; unsniffable (no BOM)
strip_nul: true
ragged: pad # rare short rows -> NULL-fill to header width
asserts:
delimiter: "|" # | IS sniff-visible, so it's asserted as a drift guard
tables:
- name: scheduled_appointments
file_mask: "landing/source=sftp/landed_at=*/Scheduled_Appointments_*.csv"
selection: incremental
pk: [VisitNumber]
semantics: snapshot # physically a full snapshot...
deletes: in_band # ...but rows age out, so absence != deletion
soft_delete: CancelledOn # the real tombstone signal (+ Status='CANCELLED')# paa — 10 heterogeneous scheduling CSVs, per-FILE mixed encodings, and glob
# ambiguity handled with start-anchored masks.
format:
delimiter: ","
quote: "\"" # RFC4180: AnFutSched* carry quoted fields with EMBEDDED NEWLINES
encoding: utf-8
strip_nul: true
asserts:
delimiter: ","
tables:
- name: an_fut_sched
# AnFutSched[0-9]* matches ONLY the base feed — a bare AnFutSched* would also
# swallow AnFutSchedEndo / AnFutSchedMobile into this table
file_mask: "landing/source=sftp/landed_at=*/AnFutSched[0-9]*.csv"
selection: incremental
semantics: snapshot # future-only full snapshot; rows age out
deletes: none # cancel signal UNCONFIRMED for this feed (follow-up)
format_override: { encoding: cp1252 } # THIS file is windows-1252 (É/á/é)
- name: fut_sched
file_mask: "landing/source=sftp/landed_at=*/FutSched[0-9]*.csv"
selection: incremental
pk: [CaseID]
semantics: snapshot
deletes: in_band # cancellations show in CaseStatus — but LEAKY: some
soft_delete: CaseStatus # future cases vanish with no prior cancelled statusformat_override (paa’s two cp1252 files inside a utf-8 customer).ragged: pad at customer level (SSR’s rare short rows).AnFutSched[0-9]*) — verified against fsspec’s glob translation, because the unanchored form swallows two sibling feeds.The library grew exactly one capability per real failure shape. Fir = set at customer level; violet = reached via per-table override.
| capability | heatoneye | mneye | ushp | middletown | sunlife | ccp | jewishbd | ssr | paa | why it exists |
|---|---|---|---|---|---|---|---|---|---|---|
^|^ multi-byte delimiter | why DuckDB, not pyarrow.csv | |||||||||
unquoted feed (quote: "") | None ≠ inherit — the override() lesson | |||||||||
RFC4180 quoting | quoted embedded newlines parse cleanly | |||||||||
cp1252 pinned (unsniffable) | no BOM → pin, never assert | |||||||||
utf-16 transcode | the encoding knob | |||||||||
NUL-sentinel strip | forces whole-file buffering | |||||||||
ragged: pad | NULL-fill short rows | |||||||||
ragged: reconstruct | unquoted embedded newlines | |||||||||
ragged: quarantine → RejectSink | visible rejects in run metadata | |||||||||
asserts drift guard | jewishboard also asserts encoding | |||||||||
per-table format_override | quote / ragged / encoding | |||||||||
glob discipline ([!I] · [0-9] · ** · */) | masks that can silently swallow siblings |
Seven customers stop at bronze raw (silver deferred pending feed-health checks and owner confirmations). Two run the whole chain — and are the template the other seven will clone when their silver is greenlit.
dlt append-log per table
mneye/bronze/nextgen/raw/*
dbt-duckdb dedup + row filter
…/nextgen/clean/* · dt= partitioned
DuckDB → MSSQL rebuild into shadow tables
dbo.Import*_dagster
sensor fires on 13/13, same batch; writes the run-complete marker
MDClarity tasks consume Import* tables
All 26 dbt models are this same 19-line shape — review one, trust the template. It is the consumer of everything bronze promised: the append-log (dedup latest-per-PK), the provenance column (landed_at parsed from _source_path), and the S3 write time as tiebreak (_s3_modified, because a re-drop day holds two files and there is no row-level watermark).
-- depends_on: {{ source('mneye', 'patient') }}
{{ config(location=cleaned_location()) }}
-- Rolling-window feed: bronze is an append-log of daily drops (records age out and
-- return). Keep the latest row per PK by landed_at (parsed from the _source_path
-- provenance column), tiebroken by _s3_modified (S3 write time) so a re-drop or
-- multi-file day picks the newest file deterministically; there is no row-level
-- modified timestamp to use instead.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY PersonNumber
ORDER BY regexp_extract(_source_path, 'landed_at=(\d{4}-\d{2}-\d{2})', 1) DESC,
_s3_modified DESC
) AS _rn
FROM {{ mneye_bronze_parquet('patient') }}
)
SELECT * EXCLUDE (_rn, _source_path, _s3_modified)
FROM ranked
WHERE _rn = 1 AND NOT ({{ bad_row_predicate('patient') }}){# {entity: precompiled SQL predicate}, set on the environment by MneyeCleanupComponent. #}
{% macro bad_row_predicate(entity) -%}
{{- fromjson(env_var('MNEYE_PREDICATES', '{}')).get(entity, 'FALSE') -}}
{%- endmacro %}
{% macro mneye_bronze_parquet(entity) -%}
read_parquet('{{ var("customer_root") }}/bronze/{{ var("source") }}/raw/{{ entity }}/*.parquet', union_by_name=true)
{%- endmacro %}version: 2
# asset_key pins each dbt source to the 5-part dlt RAW bronze key
# (customer/bronze/source/raw/entity, e.g. mneye/bronze/nextgen/raw/charges) so the
# clean models depend on the real raw bronze asset rather than a 2-part orphan. Keep
# in sync with the FilesystemSourceComponent key scheme in defs/bronze/defs.yaml
# (layer-first, mirrors the S3 layout; the clean step is the sibling .../clean/...).
sources:
- name: mneye
tables:
- name: adjustments
meta: { dagster: { asset_key: ["mneye", "bronze", "nextgen", "raw", "adjustments"] } }
- name: appointmentsBad-row rules (test rows, known-junk locations) live in a YAML file on S3 — editable without a deploy — and compile to a SQL predicate injected into every clean model via bad_row_predicate(). Missing file means filter nothing; the compiler is the one piece of per-customer Python with unit tests.
def compile_filters(filters: dict[str, list[dict]]) -> dict[str, str]:
result = {}
for entity, entries in filters.items():
if not entries:
continue
parts = [_compile_entry(e) for e in entries]
if len(parts) == 1:
result[entity] = parts[0]
else:
result[entity] = "(" + " OR ".join(parts) + ")"
return result
def load_filter_rules(datalake_root: str) -> dict[str, str]:
path = f"{datalake_root}/config/filter_rules.yaml"
fs = s3fs.S3FileSystem()
try:
with fs.open(path, "r") as f:
doc = yaml.safe_load(f) or {}
except FileNotFoundError:
# No rules file yet → no rows filtered. The bad_row_predicate macro
# defaults each entity to FALSE (keep everything) on a missing key.
return {}
filters = doc.get("filters", {})
return compile_filters(filters)Each clean parquet is bulk-loaded into MSSQL through sqlserver_common.rebuild_table — into Import*_dagstershadow tables while the legacy pipeline still owns the live ones. Cutover is a one-line change (drop the suffix, swap rebuild → reload) once the parallel run is trusted.
def _sync_asset(entity: str, import_table: str):
# Shadow target while testing; cutover drops "_dagster" and swaps
# rebuild_table -> reload_table into the live Import* tables (and confirms
# which entities the product actually consumes).
target = f"{import_table}_dagster"
@dg.asset(
name=entity,
key_prefix=["mneye", "sync", "nextgen"],
group_name="mneye_sync_nextgen",
deps=[dg.AssetKey(["mneye", "bronze", "nextgen", "clean", entity])],
metadata={"dagster/table_name": f"dbo.{target}"},
)
def _asset(
context: AssetExecutionContext,
mssql_target: MssqlTargetResource,
customer_secrets: SecretsManagerSecretsResource,
) -> dg.MaterializeResult:
cleaned = _cleaned_path(entity, run_date(context))
# COLUMNS(...) drops dlt's internal _dlt_* columns from the load.
source_sql = (
f"SELECT COLUMNS(c -> NOT starts_with(c, '_dlt_')) "
f"FROM read_parquet('{cleaned}', hive_partitioning=false)"
)
user, password = fetch_mssql_login(customer_secrets)
conn = _connect_s3()
try:
rows = rebuild_table(
conn,
connection_string=mssql_target.duckdb_connection_string(user, password),
source_sql=source_sql,
target_table=target,
log=context.log,
)
finally:
conn.close()
return dg.MaterializeResult(
metadata={
"dagster/table_name": dg.MetadataValue.text(f"dbo.{target}"),
"dagster/row_count": dg.MetadataValue.int(rows),
}
)
return _assetThe legacy product must never see a half-synced day. The sensor fires pipeline_complete only when all 13 sync assets have materialized and their runs carry the same date_segment tag — a cross-run batch-coherence check, not just a count.
@dg.multi_asset_sensor(
monitored_assets=list(_SYNC_TARGET_KEYS),
job=pipeline_complete_job,
name="mneye_pipeline_complete_sensor",
minimum_interval_seconds=30,
default_status=dg.DefaultSensorStatus.RUNNING,
)
def mneye_pipeline_complete_sensor(
context: dg.MultiAssetSensorEvaluationContext,
):
records = context.latest_materialization_records_by_key()
for key in _SYNC_TARGET_KEYS:
if records.get(key) is None:
return dg.SkipReason(f"waiting on {key.to_user_string()}")
expected_batch: str | None = None
for key in _SYNC_TARGET_KEYS:
record = records[key]
run = context.instance.get_run_by_id(record.run_id)
batch = run.tags.get("date_segment") if run else None
if batch is None:
return dg.SkipReason(
f"{key.to_user_string()} run {record.run_id} has no date_segment tag"
)
if expected_batch is None:
expected_batch = batch
elif batch != expected_batch:
return dg.SkipReason(
f"sync records span two date_segments: "
f"{expected_batch} vs {batch}"
)
context.advance_all_cursors()
return dg.RunRequest(tags={"date_segment": expected_batch})The 57 commits tell a deliberate story — build against reality first, then rebuild the abstraction from what reality taught. Reviewing in that order makes every library decision legible.
| phase | commits | what happened |
|---|---|---|
| 1 · Pilot | heatoneye + first library extensions | heatoneye proves the shape: component + per-table strategies from a 4-day PHI profile; load strategies, formats and dlt incremental land in filesystem-common. |
| 2 · Buildout | mneye, then 7 customers overnight | One customer per commit — middletown, ushp, sunlife, ccp, jewishboard, paa, ssr — each preceded by a PHI-authorized probe whose findings are the defs.yaml header + DATA_NOTES.md. |
| 3 · Rebuild | filesystem-common core, TDD | With eight real feeds as the spec, the loader is redesigned red→green: CsvFormat, sniff+assert, the one decoder, visible quarantine, structured TableSpec. Legacy modules untouched. |
| 4 · Migration | all 9 onto FilesystemSourceComponent | sunlife first, then the six bronze-only customers, then mneye/heatoneye with their paired silver. Prose headers shrink; semantics move into fields. |
| 5 · Keys | customer/layer/source/step/table | Asset keys reshaped to mirror the S3 layout exactly (raw and clean as sibling steps), groups become {customer}_{layer}_{source}. |
| 6 · Hardening | validation, globs, simplification | Definition-build-time config validation (the append→replace typo class), silent-corruption glob fixes, drift guards, and deletion of every zero-consumer surface the TDD pass had speculatively built. |
| surface | lines | treatment |
|---|---|---|
| filesystem_common/ core + component | 1,075 | line-by-line — the engine all nine share |
| mneye/heatoneye Python (sync, sensors, predicates, cleanup) | ~470 | line-by-line — touches prod MSSQL |
| one defs.yaml per family (mneye · middletown · ccp · jewishboard · ssr) | ~600 | read header + spot-check masks; siblings are clones |
| one dbt clean model + one CI workflow | ~200 | template representatives; the other 25 / 8 are copies |
| library tests | 795 | skim names for coverage shape |
| DATA_NOTES × 9, shared docs, generator, packaging, lockfiles | ~46,600 | context, not review surface |