Skip to main content

Meta Schema System

A meta schema is a YAML file that defines the columns for a ClickHouse table - the single source of intent for what a table should look like. The engine reads it, generates DDL, and applies it to ClickHouse. There is no schema registry, no intermediate store, no sync state. This page is the FORMAT reference; the engine classes that consume it are documented in the dfe-engine repo (docs/data-plane/schema-classes.md).

Two sources of truth exist:

  1. Meta schema YAML -- what the schema should be
  2. ClickHouse table -- what the schema actually is

The engine bridges the gap. Rust services (loader, receiver, archiver) slave from the deployed ClickHouse schema only -- they read system.columns at runtime, never YAML.

Where Meta Schemas Live​

Meta schemas are YAML files at the root of this repo (dfe-schemas), shipped to consumers as the pinned dfe-schemas PyPI wheel. Common header profiles, hunt result schemas, and source-specific schemas all use the same format.

dfe-schemas/ <- repo root, shipped as a PyPI wheel
|-- common-header/
| |-- timeseries.yaml <- 9-column default profile
| |-- minimal.yaml <- 5-column high-volume profile
| '-- passthrough.yaml <- 4-column transparent bridge
|-- compat-profiles/ <- rule rows that read another platform's header (compat-profiles.md)
|-- meta/ <- source meta schemas (aws/ azure/ gcp/ m365/ runzero/)
|-- additional/ <- extra-field overlays (<package>/<data_stream>, snapshot/)
|-- derived/ <- derived schemas: a select list over a base
|-- hunts/
| |-- results.yaml <- hunt detection output columns
| '-- detection_checkpoint.yaml <- runner checkpoint table
|-- scripts/ <- validate / render / annotate
'-- README.md

YAML Format​

Meta schemas support two layouts: version tree (preferred) and flat (backward-compatible).

Version Tree (Preferred)​

Each version carries a complete column snapshot. No filtering or reconstruction needed -- read versions."1.0.0".columns and you have the full schema for that version.

current: "1.0.0" # Default version when no pin specified

versions:
"1.0.0":
date: "2026-01-15"
type: model # model | addition | revision
summary: "Initial schema"
columns:
- name: user_name
type: string
use_case: dimension
expr: "@source: first(user_id/uid/id)"
comment: "User identifier"

- name: source_ip
type: ip
use_case: range
expr: "@source: src_ip"
comment: "Source IP address"

- name: message
type: text
use_case: word_search
comment: "Log message body"

Flat (Backward-Compatible)​

Files without current/versions metadata work unchanged. No versioning -- just a plain column list.

columns:
- name: user_name
type: string
use_case: dimension
- name: message
type: text
use_case: word_search

Version Metadata Fields​

FieldRequiredDescription
currentYesDefault version when consumer doesn't specify a pin
versionsYesDict of version string -> version entry
dateYes (per version)When the version was created (YYYY-MM-DD)
typeYes (per version)Change category: model, addition, revision
summaryYes (per version)Human-readable change description
columnsYes (per version)Complete column snapshot for this version
profile_excludeNo (per version)Common-header columns to drop when this schema is composed onto a profile

profile_exclude trims the header for a table that does not need all of it. hunts/results.yaml uses it to drop _raw and _tags: a detection row references its source by matched_uuid, so a second copy of the payload text plus an ngram index over it is storage for nothing. Naming a column that the profile does not have is a no-op, not an error - a profile can legitimately lack it (minimal has no _raw).


Column Definition Reference​

Each column is a SchemaColumn. Only name and type are required.

columns:
- name: _org_id # Required: column name
type: string # Required: primitive type
cardinality: low # Optional: low | high | unknown
use_case: dimension # Optional: query pattern -> index
attribute: [not_null] # Optional: storage modifiers
default: null # Optional: DEFAULT expression
order: 2 # Optional: ORDER BY position
expr: "@source: _org_id" # Optional: DFE loader directive
comment: "Tenant identifier" # Optional: human description
ch_override: null # Optional: exact ClickHouse type

Field Reference​

FieldTypeDefaultDescription
namestr(required)Column name. System fields use _ prefix to avoid collision with source data.
typestr(required)Primitive type -- one of 15 values (see Type System).
cardinalitystrunknownHow many distinct values the column holds: low, high or unknown (see Cardinality).
attributelist[str][]Storage attributes: nullable, not_null, materialized, alias, and the retired lowcardinality. Accepts a single string or list.
use_casestr | nullnullThe question the column is asked, which determines index generation: dimension, exact_match, range, word_search, substring_search, key_search, similarity_search(<dims>).
defaultstr | nullnullRaw ClickHouse DEFAULT expression (e.g. "now64(3)", "generateUUIDv7()"). Used as MATERIALIZED or ALIAS expr when those attributes are set.
orderint | nullnullPosition in ORDER BY / PRIMARY KEY (0-based). Only columns with order set are included in the key.
exprstr | nullnullDFE directive that tells the loader how to populate this column (see DFE Expressions).
commentstr | nullnullHuman-readable description.
ch_overridestr | nullnullExact ClickHouse type string -- bypasses primitive mapping entirely. No auto-Nullable, no auto-codec.
_field_typestr | nullnullColumn classification annotation (base = shipped by DFE). Maps to the engine model's field_type.
max_dynamic_pathsint | nullnullJSON-column dynamic-path budget. Rendered as JSON(max_dynamic_paths=N) on a bare JSON column, and ignored on any other type.
syntheticmap | nullnullSynthetic data generation hints (see Synthetic Data Hints). Ignored by the schema loader and DDL generation.

Synthetic Data Hints​

A column may carry an optional synthetic: mapping that the DFE synthetic data generator uses when producing synthetic data from this schema - a realistic continuous inbound stream for sales demos, and repeatable data for testing. The schema loader ignores the key, so hints never affect validation or DDL. Hints are applied in priority order - static > values > templates > provider > minimum/maximum - and columns without hints fall back to the engine's semantic heuristics (name/type based).

HintTypeDescription
staticanyEmit this exact value for every event.
valueslistCategorical vocabulary to draw from.
weightslist[float]Draw weights aligned with values.
templateslist[str]Message templates with {placeholder} tokens filled from the event's entity context ({username}, {ipv4}, {external_ipv4}, {port}, {http_status}, ...).
providerstrFaker provider method to call (e.g. user_agent).
minimum / maximumnumberBounds for numeric generation.
formatstrTimestamp rendering: iso8601 (default), epoch_s, epoch_ms, or a strftime pattern.

Synthetic Data Scenarios​

Correlated columns must not draw independently (an sshd message under appname: cron reads as fake). A version entry may carry a synthetic: mapping BESIDE columns: whose scenarios: list defines coherent event shapes - one weighted scenario is drawn per event, and its values/templates override per-column hints for the columns it names:

versions:
"1.0.0":
columns: [...]
synthetic:
scenarios:
- name: sshd-auth
weight: 35
values:
app_name: sshd
facility: [auth, authpriv] # list draws uniformly
severity: [info, info, notice] # repeat entries to weight
templates:
message:
- "Failed password for {username} from {external_ipv4} port {port} ssh2"

Columns a scenario does not name fall back to per-column hints, then heuristics. The shipped reference sets (meta/syslog.yaml, meta/otel/logs.yaml, meta/beats/filebeat.yaml) use scenarios for the correlated columns and hints for the rest.

How expr and comment Become DDL​

In the generated DDL, expr and comment are combined into a single ClickHouse COMMENT clause. The loader parses the @ directives it acts on from the COMMENT at runtime (see "DFE Expressions" above for which ones):

-- Both expr and comment:
`_timestamp` DateTime64(3,'UTC') COMMENT '@source: timestamp | now() - Event timestamp'

-- Expr only (descriptive -- the DEFAULT clause is what the loader actually reads):
`_uuid` UUID DEFAULT generateUUIDv7() COMMENT '@generated: generateUUIDv7()'

-- Comment only:
`matched_uuid` UUID COMMENT 'UUID of the matching source record'

Type System​

15 Primitives​

Primitives are human-readable type names that map to ClickHouse types with sensible defaults. You don't need to know ClickHouse storage internals -- pick the primitive that describes your data.

PrimitiveWhat It IsClickHouse TypeCodecNullable Default
stringShort-to-medium text (names, IDs, codes)StringZSTD(1)Yes
textLarge text (log messages, bodies)StringZSTD(3)Yes
integerWhole number (counts, IDs, event codes)Int64ZSTD(1)Yes
floatDecimal number (scores, latency)Float64ZSTD(1)Yes
booleanTrue/falseBoolLZ4No
datetimeDate and time with timezoneDateTime64(3,'UTC')Delta, ZSTD(1)Yes
timestampDate and time -- never null (for ORDER BY)DateTime64(3,'UTC')Delta, LZ4No
dateDate onlyDateDelta, ZSTD(1)Yes
ipIP address (v4 or v6)IPv6LZ4Yes
uuidUnique identifierUUID(none)Yes
jsonStructured/semi-structured dataJSONZSTD(3)Yes
geo_pointLatitude/longitude pairPointZSTD(1)Yes
enumFixed set of allowed valuesEnum8(...)ZSTD(1)No
mapString-keyed attributes (labels, tags)Map(LowCardinality(String), String)ZSTD(1)No
vectorEmbedding for similarity searchArray(Float32)ZSTD(1)No

ClickHouse refuses Map and Array inside Nullable, so neither of the last two can be null.

Type Wrapping​

Nullable comes from the primitive's default and the nullable / not_null attributes. LowCardinality comes from cardinality and nothing else:

string -> Nullable(String)
string + cardinality: low -> LowCardinality(Nullable(String))
string + cardinality: low + [not_null] -> LowCardinality(String)
string + cardinality: high -> Nullable(String)
timestamp -> DateTime64(3,'UTC') (not nullable by default)
boolean -> Bool (not nullable by default)

Wrapping order: Nullable wraps inner, LowCardinality wraps outer.

ClickHouse Type Override (ch_override)​

When a primitive isn't sufficient, specify the exact ClickHouse type:

- name: http_status
type: integer
ch_override: UInt16
use_case: dimension

- name: precision_ts
type: datetime
ch_override: "DateTime64(6,'UTC')"

- name: nested_data
type: string
ch_override: "Nested(key String, value String)"

When ch_override is set:

  • No automatic Nullable wrapping
  • No automatic codec selection
  • The type string is used verbatim in DDL
  • Attributes are still validated but don't affect type resolution

The engine validates ch_override against a catalogue of supported ClickHouse types (integers, floats, decimals, strings, dates, geo, complex, enums, specials).


Cardinality​

How many distinct values the column holds. One declaration, because it decides two things that used to be set apart with nothing keeping them in agreement: attribute: [lowcardinality] was the storage decision, hand-set, and exact_match choosing set(0) over bloom_filter was the index decision, derived separately.

ValueStorageexact_match index
lowLowCardinality(...)set(0) -- holds every distinct value exactly
highplainbloom_filter -- bounded, probabilistic
unknown (default)plainbloom_filter

unknown is the honest default. Nobody re-reviews a field that already looks decided, so a column with nothing measured says so, and gets the safe way to be wrong: no dictionary, and an index whose cost does not grow with the column.

Measure it rather than guess it. An Elasticsearch index template does not carry cardinality, so the converter and the Rust dfe-schemagen port both leave it unknown -- correctly. dfe-engine's data-shape service reads it from the rows that have already landed (dfe_engine.services.schema.data_shape_service) and returns a reading carrying the distinct count, the rows it covers and the date it was taken.

attribute: [lowcardinality] is the retired spelling and still reads as cardinality: low. Declaring the two against each other is an error, not something the loader resolves quietly.


Attributes​

Attributes modify how the type is stored. Specified as a list in YAML.

AttributeWhat It DoesValid Primitives
lowcardinalityRetired -- the old spelling of cardinality: lowstring, text, integer, float, date, ip
nullableForce NULL allowed (overrides primitive default)all
not_nullForce NOT NULL (overrides primitive default)all
materializedColumn computed on INSERT, not stored in source dataall
aliasVirtual column computed at query timeall

How Attributes Interact with default​

The default field produces different DDL clauses depending on attributes:

AttributesDDL Clause
(none)DEFAULT {default}
[materialized]MATERIALIZED {default}
[alias]ALIAS {default}
# Materialized: computed on insert from other columns
- name: day
type: date
attribute: [materialized]
default: "toDate(_timestamp)"

# Alias: virtual, computed at query time
- name: year
type: integer
attribute: [alias]
default: "toYear(_timestamp)"

Nullability Defaults​

Each primitive has a default nullability. Override with nullable or not_null:

  • Nullable by default: string, text, integer, float, datetime, date, ip, uuid, json, geo_point
  • NOT null by default: timestamp, boolean, enum, map, vector

Why? Nullable(T) stores two columns (data + null bitmap), doubling storage and halving query speed. ORDER BY columns and booleans default to NOT NULL because null in these positions destroys index effectiveness.


Use Cases​

A use case names the question you ask the column -- never the ClickHouse index that answers it. You declare the question, the engine picks the primitive, and it can pick a different one on a later ClickHouse without your schema changing.

Use CaseThe questionExample Columns
dimensionI filter and group by this: WHERE status = 'error'status, severity, org_id
exact_matchI look up specific valuestrace_id, request_id
rangeI query ranges, between, time windows: WHERE latency > 100latency_ms, timestamp
word_searchI search for whole words: WHERE hasToken(message, 'error')message, log_body
substring_searchI search for fragments inside words: WHERE message LIKE '%refused%'syslog_message
key_searchI search the keys and values of a map columnattributes, labels
similarity_search(<dims>)I find records similar to this oneembedding
(empty)No indexraw payload, metadata

similarity_search is the one use case that takes an argument: ClickHouse needs the vector's dimension count up front and cannot read it off the column.

Use Case -> Primitive Constraints​

Use CaseValid Primitives
dimensionstring, integer, boolean, enum, ip, uuid
exact_matchstring, uuid, integer, ip
rangeinteger, float, datetime, timestamp, date, ip
word_searchstring, text
substring_searchstring, text
key_searchmap
similarity_searchvector

Invalid combinations are rejected at validation time (e.g. word_search on an integer).

Generated ClickHouse Indexes​

