Skip to content

Write specs

Spec language

Tables, columns, rules and the mask block, with every field and generator.

On this page

A scenario spec is a versioned JSON document that you write as YAML. It names tables, says how many rows each one gets, and defines every column by exactly one source: a generator, a foreign key, an expression, a lookup or a constant. The engine compiles it into a plan, then generates and checks the data.

The spec's JSON Schema is generated from the engine's Rust types and published as spec.schema.json (dogm schema prints it). The field reference below is built from that file, so it matches the engine you run.

A complete example#

An online shop: customers, their orders, and one payment per order, with calculated totals, a lookup, two rules and a mask block.

Speccompiles with dogm 0.1.0
spec_version: 1
locale: en-PH
tables:
  customers:
    rows: 1000
    primary_key: { column: customer_id, format: "CUS-{n:06}" }
    columns:
      full_name: { gen: person.full_name, pii: person.name }
      email:     { gen: person.email, from: full_name, unique: true, pii: person.email }
      city:      { gen: ph.city, weights: population }
      joined_on: { gen: date, between: [2024-01-01, 2026-10-01] }
  orders:
    rows_per_parent: { parent: customers, dist: uniform, min: 1, max: 3 }
    primary_key: { column: order_id, format: "ORD-{n:07}" }
    columns:
      customer_id:    { ref: customers.customer_id }
      order_date:     { gen: date, after: parent.joined_on, within_days: [1, 90] }
      item_count:     { gen: int, between: [1, 5] }
      unit_price_php: { gen: choice, values: [249, 399, 589, 1250, 2499], type: money }
      subtotal_php:   { expr: "item_count * unit_price_php" }
      shipping_php:   { expr: "if(subtotal_php >= 1500, 0, 99)", type: money }
      total_php:      { expr: "subtotal_php + shipping_php" }
      status:         { gen: choice, values: [processing, shipped, delivered, cancelled], weights: [1, 1, 2, 1] }
  payments:
    rows_per_parent: { parent: orders, exactly: 1 }
    primary_key: { column: payment_id, format: "PAY-{n:07}" }
    columns:
      order_id:   { ref: orders.order_id }
      method:     { gen: choice, values: [GCash, Card, Bank transfer, Cash on delivery] }
      amount_php: { expr: "parent.total_php" }
      status:     { map: parent.status, cases: { cancelled: refunded, processing: pending }, default: paid }
      paid_on:    { gen: date, after: parent.order_date, within_days: [0, 2] }
rules:
  - "orders.total_php == orders.subtotal_php + orders.shipping_php"
  - "payments.paid_on >= parent.order_date"
mask:
  key_ref: project_key
  columns:
    customers.full_name: { mode: consistent }
    customers.email:     { mode: consistent, from: customers.full_name, on_collision: suffix }

Top level#

FieldMeaning
spec_versionThe spec language version. This engine reads version 1, and it keeps reading every version it has ever accepted.
localeThe locale for every generator. Defaults to en-PH.
tablesTables by name. Order matters.
rulesChecks over the generated rows. See Rules.
maskMasking modes per column. See The mask block.

The seed isn't part of the spec. It's a run input, like row overrides (--rows customers=48), so one approved spec serves any seed and size.

Tables#

Every generated table has a primary_key and exactly one of rows or rows_per_parent.

rows sizes a root table: an integer, or { per: other_table, ratio, min, max } to size it from another table, for example about one role per six candidates.

rows_per_parent makes a child table and sets how many children each parent row gets. It takes one of four forms:

  • { parent, dist, min, max }, where dist is uniform, poisson or geometric (both with mean), or zipf (with exponent s);
  • { parent, exactly: n };
  • { parent, count: "<expression>" } over the parent's columns, such as count: "parent.reply_count";
  • { parent, by: parent.column, cases: { value: <form> }, default: <form> }, which picks a form by a parent column.

A parent row gets at most 100,000 children. A table has at most one owning parent; for many-to-many, add a join table with two ref columns, one to its owning parent and one picked.

primary_key names the key column and its format. {n} is the row's permuted number and {n:06} pads it to six digits; {uuid} is a deterministic version 8 UUID. The numbers are a permutation of the row index, so IDs don't reveal order or volume, and they're unique by construction.

Columns#

Each column has exactly one source:

SourceExampleWhat it does
gen{ gen: int, between: [1, 5] }A generator with its parameters. A numeric or date parameter can be an expression string, evaluated per row.
ref{ ref: customers.customer_id }A foreign key, valid by construction. On the owning parent it's the parent's key. On any other table it needs pick: { dist: uniform } (or zipf) to choose a row, or expr: "parent.role_id" to take a key already in scope.
expr{ expr: "subtotal_php + shipping_php" }A value from the expression language.
map{ map: parent.status, cases: { cancelled: refunded }, default: paid }A lookup on another column's value.
const{ const: PHP }A literal.

Any column can also take:

  • type, to override the inferred type: int, decimal, money, string, bool, date or datetime;
  • nullable: 0.05, the probability of a null;
  • unique: true, which the compiler accepts only where uniqueness holds by construction (see below);
  • hidden: true, for a value other columns use but the output leaves out;
  • pii, the column's personal-data type (person.name, person.email, ph.tin and so on). It labels the column in reports and is the default masking domain.

choice also takes by: column with cases: { value: [values] }, to choose from a list that depends on another column.

What a column can read#

A column reads columns defined before it in the same table, and also:

  • prev.column, the previous sibling row (null for the first);
  • ordinal, the 0-based position among its siblings, or the row number in a root table;
  • parent.column, parent.parent.column and so on up the owning-parent chain.

The compiler orders evaluation and rejects cycles. Here sibling replies alternate between customer and agent, and each one is sent after the previous reply:

Speccompiles with dogm 0.1.0
spec_version: 1
tables:
  tickets:
    rows: 200
    primary_key: { column: ticket_id, format: "TCK-{n:05}" }
    columns:
      opened_at:   { gen: datetime, between: [2026-01-05T00:00, 2026-03-26T00:00] }
      reply_count: { gen: int, between: [1, 4] }
  replies:
    rows_per_parent: { parent: tickets, count: "parent.reply_count" }
    primary_key: { column: reply_id, format: "RPL-{n:06}" }
    columns:
      ticket_id:   { ref: tickets.ticket_id }
      author_role: { expr: "if(ordinal % 2 == 0, 'customer', 'agent')" }
      sent_at:     { gen: datetime, after: "coalesce(prev.sent_at, parent.opened_at)", within_minutes: [5, 600] }
rules:
  - "replies.sent_at > parent.opened_at"

Unique columns#

Chunks are generated independently, so unique: true has to hold by construction. The compiler accepts it only here:

ColumnHow it stays unique
Primary keys, sequenceThe key permutation, or the sequence itself
int with uniqueA permutation over the range, which must be at least the row count
choice with uniqueA permutation of values; the table can't have more rows than values
person.email with uniqueA name slug plus the row's permuted number, such as bea.castillo.4821@example.com

Anything else is E_UNIQUE_UNSUPPORTED. Names are never unique; real names aren't either.

Rules#

rules are boolean expressions. Each one names exactly one table (payments.paid_on) and runs once per row of that table, where parent. means that table's owning parent. They run on generated values, before masking.

Rules are checks, not constraints. The generators are responsible for compliant data, and validation reports any row that breaks a rule, with the count and the first 20 failing row keys. A rule passes unless it's false, so a null passes, as in a SQL CHECK. A failing rule is a result in the report, never a compile error.

The mask block#

mask.columns maps table.column to a masking mode, and key_ref names a key supplied at run time; a spec never holds key material. A mask spec for real data uses the same format with source: external tables. The masking guide has both, and the modes.

Hash and versions#

  • spec_hash is the SHA-256 of the spec's canonical JSON (RFC 8785), so reordering keys doesn't change it. dogm compile prints it.
  • engine_version is recorded on every run. Output bytes for one spec hash, seed and set of row overrides are stable within an engine minor version, and any change to generator output, sampling, chunking or a writer bumps the minor version.
  • Every released CLI version stays downloadable, so an old run is reproduced with the engine version recorded on it.

Generators#

This list comes from the catalog in engine 0.1.0. Every generator is deterministic and takes the spec's locale.

GeneratorParametersNotes
Numbers
intbetween, or min and max; dist, mean, sd, s, weightsdist: uniform, normal, lognormal, zipf, poisson, geometric, weights
decimalbetween or min/max; scale (default 2); dist, mean, median, sd
moneycurrency; between or min/max; dist, median, mean, sdlognormal when median is given
boolp (default 0.5)
Choice
choicevalues, weights; or by + cases + defaultwith unique: a permutation of values
enumas choicean alias of choice
sequencestart, step
Time
datebetween; or after/before an expression plus within_days
datetimebetween; or after/before an expression plus within_minutes
timebetweenHH:MM:SS text, zero-padded so it sorts as a string
Person
person.first_nameNone
person.last_nameNone
person.full_nameNone
person.emailfrom (a name column)RFC 2606 example domains only; unique adds the permuted row number
person.birthdateage_between (default [18, 65])ages counted at 2026-01-01
Philippines
ph.cityweights: population
ph.provincefrom (a ph.city column)with from, always agrees with that city
ph.regionfrom (a ph.city column)
ph.postal_codefrom (a ph.city column)
ph.mobileNone
ph.tinNoneformat-valid, not guaranteed nonexistent
ph.sss_noNoneformat-valid, not guaranteed nonexistent
ph.philhealth_noNoneformat-valid, not guaranteed nonexistent
ph.addressfrom (a ph.city column)
Business
company.nameNone
product.nameNone
job.titleNone
Text
text.wordsbetween (word count, default [3, 6])from a fixed word list
text.sentencebetween (word count, default [6, 14])
text.paragraphbetween (sentence count, default [3, 6])
IDs
uuidNonedeterministic, version 8

31 generators, from the engine's catalog (dogm_engine.catalog()).

Generated people are fictional by construction: names come from curated pools, and emails only use RFC 2606 example domains. Government ID formats (TIN, SSS, PhilHealth) are format-valid but not guaranteed nonexistent, so a generated number can match a real one, and reports label those columns as synthetic.

PII types#

These pii types have a fictional replacement pool, so consistent masking can use them as a domain:

  • person.name
  • person.full_name
  • person.first_name
  • person.last_name
  • person.email
  • ph.city
  • ph.province
  • ph.region
  • ph.postal_code
  • ph.address
  • ph.mobile
  • ph.tin
  • ph.sss_no
  • ph.philhealth_no
  • company.name
  • job.title

Limits#

LimitValue
Tables per spec50
Columns per table200
Parent chain depth5
Children per parent row100,000
Rows per table in the CLI10,000,000,000
Characters per expression1,000
Nested parentheses and calls per expression32

In the browser demo, a table holds at most 10 million rows.

Field reference#

Every object in spec.schema.json, in file order. A field marked required must be present; the compiler checks combinations the schema can't express, such as exactly one of rows and rows_per_parent.

Spec#

A DOGM scenario spec, or a mask spec when its tables are source: external.

FieldTypeDescription
spec_versionrequiredintegerSpec language version. This engine reads version 1.
localestringLocale for every generator.Default "en-PH"
tablesrequiredmap of TableTables by name. Order is meaningful.
ruleslist of stringBoolean expressions, each naming exactly one table. Checks, not constraints: a row fails only when a rule is false.Default []
maskMaskMasking modes per column; the key itself is supplied at run time.

Table#

One table. The compiler checks that exactly one of rows and rows_per_parent is set (neither for source: external) and that generated tables have a primary_key.

FieldTypeDescription
rowsRowsRow count of a root table: an integer, or sized from another table.
rows_per_parentRowsPerParentRow count per row of the owning parent table.
primary_keyPrimaryKey
columnsrequiredmap of ColumnColumns by name. Order is evaluation visibility and output order.
sourceTableSourceexternal marks a table of real data in a mask spec.

Rows#

A root table's row count.

One of: integer ≥ 0 or RowsPer

RowsPer#

Rows sized from another table: round(rows(per) * ratio), clamped to [min, max].

FieldTypeDescription
perrequiredstring
ratiorequirednumber
mininteger ≥ 0
maxinteger ≥ 0

RowsPerParent#

Child count per parent row: {dist, min, max}, {exactly}, {count} or {by, cases, default}.

FieldTypeDescription
parentrequiredstringThe owning parent table.
diststringuniform, poisson, zipf, geometric.
mininteger ≥ 0
maxinteger ≥ 0
meannumberExpected count for poisson and geometric.
snumberZipf exponent.
exactlyinteger ≥ 0
countstringExpression over the parent's columns.
bystringA parent column whose value selects a form from cases.
casesmap of CountForm
defaultCountForm

CountForm#

A child-count form inside rows_per_parent.cases or default: the same fields without parent.

FieldTypeDescription
diststring
mininteger ≥ 0
maxinteger ≥ 0
meannumber
snumber
exactlyinteger ≥ 0
countstring
bystring
casesmap of CountForm
defaultCountForm

PrimaryKey#

FieldTypeDescription
columnrequiredstring
formatstring{n}, {n:0W} (the permuted row number, zero-padded) or {uuid}, inside literal text.Default "{n}"

Column#

One column. Exactly one source of gen, ref, expr, map, const, except ref with expr or pick, and none in an external table; the compiler checks the combination. Numeric and date parameters are Scalars because any of them may be an expression string.

FieldTypeDescription
genstringA catalog generator, e.g. int, choice, person.full_name.
refstringA foreign key, table.column.
exprstringAn expression; on a ref column, the key already in scope.
mapstringThe column whose value is looked up in cases.
constScalarA literal.
between[Scalar, Scalar]
afterstringLower bound expression for date, datetime, time.
beforestringUpper bound expression for date, datetime, time.
within_days[Scalar, Scalar]
within_minutes[Scalar, Scalar]
valueslist of Scalar
weightsWeightsRelative weights, one per value, or population for place generators.
bystringThe column whose value selects a list from cases (choice).
casesmap of CaseValuechoice with by: value -> list of values. map: value -> result.
defaultCaseValueUsed when no case matches.
fromstringThe source column, e.g. the name an email is built from.
diststringuniform, normal, lognormal, zipf, poisson, geometric, weights.
minScalar
maxScalar
medianScalar
meanScalar
sdScalar
sScalarZipf exponent.
currencystring
scaleScalarDecimal places.
pScalarProbability of true (bool).
startScalar
stepScalar
age_between[Scalar, Scalar]
pickPickHow a non-owning ref chooses a parent row.
typeTypeNameOverrides the inferred type.
nullablenumberProbability of null, 0 to 1.
uniqueboolGuaranteed by construction, or rejected.
hiddenboolComputed and readable by later columns, but not written.
piistringPersonal-data type, e.g. person.name; the default masking domain.
domainstringMasking domain, when it differs from pii.

Scalar#

A YAML/JSON scalar. Ints stay exact; dates stay strings, because YAML does not resolve them.

One of: bool or integer or number or string

Weights#

One of: list of number or NamedWeights

NamedWeights#

One of: population

CaseValue#

A cases or default entry: a list for choice, a single value for map.

One of: list of Scalar or Scalar

Pick#

FieldTypeDescription
distrequiredstringuniform or zipf.
snumber

TypeName#

The type override.

One of: int | decimal | money | string | bool | date | datetime

TableSource#

One of: external

Mask#

The mask block. Modes stay strings so the compiler reports E_MASK_*.

FieldTypeDescription
key_refrequiredstringNames the key the caller supplies at run time. Never key material.
columnsrequiredmap of MaskColumntable.column -> masking mode.

MaskColumn#

FieldTypeDescription
moderequiredstringconsistent, fpe_ff1, hash, shift_date, redact, keep.
fromstringconsistent: rebuild from this masked table.column in the same row.
domainstringDefaults to the column's pii.
keep_domainboolMasked emails keep their original domain.
on_collisionstringsuffix.
max_daysinteger ≥ 0shift_date: the offset bound in days.
entitystringshift_date: the key or foreign-key column whose dates shift together.
prefixstringhash: prefix of the output.
lengthinteger ≥ 0hash: hex characters kept.
valueScalarredact: the replacement constant; null when absent.

From dogm-engine/spec.schema.json, which the engine generates from its Rust types.

Error codes#

Errors are complete (all of them, not only the first), sorted by JSON path, and each has a path, a code, a message and sometimes a hint. The codes are a stable contract that tools can match on. This list is collected from the engine source.

E_SCHEMA_*
The spec's shape: syntax, wrong type, missing or unknown field, unsupported spec_version.
E_SCHEMA_DUPLICATE_COLUMNE_SCHEMA_EXTERNALE_SCHEMA_IDENTE_SCHEMA_MISSING_FIELDE_SCHEMA_ROWSE_SCHEMA_SOURCEE_SCHEMA_SYNTAXE_SCHEMA_TYPEE_SCHEMA_UNKNOWN_FIELDE_SCHEMA_VERSION
E_REF_*
References: unknown table or column, a second owning parent, a parent-chain cycle, a ref with no pick or expr.
E_REF_CYCLEE_REF_NOT_KEYE_REF_NO_PICKE_REF_SECOND_PARENTE_REF_TYPEE_REF_UNKNOWN_COLUMNE_REF_UNKNOWN_TABLE
E_EXPR_*
Expressions: parse errors, unknown column or function, type mismatch, forward reads, prev or ordinal misuse, overflow.
E_EXPR_ARITYE_EXPR_FORWARD_REFE_EXPR_LOOKUPE_EXPR_OVERFLOWE_EXPR_PARSEE_EXPR_PREV_MISUSEE_EXPR_RULE_TABLEE_EXPR_TYPEE_EXPR_UNKNOWN_COLUMNE_EXPR_UNKNOWN_FUNCTION
E_GEN_*
Generators: unknown generator, bad parameter, empty range, weights that don't match values.
E_GEN_BAD_PARAME_GEN_EMPTY_RANGEE_GEN_UNKNOWNE_GEN_WEIGHTS_MISMATCH
E_UNIQUE_*
unique: true on a column with no form that is unique by construction.
E_UNIQUE_UNSUPPORTED
E_MASK_*
The mask block: unknown mode or column, FF1 domain too small, foreign-key mode mismatch, free text not redacted.
E_MASK_BAD_PARAME_MASK_FF1_DOMAINE_MASK_FK_MISMATCHE_MASK_FREE_TEXTE_MASK_NO_DOMAINE_MASK_PII_UNMASKEDE_MASK_UNIQUEE_MASK_UNKNOWN_COLUMNE_MASK_UNKNOWN_MODE
E_LIMIT_*
Over an engine limit: tables, columns, depth, children per parent, rows, expression length.
E_LIMIT_CHILDRENE_LIMIT_COLUMNSE_LIMIT_DEPTHE_LIMIT_EXPR_LENGTHE_LIMIT_ROWSE_LIMIT_TABLES
E_INPUT_*
Run inputs, such as a row override for a table that isn't a root table.
E_INPUT_ROWS
E_AUTHORING_*
The AI authoring format, which the API converts to a spec before compiling.
E_AUTHORING_DUPLICATEE_AUTHORING_FIELDE_AUTHORING_SHAPEE_AUTHORING_VALUE
E_INTERNAL
An engine bug. Report it with the spec that caused it.
E_INTERNAL

Collected from the string literals in dogm-engine/dogm-core/src.