Adapters as Contracts, Not Escape Hatches
A second event domain proves that extensibility can add meaning without weakening lineage or validation.
Generic data platforms often become generic by moving every difference into one large JSON column. That creates storage flexibility, but it does not establish a safe extension model. A new source still needs parsing rules, temporal meaning, validation, views, lineage, and tests, and each of those can quietly reintroduce assumptions from the first dataset. I approached TimeTrackDB's adapter design as a boundary problem: the core should know what every event must prove, while an adapter should own what its particular events mean.
The shared handoff is deliberately small. Bronze emits an event type, logical day, start and optional end timestamps, nested event data, schema version, deterministic fingerprint, and source file identifier. Silver does not accept that envelope on faith. It checks that the event type matches the active processor, the entry day matches its enclosing batch, required timestamps are present, required event-data fields are populated, and entry-level source lineage agrees with the batch. It then recomputes the logical day through the configured handler and rejects a mismatch. Even an apparent duplicate is validated before the idempotency shortcut can return success.
Around that contract, configuration handles the regular differences. An adapter's YAML describes source encoding and fields, type conversions, required and optional values, defaults, timestamp formats, numeric bounds, validation rules, logical-day settings, and the handler to invoke. Event-specific fields stay inside event_data; they do not expand the core tables every time a source grows a new attribute. Simple event types can use a no-op handler and configuration alone. Sources with real domain behavior get a handler for transformations and cross-event checks that cannot honestly be expressed as scalar field rules.
I wanted evidence that this split worked beyond time tracking, so I used a subjective-ratings source as a canary. It is materially different from a list of activity durations. Its records begin as point-like observations, may describe a morning, evening, or full-day interval, carry optional bounded ratings, and need a local date to remain authoritative even when the logging timestamp says something else. The adapter handler synthesizes analysis spans, preserves the original log timestamp, derives completeness metadata, adjusts paired periods at the batch boundary, and detects invalid combinations or overlaps. None of those concepts became top-level Silver columns or branches in Bronze.
The canary also exposed a useful weakness in the shared layer. Its source timestamps included abbreviations that Python's configured date parsing does not handle portably. The resulting core change was narrow and reusable: generic timestamp conversion can interpret that class of source value using the adapter's configured IANA timezone. Span synthesis and rating semantics remained in the adapter. This is the kind of pressure test I value: a second domain is allowed to improve a genuinely common capability, but it does not get permission to reshape the pipeline around itself.
Repository ownership is explicit as well. Each real adapter has a manifest naming its event configuration, handler, serving-view SQL, compatibility SQL, and focused tests. The loader rejects duplicate event types and verifies that the manifest's event type agrees with the loaded config. Contract tests check that declared files and handler classes exist and that adapter-owned view SQL remains synchronized with the deployment copy. During the transition, legacy framework configs still load first and adapter-owned configs take precedence, so the boundary could be introduced without a risky all-at-once reorganization.
Serving views complete the separation. The generic current and history tables retain timestamps, fingerprints, schema version, source identity, and the event-specific JSON document. An adapter-owned view projects that document into useful domain columns for readers. Rollback, publication history, quarantine, and audit continue to operate on the dataset-scoped generic tables. A consumer can get a familiar relational surface without forcing the storage and recovery machinery to learn every domain's vocabulary.
There are deliberate limits. The active source path is still CSV-first, handlers still live in the shared Python package, deployment keeps compatibility copies of view SQL, and one daemon instance processes one configured event type. The manifests make those seams visible rather than pretending the extraction is finished. They also provide a place to test future movement.
For me, that is the practical value of an adapter contract. TimeTrackDB can accept a source with different temporal semantics while keeping lineage, validation, publication, history, and rollback equally strict. Extensibility is not the ability to put arbitrary objects in JSON. It is the ability to add meaning without weakening trust.