← Back to site Loading…

rescriptum

Writing answers

Guide

Generated on August 30, 2026

Writing answers

Writing answers

An answer is the document an installer receives: a Proxmox answer.toml, an Ubuntu autoinstall user-data, a kickstart, a preseed, an Ignition config, an AutoYaST profile, a Windows unattend.xml. rescriptum’s whole job is to pick the right one for the machine asking and hand it back, assembled from however many layers you wrote.

The layout

One directory per identity. A machine is a directory named after it, holding one document per operating system:

answers/
├── 98fa9b50d810/           one machine
│   ├── proxmox.toml            as Proxmox
│   └── debian.preseed          …and the same hardware as Debian
├── aabbccddeeff/           another machine
│   └── ubuntu.yaml             as Ubuntu
├── default/                when nothing else matches
│   └── proxmox.toml
└── groups/
    ├── rack-a/             shared by a rack, claims its members
    │   ├── proxmox.toml
    │   └── debian.preseed
    └── rhel-compute/       claims machines by what they are
        └── rhel.ks
  • A machine is a directory named after it — a MAC address, in any separator style — holding that machine’s own configuration, or only the part of it that differs from its group.
  • A group is a directory under groups/ and is shared. It claims machines by listing them in members, or by a match block tested against the request.
  • default/ answers when nothing else does. One document per format: a TOML default must not answer a client that asked for kickstart.

The extension decides; the name does not

Inside a directory, the extension is the format and the part before it means nothing. proxmox.toml and answer.toml are the same document to the server; the name is there for whoever opens the folder. rescriptum writes readable ones — proxmox.toml, ubuntu.yaml, debian.preseed, boot.ipxe — and never renames yours.

The one rule that follows: a directory holds at most one document per format. Two .toml in one directory is reported as a problem rather than resolved, because nothing could pick between them that you would have predicted. Two different formats are not a duplicate at all — that is the whole point of the directory.

Storage layout and URL are still deliberately kept apart: a folder can be reorganised, a URL baked into an ISO cannot. See formats.

:::note[Upgrading from a flat directory] Answers used to be files at the top of the directory: 98fa9b50d810.toml beside 98fa9b50d810.preseed. Those are no longer served, and each one is reported by name with its new path. rescriptum migrate shows what it would move; rescriptum migrate --apply moves them. :::

The five things to know

How an answer is pickedBy name, by member list, or by what the machine is. Naming always wins; among selectors, more criteria wins; ties break on sorted name
One document per operating systemThe extension is the format, the endpoint chooses between them, and a machine can exist as several operating systems at once
Groups and mergingLayers apply lowest to highest and the machine always wins. Maps merge; arrays replace
Templating{{ serial }} filled from the request, so one group file covers a rack
Validatingrender shows what a machine would get; check renders everything and reports what breaks

Control keys

Four keys steer resolution and are stripped before the answer is sent, so the installer never sees them:

KeyDoes
membersthe machines this group answers for
matchcriteria tested against the request’s facts
extendsthe group this document layers on top of

They travel in whatever the format allows — top-level keys in TOML, YAML and JSON, an <answer-meta> element in XML, # answer: directives in kickstart and preseed. The per-format spelling is in formats.

Worked examples

The repository’s examples/ directory carries a commented example of every supported format, all selected differently — by hardware, by member list, by directory name — and they are exercised by the test suite:

$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum check
$ RESCRIPTUM_ANSWERS_DIR=examples rescriptum render --query "path=/rhel/ks&serial=7ABC123"

It is the only place the formats are shown composing together; start there when you are unsure what a real file looks like.

How an answer is picked

How an answer is picked

A document does not get looked up; it claims the request. Three ways to do that, ordered by how narrowly they target one machine.

1. By name

Name a directory after the machine’s MAC address, and put its documents in it:

answers/
├── 98-fa-9b-50-d8-10/
│   └── proxmox.toml
├── aabbccddeeff/
│   └── proxmox.toml
└── default/
    └── proxmox.toml

When a request arrives, the server lowercases everything it carries and drops every non-alphanumeric character, does the same to each directory’s name, and serves the first whose name appears inside the request. So 98-fa-9b-50-d8-10, 98:fa:9b:50:d8:10 and 98fa9b50d810 all name the same machine — you never have to care which separator style Proxmox happens to use this version, or how it structures its JSON.

The identity is the directory name; the filenames inside it choose nothing, they only carry the format in their extension.

