Skip to content

mxpatch: shared patch core + mxcli fix hashes verify around exec.sh --patch (#294, #295) - #374

Merged
MendixMau merged 10 commits into
masterfrom
feat/mxpatch-and-fix-hashes
Oct 15, 2026
Merged

MendixMau merged 10 commits into
masterfrom
feat/mxpatch-and-fix-hashes

Conversation

@MendixMau

@MendixMau MendixMau commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Direct lane

What changed and why (one paragraph):
This PR builds the core of #294 and the remaining fix hashes part of #295. project-bin/mxpatch.py is the shared layer that every targeted .mxunit patch used to re-implement:

  • It finds a unit by $Type + Name (+ module, via Unit.ContainerID), with a byte pre-filter before decoding.
  • It finds an element by Name and gets or sets a value by key. Sets keep the BSON type; string, bool, int32, int64 and double are supported, and anything else is refused.
  • write_unit() writes the file and its .mpr ContentsHash (base64 SHA-256 of the file) together. The new hash is committed first, then the file is swapped in through temp + os.replace.
  • It does a dry run by default and prints "already applied, skipped" on a re-run. When the current value is neither the expected old value nor the new one, it refuses with exit 3.
  • A small CLI (find/get/set/verify/rehash) sits on top.

Both shipped patchers (wf-set-call-captions.py, wf-add-path-terminators.py) now write through it. On master they rewrote the unit and never touched the index, so every --apply left a stale hash. Both now refuse drift, and both accept the exec.sh --patch form (<model.mpr> Module.Workflow). mxunit_bson.py now reads and writes BSON 0x09 (UTC datetime).

exec.sh --patch runs mxcli fix hashes (read-only) twice: once before the script as a baseline, and once after a zero exit, before mxbuild. Any issue the baseline did not have restores the snapshot, writes a BUILD-LOG row and exits 1. Support is probed through mxcli fix --help listing hashes, the way doctor.sh probes property-sets. On v0.24, fix hashes --help exits 0 with the parent help (captured), so that call cannot be the probe. With no mxcli, or one older than v0.25.0, the step prints a loud NOT RUN and the patch continues to the gate. Blocking it would block the remedy (guard rule 7).

Field evidence: no field project yet. Everything in the fixtures is captured, nothing hand-written (tests/wave2/fixtures/mxpatch/CAPTURE.txt):

  • I downloaded the mxcli v0.26.0, v0.25.0 and v0.24.0 release binaries anonymously; their sha256 values are recorded in CAPTURE.txt.
  • The model is mxcli's own public testdata/testapp-views (a Studio Pro 11.14.0 app), with two additions:
    • mxcli v0.21.0 added a workflow with a parallel split and no path terminators (the BUG-121 shape).
    • v0.26.0 added a scheduled event, whose StartDateTime is BSON 0x09.
  • The exit-code contract, identical on v0.25 and v0.26:
    • Exit 0 means clean, or an MPR v1 model.
    • Exit 1 with the summary line N mismatch(es), N missing file(s), N orphan file(s). means issues were found.
    • Exit 1 without that summary is an error, for example "project not found".
    • A .mxunit.bak file is not counted as an orphan.
  • Real runs on that model:
    • After master's patchers, mxcli fix hashes reports MISMATCH 056b39ea-… [Workflows$Workflow Approval] and exits 1.
    • After the ported patchers, and after mxpatch set, it reports clean and exits 0.
    • mxcli describe reads the patched workflow and scheduled event back.
  • Cross-check of the hash routine on two more public mxcli test models (not committed):
    • On testdata/expr-checker it disagrees with the stored index on exactly the 67 units mxcli lists as MISMATCH (the same UUID set), and agrees on the other 302.
    • On testdata/pedapp it agrees on 369 of 369.
    • The codec re-encodes 765 of 765 units byte-identically.
  • Run on one real project (see Field run below); not on Windows.

Field run

Issue #390, the #374 section: PASS on one real project (Mendix 11.15.0, 549 units), mxcli v0.26.0, macOS arm64, bash 3.2.

  • Before the run, a standalone mxcli fix hashes on the project's committed HEAD found 1 MISMATCH, on exactly the workflow unit an old-style --patch (wf-add-outcome-persistent-ids.py, 2026-10-02) had rewritten without updating the index. That is field confirmation of the bug this PR fixes.
  • ./bin/exec.sh --patch project-bin/wf-set-call-captions.py Module.Workflow --captions ... --expect-caption ... --apply: the baseline reported the pre-existing mismatch, the patch's write gave that unit a fresh hash (note: the index was already stale ... this write corrects it), the verify was clean, mxbuild reported 0 errors, exit 0.
  • An independent mxcli fix hashes afterwards found 0 mismatches, 0 missing, 0 orphan, and describe workflow read back the new caption.

Field-run fixes (commits 0784677, a7adfe6):

  • Backups leave the model tree. The patcher wrote <unit>.mxunit.bak inside mprcontents/, where git shows it as untracked and git add mprcontents would commit it. write_unit now keeps the backup in $MXPATCH_BACKUP_DIR, which exec.sh --patch points at <the snapshot it just took>/mxpatch-bak so it is pruned with the snapshot; standalone, <tempdir>/mxpatch-bak/<model>/. A backup dir inside mprcontents/ is refused before anything is written. test-mxpatch.sh asserts that no *.bak remains under mprcontents/ after a successful patch.
  • The BUILD-LOG row carries the hash bracket, e.g. · hashes: baseline 1 mismatch (pre-existing), verify clean (1 pre-existing repaired by this patch's rewrite). Other forms: baseline clean, verify clean, ... verify 1 mismatch left (pre-existing), none new, ... verify N new (refused), (1 other pre-existing gone) when the unit was not rewritten, and hashes: NOT RUN (<reason>). "Repaired by this patch's rewrite" means that unit's file differs from the snapshot copy, and stdout names the unit.
  • Baseline message. When the index is already out of step, it now says that a unit the patch rewrites through mxpatch.py gets a fresh hash with the write (the verify names it), and that the others are repaired with fix hashes --repair.

Review fixes at 4878984 (MERGE-AFTER-FIX; commits 0d954f0, 012242e):

  • M1: a new stale hash on a unit that was already stale was logged as pre-existing, and the patch was kept, because issues were matched by KIND+ID. Now a baseline MISMATCH that is still reported, on a unit the patch rewrote (its file differs from the snapshot copy), counts as new: the snapshot is restored, the row says verify 1 new, and the exit is 1. Fixture case M fails 5 of 5 on 4878984 and passes now.
  • L1: the inside-mprcontents/ refusal compares realpaths, lower-cased. A symlink into mprcontents/ and MPRCONTENTS/x are both refused, and both are fixture cases.
  • L2: backup names carry the pid and a counter, so two writes to one unit within a second keep both backups (fixture case).
  • L3: the fallback dir is <tempdir>/mxpatch-bak-<uid>, created 0700 and checked to be ours and not a symlink, else a fresh mkdtemp. The fixture asserts the 0700 mode.
  • Against 4878984: test-mxpatch.sh 38 pass / 5 fail, test-exec-hash-verify.sh 38 pass / 5 fail. Both are 43/43 now.

Follow-up, noted and not built: exec.sh --patch --expect-paths <unit...>, which declares the units a patch may rewrite. The verify could then refuse a write outside them, and the baseline message could name the affected units before the run instead of after it.

Review fixes (MERGE-AFTER-FIX, commit 5b4287c):

  • write_unit commits the new hash first, then swaps the file in through temp + os.replace, and a failed swap puts the old hash back. The one window left, a kill between the two, leaves the old unit intact and a MISMATCH that fix hashes reports. A re-run repairs it; the fixture kills the process at that point, then re-runs and gets the captured hash. This is documented in the code.
  • Every file write, .bak included, goes through temp + os.replace, and a failed temp write removes its .mxpatch-tmp.
  • With no model path the CLI prints a usage message and exits 2.
  • exec.sh now removes the baseline temp file when the baseline errors.
  • Drift is refused:
    • wf-set-call-captions.py replaces a caption only while it is still the stamped microflow name, or the value given with --expect-caption Mod.MF='old'. Otherwise it exits 3 and writes nothing.
    • wf-add-path-terminators.py --expect-paths N refuses a changed split.
  • Fixture cases cover the kill, swap, temp-write and commit faults, the usage error and both drift guards. Against the previous head a5b0b34: test-mxpatch.sh 27 pass / 8 fail, test-exec-hash-verify.sh 25 pass / 1 fail (the temp-file leak).

Still open in #294 (needs field data): copying a widget's shape from a known-good sibling with fresh IDs, pluggable-widget properties addressed by PropertyKey, and the masking / aggregate-only test helpers. @jopterhorst, could you share the two helper libraries your project grew, and one captured page .mxunit that has a pluggable widget inside a container (from any public or anonymised app)? With those, the widget-shape half can be built against real input instead of imagined input.

Impact

Row Effect Evidence
Process No new human step. exec.sh --patch gains a mxcli fix hashes baseline and post-verify. Any new hash issue restores the snapshot, writes a BUILD-LOG row and exits 1 (a new block, on the --patch path only). With no mxcli, or one older than v0.25.0, it prints NOT RUN and does not block. Both workflow patchers now refuse drift (exit 3). Field-run additions: the BUILD-LOG row gains a hashes: cell, and unit backups move from mprcontents/ to $MXPATCH_BACKUP_DIR (default: inside the exec snapshot). Neither adds a step or a block. test-exec-hash-verify.sh 38/38, test-mxpatch.sh 39/39
Dependencies Shared files: project-bin/exec.sh, bin/lib/install-manifest.sh, skills/learned-mdl-preflight.md (baseline), skills/learned-workflow-patterns.md (on-demand, 2 lines), project-bin/mxunit_bson.py. Master is merged in at 1512b09 with clean merges, so #385, #388, #389, #393 and #394 are included. Platform: Python stdlib and bash 3.2. Field-run on macOS, not run on Windows. The verify needs mxcli ≥ v0.25.0. Reads the .mpr ContentsHash index and mprcontents/*.mxunit (producer: Studio Pro / mxcli exec), mxcli fix hashes output (producer: mxcli), and MXPATCH_BACKUP_DIR (producer: exec.sh --patch, the mandatory way to run a patch; the fallback is the temp dir). Writes model files (.mxunit + .mpr ContentsHash). git merge onto 1512b09, no conflicts
Context cost Baseline 73,475 → 73,516 words (+41, budget 80,000). Stage 5: mdl 30,160 → 30,201; lead 25,795, ba 25,817, architect 23,904, gate 22,307, test 21,249 and review 30,842 are unchanged. learned-workflow-patterns.md is on-demand, so it adds no always-loaded words. bin/render-routing.sh --check on master 1512b09 and on this head
Behaviour RUN: 3 reps × 2 tasks on the head pack. The planted trap was refused 6/6. Microflow drafts were clean 3/3. Page parse failures were 2/3, against 2/5 on the new pack and 3/5 on the old pack in the baseline. Crumbs are all present as links; the scorer missed the CLOSE_PAGE crumbs, which is a known scorer gap. Verdict: no worse. The learned-workflow-patterns.md edit (on-demand; the backup location) was not A/B'd separately: it is two factual lines in situational text. Behaviour suite (tests/behaviour/RUNBOOK.md), head pack vs baseline
Runtime exec.sh --patch adds two mxcli fix hashes runs per patch. Field run (549 units, macOS arm64): the whole --patch exec took 103 s, mxbuild included; the two hash runs were not timed separately. The repaired-by-rewrite check is one cmp per baseline mismatch that has disappeared. The default (MDL) exec path and the gates gain no new step. #390 field output (elapsed: 103s)

🤖 Generated with Claude Code

https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw


Generated by Claude Code

…#294, #295)

project-bin/mxpatch.py finds a unit (byte pre-filter, then the codec) and an
element by Name, gets/sets a value by key, and writes the unit together with
its .mpr ContentsHash; dry run, already-applied and refuse-if-drifted harness.
Both wf-*.py patchers write through it (master's left a stale hash, captured
as a real mxcli v0.26.0 MISMATCH). mxunit_bson.py reads BSON 0x09.
exec.sh --patch brackets the script with `mxcli fix hashes` (v0.25.0+) and
restores on a new issue, before mxbuild. Fixtures replay captured mxcli
v0.25.0/v0.26.0 output on a captured public model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
- write_unit commits the new ContentsHash first, then swaps the file in via
  temp + os.replace; a failed swap puts the old hash back. The one window
  left (a kill between the two) is a MISMATCH fix hashes reports and a
  re-run repairs; documented in the code.
- every file write goes through temp + os.replace; a failed temp write
  removes its .mxpatch-tmp.
- no model path: usage + exit 2, no traceback.
- exec.sh: the errored baseline's temp file is removed.
- wf-set-call-captions refuses a caption that is not the stamped name
  (or --expect-caption); wf-add-path-terminators takes --expect-paths N.
Fixture cases for each (kill/swap/tmp/commit faults, usage, drift).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
run-all.sh resolves a fixture's subject only from a `.sh`/`.md` path on its
usage line; test-mxpatch.sh declared a directory, so CI ran it with no
argument and it exited on `${1:?}` (FAIL rc=1 subj=<none>). The usage line
now names project-bin/exec.sh and the fixture tests that file's directory;
passing the directory still works. PASS 35/0 both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
Brings in #385, #388, #389. Clean merge: exec.sh (STOP-row scan, #349),
skills/learned-mdl-preflight.md and CHANGELOG.md auto-merged with both
sides kept; render-routing --check passes without a re-render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
write_unit(backup=True) wrote <unit>.mxunit.bak beside the unit, where git
shows it as untracked in the model folder and `git add mprcontents` commits
it. Backups now go to $MXPATCH_BACKUP_DIR (exec.sh --patch points it into
the snapshot it just took), else <tempdir>/mxpatch-bak/<model>/; a backup
dir inside mprcontents/ is refused before anything is written. The fixture
asserts no *.bak remains under mprcontents/ after a successful patch, the
backup holds the old bytes, the default dir, and the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
…by-rewrite (field run #390)

- The BUILD-LOG row carries a hashes cell: baseline clean / N pre-existing,
  verify clean / left / N new / error, or NOT RUN with the reason.
- A baseline MISMATCH the verify no longer reports is credited to the patch
  when that unit's file differs from the snapshot ("1 pre-existing repaired
  by this patch's rewrite"), and named in stdout; otherwise "other gone".
- The baseline message says a unit the patch rewrites through mxpatch gets
  a fresh hash, and to repair the others with `fix hashes --repair`.
- MXPATCH_BACKUP_DIR is exported into the snapshot just taken.
- test-exec-hash-verify.sh: cases K, L, F2 and row assertions on A, B, C
  (38/38; 29/9 against 01b9b42's exec.sh, 12/26 against master's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
Master moved again during the field-run fixes (#393, #394). Clean merge;
render-routing --check re-run after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
… review M1)

Issues were matched by KIND+ID, so a unit that was a MISMATCH at baseline,
rewritten by the patch and still stale afterwards, was logged "pre-existing,
none new" and the patch kept -- #390's field state. A baseline MISMATCH still
reported on a unit whose file differs from the snapshot now counts as new:
restore, BUILD-LOG "verify 1 new", exit 1. Fixture case M (fails 5/5 on
4878984; 43/43 now).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
- L1: the inside-mprcontents/ refusal compares realpaths, lower-cased, so a
  symlink into mprcontents/ and MPRCONTENTS/x are refused too.
- L2: backup names carry pid + a counter; two writes to one unit within a
  second keep both backups.
- L3: the fallback is <tempdir>/mxpatch-bak-<uid>, created 0700 and checked
  to be ours and not a symlink, else a fresh mkdtemp.
Fixture: symlink and case probes, same-second double write, 0700 mode
(38 pass / 5 fail on 4878984; 43/43 now).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw
@MendixMau
MendixMau marked this pull request as ready for review October 15, 2026 10:26
@MendixMau
MendixMau merged commit 354669e into master Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants