Front-matter agreement
Two schema keys that tie a document’s front matter to the rest of its contract: frontmatter-closed:, which decides whether an undeclared front-matter key is an error, and \#(fmvar(name)) interpolation inside filename: and path-pattern: globs, which makes a path agree with a front-matter value.
Two schema keys tie a document’s front matter to
the rest of its contract. frontmatter-closed:
decides whether a key the schema never declared is
an error. \#(fmvar(name)) interpolation lets a
filename: or path-pattern: glob require the
path to agree with a field’s value.
Both sit alongside the
section schema
, which covers
the heading: grammar the same schema block
carries.
#
frontmatter-closed
Front matter is closed by default. A key absent
from frontmatter: reports:
model: got "opus", expected not declared in schemafrontmatter-closed: states that default
explicitly, or turns it off:
| Value | Effect |
|---|---|
| absent | closed (the default) |
true | closed, stated explicitly |
false | open — undeclared keys pass |
Declared keys keep their constraints under either
setting. false only stops the “not declared”
report.
The key is valid only on a schema that also
declares a non-empty frontmatter: map. Without
one mdsmith emits no front-matter constraint at
all, so the setting would be dead; that pairing
parse-errors. This mirrors the closed: /
sections: guard in the section schema.
Set the key on an inline kind schema or on a named
schema file. A proto.md cannot carry it: each
front-matter key of a proto.md names a document
field, so mdsmith reports frontmatter-closed:
there as an invalid schema.
# Example: an APM prompt
APM’s .apm/prompts/*.prompt.md preserves exactly
five front-matter keys. apm compile drops every
other one, but only at compile time — after the
file is written and shipped. Declaring the closure
catches a sixth key while the author is still in
the editor:
kinds:
apm-prompt:
path-pattern: ".apm/prompts/*.prompt.md"
schema:
frontmatter-closed: true
frontmatter:
"description?": nonEmpty
"input?": string
"allowed-tools?": '[...string]'
"model?": nonEmpty
"argument-hint?": nonEmptyEvery key is optional (trailing ?), so the kind
constrains which keys may appear without demanding
any of them.
# Layering
When two kinds claim one file, their schemas
compose. The composed front matter accepts a key
either kind declares, and stays closed unless
every kind that declares a frontmatter: map opens
it. One kind’s frontmatter-closed: false cannot
loosen another kind’s contract. A kind with no
frontmatter: map of its own — a sections-only or
filename-only kind — casts no vote either way.
A kind that declares frontmatter: but leaves
frontmatter-closed: out counts as closed, since
closed is the default. So to open a file’s front
matter, set false on every kind with a
frontmatter: map that claims the file. When one
kind opened it and another kept it closed, the
undeclared-key report says so:
extra: got 1, expected not declared in schema
(front matter stays closed: every kind composed
for this file must set `frontmatter-closed: false`
to open it)mdsmith kinds resolve <file> lists the kinds that
claim the file.
A proto.md cannot set frontmatter-closed:, so a
proto.md that declares front matter always counts
as closed. When one keeps the file closed, the hint
names it and asks you to declare the key there.
Under extends: the rule is different: the child’s
explicit value wins, and a child that says nothing
inherits the parent’s. Inheritance is a
relationship the author wrote on purpose;
composition merges kinds that met on one file.
#
fmvar in path and filename globs
\#(fmvar(name)) — the same helper the regex:
matcher uses for heading text — also works in a
schema’s filename: globs and in a kind’s
path-pattern:. Both resolve the reference against
the document’s own front matter before matching.
Only a well-formed \#(fmvar(<path>)) is
interpolated. Any other \#( keeps its old glob
meaning: an escaped # followed by (. That covers
\#(digits), the matcher’s other helper, which has
no capture group to read back in a glob. So a glob
written before interpolation existed still loads and
matches the same files: notes/\#(draft)*.md still
accepts notes/#(draft)-1.md.
On Windows a path-pattern: may use \ as its
separator, as any pattern can. mdsmith reads each
\ as /, except the \ that opens a reference.
Under the cue-frontmatter placeholder, a template’s
front-matter values are CUE constraints such as
name: string, not data. A reference then matches
any non-empty text within one path segment, and the
rest of the glob is still checked.
The resolved value’s glob metacharacters are
escaped, so it matches literally. A name of a*b
matches the directory a*b and not axxb — the
glob analogue of the regex matcher’s
regexp.QuoteMeta. The mismatch hint shows the
value as written (a*b), not in its escaped form.
A non-string value is substituted in the form YAML
decoded it to. An unquoted timestamp at midnight UTC
substitutes as its date: date: 2026-01-02 gives
2026-01-02, so \#(fmvar(date))-*.md accepts
2026-01-02-release.md. Any other timestamp
substitutes as RFC 3339, which need not be its
source text: 2026-01-02 10:00:00 gives
2026-01-02T10:00:00Z. The dates and
timestamps
guide lists every rule. A
frontmatter: constraint checks that same text, so
the date needs no quotes: date: string accepts
date: 2026-01-02, and date: =~"^2026-" matches
it. A quoted date: "2026-01-02" checks and
substitutes the same way. YAML reads
0012 as the number 10 and 1.10 as 1.1, so quote
such a value (id: "0012") to keep the digits as
written.
One byte cannot be escaped into a literal: /
stays a path separator whatever precedes it. A
reference stands for a single path segment, so a
value containing / is reported rather than
silently spanning two directories.
Do not put a reference inside a character class
([...]). A class matches one byte, not a value.
A value such as ! also leaves an invalid class
([!]); the diagnostic then names the syntax error.
# Example: an APM skill
APM’s .apm/skills/<name>/SKILL.md requires the
name field to equal the directory name. A static
glob cannot express that:
kinds:
apm-skill:
path-pattern: ".apm/skills/\\#(fmvar(name))/SKILL.md"With name: code-review the kind accepts
.apm/skills/code-review/SKILL.md. Under
.apm/skills/reviewer/SKILL.md it reports the
mismatch and shows the expansion:
path: got ".apm/skills/reviewer/SKILL.md", expected
path matching glob .apm/skills/\#(fmvar(name))/SKILL.md
(with front matter applied: .apm/skills/code-review/SKILL.md)# Unresolved references
A document with no name field at all does not
match some degenerate path. It reports which
reference could not be resolved:
(`fmvar(name)`: frontmatter value missing)A name that is present but empty reports this
instead:
(`fmvar(name)`: frontmatter value is empty)Substituting nothing would leave the path
.apm/skills//SKILL.md.
A name that holds a list or a map is present but
cannot name a path segment:
(`fmvar(name)`: frontmatter value is a list or map,
not a scalar)A name of a/b carries a path separator, which
would let one reference cover two directories:
(`fmvar(name)`: frontmatter value "a/b" contains a
path separator; an interpolated value must name a
single path segment)A filename: list is still an OR list. An entry
whose reference will not resolve drops out. A
sibling glob may still accept the basename. The
unresolved message shows only when no entry
matched.
A malformed reference is not a config error. Take
fmvar(my-key), whose key needs quotes. The same
text was a valid glob before, so it still loads. It
is matched as literal text. When the path then does
not match, the hint says why:
(`\#(fmvar(my-key))` is matched literally, not
interpolated: `fmvar(my-key)`: invalid frontmatter
path (non-identifier keys must be quoted, e.g.
`fmvar("my-key")`))# See also
- Section schema
— the
heading:grammar and the rest of the schema-level fields. - Schema field types
— the
named shortcuts (
nonEmpty,date, …) the examples above use. - File kinds — how a file is assigned to a kind in the first place.