That normalization is the whole trick, and it is why this survives Proxmox changing its body format between releases: it is a substring test over the bytes, not a schema.

Nothing prevents naming a directory after a serial number, an asset tag or a hostname instead. Any string that appears in what the machine sends will do.

2. By member list

A group claims a set of machines by listing them:

# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "98:fa:9b:50:d8:12"]

Member strings are normalized exactly like directory names, so separator style does not matter here either. A listed machine needs no directory of its own unless it has something to override — see grouping.

3. By what the machine is

A match block claims a machine by its properties rather than its identity:

# answers/groups/dell-r620/proxmox.toml
[match]
manufacturer = "Dell Inc."
product      = "PowerEdge R620"
serial       = "7ABC*"          # * and ? work

Every criterion must hold for the group to claim the request. * matches any run of characters and ? exactly one; both sides are normalized before comparing, so case and separator style never matter.

A machine document may carry a match block too — useful for “whatever machine is currently in this chassis slot”.

The facts a selector can test

Facts come from three places, deliberately layered from most to least structured.

Query parameters

?mac=…&uuid=…&serial=… — how every installer other than Proxmox identifies itself, because iPXE substitutes the values into the URL before fetching. Reliable, arbitrary keys, no guessing.

Three more are synthesized from the URL itself:

FactIs
paththe whole path, trimmed of slashes — rhel/ks
fileits last segment — ks. This is what tells cloud-init’s user-data from its meta-data
segmentevery segment, as separate values — rhel and ks

A POSTed JSON body

When the body really is JSON, it is flattened to both its full dotted paths and its bare leaf names:

{ "dmi": { "system": { "serial": "7ABC123" } } }

gives both dmi.system.serial and plain serial. The leaf form is the point. Proxmox’s own documentation warns that the contents of dmi “might vary wildly, depending on the system”, so a selector saying “a field called serial, wherever it lives” survives a reorganisation that a fixed path would not.

Array indices become part of the path but not of the leaf name, so network_interfaces.0.mac is also reachable as plain mac.

A body that is not JSON is not an error — it simply contributes nothing but the haystack.

The raw body

Normalized to lowercase alphanumerics: the substring haystack that makes matching by name work. Query values and path segments are appended to it too, so a directory named after a MAC resolves whether that MAC arrived in a POST body or a query string. Without that, a GET — which has no body at all — could never match by name.

When several documents claim the same request

The rule is fixed, and a test pins it:

  1. Naming a machine always wins. However many criteria a selector carries, an identity match beats it — naming a machine is as specific as anyone can be.
  2. Among selectors, more criteria wins. Three matching criteria beat two; a more deliberate rule is a more specific one.
  3. Ties break on sorted name. Alphabetically first.

The answer never depends on filesystem order or on the order rows came out of a database. matchbox, the closest prior art, documents that its own resolution between competing groups “will not be deterministic”. This one is.

Only the first matching group applies. Composition is expressed with extends, not by merging every group that happens to match — the order between several matching groups would be arbitrary, and an arbitrary order is how a machine quietly gets the wrong disk layout.

And if nothing matches

default.<ext> is served, if there is one for the format the endpoint asked for. Otherwise the answer is 404 — logged as no answer file applies.

Try it before booting anything

render resolves exactly as the server would, from facts you supply:

$ rescriptum render 98:fa:9b:50:d8:10                              # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"         # including the endpoint
$ rescriptum render --body captured-request.json                   # a real captured body

A bare identifier claims nothing about what kind of identifier it is — it fills the haystack and nothing else. That is enough for name matching, but a selector on serial needs --query "serial=…" to have anything to test. check works the same way, which is why a template needing a request-only fact is reported as a problem.

To capture what your machines really send, see Capturing requests.

One document per operating system

One document per operating system

An installer fetching a URL expects one particular thing back. A kickstart client wants kickstart and would choke on TOML. That is the protocol, not a convention anyone chose. So:

  • the endpoint declares the format/rhel/ks asks for kickstart;
  • the document carries it as its extensiongroups/rhel-compute/rhel.ks;
  • only documents of that format may answer.

The consequence that makes it click

A machine’s answer is specific to the operating system it is for. So this is not one machine and two files:

answers/
└── 98fa9b50d810/
    ├── proxmox.toml        "that machine, as Proxmox"
    └── debian.preseed      "that machine, as Debian"

