[0.4.3] — 2026-09-17
Security
- Frontmatter YAML parsing is bounded (#162). Adversarial frontmatter can no
longer exhaust the host through the YAML parser (an abort the JS/Python FFI
boundary cannot catch). A block over 1 MiB, or one that would expand to more
than 200,000 YAML nodes (an&anchorreferenced by many*aliases), or one
nesting flow collections deeper than 1024 levels, is rejected with
mds::resource_limitbefore the parser materialises or deep-scans it — on the
CLI, the Rust API, napi, wasm and Python. The deep-nesting guard is checked
before the parser runs because the parser's own depth limits are reported only
after an O(depth²) scan is already paid.mds::compile_str/check_str/lint_str_with
now also reject sources overMAX_FILE_SIZE(10 MiB) withmds::resource_limit;
the bindings and the CLI already did.mds lintandscan_importsno longer
swallow these errors (andscan_importsnow reports more than 256 frontmatter
importsentries asmds::resource_limitinstead of silently omitting them);
plain YAML syntax errors in those two paths stay best-effort as before.
Duplicate-key and syntax-error messages are unchanged.
Changed
- BREAKING (CLI):
mds build,mds check,mds fmtandmds lintnow exit
non-zero on a directory that contains no.mdsfiles (#204).build/check/fmt
exit 1 andlintexits 2 (its usage-error code), printing `no .mds files found in ; nothing was built` (`…checked` / `…formatted` / `…linted`) on stderr — even under `--quiet`, exactly like the existing all-under-excluded-directories diagnostic. Previously an empty tree exited 0 with `No .mds files found in ` (silent under `--quiet`), so a mistyped or not-yet-populated directory passed CI green. Scripts that relied on exit 0 for an empty directory must create at least one `.mds` file or skip the call. `mds watch ` is unchanged: it starts on an empty tree and compiles files created later. The bare auto-detect form (`mds build` with no argument in a directory holding no `.mds` file) already exited non-zero; this aligns the explicit directory form. - BREAKING (CLI):
mds build <dir>andmds check <dir>now exit 1 when every
.mdsfile in the tree is a_-prefixed partial (#387). The walker collects
partials (they are inputs forwatch,fmtandlint) butbuild/checknever
compile them, so a partials-only tree used to end0 built, 0 failedwith exit 0 —
the same silent green pass #204 closed for the empty tree. It now prints<n> .mds file(s) found in <dir> but all are _-prefixed partials; nothing was built
(…checked) on stderr, even under--quiet, and exits 1.mds fmt <dir>and
mds lint <dir>are unchanged: they format and lint partials, so a partials-only
library is real work for them.mds watch <dir>is unchanged and still starts. A
partials library that is only ever imported from elsewhere should not be passed to
build/checkon its own. mds watch --debounceis now a quiet period with a hard cap (#379).
Each content event restarts the window instead of the window expiring at a fixed
offset from the first event, so a save burst longer than the window coalesces into
one rebuild; the window is bounded bymax(10 x window, 1 s)and 10 000 events so a
file written to continuously still rebuilds and the idle-tick liveness probe cannot
be starved; raw values are clamped to 60 s (--debounce 18446744073709551615
previously watched forever without ever rebuilding);--debounce 0still means no
coalescing. No new output. Known cost, in both modes: an event that is not the edit
you care about can still extend an open window, because relevance is not re-derived
per message inside it — in directory mode every event also opens one (events under
excluded directories are filtered only afterwards), while in file mode the entry's
parent directory is watched non-recursively, so a sibling scratch write by an editor
extends a window a real edit has already opened. Either waynpm installchurn or a
noisy editor can delay a real edit and the idle tick by up to the cap.TextEdit::new,FixLineSpan::range_inclusiveandFixLineSpan::range_exclusive
now panic on a reversed range in every build profile (#220). Thestart <= end/
from <= toprecondition was a debug-only assertion; a release caller passing a
reversed range got a value the fix planner later skipped in silence. The rustdoc
# Panicssections say so. No in-tree caller constructs a reversed range.
Fixed
- Warn on duplicate keys in
--varsJSON files, at every depth, on everymds watchrebuild that writes output (#326).
mds build|check|lint|watch --vars f.jsonwith a repeated JSON object key (e.g.
{"x": 1, "x": 2}) previously compiled silently with the last value winning —
serde_json's map deserializer discards the earlier value with no signal. Now every
subcommand printswarning: key '<path>' is set more than once in vars file <file>; the last value winsfor each duplicate, exit 0, suppressed by--quiet(parity with
the existing--set/--set-stringduplicate-key warning, #200). Duplicates are
detected at every depth and reported with a dotted/bracketed key path mirroring
{{a.b}}interpolation syntax —x,x.a,x[2].a(the array-root form[0].a
is produced only by the internal scanner and pinned by a unit test; both load
functions reject a non-object root before the scan runs, so a caller of
mds::load_vars_file/load_vars_strnever sees it). At most 1 000 distinct
duplicate key paths are listed; beyond that a single tail line reports how many
more were omitted:warning: {n} more duplicate keys in vars file <file> are not listed.mds watchreloads the vars file from disk on every rebuild,
so its duplicate keys are re-reported on every rebuild that writes output too —
including a duplicate introduced mid-session by editing the vars file — while
--set/--set-stringduplicate warnings keep their existing once-at-startup
behaviour. New publicmds-coreAPI:
mds::VarsLoad { vars, duplicate_keys, duplicate_keys_omitted }(#[non_exhaustive])
andmds::load_vars_file_reporting_duplicates/
mds::load_vars_str_reporting_duplicates, implemented as a second, value-free parse
pass over the same JSON text so the already-parsedvarsmap is never re-derived and
stays byte-for-byte what the existing parser produced.load_vars_file/load_vars_str
are unchanged in every observable way (same signature, same return type, same errors)
and now delegate to the reporting variants. Known limitations: a key containing a
literal.,[, or]renders ambiguously in its reported path, and an
empty-string key renders as an empty segment; the file-load and string-load
error codes (mds::invalid_varsvsmds::json) remain deliberately
un-unified (pre-existing split, unchanged). mds buildandmds watchoutputs are written atomically (#227). Compiled
artifacts, directory-mode outputs and.mapsidecars are now written to a temporary
file in the destination directory and renamed into place — the same primitive
mds fmtandmds lint --fixalready use — so a crash, kill or write error can never
leave a truncated file; the previous output survives until the rename. Source rewrites
keep their fsync-before-rename; compiled outputs and sidecars use rename only
(regenerable; an unconditional fsync made directory-mode watch startup ~3× slower on
macOS). A first build into a new file keeps the umask default mode (typically 0644); an
existing output keeps its mode. Consequences: an output path that is a symlink (live or
dangling) is refused instead of written through; a destination directory that is not
writable fails the build even when the file itself is writable; a read-only (0444)
existing output is replaced, mode preserved; an output path that is not a regular file
in a writable directory — for example-o /dev/nullor/dev/stdout— is no longer
accepted (use stdout, i.e. omit-o, ormds checkinstead); a FIFO or other special
file at the output path is replaced by a regular file rather than opened. The spurious
cannot get metadatamessage that #240 emitted on every first write of a
not-yet-existing file is gone; a stat failure other than "not found" is now a hard
error rather than a warning (#225). A new test,write_funnel.rs, fails CI on any raw
fs::write/File::createin the CLI outside the one justified site (the test-only
readiness marker inwatch.rs;mds initjoined the funnel in #386).- Fix stale
lint_strrustdoc and lint-rule Tier tables (#329).mds-core's
lint_strrustdoc said "applies the 9 lint rules" after a 10th rule
(legacy-interpolation) had shipped; the Tier tables inlint/tier.rsand
lint/fix.rsboth omittedlegacy-interpolationfrom Tier A. Fixed all three, and
added a mechanised test (module_doc_tier_table_matches_rule_tier) that extracts
every rule name and tier from both module-doc tables and asserts they match
rule_tierfor all 10 known rules, so the tables can't drift again silently.
crates/mds-python/tests/test_parity.py:201still says "9 lint rules" — deliberately
left as-is here since fixing it would touch the release-surface Python test path;
tracked for a later step. - Release builds no longer panic on a non-boundary import/extends span offset, and a
missing skeleton block is an internal error instead of silently rendering the base
default (#220).attach_import_spanand the@extendschild-only-blocks check now
compute the underline length through the existing char-boundary-safe helper: a byte
offset that does not land on a UTF-8 character boundary (a compiler defect, not
something a template can cause) yields a zero-length span with the numeric offset and
no source snippet, instead of abyte index … is not a char boundarypanic in
release. The skeleton-splice walk asserts in every build profile that each@block
placeholder has an effective-blocks entry; release builds used to splice the base
default in silence — dropping a child's override — where debug builds panicked. The
three source-map cursor checks in the evaluator and the byte-length check in
neutralize_source_for_renderare enforced in release now as well; none can be
triggered by template input, only by a defect, and the messages carry no source text.
On the CLI a tripped invariant is a Rust panic (exit code 101); the napi, WASM and
Python bindings convert it tomds::internalas before. mds initno longer writes through a symlink at the target path (#386). The
starter file is written by the same replace-by-rename primitive as every other CLI
write (atomic_write_file): a symlink at the target — live under--force, or
dangling without it — is refused withcannot write <path>: refusing to replace a symlink(exit 1) and neither the link nor its target is touched. Previously a
dangling link was written through (creating its target) and--forcetruncated a
live link's target in place.--forceon a regular file still replaces it, now
atomically with its permission bits preserved; a new file keeps the umask default
mode. Theinitentry is gone from thewrite_funnel.rsallow-list, so the CLI's
only remaining raw write is the test-only readiness marker inwatch.rs.mds lint <dir>fails closed on a path it cannot name (#217). The directory-mode
files[].filekey and the sort key are the entry's path relative to the lint root. A
path that is not valid UTF-8, or that is not under the lint root, previously produced
a lossy (U+FFFD) or absolute key silently; it is nowmds::io(path is not valid UTF-8: …/path escapes lint root …) reported before any file is linted — exit 2,
with the analysis-failure envelope under--format json. Such a file already exited 2
as a per-file error; the difference is that the rest of the tree is no longer linted
around it and no lossy key is ever emitted.mds fmt <file>on a path that is not valid UTF-8 exits 2 (#217). It was a generic
error (exit 1); it is an I/O error (mds::io) likemds lintandmds build.
Directory mode is unchanged (per-file failure, exit 1 with the summary).- Source-map
sources[]anchors fail closed (#217). An empty project root, or one
that is not valid UTF-8, can no longer make the containment check vacuous:
NativeFs::source_root()reports no root for a non-UTF-8 root directory, and the
relativization choke-point treats an unusable root as "not contained" (basename) and
an unusablesource_map_baseas "anchor on the root" — never as an empty prefix every
path matches. No change for any UTF-8 root; not reachable from the CLI or the public
API today (non-UTF-8 entry paths are rejected before compilation) — closed as a latent
hazard. mds build/mds watchsay so when a source is written flat instead of mirrored
(#217). In--out-dirmode a source that is not under the build root is written to
<out>/<stem>.<ext>(contained, unchanged) and now printswarning: <source> is outside the build root <root>; its output is written flat as <out> (…), not suppressed
by--quiet. No walked source can trigger it; it is a tripwire for a future caller.
Two degenerate fallbacks that could have joined an absolute path into the output
directory now use a fixed relative name.mds watchno longer probes output paths for
a vanished out-of-root dependency, which could drop the write-dedup entry of an in-root
source with the same file name.
Internal
- Docs bundle (#327, #328, #313).
spec.mdgains a "Filesystem constraints" subsection under §4.6 (relative form, NUL bytes, symlink rejection, root containment, path encoding — with error codes and the tests that pin them;SECURITY.mdpoints at it), an "Error Codes" registry under §5 (26mds-corecodes and 4 binding-only codes with their CLI exit class and surfaces; §7.9 cross-references it), and afmt.sort_frontmatter_keysrow in §7.8 marked reserved and inert.SECURITY.md'sdebug-panicsroster names the crates that declare it (mds-napi,mds-wasm,mds-python— notmds-core) and its release-build claim is scoped to the three build sites it rests on.RELEASING.mdgains a partial-failure recovery runbook (failed job → what is already irreversible → recovery command). The bug-report template lists the Python package and the Rspack loader.package-lock.jsonwas verified consistent (npm ci,npm ls --all) and left untouched. atomic_write_filereplace-by-rename contract documented (#226). The temp-file-then-rename write used bymds fmt,mds lint --fixand (with #227)mds build/mds watchoutputs and.mapsidecars gives the target a new inode, so hard links, ACLs, xattrs and owner/group of a pre-existing target are not preserved (permission bits are, on Unix). Stated in spec §7.2 "Output writing",SECURITY.md, the helper's rustdoc andRELEASING.md.- Cargo dependency sweep: napi 3.9.0 → 3.12.2, napi-derive 3.5.6 → 3.6.3, napi-build 2.3.2 → 2.4.1 (napi-sys 3.3.0, napi-derive-backend 6.1.2), pyo3 0.29.0 → 0.29.2, clap 4.6.1 → 4.6.6, similar 3.1.1 → 3.2.0, wasm-bindgen 0.2.121 → 0.2.126 (js-sys 0.3.103, wasm-bindgen-futures 0.4.76, wasm-bindgen-test 0.3.76), serde 1.0.228 → 1.0.229, serde_json 1.0.150 → 1.0.151, thiserror 2.0.18 → 2.0.20, libc 0.2.186 → 0.2.189. Supersedes Dependabot #354 #360 #359 #358 #280 #251 #249 #246 #243.
- npm dependency sweep: relaxed the three phantom floor pins to caret ranges — fast-uri 3.1.5 → ^3.1.6 (oldest release patching GHSA-5jgf-p345-68v8, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc, GHSA-jqff-g426-hqxp), nanoid 3.3.18 → ^3.3.18, js-yaml 4.3.1 → ^4.3.1 (#336); @napi-rs/cli ^3.0.0 → ^3.8.6 (lock 3.7.0 → 3.8.6); vite lock 8.1.5 → 8.2.2; Dependabot
ignorerules for semver-major bumps of the three phantom pins. Supersedes Dependabot #315 #332 #346 #362 #355 #357 #279. - GitHub Actions sweep: actions/checkout v6 → v7 (16 call sites: 9 ci.yml + 7 release.yml), actions/setup-node v6 → v7 (6 sites), actions/setup-python v5 → v7 (5 sites, ci.yml only; action runtime node20 → node24), PyO3/maturin-action pin normalized from the v1.51.0 annotated-tag object (
3e2bdf6) to the commit it points to (e83996d1), same version; Dependabotignorefor typescript semver-major version updates pending the TS 7 migration (#364). Supersedes Dependabot #111, #189, #241, #356; replaces #169. - Release-surface PR gate:
release.ymlnow triggers onpull_requestevents touching.github/workflows/release.yml,.github/actions/**,crates/mds-napi/**,crates/mds-python/**, andscripts/verify-napi-names.mjs; adds an unguardedrehearse-publish-pythonjob that rehearsespublish-pythonwithout uploading — pin shape (both the annotated-tag-object SHA a892a5a6 and the commit SHA dc37677b are rejected as positive controls), GHCR manifest (404 +MANIFEST_UNKNOWNbody required), boundeddocker pull(3-attempt loop), andtwine checkfrom the pinned image with--network none, each with a positive control — plus apublish-testpypiopt-in leg (dispatch-guarded); the rehearsal never invokespypa/gh-action-pypi-publish(the action has no dry-run mode) and is deniedid-token, both pinned by specs S14/S15; credential and OIDC probes run onpull_requestevents and fail closed on fork/Dependabot PRs (no secrets, noid-token: write) with an actionable error;verify-pr-checks.mjsrequiresVersion gate,Stage + verify platform packagesandRehearse PyPI publish (no upload)on release-surface PRs and fails closed when the changed-file list cannot be read (#342, #350). verify-pr-checks.mjssuite keying (D-PR8): the skipped-publish allowance and D-PR7 context attribution are now keyed on the check-run'scheck_suite.idmapping to arelease.ymlworkflow run (one boundedGET /actions/runs?head_sha=call); the verifier exits 2 when it cannot enumerate the head's workflow runs; 2026-09 branch-protection and check fixtures added (checks-pr366-e02bcf2.json,runs-pr366-e02bcf2.json,protection-main-2026-09.json) (#341).build-napiper-leg rust-cache key: addskey: ${{ matrix.settings.target }}to theSwatinem/rust-cachestep so each cross-compile leg's target artifacts stay isolated (without the key all four ubuntu legs and both macOS legs restored one shared blob, confirmed live in run 34065573775);build-python's existingkey: matrix.target-matrix.manylinux(#347) unchanged; spec S20 inrelease-auth-probe.spec.mjspins both and failsVersion gateif a key is dropped; spec S3 extended to pin the-zCARGO_REG_TOKEN guard in executable code; #345 verified that crates.ioGET /api/v1/meisAuthCheck::only_cookie()(HTTP 403 for any API token) and the only token-accepting read route rejects scoped tokens — non-empty guard is the strongest check available, durable fix tracked in #368; #345 closed won't-fix-as-filed (#345 #352).- Alpine
node:22-alpineload tests for both musl napi addons gatepublish-crates: x64 (linux-x64-musl) as the last step ofstage-and-verify-napi(after the staged artifact upload, so the artifact is never suppressed by an x64 failure), arm64 (linux-arm64-musl) in a new unguardedload-test-musl-arm64job on a nativeubuntu-24.04-armrunner using thenapi-stagedartifact; both usescripts/musl-load-probe.cjsin adocker run --network nonestep with a positive control;publish-cratesblocks on both vianeeds:AND itsif:conjunct; spec S21 inrelease-auth-probe.spec.mjspins job existence, runner, guard shape, wiring, step order, and run-block byte-equality (#340); the first CI run surfaced #371 (string compile fails when the base directory is a filesystem root —node:22-alpinehas noWORKDIRso the default container cwd is/); the gate now runs the container from/w(docker run -w /w) and the probe asserts its cwd so a dropped flag fails loudly. - Both musl napi legs (
x86_64-unknown-linux-musl,aarch64-unknown-linux-musl) now cross-compile withnapi build … -x/ cargo-zigbuild 0.23.0: the SHA-pinnedtaiki-e/install-action(v2.85.10,fallback: none) installs cargo-zigbuild beforeSwatinem/rust-cache(rust-cache deletes~/.cargo/binon save; napi's detector is presence-only and wouldcargo installan unpinned copy mid-build otherwise); the hand-written zig cc wrappers, fake-zig self-check, and bothCARGO_TARGET_*_MUSL_LINKERexports are deleted; three new steps assert the pinned version (before and after the build) and the no-op detector reads both musl linker vars inside[ -z ]guards to confirm none is set; the readelf gate addsALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'with a plantedlibunwind.so.1control;mlugg/setup-zigSHA-pinned (v2.2.1) in the same step; spec S22 inrelease-auth-probe.spec.mjspins all of the above (#339). - manual
watch-soak.ymlLinux soak instrument for the cli_watch flake family (#129 #318 #320);workflow_dispatchonly, not a gate, not a required context, not release-surface cli_watchharness: every post-spawn write to a watched path goes throughcommon::write_atomic(temp + rename, one FS event instead of the truncate-then-write pair whose 0-byte intermediate was compiled at--debounce 0); 45 sites converted by a mechanical rule stated in the file's doc comment, with two// DELIBERATE:plain-write exceptions whose subject IS the truncate+write pair (#318).cli_watchharness: the pipe drain thread is now joinable —PipeTap::finish/finish_textreap the child and then JOIN the drain, so the final stderr read carries a happens-before edge to the child's last write; 13 of 13 post-kill flush sleeps deleted andChildGuardmoved totests/commonsofinishcan name it (#320).cli_watchharness: a piped stdout is drained before the readiness wait, not after (spawn_watch_readyreturns the tap as a third element;spawn_ready_piped_stdouthands it to the caller).mds watch -o -publishes its startup output before it writes the readiness marker, so an undrained pipe filled and blocked the child while the poller waited for a marker that could never arrive — reproduced locally as a deterministic 10sREADY_TIMEOUTfailure on 512 KiB of stdout (#320).cli_watchharness: the i16–i20 duplicate-vars-warning family waits for the expected warning count with a boundedwait_for_stderr_countbefore asserting it. In directory mode the warning is emitted after the output write, so sampling stderr the instant the artifact appeared could read one warning short (CI runs 34366009518, 34404318888) (#326 #320).cli_watch:watch_readiness_handshake_makes_ctrl_c_exit_deterministicis a two-arm control (20 iterations) proving theMDS_TEST_READYhandshake, not luck, is what makes a post-SIGINTstatus.success()deterministic — unsynchronized spawn signalled on theWatching …line dies by SIGINT; a spawn signalled after the handshake exits 0 and printsStopped watching.(#129).cli_build:watch_bare_filename_from_cwd_succeedsis synchronised on the readiness handshake and readshello.mdonce, instead of polling the output artifact for up to 10s; the privateChildGuardcopy is replaced bycommon::ChildGuardand stderr is drained rather than discarded (#318).
What's Changed
- chore(deps): add 30-day Dependabot cooldown and napi group by @dean0x in #353
- chore(deps): cargo dependency sweep (napi 3.12.2, pyo3 0.29.2, clap 4.6.6, wasm-bindgen 0.2.126, +5) by @dean0x in #361
- chore(deps): npm dependency sweep — relax phantom floor pins, @napi-rs/cli 3.8.6, vite 8.2.2 by @dean0x in #363
- chore(ci): bump checkout v7, setup-node v7, setup-python v7; normalize maturin-action pin by @dean0x in #365
- ci(release): run release.yml on release-surface PRs; rehearse the PyPI publish; add opt-in TestPyPI leg (#342, #350) by @dean0x in #366
- ci(release): per-leg rust-cache keys in build-napi; suite-keyed Tier-B allowance in verify-pr-checks; re-scope #345 (#352, #341, #345) by @dean0x in #369
- ci(release): load-test both musl addons on node:22-alpine before publish (#340) by @dean0x in #370
- ci(release): build both musl legs with cargo-zigbuild via napi -x; retire the hand-written zig cc wrappers (#339) by @dean0x in #373
- fix(cli): warn on duplicate keys in --vars JSON files at every depth; fix stale lint_str rustdoc (#326, #329) by @dean0x in #374
- fix(watch): capped quiet-period debounce; harness atomic writes, joined drains, stdout drain; #129 handshake control; #318 site (#129, #318, #320, #379) by @dean0x in #382
- fix(core): bound frontmatter YAML parsing — 1 MiB cap, 200k node budget, 1024 flow-depth guard, string-API MAX_FILE_SIZE; lint/scan_imports propagate resource_limit (#162) by @dean0x in #384
- fix(cli): atomic mds build/watch outputs and .map sidecars via atomic_write_file (removes the spurious metadata warning); empty directory exits non-zero; atomic_write_file contract documented (#227, #225, #204, #226) by @dean0x in #385
- fix(core,cli): enforce internal invariants in release builds (degrade non-boundary spans, assert skeleton/width/range/cursor invariants); path helpers fail closed on strip_prefix and non-UTF-8 paths; fmt non-UTF-8 exits 2 (#220, #217) by @dean0x in #388
- fix(cli): mds init writes through atomic_write_file — a symlink at the target is refused, --force replaces by rename (#386) by @dean0x in #391
- fix(cli): mds build/check exit 1 on a directory whose only .mds files are _-prefixed partials (#387) by @dean0x in #392
- chore: spell out legacy decision-log citations inline and add the ledger-citation gate (comment-only) by @dean0x in #393
- docs: v0.4.3 docs bundle — spec §4.6 filesystem constraints, §5 error-code registry, §7.8 reserved field; SECURITY.md debug-panics roster; RELEASING.md partial-failure runbook; bug template; KB refresh (#327, #328, #313) by @dean0x in #394
- chore(release): v0.4.3 by @dean0x in #395
Full Changelog: v0.4.2...v0.4.3