Lint rules
lakelogic lint reviews a contract for governance gaps: missing keys, unordered deduplication,
untagged personal data, steps that never run. Each rule below says what it checks, why it
matters, a contract that fails it and the smallest fix.
Every example on this page is run through the linter in LakeLogic's test suite: the failing example raises exactly that rule, and the fixed example raises none. Fixed examples are valid OLC contracts unless marked otherwise.
| Rule | Severity | What it catches |
|---|---|---|
| PK-002 | critical | Merge, upsert, SCD2 or deduplicate without a key |
| KEY-001 | critical | Deduplicate with no row ordering |
| KEY-002 | critical | Deduplicate sorted by its own key |
| KEY-003 | info | natural_key is deprecated |
| TRF-001 | critical | A transformation no engine implements |
| PHS-001 | critical | A pre-phase rule reads a column only a post step creates |
| PII-001 | warning | A field that looks like personal data is not tagged |
| PII-002 | warning (critical when the domain mandates PII protection) | Personal data with no masking |
| SENS-001 | warning | Sensitive data with no masking |
| DEL-001 | warning (critical when the domain declares an erasure policy) | A current-state entity with no delete strategy |
| SCD-001 | critical | SCD2 with no tracked columns |
| SRC-001 | warning | An unpartitioned bronze landing source |
| STREAM-001 | warning | Append on a resumable stream |
| STREAM-002 | info | A continuous trigger |
| QLT-001 | warning | A silver table with no quality rules |
| VOL-001 | info | No freshness or volume service level |
| OWN-001 | warning | An owner with no group to fall back on |
| OWN-002 | warning | Nobody accountable for blocking questions |
Keys and deduplication
PK-002: Merge, upsert, SCD2 or deduplicate without a key
Severity: critical
Checks: A merge/upsert/scd2 strategy, or a deduplicate, with no primary_key (or dedup key_columns).
Why it matters. These operations match incoming rows to existing ones by key. Without a key there is nothing to match on, so the result depends on row order: rows are duplicated, overwritten or dropped differently on every run.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
materialization:
strategy: merge
format: delta
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
materialization:
strategy: merge
format: delta
KEY-001: Deduplicate with no row ordering
Severity: critical
Checks: A deduplicate with no sort_by (or a deduplicate_by_latest with no timestamp_column).
Why it matters. Deduplication keeps one row per key and discards the rest. Without an ordering, which row survives is undefined, so the kept value can change between runs or engines. LakeLogic refuses to run this rather than guess: the wrong survivor is silent data loss.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: updated_at, type: timestamp }
primary_key: [trip_id]
transformations:
- deduplicate: { on: [trip_id] }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: updated_at, type: timestamp }
primary_key: [trip_id]
transformations:
- deduplicate: { on: [trip_id], sort_by: [updated_at], order: desc }
KEY-002: Deduplicate sorted by its own key
Severity: critical
Checks: A deduplicate whose sort_by columns are all inside its on key.
Why it matters. Every row in a duplicate group has the same key, so sorting by the key orders nothing. The surviving row is as arbitrary as with no sort_by at all, but the contract now looks correct. Sort by a column that differs between duplicates, normally the record's version time.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: updated_at, type: timestamp }
primary_key: [trip_id]
transformations:
- deduplicate: { on: [trip_id], sort_by: [trip_id] }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: updated_at, type: timestamp }
primary_key: [trip_id]
transformations:
- deduplicate: { on: [trip_id], sort_by: [updated_at], order: desc }
KEY-003: natural_key is deprecated
Severity: info
Checks: A natural_key on the contract.
Why it matters. natural_key is parsed but no engine reads it. primary_key is the business key every load matches on. A contract that states both says the same thing twice, or uses natural_key for what is really a uniqueness rule.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
natural_key: [trip_id]
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
Transformations and rule phases
TRF-001: A transformation no engine implements
Severity: critical
Checks: A transformation whose operation name is not one the runtime implements.
Why it matters. Unknown operations are accepted for forward compatibility, stored, and then skipped. The contract reads as if it aggregates, the pipeline reports success, and the table simply lacks that step. Found in practice: gold marts declaring aggregate: whose output was never aggregated.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: city, type: string }
- { name: trips, type: integer }
transformations:
- phase: post
aggregate: { group_by: [city], count: trips }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: city, type: string }
- { name: trips, type: integer }
transformations:
- phase: post
sql: SELECT city, COUNT(*) AS trips FROM source GROUP BY city
PHS-001: A pre-phase rule reads a column only a post step creates
Severity: critical
Checks: A rule written phase: pre whose SQL reads a column created by a phase: post transformation.
Why it matters. Pre-phase rules run before post-phase transformations. The column does not exist yet, so the rule fails on every row and the whole batch is quarantined.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: fee, type: float }
- { name: net, type: float }
transformations:
- phase: post
derive: { field: net, sql: fare - fee }
quality:
row_rules:
- { name: net_not_negative, phase: pre, sql: net >= 0 }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: fee, type: float }
- { name: net, type: float }
transformations:
- phase: post
derive: { field: net, sql: fare - fee }
quality:
row_rules:
- { name: net_not_negative, phase: post, sql: net >= 0 }
Personal and sensitive data
PII-001: A field that looks like personal data is not tagged
Severity: warning
Checks: A field whose name looks like personal data (email, phone, date of birth, address, bank details…) without pii: true.
Why it matters. Untagged personal data is invisible to masking, erasure and access rules, so it reaches gold and reports in the clear. Tag it, then decide its masking; or confirm it is not personal data.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: rider_email, type: string }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: rider_email, type: string, pii: true, masking: hash }
PII-002: Personal data with no masking
Severity: warning (critical when the domain mandates PII protection)
Checks: A field tagged pii: true with no masking and no security_groups.
Why it matters. Tagging records that a field is personal data; masking is what protects it. Without a strategy the value is served unmasked to everyone who can read the table.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: rider_email, type: string, pii: true }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: rider_email, type: string, pii: true, masking: hash }
SENS-001: Sensitive data with no masking
Severity: warning
Checks: A field tagged sensitive: true with no masking and no security_groups.
Why it matters. Confidential business data (salaries, pricing, contract terms) needs the same protection as personal data. Without masking or access groups it surfaces unmasked.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: driver_payout, type: float, sensitive: true }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: driver_payout, type: float, sensitive: true, masking: redact }
Loads, deletes and history
DEL-001: A current-state entity with no delete strategy
Severity: warning (critical when the domain declares an erasure policy)
Checks: A silver or gold table with a key and a merge/upsert/SCD2/dedup load, but no delete strategy.
Why it matters. A merge only adds and updates. When a record is deleted at the source the table never hears about it, so stale rows live on: counted in metrics and impossible to erase on request. Declare how deletes arrive: CDC delete events with a soft-delete column, or, when every load is a full snapshot, materialization.deletion: {strategy: snapshot_reconcile}.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
materialization:
strategy: merge
format: delta
quality:
row_rules:
- not_null: trip_id
service_levels:
freshness: 24h
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
- { name: is_deleted, type: boolean }
primary_key: [trip_id]
materialization:
strategy: merge
format: delta
soft_delete_column: is_deleted
quality:
row_rules:
- not_null: trip_id
service_levels:
freshness: 24h
SCD-001: SCD2 with no tracked columns
Severity: critical
Checks: materialization.strategy: scd2 with no materialization.scd2.track_columns.
Why it matters. SCD2 opens a new version when a tracked attribute changes. With none listed, every load cuts a new version of every row: the history fills with versions that record no change, and becomes useless.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
materialization:
strategy: scd2
format: delta
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
materialization:
strategy: scd2
format: delta
scd2:
track_columns: [fare]
SRC-001: An unpartitioned bronze landing source
Severity: warning
Checks: A bronze contract reading a landing zone with no source.partition.
Why it matters. Without a partition layout every load lists and reads the whole landing zone. It gets slower as files accumulate, and incremental loads cannot prune to new data.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: bronze }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
source:
type: landing
path: landing/trips/
format: json
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: bronze }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
source:
type: landing
path: landing/trips/
format: json
partition:
format: "y_%Y/m_%m/d_%d"
STREAM-001: Append on a resumable stream
Severity: warning
Checks: A streaming or resumable load (Kafka, Event Hubs, a resumable trigger…) written with append.
Why it matters. Streams deliver at least once: after a restart the in-flight batch is replayed. With append the replayed rows are written again as duplicates. Merge on a key, or overwrite.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
source:
type: stream
path: events/trips/
format: json
trigger: available_now
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
source:
type: stream
path: events/trips/
format: json
trigger: available_now
materialization:
strategy: merge
format: delta
Note
The fix uses a LakeLogic runtime setting that the OLC standard does not define.
STREAM-002: A continuous trigger
Severity: info
Checks: trigger: continuous.
Why it matters. A continuous trigger needs an always-on cluster, a cost paid every hour. Most freshness needs are met by available_now on scheduled or serverless compute; keep continuous for genuinely sub-minute requirements.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
source:
type: stream
path: events/trips/
format: json
trigger: continuous
materialization:
strategy: merge
format: delta
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
primary_key: [trip_id]
source:
type: stream
path: events/trips/
format: json
trigger: available_now
materialization:
strategy: merge
format: delta
Note
The fix uses a LakeLogic runtime setting that the OLC standard does not define.
Quality and service levels
QLT-001: A silver table with no quality rules
Severity: warning
Checks: A silver table with no row rules, dataset rules or field-level rules.
Why it matters. Silver is the enforcement layer: raw data is validated there. With no rules nothing is checked and quarantine can never fire, so bad rows flow to gold unchallenged.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
service_levels:
freshness: 24h
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
quality:
row_rules:
- not_null: trip_id
service_levels:
freshness: 24h
VOL-001: No freshness or volume service level
Severity: info
Checks: A silver or gold table with no service_levels.freshness/row_count, in the contract or inherited from the domain.
Why it matters. Without a freshness or volume expectation, a stalled feed or a half-empty load looks like success. Nobody is told until a consumer notices the numbers are wrong.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
quality:
row_rules:
- not_null: trip_id
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips, target_layer: silver }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
quality:
row_rules:
- not_null: trip_id
service_levels:
freshness: 24h
Ownership
OWN-001: An owner with no group to fall back on
Severity: warning
Checks: An ownership role held by a named person with no group.
Why it matters. Questions about the data (what a field means, whether a change is safe) route to the owner. A lone person with no group is one holiday away from every design stalling. Name the team alongside the person.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
ownership:
business_owner: { user: ada@example.com }
Fixed:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
ownership:
business_owner: { user: ada@example.com, group: rides-analytics }
Note
The fix uses a LakeLogic runtime setting that the OLC standard does not define.
OWN-002: Nobody accountable for blocking questions
Severity: warning
Checks: The resolved domain/system ownership names nobody who answers blocking questions.
Why it matters. Blocking questions hold a design until someone with authority answers. If no owner is accountable for them, every such question is unroutable and the design can never be approved. Judged against the domain's ownership, not the contract alone.
This rule is judged against the domain's resolved policy (_domain.yaml / _system.yaml), not the contract alone.
Fails:
version: 1.0.0
info: { title: Trips, table_name: trips }
model:
fields:
- { name: trip_id, type: string, required: true }
- { name: fare, type: float }
Fixed: