Skip to content

Sync ako/mxcli: workflow end markers, MDL-WF07, MDL080, microflow MERGE/JOIN, non-root PostgreSQL - #1094

Merged
ako merged 18 commits into
mendixlabs:mainfrom
ako:main
Sep 13, 2026
Merged

Sync ako/mxcli: workflow end markers, MDL-WF07, MDL080, microflow MERGE/JOIN, non-root PostgreSQL#1094
ako merged 18 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Sync from ako/mxcli: 11 commits not yet on mendixlabs/mxcli:main.

Workflows: shapes that built and then did nothing (ako/view-entity-examples FINDINGS §6, §7)

  • A parallel split ran every path empty. Mendix ends each path with an EndOfParallelSplitPathActivity, and the engine runs a path only up to that marker. mxcli never wrote one, so each path went straight to its end and none of its activities ran. Every path now gets the marker, from CREATE and from both INSERT PATH mutators. Checked at runtime on 11.14.0.
  • A boundary event path could not be ended. Without an EndOfBoundaryEventPathActivity, an interrupting path fails the build with CE0105, and a non-interrupting one builds but the runtime refuses to start. Every boundary path now gets the marker unless it already ends in a jump.
    • Guard fix: the create or modify guard counted the new marker as a second boundary event and refused a valid rewrite. It now matches the type suffix.
  • A bare boundary event timer is refused on 11.x (MDL-WF07). It writes Workflows$TimerBoundaryEvent, which no Mendix 11 runtime has, so the app will not start. It was the documented example; the syntax help and skill now name the kind.
  • Skill references: System.UserRoles, not System.User_UserRoles (CE1613), and a microflow datasource that takes parameters must be written with its arguments (CE1571).

View entities (FINDINGS §4)

  • An attribute that tries to hold an object is refused (MDL080). Declaring the association column (MeterRef: Trends.Meter beside select m.ID as MeterRef) was stored as an enumeration naming an entity. The build then failed with CE1613, or mx check threw before validating anything.
    • check refuses it without a project; check -p refuses an entity type under any other name.
    • exec refuses both before writing, --no-check included.

Microflows: error-handler rejoins

  • MERGE / JOIN named join points, and DESCRIBE output that uses them. MDL could not say where an error path rejoins the normal flow. Every rejoin collapsed to the enclosing branch's continuation on a describe → exec round trip, silently.
  • Docs: the Phase E plan, and a record of the error-handler population MDL-FLOW01 cannot see, because its graph drops error-handler edges.

Local runtime and tooling

ako and others added 18 commits September 13, 2026 07:34
TestSettleSourceReturnsPromptlyForOneChange failed CI on PR #449 with
"a quiet source took 196.975373ms to settle, want under 100ms" — on a
docs-only diff that cannot reach a debounce timer. The same tree passed
build-and-test in the push run and failed in the pull_request run minutes
apart, which is direct evidence of nondeterminism rather than a regression.

The test bounded elapsed wall-clock as a multiple of the poll interval:
poll * (sourceSettleWindow + 3), 100ms against a nominal 40ms. settleSource
waits on time.After(poll), which guarantees AT LEAST the duration and says
nothing about the upper bound, so a loaded runner blows the budget with
nothing wrong. It is also the anti-pattern the PR checklist names — tests must
not rely on sleeps for synchronisation.

What the test is really guarding is a POLL COUNT: a quiet source costs
sourceSettleWindow polls, not an unbounded wait. So the poll timer is now
injected (settleSourceWith) and the test counts polls. Widening the budget
would only have moved the flake threshold.

settleSource keeps its signature and is still exercised with a real timer by
the multi-file and interrupt tests, so the wrapper is not going untested.

The seam also makes a guarantee expressible that the timing test could not
reach: the window is sourceSettleWindow CONSECUTIVE quiet polls, so a write
part-way through restarts the count. Deleting `quiet = 0` from the change
branch was green against every pre-existing test in this file.

Controls, each isolating one guarantee:

- loop costs one poll more than the window it declares -> both count tests fail
- `quiet = 0` removed from the change branch -> only the consecutive test fails
- tick returning one shared channel instead of re-arming -> the multi-file test
  hangs, so the re-arm is load-bearing rather than a style choice
- widening sourceSettleWindow itself -> both still pass, deliberately: the
  assertions track the declared window rather than pinning its value

30 consecutive runs of the four tests are green, and the package passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu
`mxcli run --local --ensure-db` could not provision PostgreSQL in a non-root
devcontainer. That is the environment mxcli itself generates: `mxcli init`
writes a devcontainer from mcr.microsoft.com/devcontainers/base:bookworm which
installs the postgresql server and runs as `vscode`, and wires
`run --local --setup --ensure-db` into the Claude Code SessionStart hook — so it
fired on every fresh session in an initialized project, not only when someone
typed the flag. Fixing it here rather than in the template also repairs projects
that have already been scaffolded.

Three independent defects sat on one path, each sufficient to block it:

- The service start was never elevated. Debian's /etc/init.d/postgresql runs
  under `set -e` and calls create_socket_directory first, which chmods
  /var/run/postgresql — refused for a non-root user, so the script aborts before
  it looks at a single cluster. Now attempted with `sudo -n` first and
  unprivileged second.
- The mendixlabs#823 user-owned-cluster fallback was inert on Debian and Ubuntu, which
  wrap only the client tools into /usr/bin while initdb and pg_ctl live in
  /usr/lib/postgresql/<major>/bin. The safety net for a failed service start
  could never deploy on the platform that needs it most. Both tools are now
  resolved from the same versioned directory, ranked numerically — a lexical
  sort puts "9" above "16", and a data directory made by one major cannot be
  started by another.
- The superuser was unreachable: `sudo -u postgres` is refused by the
  devcontainer's own sudoers rule, `vscode ALL=(root) NOPASSWD:ALL`, which
  permits root as the only target. Falls back to `sudo -n -- sudo -n -u
  postgres`, since root may in turn target any account.

Everything stays non-interactive, so an unattended run cannot block on a
password prompt.

Separately, the readiness timeout discarded the service manager's own output,
which this package had already collected — the permission denial was in hand and
reported as a generic 20s timeout. A guard that names the wrong cause costs the
reader more than no guard at all, so the diagnostics now travel with the error.

Measured on Ubuntu 24.04 / PostgreSQL 16 with a real non-root user and the real
sudoers rule, against a harness calling EnsureDatabase directly: the shipped
build fails with `exec: "initdb": executable file not found in $PATH` and, once
the cluster is up, `no local PostgreSQL superuser available`; after the fix the
sudo path provisions in 2.6s and the no-sudo user-cluster path in 7.2s. Each of
the four fixes was reverted individually and its test re-run against the
reported symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dp1syGhH8yr7Hve2wwjzqg
…design system

The demo capture is most of a product-demonstration film's runtime, and its
narration overlay was the one piece of furniture drawn to nothing in
particular. Build it to video-system/DESIGN-LANGUAGE.md in
ako/mxcli-intro-video, which that repo makes normative over this skill.

Fixes a silent placement bug found while doing it. Under `html{zoom:z}` —
which take.js uses to reach a fixed-width Mendix layout — Chromium reports
getBoundingClientRect() in zoom-adjusted (video) pixels but getComputedStyle()
and style.* in CSS pixels, and narrate.js had it wrong in both directions:
point() read a rect and assigned it straight to style.left, so the highlight
landed at position x zoom (measured at 1.6842: a target at (168,202) ringed at
(274,330)), while the 96px caption plate reached the file at 162 video px
against a 184px band. Every overlay dimension is now stated in video pixels
and divided by a zoom passed to configure(); checkOverlay() measures the
installed plate against the band and throws at record time. Its control is one
line — omit the zoom and it reports a 310px plate starting at y770.

Design conformance: one accent and it belongs to the pointer; flat plane (no
shadow, no radius, an opacity pulse rather than an expanding glow); the plate
fills the caption band at the x96 gutter; the plate inherits the app's own
computed body font instead of asking for 'Segoe UI', which falls back to
DejaVu Sans on a clean build machine with nothing saying so; say() refuses
tofu glyphs. The compositor keep-alive is a sweeping hairline rather than a
spinning ring, since a system with no rounded corners can only spin a square —
still continuous, not per caption.

Narration is pinned to the catalogue: Kokoro bm_george, two-pass loudnorm to
I=-18.6:TP=-1.5 (was -16, which would have made the demo the loudest film in
the catalogue), a -30 LUFS bed, and per-line verification because TTS has
failed both 0-of-12 and 10-of-12 while passing a total-duration check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L45JFiJ6y58ftg2zWVEq7h
test(run-local): count settle polls instead of timing them
docs(record-narrated-demo): conform the capture overlay to the video design system
structure.go's successors() drops every IsErrorHandler edge before building
the graph, so an error path that rejoins the normal one scores zero in the
prevalence table — not because it is rare, but because the detector is blind
to it by construction. That is a third uncounted population beside the two
already noted, and it needs the same Mode 2 merge/join syntax by a different
route.

Measured across three whole projects (235 microflows, user-written modules
included, unlike the marketplace table): 7 carry a true error-handler flow,
1 rejoins the normal path, in 3 of 3 projects, always at an ExclusiveMerge.
That one microflow is what DESCRIBE flattens to an empty `on error { }` and
what produced the CE0709 over-connected end event fixed in #450.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
Measured the failure the caveat implies rather than asserting it: repointing
SUB_Feedback_SendToServer's error edge from the tail merge to the merge before
the AppId split gives two behaviourally different graphs that DESCRIBE renders
as the same MDL, differing only in a layout annotation and carrying no warning.
Executing that MDL reproduces the first graph in both cases, so the upstream
rejoin is silently rewritten while check, open and mxbuild all stay green.

E0 (detect and warn) is independently shippable and worth doing regardless of
Mode 2; E1 (join out of an on-error block) is a scoping rule on Mode 2's Phase 1
rather than a feature of its own; E2 pins the round trip using the mutated graph
as the control. Also records what not to do: folding error edges into
successors() would move the prevalence figure by reclassifying, since
post-dominance there treats a successor as a branch of a condition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
…es them

MDL could not say where an error path rejoins the normal one. The only two
spellings were "terminate on your own" and an empty `on error { }`, which means
"fall through to the enclosing branch's continuation" — so every other rejoin
collapsed to that one target on a describe -> exec round trip, silently.

Measured: repointing FeedbackModule.SUB_Feedback_SendToServer's error edge from
the tail merge to the merge before the AppId split ("on error, return empty" ->
"on error, re-enter the split") gave two behaviourally different graphs that
DESCRIBE rendered as the same MDL, bar one layout annotation, with no warning;
executing that MDL reproduced the tail-merge graph in both cases. mxcli check,
Studio Pro and mxbuild were clean throughout, so nothing could have caught it.

`merge <label>` declares an ExclusiveMerge; `join <label>` sends a path to it.
The label is MDL-only — a Mendix merge stores no name — so it is resolved at
build time and at describe time and never written to the model. Forward and
backward references both resolve, which is what makes a retry loop expressible.

Also covers crossed branches on the AUTHORING side: an inner split's branch
landing where an outer split's branch lands, which no nesting of `if`
reproduces. DESCRIBE of that shape without an error handler still flattens and
warns (MDL-FLOW01) — the general Mode 2 case is not done.

- grammar: MERGE token (added to annotationName so @merge(x,y) still parses, and
  to keyword so a name "merge" still parses), mergeStatement, joinStatement
- builder: label registry shared with the error-handler sub-builder, deliberately
  NOT with a loop's, whose LoopedActivity owns its own object collection; joins
  resolved in a post-pass after the graph exists
- describe: merges an error handler rejoins are labelled from POSITION, not map
  order, so re-describing an unchanged microflow is not a diff
- check: MDL-FLOW02 unresolved/unjoined label, MDL-FLOW03 duplicate declaration,
  MDL-FLOW04 merge/join inside a loop or while body

Verified: describe -> exec -> describe is a fixed point for every error-handling
form; the two FeedbackModule graphs now describe differently and each round-trips
to its own; 0 errors on mxbuild 11.14.0 for all of it.

Two deliberate deviations from the proposal's Mode 2 sketch, both recorded there:
@position goes before `merge`, matching every other statement; and fall-through
into a merge is allowed rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hifgRSawfaRWXS44YKtSJ
feat(microflow): MERGE / JOIN named join points, and DESCRIBE that uses them
…pt-smaller-37u3fu

# Conflicts:
#	CHANGELOG.md
Reported in ako/view-entity-examples FINDINGS §6. `PARALLEL SPLIT … PATH 1
{ … } PATH 2 { … }` passed `mxcli check`, built at 0 errors and
round-tripped through `describe`, and at runtime every path ran from the
split straight to its end. The activities inside never executed and left
no row in `system$workflowactivity`. Every gate said the workflow was fine,
including the one that reads the model back.

Mendix stores an `EndOfParallelSplitPathActivity` as the last activity of
each path, and its engine executes a path up to that marker. mxcli wrote
paths without one — from the builder, from both writers (neither
serialized the type; both switches returned nil) and from both
`INSERT PATH` mutators.

Settled in one boot on Mendix 11.14.0, two instances of the same workflow
differing only in a trailing marker, reading `system$workflowactivity`
joined to each instance's context object:

    without the marker   neither path's call-microflow has a record
    with the marker      WF_A2 Finished, WF_B2 Finished

