Authoring A12 document models with dmtool
You help author and validate A12 document models — both their structure (fields, groups, type definitions, includes, config) and their validation rules — with the dmtool CLI. The CLI describes itself — you explore it rather than memorizing it — so this skill carries only the judgment the tool can't give you (polarity, the traps, the kernel's laws). You don't need A12 background docs: the CLI's self-description + its kernel-checked feedback are enough if you follow the rules below.
⛔ First — is this even a rule? Prefer a field/group property
A rule is the most expensive way to state a constraint: a condition + error code + per-locale messages, plus (inside a repeatable group) a GroupFilled guard. Many constraints are really properties of the field — declared once, enforced natively, no rule. Before composing any condition, check this:
| The constraint is really… |
Declare it as a field property |
Not this rule |
| "X must be provided" |
the field's required property (a key in its spec: field add / field modify). In a repeatable group required means required per present row — it replaces a whole GroupFilled(G) And FieldNotFilled(X) rule |
FieldNotFilled(X) |
| a number within a range |
the field's min/max |
[X] > max |
| a string in a fixed format |
the field's regex |
a pattern rule |
| a value from a fixed set |
an enum field |
a value-list rule |
dmtool patterns marks these field-level alternatives explicitly. Write a rule only for what a field property can't express: cross-field logic, conditional requiredness ("required when Channel is EXPRESS"), date ordering, cross-row aggregates. Rule of thumb — if the constraint names one field and a fixed presence/limit/format, it's a field property, not a rule.
Your tools
The CLI is self-describing — explore it:
dmtool --help — the verb list; model-bound operations use dmtool -m <model.json> <target> <op> (the model is set once with -m, before or after the verb), while root/catalog/artifact operations expose their own syntax; dmtool <target> <op> --help — that op's parameters and what they mean; dmtool manifest — the same, machine-readable (each verb's target/op + params).
dmtool operators — the DSL operator catalog (each operator's meaning, operands, examples, and gotchas — read the gotchas, they often name the exact fix for a rejection); dmtool operators <id> — one in full. The list's header serves the per-kind emptyOperandDefaults (how an empty field behaves in a comparison), and an entry's semantics block states its kernel-verified facets — empty-operand behavior, strict-vs-inclusive boundaries, what an all-empty aggregate folds to. Check there instead of assuming.
dmtool schema <target> <op> — the JSON an op consumes/returns. dmtool schema result describes the standard envelope used only when the effective output kind is RESULT_ENVELOPE. In the matching manifest profile, matching operationalProfiles[].returns.views[] overrides operationalProfiles[].returns.kind; without a matching view, use that primary kind. For example, --text=true selects a RAW_TEXT view. RAW_JSON, RAW_TEXT, EMPTY_STDOUT, and NDJSON_STREAM bypass the envelope.
The four you lean on:
dmtool -m <model.json> model describe — orient: fields, kinds, enum values, groups, repeatability.
dmtool operators — pick operators by meaning.
dmtool -m <model.json> rule check --field <ABSOLUTE field path> --condition "<DSL>" --code <ID> — submit a candidate and get the real kernel's verdict (the result envelope's valid + diagnostics). This is your ground truth. (dmtool -m <model.json> model check statically re-checks a model's existing rules + computations.)
dmtool -m <model.json> computation add <computation-spec.json> --dry-run — the computation-spec counterpart of rule check: get the real kernel's static verdict without writing (outcome:preview, verification:KERNEL_CONFIRMED, written:false when accepted). Discover the spec with dmtool schema computation add; there is deliberately no separate check subcommand under computation.
Keep the evidence owner explicit:
| Question |
Use |
What the output proves |
| Is a model/rule/computation definition statically accepted? |
model check, rule check, or computation add … --dry-run |
The real kernel's consistency verdict; require verification:KERNEL_CONFIRMED. |
| What fires or computes for a document instance? |
model eval, rule eval, or model compute |
A dm-interpreter runtime observation; the envelope says engine:DM_INTERPRETER. Do not cite it as kernel-runtime evidence. |
| What does a model-shaped sample document look like? |
model seed |
A generated A12 Document JSON artifact to edit and feed to eval/compute; it is neither a consistency verdict nor runtime evidence. |
⛔ Inspect and edit a model ONLY through dmtool's structured verbs — never read or hand-edit the raw .dm.json, and don't fall back to export to inspect, either. To see a model: model describe (structure + kinds + enum values) and field read / rule read (one element in full — field read echoes the field's metadata and its per-kind config: number min/max/unit/scale, string pattern/length/patternMessage, enum values, date/time formats — so you can confirm a constraint actually persisted). To change it: the edit verbs. export is NOT an inspection tool — it dumps the model's raw DM-JSON for emitting it (saving, handing to A12 Tools); eyeballing an export dump to understand a model is the same anti-pattern as cat-ing the file, because you're reading the kernel's internal format that describe/read exist to abstract. The structured reads already show everything you'd look for — if one seems to omit something you need, that's a bug report, not a cat or an export.
Choose apply for one atomic multi-operation model edit; use batch only for independent dispatch. apply runs ordered operations against one model and rolls the whole sequence back when an operation or the terminal kernel gate fails. batch runs arbitrary CLI invocations, continues after a child failure, and never rolls back an earlier successful write. After full dispatch both batch modes report outcome:completed, data.dispatched:true, and data.allExitedZero while every child keeps its exact exit. For ordinary batch, outer ok and exit 0/1 follow allExitedZero. For batch --observations, full dispatch plus artifact write is success, so outer ok is true and the process exits 0 even when a captured child exits nonzero; preflight refusal and artifact-write failure still exit nonzero. Use batch for independent/read-only calls or when partial writes are explicitly acceptable, and inspect the child results. A multiword command puts the target in verb and the rest of its command line in args:
[
{"id":"computation-preflight","verb":"computation","args":["add","spec.json","--dry-run"]}
]
Going bilingual is the classic case for one atomic apply. Adding a second locale (config modify --add-locale de_DE) makes the kernel require, in the new locale: every enum value's labels (else MVK_INTERNAL_VALUES_AND_DISPLAY_VALUES — "display value and XML value not specified together"), every label you set on a new field, a message on every existing rule/computation — not just the ones you're editing (a rule left with only its old-locale message → MVK_ERROR_MESSAGE_FOR_LANGUAGE_MISSING), and every patterned string field's pattern message (a pattern field's patternMessage left in only the old locale hits the same MVK_ERROR_MESSAGE_FOR_LANGUAGE_MISSING — the per-locale form is the patternMessages map). The --add-locale itself is rejected until all of these are present (so it's all-or-nothing). Scope it precisely: run config modify --add-locale <loc> --dry-run first — it lists the exact per-element gaps this model actually has (which rules/enums/patterned fields are missing the new locale), so you top up only those instead of guessing from the list above (the enum-label and pattern-message items apply only if the model has them). Pre-existing single-locale field labels are the one exception (tolerated — labels, unlike the error texts above, aren't required per-locale), so the requirement is asymmetric and surprising. Do the --add-locale and the per-enum-value label top-ups, any new fields' bilingual labels, the new-locale message on every existing rule (rule read with no arg lists them), and a field modify re-supplying patternMessages for the new locale on every patterned string field in one apply (you can't set a new-locale message before --add-locale declares it, so they must ride the same atomic apply), so the single terminal gate sees a consistent model instead of rejecting a half-bilingual one.
Naming model files — match the basename to the model id. A12 Tools requires a model's file basename to equal its model name (the header id), and it does not strip the suffix — so the suffix is part of the id, not just a file decoration. When you create a model, give --id and -o the same stem: model new --id Order_DM -o Order_DM.json. The A12 Tools convention is <Name>_DM for a document model, <Name>_TDM for a type-definition model (some repos use a lowercase .dm.json with a bare id — match whatever the workspace already uses). The trap the importer flags: --id order written to Order_DM.json (id ≠ basename). dmtool itself resolves references by id, never by filename — but the A12 modeler gates basename == id, so keep them equal. When you group extract, the new sub-model is written as <reference>.dm.json in the include-dir — take its exact path from the result's subModel field; a guessed <reference>.json won't load.
If dmtool is command not found: the plugin bundles an installer next to this skill — run it to download the binary on demand: bash "${CLAUDE_SKILL_DIR}/ensure-dmtool.sh" (that variable is this skill's own directory on Claude Code; on Codex run the ensure-dmtool.sh that sits beside this SKILL.md). It fetches the per-OS native build, checksum-verifies it, and prints a line like dmtool ready: <absolute path>. Use that printed path to invoke dmtool for the rest of the session — a script you run mid-session can't reliably add it to PATH. Don't build it from source or fetch it any other way. (Where dmtool already runs, none of this applies — just use it.)
The loop
- Orient —
dmtool -m <model> model describe to learn the fields, their kinds, enum values, and which groups repeat (the structured view; not export, which only dumps the raw model).
- Pick operators — from
dmtool operators, by meaning.
- Compose the condition — minding polarity, paths, and iteration below.
- Check —
dmtool -m <model> rule check …. If valid:true, done. If not, read each diagnostic.
- Iterate — the diagnostic
code+summary name the problem; look the operator up in the catalog for the fix; adjust and re-check.
⚠️ Polarity — the single most important thing
A rule's condition is TRUE when the document is INVALID. It describes the error scenario (the violation), not the requirement. There is no Not operator — instead, pick the negative-form predicate.
So to enforce a requirement, write its violation:
| Requirement |
✅ condition (the violation) |
❌ common mistake (the opposite rule) |
| "X must be provided" |
FieldNotFilled(X) |
FieldFilled(X) |
| "amount must be ≤ 1000" |
[X] > 1000 |
[X] <= 1000 |
| "at least one of A/B set" |
NoFieldFilled(A, B) |
AtLeastOneFieldFilled(A, B) |
The kernel accepts both polarities (both are valid conditions), so check returning valid:true does not mean your polarity is right — only that the syntax/types are. Always re-read your condition as "this is true exactly when the document is wrong."
⚠️ The first row is about polarity (a rule's condition is true on a violation) — it does not mean plain requiredness should be a rule. Unconditional "X is required" is the field's required property (see the gate above); reach for FieldNotFilled only for conditional requiredness or as a row-existence guard.
Field-path references
A condition's field paths resolve relative to the rule's group (defaults to the error field's parent group). The group is only the path-resolution base — which rows the rule iterates over derives from the condition's repeatable references (see Per-row iteration), never from where the rule sits.
- Bare name = a field in the rule's own scope, else — when the name is unique across the whole model — that one field, wherever it sits (a model-config fallback, on in dmtool-created models). There is no upward search: from a rule scoped to
/Subscription/Addons, [Tier] resolves to /Subscription/Tier because Tier is unique model-wide, not because it's an ancestor's field. A bare name that exists in several groups is rejected (MVK_FIELDNAME_NOT_UNIQUE) — write the parent navigation (../Tier) or the absolute path instead.
- Absolute path for a field in a different branch (or just to be explicit):
[/Customer/Status].
- Brackets
[…] mark a field used as a value — a comparison operand: [Quantity] > 0, [/Customer/Status] == "ACTIVE". Anything inside a function/predicate/aggregate's parentheses is a BARE ref, never bracketed: FieldNotFilled(Quantity), Sum(Items*/Amount), DateRange(OrderDate, DeliveryDate), StartOfDateRange(CoverageWindow). Bracketing a call's argument is a parse error, not extra safety.
- You can compare field-to-field, not just field-to-literal — bracket both:
[EffectiveFee] < [BaseFee].
- Strict vs inclusive: map the wording carefully. "lower than / below / more than / exceeds" → strict (
< / >); "at least / no less than / at most / no more than" → inclusive (<= / >=). And remember the violation is the opposite of the requirement: requirement "must be at least base" (>= base, valid) → violation < base.
- Enums compare by stored value, not the display label:
== "ACTIVE", not == "Active". (model describe lists the stored values.)
- Booleans/confirms compare to the capitalized
True/False — [Active] == True, not the JSON true/false (a lowercase true is a parse error, MVK_UNEXPECTED_TOKEN). A confirm field compares only to True (== True / != True); [Sig] == False is rejected (MVK_INVALID_COMPARE_TO_YES) — a confirm is checked-or-not, so test the unchecked side with != True (or FieldNotFilled).
Empty values in a comparison
How an empty (unspecified) field behaves in a comparison depends on its type — the trap: an empty number participates as 0, so [Amount] < 100 fires on an empty Amount (0 < 100). The per-type table is served by the tool (the dmtool operators header emptyOperandDefaults; per-operator deviations in each entry's semantics.emptyOperand), and rule check warns (RK_UNGUARDED_NUMBER_COMPARISON) exactly where an empty value would fire your comparison — its fix names both remedies; decide by intent, guarding with FieldFilled(…) And … when absence shouldn't trip the rule.
One corner draws no warning: there are no empty-string values (an empty string field is just unfilled), so [F] == "" is never true — test absence with FieldNotFilled(F).
Per-row iteration & the negative guard
- Putting the error field inside a repeatable group gives a rule once-per-row behavior only when its condition carries an ordinary iteration-bearing reference in that row. That usually happens automatically because every rule must reference its error field. A
* consumed by an aggregate is the exception: it reopens the operand's rows without creating one rule evaluation per reopened row (see the aggregate section below). The error field itself selects no iteration — it just must share the scope the references set.
- "Each X must …" is a per-row rule — preserve both its row locus and row iteration. Put the error field inside the repeatable row and write the condition with an ordinary reference in that row. Recasting it as one whole-document check over
Lines*/ShippedDate (a count, or NotAllFieldsFilled(Lines*/ShippedDate) on a top-level field) is a different rule: the aggregate star reopens the values but fires once for the whole document and flags the wrong locus.
- A negative presence condition (
FieldNotFilled, NoFieldFilled, NotAllFieldsFilled, NotExactlyOneFieldFilled) inside an iterating rule is rejected (MVK_NEG_CONDITION_IN_ITERATION) unless guarded by a positive existence check on the row: GroupFilled(<the repeatable group>) And <your negative condition>. Add the guard; don't try to predict the gate. It is the kernel's own syntactic analysis and it tracks neither the operator's name nor the condition's meaning — FieldsNotCollectivelyFilled(G) is negative and accepted unguarded, while NumberOfFilledFields(G) < 1 contains no negation and is rejected (and <= 0, the same predicate on a count, is accepted). So when this code appears, apply the guard rather than reasoning about why it fired.
- Guard row existence with
GroupFilled(<the repeatable group>), not FieldFilled(<some sibling field>). A sibling field can be empty while the row exists, so an arbitrary-field guard quietly changes which rows the rule covers — GroupFilled is the row-presence check.
Aggregates over a repeatable group
When a rule reasons about all the rows at once (not one row), it folds the repetitions with an aggregate, and the * wildcard is what flattens them. Such a rule is model-level (it spans rows), so its error field is normally a non-repeatable field — the rule then fires once, not per row.
- The
* goes on whatever flattens the repetitions — the field for a value aggregate (Sum(Lines*/Amount), NumberOfFilledFields(Lines*/Sku), MaxValue / MinValue — not the operand-list Min/Max, which take value expressions, never a starred path), or the group itself to count rows (NumberOfFilledGroups(Lines*)). A single repeatable group reference needs that *: NumberOfFilledGroups(Lines) without it is rejected MVK_NO_WILDCARD, and a * where the group must stay whole (GroupFilled(Lines*)) is rejected MVK_NO_WILDCARDS_ALLOWED. (Plain GroupFilled(Group) takes no * — it's the per-row existence guard from the section above, valid only from inside the iterating group, never as a model-level reference.)
- Pick the operator by the question: total of the amounts →
Sum(Lines*/Amount); how many rows → NumberOfFilledGroups(Lines*); how many filled instances of a field → NumberOfFilledFields(Lines*/Sku). Confirm names/operands with dmtool operators.
- Choose the message scope for "no two rows share a key":
FieldValuesNotUnique(/Group*/Key) validates and persists under the default grouping and produces one cross-row aggregate verdict. If every duplicate row must receive its own message, use RepetitionNotUnique from the repeated group's PARENT (--group <parent>; the default grouping rejects it). Their operators entries carry the exact firing locus and authoring constraints.
- Resolve from the rule's scope, or go absolute. A wildcard path resolves relative to the error field's group; from a different branch a relative
Lines*/Amount is MVK_INVALID_ENTITY — write the absolute /Invoice/Lines*/Amount. When unsure, go absolute.
- An aggregate is a number, so compare it:
Sum(Lines*/Amount) > 500, or against a field by bracketing it: [FeeCap] < Sum(Lines*/Amount).
Having filters which rows are folded: Sum(Lines*/Amount Having [Lines/Type] == "FEE") sums only the fee lines.
- The error field must appear in the condition (any rule — kernel
MVK_ERROR_FIELD_NOT_REFERENCED). A model-level aggregate's error field is not referenced by the aggregate's own path, so reference it explicitly: put the error field on the cap/limit you compare the aggregate against ([FeeCap] < Sum(...) references FeeCap), or guard with FieldFilled(<errorField>). "Put it on a non-repeatable field" is necessary but not sufficient — the field still has to be named in the condition.
- The error field may instead sit inside the aggregated group — the message then lands on a row's field, but only the FIRST row's. It's valid (the starred path references the in-row field, satisfying the bullet above), but the default scope is then the repeatable group itself, which can't resolve a relative starred path (the
MVK_INVALID_ENTITY above) — write it absolute: error field /Invoice/Lines/Amount, condition Sum(/Invoice/Lines*/Amount) > 500 (or lift the scope with --group to the repeatable group's parent /Invoice, where the path may stay relative). The scope choice does not change the runtime: an aggregate-only condition fires once either way, pinned to the first row's Amount — it never marks every row (marking each offending row is a per-row rule — see Per-row iteration above — not an aggregate). When a natural non-repeatable field exists, prefer it — one error at the locus that explains it.
Example — "the FEE-line total must not exceed the invoice's FeeCap" (repeatable /Invoice/Lines with Amount/Type; non-repeatable /Invoice/FeeCap):
dmtool -m invoice.json rule check --field /Invoice/FeeCap \
--condition "FieldFilled(FeeCap) And [FeeCap] < Sum(Lines*/Amount Having [Lines/Type] == \"FEE\")" \
--code FEE_OVER_CAP
# → "valid": true — FeeCap is referenced (via the comparison), so the error field appears in the condition
Dates
- A date/time constant is German-format and quoted — date
"31.12.2024" (dd.MM.yyyy), time "17:00:00". An ISO-style literal ("2024-12-31") is read as a string, so an ordering comparison is rejected as MVK_INVALID_TYPE_FOR_COMPARISON — the code name is unhelpful here, but its fix hint points the right way: write the German format, not switch to ==. (Often cleaner to skip the literal: compare to another date field or Today, or pull a part — YearFromDate(D) < 2020.)
- An empty date operand does not suppress a date function — e.g.
DifferenceInDays reads an empty operand as a 0-difference, so the comparison can fire on absence (each operator's semantics.emptyOperand facet states its behavior; the rule check guard warning covers these too). Lead with AllFieldsFilled(DateA, DateB) And … when absence shouldn't trip the rule.
- Argument order matters — the
DifferenceIn* family is directional. Take the direction from the operator's meaning and example (dmtool operators DifferenceInDays) rather than assuming it.
Custom conditions (host-delegated)
CustomCondition <Name> is an escape hatch: the named check runs in the host application's code, not in the rule language — its logic is not visible in the model. Two rules:
- Don't guess what it decides. Reading a rule that uses one, name it as a host-delegated check ("delegates to the app-defined
CreditApproved check") and stop — inventing its meaning from the name is wrong.
- Polarity is unchanged (see above): like any condition it is part of the violation, so the rule fires (document invalid) when the whole
errorCondition is true — not when it's false. CustomCondition references no field, so pair it with one to cover the error field: FieldFilled(Applicant) And CustomCondition CreditApproved.
Worked example (a different model, to show the pattern)
Requirement: "When an order's Channel is EXPRESS, each line item's DeliveryDate must be provided."
Model has enum /Order/Channel (values STANDARD, EXPRESS) and a repeatable group /Order/LineItems with field DeliveryDate.
- Error field
/Order/LineItems/DeliveryDate — in the repeatable row, so referencing it makes the rule fire per row.
- Violation = the row exists and channel is EXPRESS and the date is missing — guarded because it iterates and uses a negative:
GroupFilled(/Order/LineItems) And [/Order/Channel] == "EXPRESS" And FieldNotFilled(DeliveryDate)
- Confirm:
dmtool -m order.json rule check \
--field /Order/LineItems/DeliveryDate \
--condition "GroupFilled(/Order/LineItems) And [/Order/Channel] == \"EXPRESS\" And FieldNotFilled(DeliveryDate)" \
--code EXPRESS_ITEM_NEEDS_DELIVERY_DATE
# → the envelope reports "valid": true, "diagnostics": []
Apply the same shape to your own model: find the enum + the repeatable group with describe, choose the error field for the per-row scope, write the violation, guard it if it iterates with a negative, then check.
Reading a rejection
check returns diagnostics with a code and summary, and the common structural/syntax codes carry an enriched fix/explain naming the exact correction (dmtool diagnostics <code> serves the same guidance on its own). Trust the fix first; for anything operator-specific, look the operator up with dmtool operators <id> — its gotchas and examples usually say exactly what to do.
1---2name: a12-dmtool3description: Author and validate A12 Kernel document models with the dmtool CLI — both a model's structure (fields, groups, type definitions, includes, config) and its validation rules. Use when a user (often a document modeller or business analyst) wants to create, edit, check, or understand an A12 document model — add or change fields and groups, factor out reusable includes, refactor structure, or author validation rules on it. Covers model creation, the structure edits and refactors (extract/move/rename), the rule envelope and error-scenario polarity, field-path references, per-row iteration, and the explore→compose→check loop.4---56# Authoring A12 document models with dmtool78You help author and validate **A12 document models** — both their structure (fields, groups, type definitions, includes, config) and their validation rules — with the `dmtool` CLI. The CLI **describes itself** — you explore it rather than memorizing it — so this skill carries only the **judgment the tool can't give you** (polarity, the traps, the kernel's laws). You don't need A12 background docs: the CLI's self-description + its kernel-checked feedback are enough if you follow the rules below.910## ⛔ First — is this even a rule? Prefer a field/group property1112A rule is the **most expensive** way to state a constraint: a condition + error code + per-locale messages, plus (inside a repeatable group) a `GroupFilled` guard. Many constraints are really **properties of the field** — declared once, enforced natively, no rule. **Before composing any condition, check this:**1314| The constraint is really… | Declare it as a field property | Not this rule |15|---|---|---|16| "X must be provided" | the field's **`required`** property (a key in its spec: `field add` / `field modify`). In a repeatable group `required` means *required per present row* — it **replaces** a whole `GroupFilled(G) And FieldNotFilled(X)` rule | ~~`FieldNotFilled(X)`~~ |17| a number within a range | the field's **min/max** | ~~`[X] > max`~~ |18| a string in a fixed format | the field's **regex** | ~~a pattern rule~~ |19| a value from a fixed set | an **enum** field | ~~a value-list rule~~ |2021`dmtool patterns` marks these field-level alternatives explicitly. **Write a rule only for what a field property can't express:** cross-field logic, *conditional* requiredness ("required *when* Channel is EXPRESS"), date ordering, cross-row aggregates. Rule of thumb — if the constraint names **one field and a fixed presence/limit/format**, it's a field property, not a rule.2223## Your tools2425The CLI is **self-describing** — explore it:2627- `dmtool --help` — the verb list; model-bound operations use `dmtool -m <model.json> <target> <op>` (the model is set once with `-m`, before or after the verb), while root/catalog/artifact operations expose their own syntax; `dmtool <target> <op> --help` — that op's parameters and what they mean; `dmtool manifest` — the same, machine-readable (each verb's `target`/`op` + params).28- `dmtool operators` — the DSL operator catalog (each operator's meaning, operands, examples, and **gotchas** — read the gotchas, they often name the exact fix for a rejection); `dmtool operators <id>` — one in full. The list's header serves the per-kind **`emptyOperandDefaults`** (how an empty field behaves in a comparison), and an entry's **`semantics`** block states its kernel-verified facets — empty-operand behavior, strict-vs-inclusive boundaries, what an all-empty aggregate folds to. Check there instead of assuming.29- `dmtool schema <target> <op>` — the JSON an op consumes/returns. `dmtool schema result` describes the standard envelope used only when the effective output kind is `RESULT_ENVELOPE`. In the matching manifest profile, matching `operationalProfiles[].returns.views[]` overrides `operationalProfiles[].returns.kind`; without a matching view, use that primary kind. For example, `--text=true` selects a `RAW_TEXT` view. `RAW_JSON`, `RAW_TEXT`, `EMPTY_STDOUT`, and `NDJSON_STREAM` bypass the envelope.3031The four you lean on:32- **`dmtool -m <model.json> model describe`** — orient: fields, kinds, enum values, groups, repeatability.33- **`dmtool operators`** — pick operators by meaning.34- **`dmtool -m <model.json> rule check --field <ABSOLUTE field path> --condition "<DSL>" --code <ID>`** — submit a candidate and get the **real kernel's** verdict (the result envelope's `valid` + `diagnostics`). This is your ground truth. (`dmtool -m <model.json> model check` statically re-checks a model's *existing* rules + computations.)35- **`dmtool -m <model.json> computation add <computation-spec.json> --dry-run`** — the computation-spec counterpart of `rule check`: get the real kernel's static verdict without writing (`outcome:preview`, `verification:KERNEL_CONFIRMED`, `written:false` when accepted). Discover the spec with `dmtool schema computation add`; there is deliberately no separate check subcommand under `computation`.3637Keep the evidence owner explicit:3839| Question | Use | What the output proves |40|---|---|---|41| Is a model/rule/computation definition statically accepted? | `model check`, `rule check`, or `computation add … --dry-run` | The real kernel's consistency verdict; require `verification:KERNEL_CONFIRMED`. |42| What fires or computes for a document instance? | `model eval`, `rule eval`, or `model compute` | A dm-interpreter runtime observation; the envelope says `engine:DM_INTERPRETER`. Do not cite it as kernel-runtime evidence. |43| What does a model-shaped sample document look like? | `model seed` | A generated A12 Document JSON artifact to edit and feed to `eval`/`compute`; it is neither a consistency verdict nor runtime evidence. |4445**⛔ Inspect and edit a model ONLY through `dmtool`'s structured verbs — never read or hand-edit the raw `.dm.json`, and don't fall back to `export` to inspect, either.** To *see* a model: `model describe` (structure + kinds + enum values) and `field read` / `rule read` (one element in full — `field read` echoes the field's metadata **and its per-kind config**: number min/max/unit/scale, string pattern/length/`patternMessage`, enum values, date/time formats — so you can confirm a constraint actually persisted). To *change* it: the edit verbs. **`export` is NOT an inspection tool** — it dumps the model's raw DM-JSON for *emitting* it (saving, handing to A12 Tools); eyeballing an export dump to understand a model is the same anti-pattern as `cat`-ing the file, because you're reading the kernel's internal format that `describe`/`read` exist to abstract. The structured reads already show everything you'd look for — if one seems to omit something you need, that's a bug report, not a `cat` or an `export`.4647**Choose `apply` for one atomic multi-operation model edit; use `batch` only for independent dispatch.** `apply` runs ordered operations against one model and rolls the whole sequence back when an operation or the terminal kernel gate fails. `batch` runs arbitrary CLI invocations, continues after a child failure, and never rolls back an earlier successful write. After full dispatch both batch modes report `outcome:completed`, `data.dispatched:true`, and `data.allExitedZero` while every child keeps its exact exit. For ordinary batch, outer `ok` and exit 0/1 follow `allExitedZero`. For `batch --observations`, full dispatch plus artifact write is success, so outer `ok` is true and the process exits 0 even when a captured child exits nonzero; preflight refusal and artifact-write failure still exit nonzero. Use `batch` for independent/read-only calls or when partial writes are explicitly acceptable, and inspect the child results. A multiword command puts the target in `verb` and the rest of its command line in `args`:4849```json50[51 {"id":"computation-preflight","verb":"computation","args":["add","spec.json","--dry-run"]}52]53```5455**Going bilingual is the classic case for one atomic `apply`.** Adding a second locale (`config modify --add-locale de_DE`) makes the kernel require, in the new locale: every **enum value's** labels (else `MVK_INTERNAL_VALUES_AND_DISPLAY_VALUES` — *"display value and XML value not specified together"*), every label you set on a **new** field, a message on **every existing rule/computation** — not just the ones you're editing (a rule left with only its old-locale message → `MVK_ERROR_MESSAGE_FOR_LANGUAGE_MISSING`), **and** every **patterned string field's pattern message** (a `pattern` field's `patternMessage` left in only the old locale hits the *same* `MVK_ERROR_MESSAGE_FOR_LANGUAGE_MISSING` — the per-locale form is the `patternMessages` map). The `--add-locale` itself is *rejected* until all of these are present (so it's all-or-nothing). **Scope it precisely: run `config modify --add-locale <loc> --dry-run` first** — it lists the *exact* per-element gaps this model actually has (which rules/enums/patterned fields are missing the new locale), so you top up only those instead of guessing from the list above (the enum-label and pattern-message items apply only if the model has them). Pre-existing single-locale *field* labels are the one exception (tolerated — labels, unlike the error texts above, aren't required per-locale), so the requirement is asymmetric and surprising. Do the `--add-locale` **and** the per-enum-value label top-ups, any new fields' bilingual labels, **the new-locale message on every existing rule** (`rule read` with no arg lists them), **and a `field modify` re-supplying `patternMessages` for the new locale on every patterned string field** in **one** `apply` (you can't set a new-locale message before `--add-locale` declares it, so they must ride the same atomic apply), so the single terminal gate sees a consistent model instead of rejecting a half-bilingual one.5657**Naming model files — match the basename to the model `id`.** A12 Tools requires a model's **file basename to equal its model name (the header `id`)**, and it does **not** strip the suffix — so the suffix is part of the *id*, not just a file decoration. When you create a model, give `--id` and `-o` the **same** stem: `model new --id Order_DM -o Order_DM.json`. The A12 Tools convention is `<Name>_DM` for a document model, `<Name>_TDM` for a type-definition model (some repos use a lowercase `.dm.json` with a bare id — match whatever the workspace already uses). The trap the importer flags: `--id order` written to `Order_DM.json` (id ≠ basename). dmtool itself resolves references by `id`, never by filename — but the A12 modeler gates basename == id, so keep them equal. When you `group extract`, the new sub-model is written as `<reference>.dm.json` in the include-dir — take its exact path from the result's `subModel` field; a guessed `<reference>.json` won't load.5859**If `dmtool` is `command not found`:** the plugin bundles an installer next to this skill — run it to download the binary on demand: `bash "${CLAUDE_SKILL_DIR}/ensure-dmtool.sh"` (that variable is this skill's own directory on Claude Code; on Codex run the `ensure-dmtool.sh` that sits beside this `SKILL.md`). It fetches the per-OS native build, checksum-verifies it, and prints a line like `dmtool ready: <absolute path>`. **Use that printed path to invoke `dmtool`** for the rest of the session — a script you run mid-session can't reliably add it to `PATH`. **Don't build it from source or fetch it any other way.** (Where `dmtool` already runs, none of this applies — just use it.)6061## The loop62631. **Orient** — `dmtool -m <model> model describe` to learn the fields, their kinds, enum values, and which groups repeat (the structured view; not `export`, which only dumps the raw model).642. **Pick operators** — from `dmtool operators`, by meaning.653. **Compose** the condition — minding **polarity**, **paths**, and **iteration** below.664. **Check** — `dmtool -m <model> rule check …`. If `valid:true`, done. If not, read each diagnostic.675. **Iterate** — the diagnostic `code`+`summary` name the problem; look the operator up in the catalog for the fix; adjust and re-check.6869## ⚠️ Polarity — the single most important thing7071**A rule's condition is TRUE when the document is INVALID.** It describes the *error scenario* (the violation), **not** the requirement. There is **no `Not` operator** — instead, pick the *negative-form* predicate.7273So to enforce a requirement, write its **violation**:7475| Requirement | ✅ condition (the violation) | ❌ common mistake (the opposite rule) |76|---|---|---|77| "X must be provided" | `FieldNotFilled(X)` | `FieldFilled(X)` |78| "amount must be ≤ 1000" | `[X] > 1000` | `[X] <= 1000` |79| "at least one of A/B set" | `NoFieldFilled(A, B)` | `AtLeastOneFieldFilled(A, B)` |8081**The kernel accepts both polarities** (both are valid conditions), so `check` returning `valid:true` does **not** mean your polarity is right — only that the syntax/types are. Always re-read your condition as "this is true exactly when the document is *wrong*."8283> ⚠️ The first row is about **polarity** (a rule's condition is true on a violation) — it does **not** mean plain requiredness should be a rule. Unconditional "X is required" is the field's **`required`** property (see the gate above); reach for `FieldNotFilled` only for **conditional** requiredness or as a row-existence guard.8485## Field-path references8687A condition's field paths resolve relative to the rule's **group** (defaults to the error field's parent group). The group is only the path-resolution base — **which rows the rule iterates over derives from the condition's repeatable references** (see *Per-row iteration*), never from where the rule sits.8889- **Bare name** = a field in the rule's **own scope**, else — when the name is **unique across the whole model** — that one field, wherever it sits (a model-config fallback, on in dmtool-created models). There is **no** upward search: from a rule scoped to `/Subscription/Addons`, `[Tier]` resolves to `/Subscription/Tier` because `Tier` is unique model-wide, not because it's an ancestor's field. A bare name that exists in **several** groups is rejected (`MVK_FIELDNAME_NOT_UNIQUE`) — write the parent navigation (`../Tier`) or the absolute path instead.90- **Absolute path** for a field in a *different branch* (or just to be explicit): `[/Customer/Status]`.91- **Brackets `[…]` mark a field used as a *value*** — a comparison operand: `[Quantity] > 0`, `[/Customer/Status] == "ACTIVE"`. **Anything inside a function/predicate/aggregate's parentheses is a BARE ref**, never bracketed: `FieldNotFilled(Quantity)`, `Sum(Items*/Amount)`, `DateRange(OrderDate, DeliveryDate)`, `StartOfDateRange(CoverageWindow)`. Bracketing a call's argument is a parse error, not extra safety.92- You can compare **field-to-field**, not just field-to-literal — bracket both: `[EffectiveFee] < [BaseFee]`.93- **Strict vs inclusive**: map the wording carefully. "lower than / below / more than / exceeds" → strict (`<` / `>`); "at least / no less than / at most / no more than" → inclusive (`<=` / `>=`). And remember the *violation* is the opposite of the requirement: requirement "must be **at least** base" (`>= base`, valid) → violation `< base`.94- **Enums compare by stored value**, not the display label: `== "ACTIVE"`, not `== "Active"`. (`model describe` lists the stored values.)95- **Booleans/confirms compare to the capitalized `True`/`False`** — `[Active] == True`, **not** the JSON `true`/`false` (a lowercase `true` is a parse error, `MVK_UNEXPECTED_TOKEN`). A **confirm** field compares only to `True` (`== True` / `!= True`); `[Sig] == False` is rejected (`MVK_INVALID_COMPARE_TO_YES`) — a confirm is checked-or-not, so test the unchecked side with `!= True` (or `FieldNotFilled`).9697## Empty values in a comparison9899How an **empty** (unspecified) field behaves in a comparison depends on its type — the trap: an empty **number** participates as **`0`**, so `[Amount] < 100` **fires** on an empty Amount (0 < 100). The per-type table is served by the tool (the `dmtool operators` header `emptyOperandDefaults`; per-operator deviations in each entry's `semantics.emptyOperand`), and `rule check` warns (`RK_UNGUARDED_NUMBER_COMPARISON`) exactly where an empty value would fire your comparison — its `fix` names both remedies; decide by intent, guarding with `FieldFilled(…) And …` when absence shouldn't trip the rule.100101One corner draws **no** warning: there are no empty-string *values* (an empty string field is just unfilled), so `[F] == ""` is never true — test absence with `FieldNotFilled(F)`.102103## Per-row iteration & the negative guard104105- Putting the **error field inside a repeatable group** gives a rule **once-per-row** behavior only when its condition carries an ordinary iteration-bearing reference in that row. That usually happens automatically because every rule must reference its error field. A `*` consumed by an aggregate is the exception: it reopens the operand's rows without creating one rule evaluation per reopened row (see the aggregate section below). The error field itself selects no iteration — it just must share the scope the references set.106- **"Each X must …" is a per-row rule — preserve both its row locus and row iteration.** Put the error field inside the repeatable row and write the condition with an ordinary reference in that row. Recasting it as one whole-document check over `Lines*/ShippedDate` (a count, or `NotAllFieldsFilled(Lines*/ShippedDate)` on a top-level field) is a **different rule**: the aggregate star reopens the values but fires once for the whole document and flags the wrong locus.107- A **negative presence** condition (`FieldNotFilled`, `NoFieldFilled`, `NotAllFieldsFilled`, `NotExactlyOneFieldFilled`) inside an iterating rule is **rejected** (`MVK_NEG_CONDITION_IN_ITERATION`) unless guarded by a positive existence check on the row: `GroupFilled(<the repeatable group>) And <your negative condition>`. **Add the guard; don't try to predict the gate.** It is the kernel's own syntactic analysis and it tracks neither the operator's name nor the condition's meaning — `FieldsNotCollectivelyFilled(G)` is negative and *accepted* unguarded, while `NumberOfFilledFields(G) < 1` contains no negation and is *rejected* (and `<= 0`, the same predicate on a count, is accepted). So when this code appears, apply the guard rather than reasoning about why it fired.108- **Guard row existence with `GroupFilled(<the repeatable group>)`, not `FieldFilled(<some sibling field>)`.** A sibling field can be empty while the row exists, so an arbitrary-field guard quietly changes *which* rows the rule covers — `GroupFilled` is the row-presence check.109110## Aggregates over a repeatable group111112When a rule reasons about **all the rows at once** (not one row), it folds the repetitions with an **aggregate**, and the **`*` wildcard is what flattens them**. Such a rule is **model-level** (it spans rows), so its error field is **normally** a **non-repeatable** field — the rule then fires **once**, not per row.113114- **The `*` goes on whatever flattens the repetitions** — the **field** for a value aggregate (`Sum(Lines*/Amount)`, `NumberOfFilledFields(Lines*/Sku)`, `MaxValue` / `MinValue` — *not* the operand-list `Min`/`Max`, which take value expressions, never a starred path), or the **group itself** to count rows (`NumberOfFilledGroups(Lines*)`). A *single* repeatable group reference needs that `*`: `NumberOfFilledGroups(Lines)` without it is rejected `MVK_NO_WILDCARD`, and a `*` where the group must stay whole (`GroupFilled(Lines*)`) is rejected `MVK_NO_WILDCARDS_ALLOWED`. (Plain `GroupFilled(Group)` takes no `*` — it's the **per-row** existence guard from the section above, valid only from *inside* the iterating group, never as a model-level reference.)115- **Pick the operator by the question:** total of the amounts → `Sum(Lines*/Amount)`; **how many rows** → `NumberOfFilledGroups(Lines*)`; how many filled instances of a field → `NumberOfFilledFields(Lines*/Sku)`. Confirm names/operands with `dmtool operators`.116- **Choose the message scope for "no two rows share a key":** `FieldValuesNotUnique(/Group*/Key)` validates and persists under the default grouping and produces one cross-row aggregate verdict. If every duplicate row must receive its own message, use `RepetitionNotUnique` from the repeated group's **PARENT** (`--group <parent>`; the default grouping rejects it). Their `operators` entries carry the exact firing locus and authoring constraints.117- **Resolve from the rule's scope, or go absolute.** A wildcard path resolves relative to the error field's group; from a different branch a relative `Lines*/Amount` is `MVK_INVALID_ENTITY` — write the absolute `/Invoice/Lines*/Amount`. When unsure, go absolute.118- **An aggregate is a number**, so compare it: `Sum(Lines*/Amount) > 500`, or against a field by bracketing it: `[FeeCap] < Sum(Lines*/Amount)`.119- **`Having` filters which rows are folded:** `Sum(Lines*/Amount Having [Lines/Type] == "FEE")` sums only the fee lines.120- **The error field must appear in the condition** (any rule — kernel `MVK_ERROR_FIELD_NOT_REFERENCED`). A model-level aggregate's error field is *not* referenced by the aggregate's own path, so reference it explicitly: put the error field on the **cap/limit you compare the aggregate against** (`[FeeCap] < Sum(...)` references `FeeCap`), or guard with `FieldFilled(<errorField>)`. "Put it on a non-repeatable field" is necessary but **not sufficient** — the field still has to be named in the condition.121- **The error field *may* instead sit inside the aggregated group — the message then lands on a row's field, but only the FIRST row's.** It's valid (the starred path references the in-row field, satisfying the bullet above), but the default scope is then the repeatable group itself, which can't resolve a relative starred path (the `MVK_INVALID_ENTITY` above) — write it **absolute**: error field `/Invoice/Lines/Amount`, condition `Sum(/Invoice/Lines*/Amount) > 500` (or lift the scope with `--group` to the repeatable group's **parent** `/Invoice`, where the path may stay relative). The scope choice does **not** change the runtime: an aggregate-only condition fires **once** either way, pinned to the first row's `Amount` — it never marks every row (marking each offending row is a per-row rule — see *Per-row iteration* above — not an aggregate). When a natural non-repeatable field exists, prefer it — one error at the locus that explains it.122123Example — *"the FEE-line total must not exceed the invoice's FeeCap"* (repeatable `/Invoice/Lines` with `Amount`/`Type`; non-repeatable `/Invoice/FeeCap`):124```125dmtool -m invoice.json rule check --field /Invoice/FeeCap \126 --condition "FieldFilled(FeeCap) And [FeeCap] < Sum(Lines*/Amount Having [Lines/Type] == \"FEE\")" \127 --code FEE_OVER_CAP128# → "valid": true — FeeCap is referenced (via the comparison), so the error field appears in the condition129```130131## Dates132133- **A date/time *constant* is German-format and quoted** — date `"31.12.2024"` (`dd.MM.yyyy`), time `"17:00:00"`. An **ISO-style literal** (`"2024-12-31"`) is read as a *string*, so an ordering comparison is rejected as `MVK_INVALID_TYPE_FOR_COMPARISON` — the code *name* is unhelpful here, but its `fix` hint points the right way: write the German format, **not** switch to `==`. (Often cleaner to skip the literal: compare to another date field or `Today`, or pull a part — `YearFromDate(D) < 2020`.)134- **An empty date operand does not suppress a date function** — e.g. `DifferenceInDays` reads an empty operand as a 0-difference, so the comparison can fire on absence (each operator's `semantics.emptyOperand` facet states its behavior; the `rule check` guard warning covers these too). Lead with `AllFieldsFilled(DateA, DateB) And …` when absence shouldn't trip the rule.135- **Argument order matters** — the `DifferenceIn*` family is directional. Take the direction from the operator's `meaning` and example (`dmtool operators DifferenceInDays`) rather than assuming it.136137## Custom conditions (host-delegated)138139`CustomCondition <Name>` is an **escape hatch**: the named check runs in the host application's code, **not** in the rule language — its logic is *not visible in the model*. Two rules:140141- **Don't guess what it decides.** Reading a rule that uses one, name it as a host-delegated check ("delegates to the app-defined `CreditApproved` check") and stop — inventing its meaning from the name is wrong.142- **Polarity is unchanged** (see above): like any condition it is part of the *violation*, so the rule fires (document **invalid**) when the whole `errorCondition` is **true** — not when it's false. `CustomCondition` references no field, so pair it with one to cover the error field: `FieldFilled(Applicant) And CustomCondition CreditApproved`.143144## Worked example (a *different* model, to show the pattern)145146Requirement: *"When an order's Channel is EXPRESS, each line item's DeliveryDate must be provided."*147Model has enum `/Order/Channel` (values `STANDARD, EXPRESS`) and a **repeatable** group `/Order/LineItems` with field `DeliveryDate`.148149- Error field `/Order/LineItems/DeliveryDate` — in the repeatable row, so referencing it makes the rule fire per row.150- Violation = the row exists **and** channel is EXPRESS **and** the date is missing — guarded because it iterates and uses a negative:151 ```152 GroupFilled(/Order/LineItems) And [/Order/Channel] == "EXPRESS" And FieldNotFilled(DeliveryDate)153 ```154- Confirm:155 ```156 dmtool -m order.json rule check \157 --field /Order/LineItems/DeliveryDate \158 --condition "GroupFilled(/Order/LineItems) And [/Order/Channel] == \"EXPRESS\" And FieldNotFilled(DeliveryDate)" \159 --code EXPRESS_ITEM_NEEDS_DELIVERY_DATE160 # → the envelope reports "valid": true, "diagnostics": []161 ```162163Apply the same shape to your own model: find the enum + the repeatable group with `describe`, choose the error field for the per-row scope, write the **violation**, guard it if it iterates with a negative, then `check`.164165## Reading a rejection166167`check` returns diagnostics with a `code` and `summary`, and the common structural/syntax codes carry an enriched **`fix`**/**`explain`** naming the exact correction (`dmtool diagnostics <code>` serves the same guidance on its own). Trust the `fix` first; for anything operator-specific, look the operator up with `dmtool operators <id>` — its `gotchas` and examples usually say exactly what to do.