What a use case renders today, on ClickHouse 26.3. These are the answers, not part of the vocabulary -- read them to size the cost, not to name a column. They live as data in registries/ddl.yaml, the DDL catalogue, read through dfe_schemas.ddl_catalogue. A changed answer is a catalogue edit and a dfe-schemas release, not a code change.

Use CaseIndex TypeGranularityNotes
dimensionset(0), or bloom_filter on a Nullable column4set(0) holds the exact distinct values per granule, but prunes nothing on a Nullable column on 26.3
exact_matchset(0) on cardinality: low when not Nullable, else bloom_filter4The bloom filter is probabilistic -- false positives, no false negatives. It takes IPv6, and on an Array column it answers has(), hasAny() and hasAll()
rangeminmax4Stores min/max per granule
word_searchtext(tokenizer=splitByNonAlpha)1GA text index (v26.2+). Deterministic, row-level filtering.
substring_searchtext(tokenizer=ngrams(3))1Character n-gram text index for substring matching.
key_searchtwo indexes, text(tokenizer=array) over mapKeys(col) and mapValues(col)1A text index refuses a Map column itself, so the keys and the values are indexed apart
similarity_search(<dims>)vector_similarity('hnsw', 'cosineDistance', <dims>)100000000One index per part: at granularity 1 each granule gets its own graph and the top-k comes back wrong. hnsw is the only method 26.3 implements

The catalogue carries no pre-25.10 variant. DFE ships ClickHouse 26.3, where tokenbf_v1 and ngrambf_v1 are deprecated in favour of the text index.

Which use case for which question​

You look forDeclareQuery it as
One id, hash or user among millionsexact_match on a string or uuidcol = 'x', col IN (...)
One IP addressexact_match on an ipsrc_ip = toIPv6('10.1.2.3')
A value inside an array columnexact_match on the element's primitive, with ch_override: Array(...)has(col, 'x'), hasAny, hasAll
A numeric id or event code with many valuesexact_match on an integercol = 4624
A handful of values, filtered and groupeddimension, with cardinality: lowcol = 'error', GROUP BY col
A time window, a numeric range, an address rangerangecol > 100, ip BETWEEN toIPv6('10.0.0.0') AND toIPv6('10.255.255.255')
A whole word in free textword_searchhasToken(col, 'refused'), hasAnyTokens, hasAllTokens
A fragment of at least 3 characterssubstring_searchcol LIKE '%efuse%'
A key or value in a mapkey_searchmapContainsKey(col, 'k'), has(mapValues(col), 'v')
The nearest embeddingssimilarity_search(<dims>)ORDER BY cosineDistance(col, [...]) LIMIT 10

A vector search with a narrow WHERE can return fewer than the LIMIT: ClickHouse takes the index's k nearest, then filters them.

An address range is a BETWEEN on the two ends: isIPAddressInRange() reads every row, because no index can prune it.

exact_match on an integer or an ip is a bloom filter. range stores min and max per granule, which prunes only when the values arrive roughly in order. A lookup on one address in unsorted traffic needs exact_match.


DFE Expressions​

The expr field carries directives, emitted as ClickHouse column comments. The Rust loader reads them back from system.columns at runtime, but of the five distinct directives below it only acts on three -- @source, @renamed, @computed. @generated and @config are dropped as unknown directives (DIRECTIVE_NAMES in dfe-loader's src/column_meta/mod.rs).

DirectivePurposeExampleLoader-parsed
@source: fieldCopy from source data@source: timestamp | now()Yes
@source: first(a/b/c)First non-null from list@source: first(user_id/uid/id)Yes
@generated: exprDescriptive: ClickHouse generates via DEFAULT@generated: now64(3)No -- see below
@renamed: fieldZero-copy field rename@renamed: logoriginalYes
@computed: exprDerived/enriched value@computed: geoip(ip).country_codeYes
@config: pathDescriptive: mapping is configurable@config: routing.org_id_fieldNo -- see below

@generated describes a DEFAULT/MATERIALIZED/ALIAS clause that already lives in the column's own default:/attribute: fields; the loader omits the field because it reads that clause's kind straight from system.columns (default_kind), not because it parsed the @generated: text. @config documents that some other field's value is driven by loader config (for example routing.org_id_field) rather than hardcoded -- the loader never reads the comment's path to resolve it. See dfe-loader docs/clickhouse/DDL-DIRECTIVES.md for the full mechanism.

_raw and _json carry no directive. What they hold depends on the loader's capture mode, not on an expr.

Column comments have highest precedence in the loader -- above built-in presets and external remap files. The schema definition IS the authoritative field mapping.


Common Header Profiles​

Every DFE table starts with a standard header of underscore-prefixed system fields. The profile determines which header columns are included.

Profile Summary​

ProfileColumnsUse Case
timeseries (default)9Logs, alerts, audit trails -- full event ingestion
minimal5Metrics, flow records -- high-volume structured data
passthrough4Transparent bridge -- no timestamp injection

timeseries (default) -- 9 columns​

ColumnTypeCardinalityUse CaseORDER BYExpr
_timestamp_loadtimestamp0@generated: now64(3)
_timestamptimestamprange1@source: timestamp | now()
_timestamp_receivedtimestamp@source: first(_timestamp_received/timestamp_received/received_at)
_uuiduuid@generated: generateUUIDv7()
_org_idstringlowdimension2@source: _org_id
_sourcestringlowdimension@source: first(_source) | topic_name
_rawtextsubstring_searchcapture mode
_jsonjsoncapture mode
_tagsjson@source: first(_tags/tags/meta/metadata.tags)

Each header field reads its _-prefixed wire name first, except the event timestamp. The header expects the first hop, dfe-receiver or dfe-fetcher, to stamp _org_id and _timestamp_received on every record. dfe-loader takes a column's directive ahead of its own routing.org_id_field, so the _org_id directive decides which field every row's tenant comes from. A sender's own spelling of the tenant, such as a DFE 2.x org_id, reaches _org_id only through a compatibility profile at the first hop.

minimal -- 5 columns​

_timestamp_load, _timestamp, _uuid, _org_id, _json

passthrough -- 4 columns​

_timestamp_load, _uuid, _org_id, _json

How a Source Selects a Profile​

# In the Source YAML:
header:
type: timeseries # Profile name
version: "1.0.0" # Pin to specific profile version

Profile columns are injected first. Source-specific columns follow. If a source column duplicates a profile column name, the profile wins (with warning).


Composition Pipeline​

A fully composed schema is built from up to four layers:

How It's Wired in a Source YAML​

source: windows_audit
header:
type: timeseries
version: "1.0.0"
schema:
meta_schema: meta/windows_audit.yaml # Layer 2: base columns
meta_schema_version: "2.1.0" # Pin meta schema version
derived_schema: derived/windows_custom.yaml # Layer 3: narrows to a select list
additional_fields: add/windows_extra.yaml # Layer 4: extra columns
ttl_days: 90
engine: MergeTree

Schema file paths are resolved relative to schemas_base_dir (the schemas directory).

Composition Semantics​

LayerMerge behaviour
ProfileAlways comes first. Profile columns cannot be overridden by source columns.
Meta schemaBase source-specific columns.
Derived schemaNarrows the source columns to its select list, in that order. A selected column may override index and nothing else; a name the base lacks is an error.
Additional fieldsNew columns are appended. Existing names override with warning.

DDL Generation​

dfe_schemas.render.render_table(spec) renders a table that is not a manifest object, such as a source table, from a TableSpec. It runs the same TableDDL code the manifest renderer does, so the two cannot drift.

The engine generates complete ClickHouse DDL from composed columns. Here is what a generated CREATE TABLE statement looks like for the timeseries profile plus the three source columns from the example above, with ttl_days: 90:

-- =============================================================================
-- DFE Schema DDL: windows_audit
-- Profile: timeseries
-- Generated by: dfe-engine DDLFileWriter
-- Generated at: 2026-03-03 12:00:00 UTC
--
-- This file was auto-generated. Use {db} as database placeholder.
-- =============================================================================
CREATE TABLE IF NOT EXISTS `{db}`.`windows_audit`
(
`_timestamp_load` DateTime64(3,'UTC') DEFAULT now64(3) COMMENT '@generated: now64(3) - Insertion timestamp (ms precision)' CODEC(Delta, LZ4),
`_timestamp` DateTime64(3,'UTC') COMMENT '@source: timestamp | now() - Event timestamp from source data' CODEC(Delta, LZ4),
`_timestamp_received` DateTime64(3,'UTC') COMMENT '@source: first(_timestamp_received/timestamp_received/received_at) - When DFE first received the event: the dfe-receiver or dfe-fetcher stamp' CODEC(Delta, LZ4),
`_uuid` Nullable(UUID) DEFAULT generateUUIDv7() COMMENT '@generated: generateUUIDv7() - Time-ordered unique event identifier',
`_org_id` LowCardinality(String) COMMENT '@source: _org_id - Tenant/organisation identifier' CODEC(ZSTD(1)),
`_source` LowCardinality(Nullable(String)) COMMENT '@source: first(_source) | topic_name - Data source label (e.g. beats, syslog, crowdstrike-edr)' CODEC(ZSTD(1)),
`_raw` Nullable(String) COMMENT 'Raw payload text, when the loader''s capture mode writes one. A JSON payload lands in _json instead, so _raw is often empty. (full-text indexed)' CODEC(ZSTD(3)),
`_json` JSON(max_dynamic_paths=2048) COMMENT 'The payload as structured JSON, when the loader''s capture mode writes one' CODEC(ZSTD(3)),
`_tags` JSON COMMENT '@source: first(_tags/tags/meta/metadata.tags) - Event metadata tags' CODEC(ZSTD(3)),
`user_name` Nullable(String) COMMENT '@source: first(user_id/uid/id) - User identifier' CODEC(ZSTD(1)),
`source_ip` Nullable(IPv6) COMMENT '@source: src_ip - Source IP address' CODEC(LZ4),
`message` Nullable(String) COMMENT 'Log message body' CODEC(ZSTD(3)),
INDEX idx__timestamp `_timestamp` TYPE minmax GRANULARITY 4,
INDEX idx__org_id `_org_id` TYPE set(0) GRANULARITY 4,
INDEX idx__source `_source` TYPE set(0) GRANULARITY 4,
INDEX idx__raw `_raw` TYPE text(tokenizer = 'splitByNonAlpha') GRANULARITY 64,
INDEX idx_user_name `user_name` TYPE set(0) GRANULARITY 4,
INDEX idx_source_ip `source_ip` TYPE minmax GRANULARITY 4,
INDEX idx_message `message` TYPE text(tokenizer=splitByNonAlpha) GRANULARITY 1
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(_timestamp_load)
PRIMARY KEY (`_timestamp_load`, `_timestamp`, `_org_id`)
ORDER BY (`_timestamp_load`, `_timestamp`, `_org_id`)
TTL _timestamp_load + INTERVAL 90 DAY DELETE WHERE _timestamp_load >= 0
SETTINGS
index_granularity = 2048,
ttl_only_drop_parts = 1,
merge_max_dynamic_subcolumns_in_wide_part = 64
COMMENT '@profile: timeseries | @profile_version: 1.0.3 | @windows_audit_version: 2';

Clauses follow ClickHouse's order: ENGINE, PARTITION BY, PRIMARY KEY and ORDER BY, SAMPLE BY, TTL, SETTINGS, then COMMENT. There is no projection unless projection_order_by is set.

Table SETTINGS​

SettingEmitted whenValue
index_granularityAlwaysDDLConfig.index_granularity
ttl_only_drop_partsThe table has a TTL and ttl_only_drop_parts is true1 when every TTL column appears in the PARTITION BY expression, else 0. An unaligned TTL then deletes rows instead of dropping parts, because a part is dropped only once every row in it has expired.
merge_max_dynamic_subcolumns_in_wide_partAny column resolves to JSON or a type wrapping it64, from JSON_TABLE_SETTINGS in dfe_schemas/deploy_defaults.py

Uncapped, a merge over a JSON column with map-shaped keys writes one stream per dynamic subcolumn and runs ClickHouse out of memory. json_table_settings() in deploy_defaults.py decides whether a table carries the cap, after dropping quoted literals so an Enum8('JSON' = 1) label does not count, and adds the platform's own JSON settings from dfe_schemas/sql/platform.sql (write_marks_for_substreams_in_compact_parts = 0 on self-hosted only). Every table renderer (Renderer for the manifest tables, the engine's DDLGenerator for source tables) emits them on CREATE. dfe-engine also issues ALTER TABLE ... MODIFY SETTING on a JSON table that exists without them or with another value, and RESET SETTING for a platform-only setting the platform does not take, on engine start and on a source redeploy. Any other setting the table carries is left alone.

DDL Configuration (DDLConfig)​

FieldDefaultDescription
db"{db}"Database placeholder (replaced at deploy time)
engine"MergeTree"Engine variant, with its arguments if it takes any (ReplacingMergeTree(ver)). The renderer adds the Replicated prefix, never this field.
ttl_daysNoneData retention in days. None is undeclared and adds no TTL. 0 declares none.
ttl_columns["_timestamp_load"]Columns used in TTL expressions. The partition column alone, so ttl_only_drop_parts can drop whole parts. ttl_days over an empty list, or over a column the table lacks, is refused.
partition_column"_timestamp_load"Column PARTITION BY is built from. No PARTITION BY when the table lacks it.
partition_granularity"day"day gives toYYYYMMDD(column), month gives toYYYYMM(column).
partition_byNoneA raw PARTITION BY expression, emitted verbatim. Wins over partition_column and partition_granularity.
order_byNoneA raw ORDER BY expression. Wins over the columns' own order positions, and sets no PRIMARY KEY unless primary_key is also set.
primary_keyNoneA raw PRIMARY KEY expression. Used only with order_by.
extra_indexes[]Raw INDEX definitions appended to the column list.
index_granularity2048ClickHouse granule size
ttl_only_drop_partsTrueEmit the ttl_only_drop_parts setting on a table with a TTL. See Table SETTINGS.
sample_byNoneOptional SAMPLE BY expression
projection_order_byNoneColumn for a SELECT * ORDER BY projection named <column>_optimized. Off unless set: on a JSON-heavy table the projection copies every column and doubles the substream metadata ClickHouse holds in memory.
schema_versionNoneWritten to the table comment as @<table>_version.
profile_nameNoneProfile name for the table comment and header.
profile_versionNoneProfile version for the table comment
descriptionNoneA line in the header comment

The manifest tables under tables/ are rendered by dfe_schemas/render.py, whose table: block defaults differ: ttl_columns is empty and projection_order_by is unset, so a table declares its TTL columns itself or keeps every row. Its ttl_days falls back to the deployment default only when ttl_columns is set. Its partition_column, partition_granularity and index_granularity defaults, and the granularity -> function map behind PARTITION BY, come from the table block of registries/ddl.yaml. docs/tables.md has the block.

Table Comment Metadata​

The generated table comment contains machine-readable tags:

COMMENT '@profile: timeseries | @profile_version: 1.0.0 | @windows_audit_version: 2'

The tags are @profile, @profile_version and @<table>_version (from schema_version), each present only when its field is set. A manifest table rendered from a header carries @profile and @profile_version only. This lets migration tooling detect a table's schema version and profile at runtime.

Views​

The engine generates CREATE OR REPLACE VIEW statements for field mapping standards (Sigma, ECS, CIM). Views alias source column names to standard field names:

CREATE OR REPLACE VIEW {db}.windows_audit_sigma AS
SELECT
`source_ip` AS `SourceIP`,
`user_name` AS `User`,
`event_id` AS `EventID`,
*
FROM {db}.windows_audit;

Zero storage overhead -- views are computed at query time.


Version Management​

Immutable Versions​

Published version entries are never modified. To change a schema:

  1. Add a new version entry with the updated column snapshot
  2. Update current to point to the new version
  3. Commit

SchemaVer Semantics​

Uses SemVer format (MAJOR.MINOR.PATCH) with schema-specific semantics:

ChangeBumpDescriptionMigration Safety
MODELMajorColumn removed, type changed, ORDER BY changedManual review required
ADDITIONMinorNew column added, new default expressionSafe auto-migrate (ALTER TABLE ADD COLUMN)
REVISIONPatchComment updated, attribute tweakedNo DDL change

Version Pinning​

Different sources can pin different versions from the same file:

# Source A: pinned to header v1.0.0
header:
type: timeseries
version: "1.0.0"
schema:
meta_schema: meta/syslog.yaml
meta_schema_version: "1.0.0"

# Source B: pinned to header v1.0.0, meta v2.0.0
header:
type: timeseries
version: "1.0.0"
schema:
meta_schema: meta/syslog.yaml
meta_schema_version: "2.0.0"

No pin (meta_schema_version: null) uses the file's current marker.


Schema Lifecycle​

EventDDL Action
Source createdCREATE TABLE IF NOT EXISTS from profile + source schema
Schema field addedALTER TABLE ADD COLUMN IF NOT EXISTS
Schema field modifiedALTER TABLE MODIFY COLUMN (type/codec/comment)
Source disabledNo DDL change -- table stays, no new data
Source deletedTable preserved -- manual DROP TABLE if needed

The deployed ClickHouse table is always authoritative at runtime.


Using Meta Schemas in the dfe-schemas Repo​

Directory Structure​

dfe-schemas/
|-- common-header/ # Common header profiles (shipped, read-only)
| |-- timeseries.yaml
| |-- minimal.yaml
| '-- passthrough.yaml
|-- compat-profiles/ # Compatibility profiles and their JSON schema (shipped)
|-- hunts/ # Hunt output + checkpoint schemas (shipped)
| |-- results.yaml
| '-- detection_checkpoint.yaml
|-- meta/ # Source meta schemas, grouped by provider
| |-- aws/ azure/ beats/ elastic/ gcp/ m365/ otel/ runzero/ spamhaus/ abusech/
|-- additional/ # Extra-field overlays (<package>/<data_stream>, snapshot/)
|-- derived/ # Derived schemas: a select list over a base
'-- README.md

Generating a Meta Schema from Live Rows​

A provider whose export is wider than its documentation (runZero's is) gets its meta schema from a dump of real rows, not from the docs: scripts/generate_meta_schemas.py reads a directory of <store>.jsonl files (one JSON object per line), takes the union of keys across every row, types each column from the values it saw, and writes meta/<provider>/<store>.yaml in the version-tree layout. The typing rules, the row-key handling and the ch_override: Array(...) escape for list values are in the script's docstring; the provider's arguments live in an @ file beside it (scripts/meta-runzero.args), so a regeneration is one command and --check proves the checked-in files still match the dump.

Generating the Elastic ECS Meta Schema​

meta/elastic/ecs.yaml is the ECS vocabulary shared by every Beat and the Elastic Agent, generated rather than written: at 1018 columns nobody maintains it by hand. The generator is the dfe-schemagen crate in dfe-transform-elastic-dev, which takes column types from the ECS spec and the set of referenced names from an elastic/integrations clone. A field's column falls out of its whole spec entry, not its type alone: normalize: [array] makes an array column, object_type a searchable map, and index: false drops the index Elasticsearch itself never uses.

The file carries no use_case: a meta schema is the full column set, and the index on a column is chosen by each derived schema that selects it. Every column keeps its Elasticsearch mapping type comment, which is the evidence those index choices rest on.

Both inputs are pinned by commit. --ecs-version has no default, so the version on the output is always one somebody chose:

InputPin
elastic/ecsv9.5.0, released 2026-08-04, commit 401807e0547301525acd28c4fb667203fec66d59
elastic/integrationscommit 2c934eb5223bdfcf0ea0db9e3230154933352bda
env DFE_ECS_SPEC=<ecs checkout>/generated/ecs/ecs_flat.yml \
DFE_ELASTIC_SOURCES=<clone root> \
cargo run -p dfe-schemagen --release -- \
--out <tree> --date <YYYY-MM-DD> --ecs-version 9.5.0

--date is passed rather than read from the clock, so the same inputs render the same bytes on any day.

Every column declares cardinality: unknown. An Elasticsearch index template carries no cardinality and the generator cannot measure one, so it says so instead of guessing: exact_match on an undeclared column renders a bounded bloom_filter rather than a dictionary the column may never earn.

Store-Snapshot Stores​

A store that arrives as a periodic full dump (dfe-fetcher's dump shape) lands every frame inside a snapshot envelope: kind, snapshot_id, snapshot_at, store, seq, the end marker's row_count and completed_at, with the provider's record under record. The envelope columns are declared once in additional/snapshot/envelope.yaml and composed onto each store through schema.additional_fields; the per-store meta schema reads @source: record.<key>. The envelope's timestamp repeats snapshot_at, so the header's _timestamp groups a whole dump with no extra wiring.

Creating a New Meta Schema​

  1. Create a YAML file in the appropriate directory:

    # meta/my_new_source.yaml
    current: "1.0.0"

    versions:
    "1.0.0":
    date: "2026-03-03"
    type: model
    summary: "Initial schema for my_new_source"
    columns:
    - name: event_type
    type: string
    cardinality: low
    use_case: dimension
    comment: "Type of event"

    - name: message
    type: text
    use_case: word_search
    expr: "@source: message"
    comment: "Event message body"

    - name: severity
    type: string
    cardinality: low
    use_case: dimension
    expr: "@source: severity"

    - name: client_ip
    type: ip
    use_case: range
    expr: "@source: first(client_ip/src_ip/ip)"
    comment: "Client IP address"
  2. Reference it from a Source definition:

    source: my_new_source
    header:
    type: timeseries
    version: "1.0.0"
    schema:
    meta_schema: meta/my_new_source.yaml
    meta_schema_version: "1.0.0"
    ttl_days: 90
    engine: MergeTree

Adding a New Version to an Existing Schema​

Add a new version entry with a complete column snapshot (not a delta):

current: "1.1.0" # Updated current marker

versions:
"1.0.0":
date: "2026-01-15"
type: model
summary: "Initial schema"
columns:
- name: event_type
type: string
# ... all columns for v1.0.0

"1.1.0":
date: "2026-03-03"
type: addition
summary: "Added geo_country column"
columns:
- name: event_type
type: string
# ... all columns from v1.0.0 PLUS:
- name: geo_country
type: string
cardinality: low
use_case: dimension
expr: "@computed: geoip(client_ip).country_code"
comment: "GeoIP country code"

Each version is a complete snapshot -- copy all existing columns, then add/modify/remove.

Updating the Version Pin​

After merging changes to the dfe-schemas repo and cutting a release, raise the floor in the consumer's pyproject.toml and relock:

# In dfe-engine (or any consumer):
# bump the dfe-schemas floor in pyproject.toml, e.g. "dfe-schemas>=0.2.9"
uv lock
git add pyproject.toml uv.lock
git commit -m "fix: bump dfe-schemas pin"

Bundled Profiles​

The wheel carries common-header/ (and the rest of the schema trees) as package data under dfe_schemas/data/, so bumping the version pin above is the whole update.

Resolution Chain​

dfe-engine resolves the common-header profiles in this order, first match wins. dfe-loader reads no schema files at runtime: it takes the same directives from the ClickHouse column comments the engine wrote.

  1. An explicit profiles_dir argument to SchemaLoader.load_profile, which skips the rest of the chain
  2. DFE_ENGINE_SCHEMAS_DIR env var -> {dir}/common-header/
  3. common-header/ under the installed dfe-schemas package (via importlib.resources)
  4. common-header/ under the image's schema seed directory (DFE_ENGINE_SCHEMAS_SEED_DIR, default /app/schemas-seed)

Engine class reference​

The engine-side classes that consume this format (SchemaLoader, SchemaBuilderV2, DDLGenerator, SchemaManager, DDLFileWriter), with worked examples, validation rules, and gotchas, are documented in the dfe-engine repo: docs/data-plane/schema-classes.md. One home per fact - this page owns the YAML FORMAT only.