Diff Report Format

September 10, 2026 · View on GitHub

This document explains the diff report format that modl consumes and describes exactly what a language-specific adapter must produce to make any modeling language compatible with the ledger.


What is an adapter?

modl is language-agnostic. It does not parse model files directly. A language-specific adapter is a tool (script, library, CI step) that:

  1. Takes a current model snapshot, and optionally a previous one
  2. Computes what changed between them (or treats everything as new when no previous snapshot exists)
  3. Produces a diff report — a JSON file in the format described below
  4. Passes that file to modl sync --diff-report <file> ...

The previous snapshot is optional. When absent, the adapter is in first-run mode: every element in the current snapshot is treated as ADDED and emitted with its complete aspects snapshot. From modl's perspective the format is identical — it always receives a diff report and does not know whether it was a first run.

A typical adapter invocation:

adapter --curr model-v2.yaml               # first run: no prev, all ADDED
adapter --prev model-v1.yaml --curr model-v2.yaml  # subsequent runs: real diff

One adapter exists per modeling language (e.g., vspec, GraphQL SDL, JSON Schema). The adapter is a thin, replaceable component; modl's ledger logic does not change when a new language is supported.


Terminology

IR termAlso known as
EntityContainer, object type, branch, class, feature of interest
PropertyField, attribute, signal, sensor, actuator, characteristic
AspectAny named attribute of a property that can change (output type, unit, constraints, …)

Label namespaces

label values live in two independent namespaces, mirroring GraphQL SDL:

  • Container namespace (ENTITY + ENUMERATION_SET): labels are globally unique against each other — just as GraphQL type and enum names share one global namespace.
  • Member namespace (PROPERTY + ENUM_VALUE): labels are unique only among siblings sharing the same parent_label — just as GraphQL field names are scoped to their enclosing type and enum value names are scoped to their enclosing enum.

The two namespaces are never compared against each other: an entity and a property may share a label, since GraphQL never resolves a field by global name lookup either. This matters for languages without a separate standalone-type layer — e.g. vspec, where a branch's name is just its path segment, exactly like a leaf's — letting an adapter use natural node names without inventing suffixes to dodge collisions between unrelated branches and signals.


Top-level structure

{
  "changes": [ <event>, <event>, ... ]
}

The changes array is an ordered list of change events. Order does not affect correctness — the sync engine processes events independently. Each event describes a change to either an entity or a property.


Entity event

{
  "label":             "<string>",
  "kind":              "ENTITY",
  "change_type":       "ADDED" | "REMOVED" | "MODIFIED",
  "renamed_from":      "<string>" | null,
  "aspects":           { "<key>": <value>, ... },
  "previous_aspects":  { "<key>": <value>, ... },
  "content":           [ { "label": "<string>", "change_type": "ADDED" | "REMOVED" | "MODIFIED" }, ... ]
}
FieldRequiredNotes
labelalwaysThe current label of the entity (after any rename). Must be unique among all ENTITY and ENUMERATION_SET concepts in the ledger — use the full dotted path (e.g. Vehicle.Door). Not compared against PROPERTY/ENUM_VALUE labels (see Label namespaces).
kindalwaysMust be "ENTITY".
change_typealwaysADDED, REMOVED, or MODIFIED.
renamed_fromMODIFIED onlyPrevious label. Signals the ledger to record a rename rather than a separate removal and addition. Must be null or absent on ADDED and REMOVED.
aspectsADDEDFull initial-state snapshot of all entity-level attributes. Empty on REMOVED. Delta (changed keys only) on MODIFIED — every value must be wrapped with both _value and _previous (see Operation annotation).
previous_aspectsREMOVEDThe full aspects snapshot as it existed immediately before removal. Mandatory and non-empty on REMOVED events — records what is being lost so it can be written to revision_aspects.csv. Must be absent on ADDED. Accepted but ignored on MODIFIED — the sync engine never reads previous_aspects for MODIFIED events.
contentMODIFIED onlyDeclares which child elements changed. Each item carries label and change_type. The engine evaluates ADDED and REMOVED content items against the properties.added / properties.removed config keys (or values.added / values.removed for ENUMERATION_SET) to decide whether a new entity contract is warranted. Every label in content must have a corresponding standalone event in the same diff report, and vice versa. Absent on ADDED and REMOVED.

