A12 dmtool
Skill mbackschat/a12-dmtool-releases/codex/skills/a12-dmtool
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.From its SKILL.md
npx -y skills add mbackschat/a12-dmtool-releases --skill a12-dmtoolAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
23.1 KB, ~5.7k tokens by cl100k_base, as published. Nobody here has run it
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 value from a fixed set | an enum field |
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; every command isdmtool -m <model.json> <target> <op>(the model is set once with-m, before or after the verb);dmtool <target> <op> --help— that op's parameters and what they mean;dmtool manifest— the same, machine-readable (each verb'starget/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-kindemptyOperandDefaults(how an empty field behaves in a comparison), and an entry'ssemanticsblock 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 (anddmtool schema result— the universal output envelope every verb emits).
The three 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'svalid+diagnostics). This is your ground truth. (dmtool -m <model.json> model checkstatically re-checks a model's existing rules + computations.)
⛔ 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.
Batch the edits you already know into one apply/batch call. Binary startup is sub-100 ms (no warmup to amortize — so don't batch for that), but every call is a round-trip you pay for: reading the output and reasoning before the next one. So once you know several edits — fields, rules, an include mounted twice — land them in a single apply/batch (also atomic: all-or-nothing, rolled back on any failure) rather than one verb at a time. Keep single calls for exploring and checking (describe/operators/rule check), where you genuinely need each result before deciding the next.
Going bilingual is the classic case to batch. 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 describeto learn the fields, their kinds, enum values, and which groups repeat (the structured view; notexport, 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 …. Ifvalid:true, done. If not, read each diagnostic. - Iterate — the diagnostic
code+summaryname 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
requiredproperty (see the gate above); reach forFieldNotFilledonly for conditional requiredness or as a row-existence guard.
Field-path references
A condition is evaluated relative to the rule's group (its iteration scope — defaults to the error field's parent group).
- 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/TierbecauseTieris 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 describelists the stored values.) - Booleans/confirms compare to the capitalized
True/False—[Active] == True, not the JSONtrue/false(a lowercasetrueis a parse error,MVK_UNEXPECTED_TOKEN). A confirm field compares only toTrue(== True/!= True);[Sig] == Falseis rejected (MVK_INVALID_COMPARE_TO_YES) — a confirm is checked-or-not, so test the unchecked side with!= True(orFieldNotFilled).
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 makes the rule fire once per row ("each …"). That happens automatically — you don't ask for it; you choose the error field.
- "Each X must …" is a per-row rule — don't recast it as one document-level count/aggregate. The error field's location is the decision: a field inside the repeatable group → the rule fires per offending row and points at that row. Rewriting (e.g.) "each line item needs a ShippedDate" as a single whole-document check over
Lines*/ShippedDate(a count, orNotAllFieldsFilled(Lines*/ShippedDate)on a top-level field) is a different rule — it fires once for the whole document and flags the wrong locus. When the requirement says "each", keep the error field in the row. - A negative condition (
FieldNotFilled,NoFieldFilled, …) 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>. - Guard row existence with
GroupFilled(<the repeatable group>), notFieldFilled(<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 —GroupFilledis 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-listMin/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 rejectedMVK_NO_WILDCARD, and a*where the group must stay whole (GroupFilled(Lines*)) is rejectedMVK_NO_WILDCARDS_ALLOWED. (PlainGroupFilled(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 withdmtool operators. - "No two rows share a key" →
FieldValuesNotUnique(/Group*/Key)(absolute starred path; error field the key itself inside the repeated group, e.g.--field /Order/Items/Sku). It validates and persists under the default grouping and still iterates per row (points at the offending row). The siblingRepetitionNotUniqueinstead needs the rule at the repeated group's PARENT (--group <parent>; the default grouping rejects it) — itsoperatorsentry walks through that authoring. Reach forFieldValuesNotUniquefirst. - 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*/AmountisMVK_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). Havingfilters 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(...)referencesFeeCap), or guard withFieldFilled(<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_ENTITYabove) — write it absolute: error field/Invoice/Lines/Amount, conditionSum(/Invoice/Lines*/Amount) > 500(or lift the scope with--groupto 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'sAmount— 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 asMVK_INVALID_TYPE_FOR_COMPARISON— the code name is unhelpful here, but itsfixhint points the right way: write the German format, not switch to==. (Often cleaner to skip the literal: compare to another date field orToday, or pull a part —YearFromDate(D) < 2020.) - An empty date operand does not suppress a date function — e.g.
DifferenceInDaysreads an empty operand as a 0-difference, so the comparison can fire on absence (each operator'ssemantics.emptyOperandfacet states its behavior; therule checkguard warning covers these too). Lead withAllFieldsFilled(DateA, DateB) And …when absence shouldn't trip the rule. - Argument order matters — the
DifferenceIn*family is directional. Take the direction from the operator'smeaningand 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
CreditApprovedcheck") 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
errorConditionis true — not when it's false.CustomConditionreferences 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 (drives per-row iteration):
/Order/LineItems/DeliveryDate. - 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.
What ships with it: 2 files
9.4 KB alongside SKILL.md, 2 of them executable
- ensure-dmtool.shruns8.2 KB
- release-assets.shruns1.2 KB