It is one piece of hardware with two answers, and both can exist at once. Which one a request receives depends on the URL it arrived on — /proxmox/answer gets the TOML, /debian/preseed gets the preseed. Neither is more “the” answer than the other.

Internally this is why a document is keyed by (identifier, format) rather than by identifier alone — and why one directory holds one document per format and no more.

Storage is not the URL

Documents are grouped by identity, never by format, and that is deliberate. Directories and database rows are a lookup space — they must stay free to be reorganised. A URL is a public contract baked into an ISO — it must not move because someone renamed a folder. An earlier design made the directory name be the URL segment and was discarded for exactly that reason.

The same rule is why the filename inside a machine’s directory carries no meaning: proxmox.toml reads well and matches the /proxmox/ endpoint, but only the .toml is load-bearing. Rename it answer.toml and nothing changes.

Which alias serves which extension is in the format reference; how to pick one for your media is in preparing installer media.

The formats

ExtensionForLayering
tomlProxmox VEstructural merge
yaml, ymlUbuntu autoinstall, cloud-initstructural merge
json, ignIgnition, Flatcar, Fedora CoreOSstructural merge
xml, autoyast, unattendAutoYaST, Windows unattend.xmlstructural merge, by element
kskickstart — RHEL, CentOS, Fedora, Alma, Rockyconcatenation
preseed, seedDebian preseedconcatenation
cfg, ipxeboot scripts and other line-oriented configconcatenation

The allowlist is deliberate: txt is not on it, so a stray notes file next to your answers never becomes a candidate.

.autoyast and .unattend are XML under a name that says which one it is, so a store holding both a SUSE profile and a Windows unattend can keep them apart. Plain .xml still answers either, which is fine right up until you have both.

Structural merge

For toml, yaml, json and xml, layering is a real merge:

  • Maps merge key by key, recursively — including TOML’s inline and dotted tables.
  • Any other value is replaced outright by the higher layer.
  • Arrays replace, they do not append. Appending would make a list impossible to shorten from a higher layer, and “this node has two disks, not four” has to be expressible.

The details, with examples, are in grouping.

Concatenation

For ks, preseed, cfg, seed and ipxe, layering is concatenation in layer order, and the module says so rather than pretending otherwise. A directive in a later layer follows an earlier one rather than removing it.

Whether that amounts to an override is the target format’s business: preseed’s last answer wins, kickstart’s does not always. Render the result and read it before trusting a rack to it.

One thing worth knowing before you write an essay at the top of a kickstart: ordinary comments are served. Only # answer: directive lines are stripped. That is fine — kickstart and preseed both allow comments — but the installer will see everything else.

XML

XML pairs siblings by element name plus a discriminating attributename, id, key, alias or pass. That is what makes

<settings pass="specialize">
  <component name="Microsoft-Windows-Shell-Setup" …>

mergeable: overriding one pass leaves the others alone, and overriding one component does not replace every other component in the file. Repeated siblings without a discriminating attribute are treated as a list, and AutoYaST’s config:type="list" is honoured.

What survives a merge: the <?xml?> declaration, the <!DOCTYPE>, namespaces, and attributes. What does not: the original indentation and comment placement — the output is re-rendered, not patched.

It understands no schema. Render and check before trusting a rack to it.

Where the control keys live

The control keys travel in whatever each format allows, and are stripped before the answer is sent.

TOML

extends = "base"
members = ["98:fa:9b:50:d8:10"]

[match]
product = "PowerEdge R6*"

YAML / JSON — the same three as top-level keys:

extends: base
members: ["98:fa:9b:50:d8:10"]
match:
  file: "user-data"
  product: "PowerEdge R6*"

XML — an <answer-meta> element, with extends as an attribute on it:

<answer-meta extends="base">
  <member>52:54:00:11:22:33</member>
  <match manufacturer="Dell Inc." product="PowerEdge R6*" />
</answer-meta>

Kickstart, preseed, and anything line-oriented# answer: directives (// works too, for formats that comment that way):

# answer: extends base
# answer: member 00:11:22:33:44:55, 00:11:22:33:44:56
# answer: match serial=7ABC* product=PowerEdge*

match takes space-separated key=pattern pairs, member a comma-separated list.

One answer, one format

Every layer of one answer must be the same format. A YAML machine document over a TOML group is refused, not half-served, and extends resolves within one format for the same reason — layering a preseed onto a TOML base is meaningless.

Grouping is otherwise untouched by any of this: a rack shares one group per format, and a machine that exists as two operating systems joins two of them.