Rules

  • ADDED: aspects carries the full snapshot. Use instances to carry the full list of instance labels. content must be absent. renamed_from must be absent. previous_aspects must be absent.
  • MODIFIED: aspects carries only the keys that actually changed, and every value must be wrapped as {"_op": "modified", "_value": <new>, "_previous": <old>} — plain (unwrapped) values are no longer accepted on MODIFIED events. For instance-list changes use instances_added and instances_removed (the directional delta — not the full list; these two keys are exempt from the wrapping requirement). renamed_from is set only when a rename occurred. content lists affected children — every item must have a corresponding standalone event in the same report, and every standalone child event must be reflected in the parent's content.
  • REMOVED: aspects must be empty. previous_aspects is mandatory and must be non-empty — it carries the full prior-state snapshot being removed. content must be absent. renamed_from must be absent. Additionally, every existing child PROPERTY/ENUM_VALUE concept of this entity must also have an explicit REMOVED event in the same diff report — modl sync raises an error and aborts the whole sync (no ledger write) if any child concept lacks one.

Reserved keys on entity events: "name" is forbidden in aspects — signal renames via renamed_from. On MODIFIED events, "instances" is also forbidden — use "instances_added" / "instances_removed" to report the directional delta. "instances" is only valid on ADDED events (full initial snapshot).


Property event

{
  "label":             "<string>",
  "parent_label":      "<string>",
  "kind":              "PROPERTY",
  "change_type":       "ADDED" | "REMOVED" | "MODIFIED",
  "is_leaf":           true | false,
  "instantiate":       true | false | null,
  "renamed_from":      "<string>" | null,
  "aspects":           { "<key>": <value>, ... },
  "previous_aspects":  { "<key>": <value>, ... }
}
FieldRequiredNotes
labelalwaysThe current label of the property. Must be unique among sibling PROPERTY/ENUM_VALUE concepts sharing the same parent_label — not globally unique, and never compared against ENTITY/ENUMERATION_SET labels (see Label namespaces). Use the full dotted path (e.g. Vehicle.Door.IsOpen).
parent_labelalwaysThe label of the immediate parent entity.
kindalwaysMust be "PROPERTY".
change_typealwaysADDED, REMOVED, or MODIFIED.
is_leafalways, PROPERTY onlytrue when output_type resolves to a primitive/scalar (the property is a leaf, and thus binding-eligible); false when output_type names another entity (a reference — no bindings are ever minted for it). Required on every PROPERTY event, all change_types. A change in is_leaf between snapshots always forces a new contract and a binding-lifecycle transition, independent of the breaking-change config. Not an aspect — see Aspect keys.
instantiateoptional, PROPERTY onlyfalse pins the property to a single non-instantiated path — it never mirrors the parent entity's instance list, regardless of how many instances the parent declares, and receives exactly one singleton binding. Omitted or null (the default) means "inherit the parent's instances" — the property is expanded once per parent instance, same as if the field didn't exist. Forbidden (must be null/absent) on ENUM_VALUE events. A change in the effective instantiation outcome between snapshots always forces a new contract and a binding-lifecycle transition, independent of the breaking-change config — same treatment as is_leaf. Not an aspect — see Aspect keys.
renamed_fromMODIFIED onlyPrevious label. Must be null or absent on ADDED and REMOVED.
aspectsADDEDFull initial-state snapshot on ADDED. Empty on REMOVED. Delta on MODIFIED — every value must be wrapped with both _value and _previous (see Operation annotation).
previous_aspectsREMOVEDThe full aspects snapshot as it existed immediately before removal. Mandatory and non-empty on REMOVED events. Must be absent on ADDED. Accepted but ignored on MODIFIED — the sync engine never reads previous_aspects for MODIFIED events.

Rules

  • ADDED: aspects carries the full snapshot; output_type is expected to be present for typed properties (signals, fields). Omit it for vocabulary elements such as enum values or unit definitions where no type resolution is involved. is_leaf is mandatory and must reflect whether output_type is primitive (true) or another entity (false). Set instantiate: false when the source model pins this property to a single non-instantiated path — otherwise omit it (default inherit-from-parent). renamed_from and previous_aspects must be absent.
  • MODIFIED: aspects carries only the keys that changed, each wrapped as {"_op": "modified", "_value": <new>, "_previous": <old>} — plain (unwrapped) values are no longer accepted. is_leaf is still mandatory and must reflect the property's current state, even when it hasn't changed. instantiate should likewise reflect the property's current effective state, even when unchanged, so the ledger can detect a transition. renamed_from is set only when a rename occurred.
  • REMOVED: aspects must be empty. previous_aspects is mandatory and must be non-empty — it carries the full prior-state snapshot being removed. is_leaf is still mandatory (reflects the state being removed). renamed_from must be absent.

Reserved key: "name" is forbidden in aspects on property events — signal renames via renamed_from.

is_leaf and instantiate are forbidden on ENUM_VALUE events. Vocabulary member properties (kind: "ENUM_VALUE") never receive bindings or instances regardless of shape, so both fields must be omitted (or null) on those events — see Vocabulary and governed elements.


Aspect keys

aspects is a flat string → any dictionary. Keys and their semantics are adapter-definedmodl stores them verbatim and compares them on future syncs to detect changes. The breaking-change config references them by their exact key name.

is_leaf is not an aspect — it is a first-class field on property events (see Property event). It is never subject to the breaking-change config in the usual sense (there is no is_leaf config key), though a change in its value always forces breaking = True unconditionally. It is also not persisted as its own column anywhere in the ledger — modl derives the equivalent fact from whether any binding row was ever minted for the property's contract.

instantiate is likewise not an aspect — it is a first-class, optional field on property events (see Property event), analogous to is_leaf. It is never subject to the breaking-change config (there is no instantiate config key), though a change in the effective instantiation outcome always forces breaking = True unconditionally. It is not persisted as its own column — modl derives the equivalent fact from whether the property concept's own instances column is populated.

Widely-used conventions for typed modeling languages:

KeyTypeMeaning
output_typestringBase type name the property resolves to (e.g. "Float", "Boolean", "Door"). Does not include list or nullability modifiers.
is_listbooleantrue when the property resolves to a list of output_type.
is_requiredbooleantrue when the value is guaranteed non-null / mandatory.

For entity ADDED events:

KeyTypeMeaning
instancesstring[]Full list of instance labels (initial snapshot). Each label expands every child property into a separate runtime-addressable binding. Only valid on ADDED events.

For entity MODIFIED events — use directional delta keys instead of instances:

KeyTypeMeaning
instances_addedstring[]Instance labels that appeared since the last sync. Values must be unique and must not overlap with the instance labels already stored in the ledger for this entity.
instances_removedstring[]Instance labels that disappeared since the last sync.

All other keys are adapter-defined. Examples: unit, min, max, accuracy, description.

Adapter-defined keys in MODIFIED events that are not declared in the breaking-change config are treated as non-breaking by default and produce a warning. Pass --strict to modl sync to treat them as errors.


Operation annotation (MODIFIED events)

Every key present in a MODIFIED event's aspects dict must be wrapped to declare its operation and carry both the new and previous value:

"aspects": {
    "unit":        { "_op": "added",    "_value": "mph"                        },
    "accuracy":    { "_op": "removed",                  "_previous": 0.5       },
    "description": { "_op": "modified", "_value": "new text", "_previous": "old text" }
}
_op_value (newer)_previousMeaning
"added"requiredmust be absent/nullThe aspect key did not exist before and now has a value.
"removed"must be absent/nulloptionalThe aspect key existed before and no longer applies.
"modified"requiredrequiredThe aspect key's value changed from _previous to _value.

Plain (unwrapped) values are no longer valid on MODIFIED events — they were previously accepted as shorthand for {"_op": "modified", "_value": <value>}, but since _previous is now mandatory for the "modified" op, every changed aspect must use the explicit wrapped form. The two directional instance keys (instances_added, instances_removed) are exempt from this rule since they are list-valued deltas, not single old/new value pairs.

Each wrapped aspect on a MODIFIED event (excluding instances_added/instances_removed) produces one row in revision_aspects.csv recording the operation, previous value, and newer value — see The revision_aspects.csv table.

This matters when the breaking-change config uses per-op granular keys (e.g., unit.added: true, unit.removed: false) — the _op annotation is what lets the engine match against the correct rule instead of falling back to the generic modified rule.


Rename semantics

A rename is represented as a MODIFIED event with renamed_from set to the previous label. This preserves concept identity in the ledger — the concept URI does not change.

{
  "label":        "Vehicle.Velocity",
  "parent_label": "Vehicle",
  "kind":         "PROPERTY",
  "change_type":  "MODIFIED",
  "is_leaf":      true,
  "renamed_from": "Vehicle.Speed",
  "aspects":      {}
}

If the adapter cannot detect a rename (no explicit annotation in the model), it should emit a REMOVED event for the old label and an ADDED event for the new label. The ledger will treat these as two distinct concepts with separate URIs, and concept identity is lost.

Modeling languages that support explicit rename annotations (e.g., fka in vspec, @renamed directives in GraphQL SDL) should map them to renamed_from.

Rename with simultaneous attribute change

A single MODIFIED event can carry both renamed_from and a non-empty aspects delta when an element was renamed and had other attributes change in the same release:

{
  "label":        "Vehicle.Velocity",
  "parent_label": "Vehicle",
  "kind":         "PROPERTY",
  "change_type":  "MODIFIED",
  "is_leaf":      true,
  "renamed_from": "Vehicle.Speed",
  "aspects":      { "unit": { "_op": "modified", "_value": "m/s", "_previous": "km/h" } }
}

The sync engine evaluates the rename and the aspect delta independently against the config. Either one may independently trigger a new contract.


Vocabulary and governed elements

Models often include shared vocabulary that properties reference — units of measurement, quantity kinds, code lists, enum types. These are first-class model elements with their own identity and change history. ModL treats them exactly like any other ENTITY or PROPERTY: they receive concept URIs, revisions, and contracts, but no bindings (vocabulary elements are not runtime-addressable paths — and neither are ENTITY concepts; only leaf PROPERTY concepts — those with is_leaf: true — receive bindings).

Mapping vocabulary to the IR

The adapter decides how to represent vocabulary elements. Declare the structural kind by setting the event’s kind field to ENUMERATION_SET (for the container) or ENUM_VALUE (for each member). modl reads this from the kind column of the concept row and suppresses binding minting automatically — no extra field or aspect is needed.

Two common patterns:

GraphQL SDL — a unit enum is a type with values:

{ "label": "SpeedUnit", "kind": "ENUMERATION_SET", "change_type": "ADDED", "aspects": { "type": "enum" } }
{ "label": "SpeedUnit.KMH", "parent_label": "SpeedUnit", "kind": "ENUM_VALUE", "change_type": "ADDED", "aspects": { "symbol": "km/h" } }
{ "label": "SpeedUnit.MPH", "parent_label": "SpeedUnit", "kind": "ENUM_VALUE", "change_type": "ADDED", "aspects": { "symbol": "mph" } }

vspec — units are declared in a flat YAML vocabulary file. The adapter can model them as a synthetic Units parent entity (kind: ENUMERATION_SET) with each unit as a child property (kind: ENUM_VALUE), or as standalone entities — whichever maps most naturally to the source format.

The property canonical keys (output_type, is_list, is_required) carry no meaning for vocabulary elements and should be omitted. Use adapter-defined aspect keys instead (e.g., symbol, definition, quantity_kind).

Linking a property to a vocabulary element

The link lives in the property's aspects snapshot. Emit the unit as the value of the unit aspect key — either as a label or, preferably, as the unit concept's URI:

{
  "label":        "Vehicle.Speed",
  "parent_label": "Vehicle",
  "kind":         "PROPERTY",
  "change_type":  "ADDED",
  "is_leaf":      true,
  "aspects": {
    "output_type": "Float",
    "unit":        "https://myproject.org/model/concepts/5"
  }
}

Using the concept URI makes the reference unambiguous and stable across renames. When unit: true in the breaking-change config, a change to the referenced unit triggers a new property contract — the old contract permanently records the previous unit URI.

The unit aspect value is treated as an opaque string by modl. Use a plain label ("km/h") or a concept URI — whichever convention your project adopts. modl does not resolve or validate the value; it is stored verbatim and compared on future syncs to detect changes.

Ledger table assignment

Element kindconceptsrevisionscontractsbindings
Model entity (ENTITY, e.g. Vehicle.Door)
Model property (PROPERTY, is_leaf: true, parent has instances)✅ one per instance
Model property (PROPERTY, is_leaf: true, no instances)✅ one singleton
Model property (PROPERTY, is_leaf: false, reference to another entity)
Vocabulary entity (ENUMERATION_SET, e.g. SpeedUnit)
Vocabulary property (ENUM_VALUE, e.g. SpeedUnit.KMH)

The kind column in concepts.csv records the structural kind permanently. Only PROPERTY concepts ever receive bindings, and only when the property is a leaf (is_leaf: true on its most recent event — see Property event). ENTITY, ENUMERATION_SET, and ENUM_VALUE concepts never receive bindings — the ledger validator enforces this as a hard constraint. Reference properties (is_leaf: false) are a PROPERTY-kind exception that also never receive bindings, but this is controlled by the sync engine's binding-minting logic, not by a ledger schema constraint.


The revision_aspects.csv table

Every revision minted by modl sync — for ADDED, MODIFIED, and REMOVED events alike — also writes one row per changed aspect key to revision_aspects.csv. This table is a detailed audit trail of exactly which aspect values changed, from what, and to what, for every revision in the ledger.

ColumnNotes
revision_uriForeign key into revisions.csv.
aspect_keyThe aspect name (e.g. unit, output_type, description).
operationOne of added, modified, removed — mirrors the _op of the source aspect.
previous_valueJSON-encoded old value. null when operation is added.
newer_valueJSON-encoded new value. null when operation is removed.

The identity of a row is the composite key (revision_uri, aspect_key) — there is no separate serial or URI, since nothing external ever references an individual revision_aspects row.

Population rules, mirroring the event's payload:

  • ADDED event → one row per key in aspects excluding instances, operation="added", previous_value=null.
  • MODIFIED event → one row per key in aspects excluding instances_added/instances_removed (already wrapped with _op/_value/_previous), using the wrapped operation and both values.
  • REMOVED event → one row per key in previous_aspects, operation="removed", newer_value=null.

The instance-list keys (instances on ADDED events, instances_added/instances_removed on MODIFIED events) are excluded — they carry list payloads rather than single old/new values, and are tracked via the concepts.instances column instead of revision_aspects.csv.


When a new property is added to an entity

Emit two events: one MODIFIED on the parent entity (content changed) and one ADDED on the new property. Each is processed independently.

{
  "changes": [
    {
      "label":       "Vehicle.Door",
      "kind":        "ENTITY",
      "change_type": "MODIFIED",
      "content": [
        { "label": "Vehicle.Door.IsLocked", "change_type": "ADDED" }
      ]
    },
    {
      "label":        "Vehicle.Door.IsLocked",
      "parent_label": "Vehicle.Door",
      "kind":         "PROPERTY",
      "change_type":  "ADDED",
      "is_leaf":      true,
      "aspects": {
        "output_type": "Boolean",
        "is_list":     false,
        "is_required": false
      }
    }
  ]
}

Complete example

The following diff report covers a range of typical changes:

{
  "changes": [
    {
      "label":       "Vehicle.Window",
      "kind":        "ENTITY",
      "change_type": "ADDED",
      "aspects": { "type": "branch" }
    },
    {
      "label":        "Vehicle.Window.Position",
      "parent_label": "Vehicle.Window",
      "kind":         "PROPERTY",
      "change_type":  "ADDED",
      "is_leaf":      true,
      "aspects": {
        "output_type": "Float",
        "is_list":     false,
        "is_required": false,
        "unit":        "percent",
        "min":         0,
        "max":         100
      }
    },
    {
      "label":       "Vehicle.Door",
      "kind":        "ENTITY",
      "change_type": "MODIFIED",
      "aspects": { "instances_added": ["Center"] },
      "content": [
        { "label": "Vehicle.Door.IsLocked", "change_type": "ADDED" }
      ]
    },
    {
      "label":        "Vehicle.Door.IsLocked",
      "parent_label": "Vehicle.Door",
      "kind":         "PROPERTY",
      "change_type":  "ADDED",
      "is_leaf":      true,
      "aspects": { "output_type": "Boolean" }
    },
    {
      "label":        "Vehicle.Speed",
      "parent_label": "Vehicle",
      "kind":         "PROPERTY",
      "change_type":  "MODIFIED",
      "is_leaf":      true,
      "aspects": { "output_type": { "_op": "modified", "_value": "Float", "_previous": "Int" } }
    },
    {
      "label":        "Vehicle.Velocity",
      "parent_label": "Vehicle",
      "kind":         "PROPERTY",
      "change_type":  "MODIFIED",
      "is_leaf":      true,
      "renamed_from": "Vehicle.OldSpeed",
      "aspects":      {}
    },
    {
      "label":             "Vehicle.OldFeature",
      "parent_label":      "Vehicle",
      "kind":              "PROPERTY",
      "change_type":       "REMOVED",
      "is_leaf":           true,
      "previous_aspects":  { "output_type": "Boolean" }
    }
  ]
}

Adapter implementation checklist

Use this checklist when building an adapter for a new modeling language:

  • Parse both the previous and current model snapshots. When no previous snapshot is provided (first run), treat every element as ADDED and emit the complete aspects snapshot for each entity and property — not a delta. This is identical to the standard ADDED event contract and requires no special handling from modl.
  • For each entity that exists in current but not previous: emit ADDED entity event with full aspects snapshot
  • For each entity that exists in previous but not current: emit REMOVED entity event with previous_aspects set to the full prior-state snapshot (mandatory and non-empty), and emit a REMOVED event for every one of its child property/enum-value concepts in the same report \u2014 modl sync aborts with an error if any are missing
  • For each entity that exists in both:
    • Detect renames via explicit model annotations → emit MODIFIED with renamed_from
    • If the element was also modified in the same release, include both renamed_from and the changed keys in aspects within the same event
    • Detect changes to entity-level attributes → emit MODIFIED with changed keys in aspects, each value wrapped as {"_op": "modified", "_value": <new>, "_previous": <old>}
    • Detect added/removed instances → emit MODIFIED with instances_added and/or instances_removed (the directional delta — not the full list; these two keys are exempt from the wrapping requirement)
    • Detect added/removed/modified child properties → emit MODIFIED entity event with content summary and individual property events. The content list and the set of standalone property events in the same report must be consistent: every label listed in content must have a standalone event, and every standalone child event whose parent has a MODIFIED event must appear in that parent's content. Mismatches produce warnings (errors with --strict).
  • For each vocabulary entity (enum type, unit group, code list): set kind to ENUMERATION_SET in the entity ADDED event
  • For each vocabulary property (enum value, unit entry): set kind to ENUM_VALUE in the property ADDED event
  • For each property that exists in current but not previous: emit ADDED property event with full aspects; include output_type for typed properties (signals, fields) — omit for vocabulary elements (enum values, unit definitions) where no type resolution is involved
  • Set is_leaf on every PROPERTY-kind event (ADDED/MODIFIED/REMOVED): true when output_type is primitive/scalar, false when it names another entity (a reference). Omit is_leaf on ENUM_VALUE events.
  • For each property that exists in previous but not current: emit REMOVED property event with previous_aspects set to the full prior-state snapshot (mandatory and non-empty)
  • For each property that exists in both and has changed:
    • Detect renames → emit MODIFIED with renamed_from
    • If the element was also modified in the same release, include both renamed_from and the changed keys in aspects within the same event
    • Compute delta of changed aspect keys → emit MODIFIED with only changed keys in aspects, each value wrapped as {"_op": "modified", "_value": <new>, "_previous": <old>}
    • Recompute is_leaf from the property's current output_type even when unchanged — a leaf/reference transition always triggers a new contract, independent of the breaking-change config
  • Map language-specific attribute names to consistent aspect key names (e.g., vspec datatypeoutput_type)
  • Ensure output_type carries the base type name only (no list brackets, no ! suffix)
  • Set is_list and is_required separately for languages that express them (e.g., GraphQL [Type]!)
  • Output valid JSON with a top-level "changes" array
  • Validate the output against modl's schema before passing it to modl sync