The shipped code was then booted on its own and the same query shows both
paths' activities Finished, plus an end record for an empty path.

Every path now ends with the marker, an empty path included (a flow
holding only the marker, as Mendix stores it). `workflows.EndParallelSplitPath`
is idempotent and does not append after a jump or an end-of-workflow, where
a marker would be unreachable. The modelsdk reader reads the marker back as
its typed activity: read generically, `describe` printed a
`-- [Workflows$EndOfParallelSplitPathActivity]` comment in every split.

The first cut named every marker `EndOfParallelSplitPath` and mxbuild
refused it as CE0495 "Duplicate name". Every unit test on the new shape was
green; only the build saw it, because `deduplicateActivityNamesInFlow`
renames only the activity types its switch lists and the markers were not
among them. An env-gated experiment had used unique names, which is how the
collision hid until the shipped code was built.

Controls: reverting the builder call fails `TestBuildParallelSplitEndsEveryPath`
with each path's real last activity; taking the marker types out of the
dedupe switch fails `TestDeduplicateNamesEndOfPathMarkers` with the
duplicated name. The doctype gate runs `mx check` on 24-workflow-examples
(a three-path split plus INSERT PATH) at 0 errors on both engines.

A workflow already written with an affected split needs a
`create or modify` to pick up the markers; running instances are not
changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both from ako/view-entity-examples FINDINGS.

- system-module: the System.User association table named `User_UserRoles`.
  XPath wants `System.UserRoles`; the table's spelling fails the build with
  CE1613 "The selected association … no longer exists" — three user tasks
  carrying the same targeting constraint made it three errors. mxcli's own
  executor and its code comments already use `System.UserRoles`.

- create-page widgets reference: `datasource: microflow Module.GetData` was
  described as "no `()`, the name alone". That holds only for a microflow
  with no parameters; with one, Mendix fails CE1571, and `mxcli syntax page
  datasource` already says so ("one argument per PARAMETER, required"). The
  reference now shows the argument form beside the bare one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mendix ends each boundary event path with EndOfBoundaryEventPathActivity,
and MDL has no end statement, so a path written from MDL had no ending
unless it was a jump. Measured on 11.14.0: an interrupting path is CE0105
at build; a non-interrupting one builds at 0 errors and the runtime refuses
to start ("Expected the flow to end with an end event").

CREATE WORKFLOW and both INSERT BOUNDARY EVENT mutators now append the
marker unless the path already ends in a jump or end-of-workflow (CE6692
if a marker follows one).

The create-or-modify guard counted $Types containing "BoundaryEvent",
which includes the marker, so re-running a fixed script against a fixed
workflow was refused as dropping an event. It now matches the suffix.

Reported in ako/view-entity-examples FINDINGS §7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`boundary event timer '…'` writes Workflows$TimerBoundaryEvent, which no
Mendix 11 runtime has. mx check and mxbuild accept it; the runtime then
fails to start: "Class 'Workflows$TimerBoundaryEvent' could not be found"
(measured on 11.14.0). It was the example in `mxcli syntax workflow
boundary-event`.

check --references and exec refuse it on 11.x, in CREATE WORKFLOW and in
every ALTER WORKFLOW op that can carry a boundary event. 10.x is left
alone. The syntax help, the write-workflows skill and the #415 bug-test
script now name the kind and use a DateTime expression for the delay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(workflow): end split and boundary paths, refuse a bare boundary timer
…DL080)

`select m.ID as MeterRef` gives a view entity an association named
MeterRef. Declaring it in the attribute list as well — `MeterRef:
Trends.Meter` or `MeterRef: Trends.Meter.ID` — parsed as an enumeration
type and exec stored it as one. Measured on 11.14.0: CE1613 "The
selected enumeration 'Trends.Meter' no longer exists", and for the
three-part form mx check throws before validating anything. check
passed it; check -p reported "1 columns but 2 attributes" and compared
the attribute against the next column.

- check, with no project (MDL080): a declared name that is an association
  column's alias, or a three-part type name.
- check -p: an attribute typed with an entity under any other name,
  before the type comparison that misreported it.
- exec: both, before any write, --no-check included (0 files changed).

Reported in ako/view-entity-examples FINDINGS §4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(view-entity): refuse an attribute that tries to hold an object (MDL080)
@ako
ako merged commit adc6d26 into mendixlabs:main Sep 13, 2026
4 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.

run --local --ensure-db: PostgreSQL start step doesn't use sudo, silently fails as non-root, misreports as 20s readiness timeout

2 participants