default follows the same rule — a .toml in default/ answers a request that asked for TOML, and never one that asked for kickstart.

Groups and merging

Groups and merging

A rack of machines usually shares everything except its MAC addresses. Writing that out once per machine is how a fleet’s configuration drifts. So answers compose.

answers/
├── groups/
│   ├── base/
│   │   └── proxmox.toml       shared by everything
│   └── rack-a/
│       └── proxmox.toml       extends = "base"; members = [ … ]
├── 98-fa-9b-50-d8-10/
│   └── proxmox.toml           one machine's overrides (optional)
└── default/
    └── proxmox.toml           only when nothing else matches

The shared part

# answers/groups/rack-a/proxmox.toml
members = [
  "98:fa:9b:50:d8:10",
  "98:fa:9b:50:d8:11",
  "98:fa:9b:50:d8:12",
]

[global]
keyboard = "fr"
country  = "fr"
timezone = "Europe/Paris"

[disk-setup]
filesystem = "zfs"
zfs.raid   = "raid1"
disk-list  = ["sda", "sdb"]

The difference

A machine that differs gets a document with only the difference in it:

# answers/98-fa-9b-50-d8-10/proxmox.toml
[global]
fqdn = "node01.example.com"

[disk-setup]
zfs.raid  = "raid10"                       # this one has four disks
disk-list = ["sda", "sdb", "sdc", "sdd"]

…and receives the two merged, with its own values winning:

$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a

[global]
keyboard = "fr"
country = "fr"
timezone = "Europe/Paris"
fqdn = "node01.example.com"

[disk-setup]
filesystem = "zfs"
zfs.raid = "raid10"
disk-list = ["sda", "sdb", "sdc", "sdd"]

Merge rules

Layersgroup chain first, machine document last — the machine always wins
Mapsmerge recursively, including TOML’s inline and dotted tables
Other valuesreplaced outright by the higher layer
Arraysreplace, they do not append
Text formatsconcatenated in layer order — see formats

Why arrays replace. Appending is the intuitive choice right until you need to shorten a list. disk-list = ["sda", "sdb"] in a group and ["sda"] in a machine document has exactly one sensible meaning — this one has a single disk — and appending cannot express it. The same rule holds in every format, so you never have to remember which one you are in.

extends

A group may extend another group, giving a chain — what every rack shares in one file, per-rack differences in another:

# answers/groups/base/proxmox.toml
[global]
mailto   = "ops@example.com"
timezone = "Europe/Paris"
root-ssh-keys = ["ssh-ed25519 AAAA…REPLACE ops@example.com"]
# answers/groups/rack-a/proxmox.toml
extends = "base"
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11"]

[disk-setup]
filesystem = "zfs"

Layers then apply baserack-a → machine document.

extends in a machine document overrides membership. It is the escape hatch for a machine that needs a group it is not listed in:

# answers/98-fa-9b-50-d8-99/proxmox.toml
extends = "rack-a"          # even though rack-a does not list this MAC

[global]
fqdn = "spare01.example.com"

extends resolves within one format — layering a preseed onto a TOML base is meaningless, and the merge would refuse it anyway.

Only the first matching group applies

If two groups both claim a machine, one of them applies — the most specific, ties broken on sorted name. Compose with extends rather than relying on several groups matching at once: the order between them would be arbitrary, and an arbitrary order is how a machine quietly gets the wrong disk layout.

When a group is broken

Cycles and missing parents are detected when the store is read, reported in the log once, and the broken group is dropped rather than half-applied:

2026-08-24T08:43:36Z - warning: group "rack-a": extends unknown group "base"

One bad group does not stop the other racks from installing. A machine that needed that group gets a loud 500 rather than a half-built answer — serving a configuration whose base is missing would install the machine half-configured, and nobody would find out until it was running.

rescriptum check reports the same problems, which is a better place to find out than the log at 3am.

Grouping is the fast path

Measured at 2,000 machines, 3,000 requests at 100 concurrent:

LayoutThroughput
2,000 machine documents, no group12,132 req/s
one group of 2,000 members, no machine documents13,036 req/s
2,000 machine documents plus a group (a merge per request)8,816 req/s

A group with no machine overrides and no placeholders is rendered once, when the store is read, and served afterwards as a prepared string. The common datacenter case parses nothing per request. Adding a per-machine override buys a merge per request — worth it where it is needed, and worth avoiding where it is not.

The other half of the same argument is what a read costs. The whole store is re-read at most once a second, and with a directory per identity that read is a readdir per machine on top of the file it already opened — measured at 2,000 machines on an M1 Pro, 28 ms before the layout changed and 63 ms after. It is amortised over a second’s worth of requests either way, and the throughput figures above did not move measurably; but a group that needs no per-machine directory avoids that cost too.

Next

  • Templating{{ serial }} removes the remaining reason for a directory per machine.
  • Validating — a merged answer is a document nobody wrote; look at it before a rack does.

Templating

Templating

Grouping removes the duplication between machines that agree. Templating removes the last reason to write a document per machine at all: the values that must differ.

# answers/groups/rack-a/proxmox.toml
members = ["98:fa:9b:50:d8:10", "98:fa:9b:50:d8:11", "…"]

[global]
fqdn = "node-{{ serial }}.example.com"

[network]
filter.ID_NET_NAME_MAC = "*{{ mac }}"

Five hundred machines, one document. Without this, a per-machine hostname means a document per machine — and five hundred documents that differ in one line each.

Placeholders work in every format: TOML, YAML, JSON, XML, kickstart, preseed.

In the structured formats, substitution happens on parsed string values — so a comment is just a comment. In the line-oriented ones (ks, preseed, cfg, ipxe, seed) the document is an opaque string, so a placeholder written inside a comment is still a placeholder and still has to resolve. Mentioning {{ serial }} in a # line to explain it to the next reader will fail the render just as a real one would.

What you can put in one

PlaceholderFilled from
{{ mac }}, {{ serial }}, {{ uuid }}, …any fact the request carries — query parameters, and the fields of a POSTed JSON body by leaf name or full path
{{ dmi.system.serial }}the same body, by its exact path
{{ path }}, {{ file }}, {{ segment }}the URL the request arrived on
{{ group }}the name of the group that applied
{{ machine }}the identifier of the machine document that matched

Whitespace inside the braces is optional: {{serial}} and {{ serial }} are the same.

machine needs a machine document

{{ machine }} is the identifier of the machine document that matched — so it is only available when the machine has a document of its own. A machine claimed by a group’s members list, with no directory of its own, has no machine value and rendering fails with template needs {{ machine }}, but this request carries no "machine".

In a group, use a request fact instead:

[global]
fqdn = "node-{{ mac }}.example.com"      # works for every member

{{ machine }} is for a machine document that wants to name itself without repeating its own MAC.

A missing value is an error

A placeholder the request cannot fill is a 500 with the reason, never an empty string:

$ rescriptum render 98:fa:9b:50:d8:10
error: template needs {{ serial }}, but this request carries no "serial"

This is deliberate. Serving node-.example.com installs a machine with a broken hostname and nobody notices until later — possibly much later, on a machine that is already in production. Failing the install is the cheaper outcome.

Control characters are refused outright for the same class of reason: a newline in a kickstart value would inject a directive into the file the installer executes.

$ rescriptum render --query "mac=aa:bb&serial=$(printf 'a\nb')"
error: value for "serial" contains a control character and will not be substituted

Substitution is escape-safe

Substitution happens on parsed values, never on raw document text. The value is put into the document’s own data model and the format’s serializer writes it out — so the serializer does the escaping.

A serial containing a quote cannot break the TOML it lands in:

$ rescriptum render --query 'mac=aa:bb&serial=a"b'"'"'c<d>e'
[global]
fqdn = """node-a"b'c<d>e.example.com"""

The TOML writer reached for a multi-line string on its own. The same value in an XML document comes back entity-escaped, and in JSON, JSON-escaped. A test feeds a"b'c<d>e&f through all four structured formats and reparses the output.

This is why templating is safe to feed from a request that a machine you have never met controls.

check and request-only facts

rescriptum check renders every machine from its identity alone — it has no request to draw on, because there is no request. A template that needs serial, which only ever arrives in a body or a query string, is therefore reported as a problem:

$ rescriptum check
  FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"

That is honest — check genuinely cannot prove that answer renders — but it is noisy for a set that deliberately templates on request facts. Verify those with render and representative facts instead:

$ rescriptum render --query "mac=98:fa:9b:50:d8:11&serial=7ABC123"

The cost

None, when you are not using it. A group whose prepared string carries no {{ is served as-is, without being parsed per request — the check for placeholders happens once, when the store is read. Templating moves a group onto the merge-per-request path only for the documents that actually contain one.

Next

Validating what will be served

Validating what will be served

Before answers composed, an admin wrote a complete document and validated it:

$ proxmox-auto-install-assistant validate-answer answer.toml

Once an answer is assembled from a group chain plus a machine document plus a template fill, the document the installer receives is one nobody has ever seen — and a bad merge surfaces as a failed unattended install at 3am. Two subcommands exist to close that gap, and any change to merging has to keep them working.

render — what this machine would get

$ rescriptum render 98:fa:9b:50:d8:10                              # by identity
$ rescriptum render --query "serial=7ABC123&mac=98:fa:9b:50:d8:10" # by label
$ rescriptum render --query "path=/rhel/ks&serial=7ABC123"         # including the endpoint
$ rescriptum render --body captured-request.json                   # a real captured body

It resolves exactly as the server does — same matching, same layering, same template fill — and prints the result. The document goes to stdout; the line explaining how it was reached goes to stderr:

$ rescriptum render 98:fa:9b:50:d8:10
# format=toml machine=98-fa-9b-50-d8-10 group=rack-a
[global]

so redirecting gives you just the document:

$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml

Add path=… to --query when you want to check what a particular endpoint would answer — without it, resolution is unconstrained by format and may pick a document the real URL would have excluded.

Exit status is 0 when something resolved, non-zero when nothing applied (the server would have returned 404) or when rendering failed.

check — render everything, report what breaks

$ rescriptum check
checking files:examples
  10 group(s), 8 machine document(s)
  group "rhel-compute" selects on serial=7ABC*
    (verify with: rescriptum render --query "...")
  group "ubuntu-web" selects on file=user-data product=PowerEdge R6*
    (verify with: rescriptum render --query "...")
  1 answer(s) validated by their installer's own tool
  note: no schema validator exists for preseed answers
  note: toml answers not schema-checked — proxmox-auto-install-assistant is not on PATH
  ok — everything renders

Well-formed and merging cleanly is not the same as valid for an
installer. Where a validator exists and is installed it was used above;
install proxmox-auto-install-assistant, xmllint or ksvalidator for the rest.

What it does:

  • Reports load-time problems — a group extending one that does not exist, a cycle between groups, a document that will not parse.
  • Renders every machine document, and every member of every group. That is what actually exercises the merge.
  • Names the groups that select on a match block and says it could not try them, rather than implying they were verified — a selector needs a real request.
  • Flags a group with neither members nor match as reachable only via extends, in case that was not the intention.
  • Calls the installer’s own validator where one exists and is on PATH, and says which formats it could not check.

Exit status is 0 when everything renders, 1 when anything failed — so it drops straight into CI.

The validators it knows

FormatTool
tomlproxmox-auto-install-assistant validate-answer
xml, autoyast, unattendxmllint --noout
ksksvalidator
yaml, json, ign, preseed, cfg, ipxenone exists — render and read it

A missing tool is reported once as a note, never treated as a failure. A checker that refuses to run without optional tooling is a checker nobody runs.

check is not a schema checker itself: it proves your documents are well-formed and merge cleanly. For anything it cannot call a validator for, pipe a rendered answer in yourself:

$ rescriptum render 98:fa:9b:50:d8:10 > /tmp/answer.toml
$ proxmox-auto-install-assistant validate-answer /tmp/answer.toml

What check cannot prove

check renders each machine from its identity alone. It has no request, so it cannot supply facts that only arrive with one — a serial from a POSTed body, a mac from a query string. A template needing those is reported as a problem:

FAIL group "rack-a" member "98fa9b50d811": template needs {{ serial }}, but this request carries no "serial"

That is accurate — check genuinely cannot prove that answer renders — but it means a set that deliberately templates on request facts will not come back clean. Verify those with render --query and representative facts. See templating.

In CI

If your answers live in git, this is worth a job of its own:

# .github/workflows/answers.yml
name: answers
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Get rescriptum
        run: |
          curl -fsSL https://github.com/z29k/rescriptum/releases/latest/download/rescriptum-x86_64-unknown-linux-musl.tar.gz \
            | tar xz --strip-components=1
      - run: RESCRIPTUM_ANSWERS_DIR=answers ./rescriptum check

Add proxmox-auto-install-assistant to the runner and the same job schema-checks the TOML too.

Before deploying

deploy.sh runs check before it ships anything, and refuses to deploy if the answers do not come back clean. Serving a broken answer set is worse than not deploying.