Breaking-change configuration

The breaking-change config tells modl which aspect keys constitute a data-contract change. The adapter does not need to know this — it reports all changes; modl decides which are breaking.

modl sync takes two config files:

metadata.yaml — identifies the model project (follows the s2dm convention):

name: MyModel
id: "https://myproject.org/model/"   # must end with '/' or '#'
preferred_prefix: "mp"               # optional display alias

breaking-aspects.yaml — declares which aspect keys are breaking. The config has four sections — one per element kind:

entity:
  name.modified: false      # renames are non-breaking; suppresses --strict warnings
  properties.added: false   # adding a child property is non-breaking
  properties.removed: true  # removing a child property is breaking
  instances.added: false    # adding an instance is non-breaking
  instances.removed: true   # removing an instance is breaking
  type: true                # any change to 'type' aspect is breaking

property:
  name.modified: false
  output_type: true         # breaking — triggers a new contract
  unit: true                # breaking — triggers a new contract
  unit.removed: false       # override: dropping a unit annotation is non-breaking
  accuracy: true            # user-defined domain attribute; breaking
  description: false        # known, non-breaking; suppresses --strict warnings

enumeration_set:
  name.modified: false
  values.added: false       # adding a new enum value is non-breaking
  values.removed: true      # removing an enum value is breaking

enum_value:
  name.modified: true       # renaming a value is breaking (consumers match on string)
  symbol: true

All four sections default to empty dicts when omitted — all changes for that kind are treated as non-breaking.

Each key maps to a boolean with three distinct states:

ValueMeaning
trueAspect is breaking — a change triggers a new contract.
falseAspect is known but non-breaking — changes are accepted silently; no warning even with --strict.
(absent)Aspect is unknown — treated as non-breaking but produces a warning (error with --strict).

Key format

Keys use a flat dotted form to express per-operation classification:

unit: true               # shorthand — any op (added/removed/modified) is breaking
unit.added: true         # only gaining a unit for the first time is breaking
unit.modified: true      # changing the unit value is breaking
unit.removed: false      # dropping a unit annotation is non-breaking

A plain key (no dot) is shorthand for all three operations having the same classification. The granular dotted form takes precedence over the shorthand when both are present.

Structural keys are derived by the engine from event fields, not from the aspects dict. They are always recognised (never produce an unknown-key warning), but their breaking classification must still be declared explicitly:

KeyKindMeaning
name.modifiedallRename (renamed_from set on the event)
properties.addedentityChild property added
properties.removedentityChild property removed
instances.addedentityInstance label added
instances.removedentityInstance label removed
values.addedenumeration_setENUM_VALUE child added
values.removedenumeration_setENUM_VALUE child removed

name.added and name.removed are forbidden (an element always has exactly one label). The plain key name is also forbidden — use name.modified.