Skip to content

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.

lakelogic lint contracts/
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:

version: 1.0.0
info: { title: Trips, table_name: trips }
model:
  fields:
    - { name: trip_id, type: string, required: true }
    - { name: fare, type: float }