diff --git a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl index f05ae9bbf7..dbd41266cc 100644 --- a/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl +++ b/.claude/skills/fix-issue/findings/cmd-mxcli.jsonl @@ -105,3 +105,5 @@ {"area": "cmd/mxcli", "date": "2026-09-11", "symptom": "Two opposite `brain plan` failures with one root cause. A requirement anchored at a bare MODULE (`@Maintenance`) reports BUILT the moment the module exists, with none of its work done. A requirement anchored at a MODULE ROLE (`@Maintenance.Coordinator`) reports PLANNED forever, even once the roles exist \u2014 `describe` refuses it ('no describable document named \u2026') and `mxcli refs` finds nothing", "cause": "`catalogResolver.Resolve` in `cmd/mxcli/cmd_brain.go`. A module has a row in the catalog's `objects` view, so it resolves immediately \u2014 fine for a decision (anchors point backward) and useless for a requirement (anchors point forward, so resolution IS the progress signal). A module role is in NEITHER the objects view NOR `FindDocumentUnit`, because it is not a document \u2014 so both lookups miss and it falls through to NotFound, which for a requirement means 'not built yet' permanently", "file": "`cmd/mxcli/cmd_brain.go` (`catalogResolver.Resolve`, `moduleRoleExists` via `GetModuleSecurity`), `cmd/mxcli/brain/entry.go` (`requirementAnchorsArePlannable`)", "insight": "**A resolver's vocabulary has to cover what people actually anchor at, and the two failure directions need opposite fixes.** The module-role gap is a LOOKUP gap \u2014 fixed by consulting `GetModuleSecurity`, case-insensitively because Mendix treats role names that way and an anchor is hand-written. The module-anchor gap is SEMANTIC and cannot be fixed by lookup: the anchor resolves correctly and is still useless, so it is refused at capture time with the alternative named (an author told only 'no' deletes the anchor, which loses the signal entirely rather than fixing it). Keep the control in BOTH directions: a module anchor stays legal on a decision and on a question, or the fix degrades to 'refuse every module anchor' and the negative test still passes. Verified end to end: `@MyFirstModule.User` \u2192 1 anchor 1 resolved; `@MyFirstModule.NoSuchRole` \u2192 NOT FOUND, exit 1, so the resolver did not simply become permissive. Reported by ako/ChipCoV1", "refs": ["ako/ChipCoV1 FINDINGS.md"]} {"area": "cmd/mxcli", "date": "2026-09-11", "symptom": "`.claude/bootstrap-mxcli.sh` (the SessionStart hook) re-downloads ~85 MB of mxcli on EVERY fresh session in an environment where mxcli is already installed on PATH", "cause": "The script gated only on `[ ! -x ./mxcli ]` and never consulted PATH, while the bootstrap skill tells you to `rm -f /mxcli` after moving the project to the repo root \u2014 so in a session image with mxcli pre-installed the guard could never be satisfied by the binary already present, forever", "file": "`cmd/mxcli/init_hook.go` (`bootstrapScriptTemplate`)", "insight": "**A guard that asks 'is the artifact HERE' rather than 'is it AVAILABLE' pays full cost for something already on the machine.** Hardlink a PATH copy in first (what `mxcli new` itself does), falling back to symlink across filesystems then to a copy, and only download when none of those work \u2014 every path leaves ./mxcli working, which the rest of the script and the generated project CLAUDE.md both assume. Keep BOTH controls when testing: nothing on PATH must still download (not silently no-op), and an existing ./mxcli must be left untouched. Reported by ako/ChipCoV1", "refs": ["ako/ChipCoV1 FINDINGS.md"]} {"area": "cmd/mxcli", "date": "2026-09-11", "symptom": "`mxcli theme create --from ` seeds the palette and nothing else: the scaffolded brand theme still described itself in `theme list` as 'Cool slate, one teal signal colour' with Signal's six swatches, and vendored ~500 KB of IBM Plex woff2 the seeded --mxt-font never names, plus a SIL OFL licence for fonts it does not use. Separately, the primary button was never the brand colour: Atlas derives --btn-primary-bg from --brand-primary-600 = color-mix(brand, contrast 20%), so a brand blue #10069F rendered rgb(21,13,140)", "cause": "`manifest()` copied the base theme's Summary and Colorway verbatim and the walk copied every file unconditionally. The Atlas map pinned `--btn-primary-color` to `--mxt-brand-ink` \u2014 an ink each theme picks to sit on `--mxt-brand` (console pairs near-black #04211d with bright teal #2dd4bf) \u2014 while leaving the background to Atlas's derivative, so the pairing the theme designed for was never the pairing that rendered. The map's own comment already called it 'a brand-filled button'", "file": "`cmd/mxcli/theme/create_seeded.go`, `create.go` (`manifest`, the scaffold walk), `assets/*/files/theme/web/_mxcli-atlas-map.scss`", "insight": "**Inheriting a statement ABOUT the base theme into a theme whose palette is no longer the base's is a confident lie; derive it or drop it.** Two traps in the font half. (1) The decision must be made BEFORE the walk: it is taken by reading the partial and applied to files elsewhere in the tree, so doing it inline depended on WalkDir's lexical order putting `_mxcli-.scss` before `mxcli-fonts/` \u2014 true only because of the leading underscore. (2) It touches two halves \u2014 the @font-face rules and the woff2 files \u2014 and getting either alone wrong is silent: a surviving rule for a deleted file 404s, a surviving file nothing loads is the dead weight being removed. Unit tests on each half cannot catch a mismatch; the guard is an integration assertion that a scaffolded theme ships exactly the fonts it loads (control: stubbing the file half fails it with 'X is shipped but no @font-face loads it'). Reported by ako/ChipCoV1", "refs": ["ako/ChipCoV1 FINDINGS.md"]} +{"area": "cmd/mxcli", "date": "2026-09-13", "symptom": "`mxcli run --local --ensure-db` cannot provision PostgreSQL in a non-root devcontainer (Debian/Ubuntu base, remoteUser vscode): reported as a bare \"PostgreSQL did not become ready at 127.0.0.1:5432 within 20s\", or as `exec: \"initdb\": executable file not found in $PATH`. Fires on every fresh Claude Code session in an initialized project, since `mxcli init` wires `run --local --setup --ensure-db` into the SessionStart hook", "cause": "THREE independent defects on one path, each sufficient to block it. (1) `service postgresql start` ran unelevated: 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. (2) The #823 user-owned-cluster fallback was INERT on Debian/Ubuntu: postgresql-common wraps only the CLIENT tools (psql, pg_isready, pg_ctlcluster) into /usr/bin, while initdb/pg_ctl live in /usr/lib/postgresql//bin — so the safety net for a failed service start could never deploy on the platform that needs it most. (3) `resolveSuperuser` used `sudo -n -u postgres psql`, but mcr.microsoft.com/devcontainers/base grants its user sudo to root ONLY (`vscode ALL=(root) NOPASSWD:ALL`, confirmed in devcontainers/features main.sh) — so the target is refused even though the user is effectively an administrator. Plus a diagnostic defect: the 20s readiness timeout discarded the service-manager output the package had already collected", "file": "`cmd/mxcli/docker/ensuredb.go` (`serviceStartAttempts`, `postgresServerBinDir`/`postgresTool`, `superuser.viaRoot`, `withServiceDiag`)", "insight": "mxcli GENERATES the broken environment — `generateDockerfile` emits that exact base image, installs postgresql, and runs as vscode — so this was not user misconfiguration, and fixing it in code (not the template) also repairs projects already scaffolded. Root may target any account, so `sudo -n -- sudo -n -u postgres` reaches postgres under a root-only sudoers policy; try the direct form first and nest only on refusal. Resolve initdb and pg_ctl from the SAME bin directory and rank majors NUMERICALLY — a lexical sort puts \"9\" above \"16\", and a data directory made by one major cannot be started by another. **Measurement trap that cost the most time**: reasoning about which error the user would see is unreliable here — four plausible code paths produce four different messages, and the reported wording was reproducible by none of them on Ubuntu 24.04/PG16. What settled it was building a harness that calls `EnsureDatabase` directly and running it as a real non-root user with the real sudoers rule, then isolating each defect with a one-variable control (widen sudoers to `(ALL)` and nothing else changes → provisioning succeeds; prepend /usr/lib/postgresql/16/bin → the fallback completes). Each of the four fixes was reverted individually and its test re-run: two controls initially failed to COMPILE rather than reproducing the symptom, which proves nothing — they were redone faithfully before being believed", "refs": ["mendixlabs/mxcli#984", "#823"]} +{"area": "cmd/mxcli", "date": "2026-09-13", "symptom": "`build-and-test` fails in CI on `TestSettleSourceReturnsPromptlyForOneChange` \u2014 \"a quiet source took 196.975373ms to settle, want under 100ms\" \u2014 while the SAME tree passes in another run of the same workflow minutes earlier", "cause": "The test bounded elapsed wall-clock time 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 nothing about the upper bound, so a loaded runner blows the budget with no defect present.", "file": "cmd/mxcli/docker/runlocal.go (settleSourceWith, the injected tick), cmd/mxcli/docker/runlocal_settle_test.go", "insight": "The property being guarded was a POLL COUNT, not a duration \u2014 'a quiet source costs one extra poll' \u2014 so the fix is to make polls countable (inject the timer) rather than to widen the budget, which only moves the flake threshold. Diagnosis shortcut worth reusing: the same workflow ran twice on the same tree, once from the push event and once from the pull_request merge commit, and disagreed \u2014 two runs of one tree is direct evidence of nondeterminism and cheaper than reading the test. Two things the controls settled that reasoning did not: (1) the assertions are written in terms of `sourceSettleWindow`, so WIDENING that constant leaves both tests green \u2014 they assert the loop honours whatever window is declared, never the number itself, and the real control is a loop that costs one poll MORE than it declares (both fail). (2) Each tick call must return a freshly-armed channel; returning one shared channel makes the multi-file test HANG rather than miscount, so the re-arm is load-bearing and not a style choice. The seam also made a previously untestable guarantee expressible: the window must be sourceSettleWindow CONSECUTIVE quiet polls, and dropping `quiet = 0` from the change branch was green against every pre-existing test in the file.", "refs": ["ako/mxcli#449"]} diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index bfbec4a657..1f47e0c3f9 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -599,3 +599,8 @@ {"area": "mdl/executor", "date": "2026-09-12", "symptom": "`describe widget datagrid -p app.mpr` — the command the MDL-WIDGET26/29 messages and the docs all point at for \"what does this widget's body take?\" — does not mention that a column can hold a filter. `Body containers` lists `column`, `controlbar`, `emptyplaceholder`, and the column's `items:` line lists only its scalar sub-properties", "cause": "`describeContainers` looped `ol.ItemProperties` and never `ol.ItemSlots`, so the two WIDGETS-typed slots of a DataGrid column (`content`, `filter`) were invisible — even though the property dump higher up in the same output shows them as `widgets`. The generated MDL example had the matching hole: it emitted `column item1 (showContentAs: 'attribute')` with no body, so nothing suggested a column takes one", "file": "`mdl/executor/widget_describe.go` (`DescribedItemSlot`, `describeContainers`, the container printer, and the object-list branch of the example generator)", "insight": "**An incomplete description was worse here than no description.** The only filter-shaped container it named was `controlbar` — the grid-WIDE filter bar — so an author asking this command where the column filter goes was steered to the one place that renders \"Unable to get filter store. Check parent widget configuration.\" That closes a loop with MDL-WIDGET29: the refusal names what the parent declares and says to look here, so the answer had to actually be here. **Say how a widget REACHES the slot, not just that the slot exists**: the useful line is `slot filter -> filter: textfilter | numberfilter | datefilter | dropdownfilter`, because AcceptedChildTypes is precisely what makes the filter go in the column's own braces with no wrapper. Both the accepted types and which slot is the default come from the engine (`itemSlotAcceptedChildTypes`, `defaultItemSlotKey`) rather than being restated — a second copy of the routing rule is the #1036 defect one layer up. Control: a widget whose containers are plain child slots (Gallery, Timeline) must report no item slots, and stubbing the loop makes all four tests fail with `got map[]`. Tests `widget_describe_item_slots_test.go`. Found by asking whether `describe widget` answered the question behind ako/view-entity-examples FINDINGS §2 — it did not", "refs": ["ako/view-entity-examples", "#1036"]} {"area": "mdl/executor", "date": "2026-09-12", "symptom": "A view entity cannot be given an association to a persistent entity. Three separate failures: (a) `mxcli check` misreports types — with the id column first, \"attribute 'TotalKwh': declared as Decimal but OQL expression returns Integer\"; with it last, \"OQL select has 4 columns but 3 attributes declared\"; (b) `exec` writes the view entity and creates NO association member, so the build fails CE1613 \"The selected association 'X' no longer exists\" (11.13/11.14) or CE6770 \"View Entity is out of sync with the OQL Query\" (10.24); (c) adding it by hand with `create association` gives CE6771 \"It is not possible to create associations to/from View Entities\"", "cause": "Selecting a persistent entity's id under an alias gives a view entity an ASSOCIATION whose name is the alias — the column IS the declaration, and Studio Pro creates the member when the column is added. mxcli modelled the select list as attributes only. (a) columns are aligned with declared attributes BY POSITION, so an unrecognised id column SHIFTS every attribute after it; (b) nothing created the member; (c) the association Studio Pro writes carries `Source: {$Type: DomainModels$OqlViewAssociationSource, Reference: }` and mxcli wrote `Source: null` — the `default:` arm of the source switch in both engines' association serializers", "file": "`mdl/executor/oql_view_associations.go` (new), `mdl/executor/cmd_entities.go` (execCreateViewEntity), `mdl/executor/oql_type_inference.go` (extractAliasMap, the two alignment sites), `mdl/executor/cmd_associations.go` + `validate.go` (CE6771 refusal), `sdk/domainmodel/domainmodel.go` (OqlViewAssociationSource, Association/CrossModuleAssociation.ViewSourceReference), `sdk/mpr/{parser,writer}_domainmodel.go`, `mdl/backend/modelsdk/{domainmodel,domainmodel_write,association_move_write}.go`", "insight": "**One field clears two errors, and measuring that first was the whole plan.** Before writing any Go: build the broken model with mxcli, apply the reporter's python patch that adds only the Source subdocument, re-run `mx check` — 2 errors (CE6771 + CE6770) to 0 on 11.13.0. That proves the target shape AND proves the association is also what makes the id column legal on the view entity, which no amount of reading would have settled. **Derive the association from the OQL rather than inventing syntax**: the column is the declaration, so describe → exec round-trips with no second statement to keep in step (verified: describe into a fresh project, 0 errors). **Correction to the report**: it says Studio Pro writes a `CrossAssociation`; that is true only when the target is in ANOTHER module — same-module is a plain `Association`, and both needed the field, so a fix pinned to CrossAssociation alone would have worked for one project layout and not the other. **The read path is not optional**: `create or modify association` round-trips through the semantic model and wrote `Source: null` back, silently converting a working project into CE6771 — A/B'd (pre-fix `Source = null`, fixed `Unchanged association`). The ALTER path does NOT lose it, because it gen-mutates the stored document, so the loss is narrower than the report implies but real. **Two alias-resolution gaps found by running the reporter's own example**: `extractAliasMap` matched neither `join r/Mod.Assoc/Mod.Target as m` (association-path join — the ordinary way to reach a related entity) nor `from Mappings.\"Order\" as o` (a quoted reserved word — the only way to write that entity), so the reported case resolved to nothing and produced no association at all. Run the reporter's literal example, not a tidied one. **`cast(m.ID as string)` must NOT be treated as an association** — it is a legitimate flat design (one query instead of two, no objects materialised in the client), so detection is the bare `.ID` form only. Measured on 11.13.0, both engines, 0 errors; controls: pre-fix binary on the same script gives 2 × CE1613, and re-running is `Unchanged`. Example `mdl-examples/bug-tests/view-entity-association.mdl`; tests `oql_view_associations_test.go`, `sdk/mpr/writer_domainmodel_test.go`. Reported by ako/view-entity-examples FINDINGS §1", "ce": ["CE6770", "CE6771", "CE1613"], "refs": ["ako/view-entity-examples"]} {"area": "mdl/executor", "date": "2026-09-12", "symptom": "A rule that consults the script's own definitions fires for a project object but not for one the SAME script creates — e.g. the CE6771 \"no association to a view entity\" check passed on a script that created the view entity and the association together, which is the ordinary shape", "cause": "`scriptContext` had TWO parallel collectors — `collectDefinitions` (whole program) and `collectSingle` (one statement) — switching over the same statement types and kept in step by hand. They were already out of step before this fix: `collectSingle` had no `CreateConstantStmt` case. Adding view-entity tracking to one left the other silent", "file": "`mdl/executor/validate.go` (`collectDefinitions` is now a loop over `collectSingle`)", "insight": "**Two lists that must agree are one list waiting to happen** — the third occurrence of this shape in the executor (see the `Associations`/`CrossAssociations` findings). The tell is a switch statement duplicated with a different signature; diff them before adding a case, because the drift is already there. Collapsing `collectDefinitions` into a loop over `collectSingle` removed the class, and picked up the pre-existing constants gap for free. Found because a new rule tested fine against a project and silently did nothing in a one-script repro", "refs": ["ako/view-entity-examples"]} +{"area": "mdl/executor", "date": "2026-09-13", "symptom": "Two microflows with DIFFERENT runtime behaviour describe to the SAME MDL, and executing that MDL produces one of them. 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 byte-identical DESCRIBE output bar one @merge layout annotation, no warning; exec reproduced the tail-merge graph in both cases. mxcli check clean, project opens, mxbuild 0 errors", "cause": "MDL had no way to say where an error path rejoins. `collectErrorHandlerStatements` stopped at the first ExclusiveMerge and emitted an empty `on error { }`; the builder's rule for an empty handler is \"fall through to the enclosing branch's continuation\", so every rejoin collapsed to that one target. MDL-FLOW01 could not flag it either: `successors()` in mdl/microflowgraph/structure.go drops every IsErrorHandler edge before building the graph, so the whole population scores zero in the prevalence scan", "file": "`mdl/executor/cmd_microflows_builder_merge.go` (new, build side), `mdl/executor/cmd_microflows_show_merge.go` (new, describe side), `mdl/grammar/domains/MDLMicroflow.g4` (`mergeStatement`/`joinStatement`)", "insight": "**A round trip that is a fixed point on the cases you have is not evidence the round trip is faithful.** The real corpus microflow round-tripped correctly by luck: its rejoin target happened to be exactly where an empty handler falls through to. Every fixture agreed, and the bug was invisible until a graph was CONSTRUCTED that disagreed — repoint one BSON pointer in a copy and re-describe. When a feature's correctness rests on an inference rule (\"an empty handler means X\"), the test that matters is one where the stored graph means something OTHER than X; the corpus will not supply it, because the rule was reverse-engineered from the corpus. Also: an ExclusiveMerge has no Name, so the label has to be minted at describe time — and minted from POSITION, not map order, or every re-describe is a diff", "refs": ["ako/mxcli#923", "docs/11-proposals/PROPOSAL_structured_microflow_description.md"]} +{"area": "mdl/executor", "date": "2026-09-13", "symptom": "A PARALLEL SPLIT written from MDL passes `mxcli check`, builds at 0 errors and round-trips through `describe`, but at runtime every path runs from the split straight to its end: the activities inside never execute and leave no row in `system$workflowactivity` (only 'End of parallel split path' / 'Merge of Parallel split' records appear)", "cause": "Mendix stores an `EndOfParallelSplitPathActivity` as the last activity of every path and the engine executes a path up to it. mxcli wrote path flows without one, in `buildParallelSplit`, in both writers (neither serialized the type \u2014 both switches returned nil) and in both `InsertPath` mutators. The modelsdk reader also read the marker as a GenericWorkflowActivity, so a Studio Pro split described with a leaked `-- [Workflows$EndOfParallelSplitPathActivity]` comment", "file": "`sdk/workflows/workflow.go` (`EndParallelSplitPath`), `mdl/executor/cmd_workflows_write.go` (`buildParallelSplit`, `deduplicateActivityNamesInFlow`), `mdl/backend/modelsdk/workflow_write.go` + `workflow_read.go`, `sdk/mpr/writer_workflow.go`, `mdl/backend/wfmutator/mutator.go` + `mdl/backend/mcp/workflow.go` (`InsertPath`)", "insight": "**A symptom that lives in the running app can only be settled there, and an A/B instance is the cheapest way.** Booted once with two copies of the workflow differing only in a trailing marker written by an env-gated experiment, then read `system$workflowactivity` joined to the context object: without the marker neither path's microflow had a record, with it both finished \u2014 root cause and fix in one boot. Two traps followed. (1) The experiment gave each marker a unique name; the real fix gave them one name and built with CE0495, because `deduplicateActivityNamesInFlow` renames only the types its switch lists \u2014 every unit test on the shape was green and only mxbuild caught it, so re-run the build on the SHIPPED code, never trust the experiment's. (2) `describe` skips the marker by its semantic type, so it hid whether the marker existed at all \u2014 dump the BSON (`mxcli bson dump --type workflow --format ndsl`, or decode the .mxunit) when the describer is designed to omit the thing in question. Controls: reverting the builder call fails `TestBuildParallelSplitEndsEveryPath` with the path's real last activity; removing the marker types from the dedupe switch fails `TestDeduplicateNamesEndOfPathMarkers` with the duplicated name", "refs": ["ako/view-entity-examples FINDINGS \u00a76"]} +{"area": "mdl/executor", "date": "2026-09-13", "symptom": "A boundary event path written from MDL fails the build with CE0105 'Call microflow cannot be the last object of a flow, it should end with a jump or end activity' (interrupting), or builds at 0 errors and the runtime refuses to start: 'Expected the flow to end with an end event' (non-interrupting). After the fix, `create or modify` of the same workflow was refused by the dropped-construct guard", "cause": "Mendix ends every boundary event path with `EndOfBoundaryEventPathActivity`; mxcli never wrote one, and MDL has no end statement. The rewrite guard then counted `$Type`s containing 'BoundaryEvent', which includes the marker, so a stored workflow with one event counted two", "file": "`sdk/workflows/workflow.go` (`EndBoundaryEventPath`), `mdl/executor/cmd_workflows_write.go` (`buildBoundaryEvents`), `mdl/backend/wfmutator/mutator.go` + `mdl/backend/mcp/workflow.go` (`InsertBoundaryEvent`), `mdl/executor/validate_workflow_rewrite.go` (`countRawBoundaryEvents`)", "insight": "**Same class as the parallel-split marker: a Studio Pro terminal marker that no MDL statement spells.** Grep the writers for every `EndOf*` type the reader knows before assuming one is the only one. The guard miscount was only visible by re-running the fixed script against the fixed workflow \u2014 a substring match on `$Type` is wrong the moment a sibling type shares the stem; match the suffix, and keep a control asserting the substring count so the test says why", "fix": "Append the marker unless the path ends in a jump or end-of-workflow (CE6692 otherwise); count boundary events by `strings.HasSuffix($Type, \"BoundaryEvent\")`"} +{"area": "mdl/executor", "date": "2026-09-13", "symptom": "A workflow with `boundary event timer '\u2026'` (no interrupting / non interrupting) passes check and builds at 0 errors; the runtime then fails to start: `Class 'Workflows$TimerBoundaryEvent' could not be found`", "cause": "The bare form maps to `Workflows$TimerBoundaryEvent`, which exists in no cached 11.x runtime (only Interrupting/NonInterruptingTimerBoundaryEvent). mxbuild tolerates the unknown type. It was the documented syntax example", "file": "`mdl/executor/validate_workflow_refs.go` (`bareTimerBoundaryEventErrors`, MDL-WF07), `cmd/mxcli/syntax/features_workflow.go`", "insight": "**A type mxbuild accepts is not a type the runtime has.** Found only because a verification boot of an unrelated fix loaded it. When a grammar has a default branch that maps to a storage type, check that type against the runtime's class list, not against `mx check`", "fix": "Refuse the bare form on 11+ at check and exec, CREATE and every ALTER op that can carry a boundary event; update syntax help, skill table and the ako/mxcli#415 bug-test script to name the kind"} +{"area": "mdl/executor", "date": "2026-09-13", "symptom": "A view entity whose association column is also declared as an attribute (`MeterRef: Trends.Meter` or `MeterRef: Trends.Meter.ID` beside `select m.ID as MeterRef`) passes `mxcli check`; `check -p` says 'OQL select has 1 columns but 2 attributes declared'; exec writes `Enumeration(Trends.Meter)` and mx check reports CE1613, or throws 'An error occurred when trying to set the Enumeration property' for the three-part form", "cause": "A bare qualified name parses as TypeEnumeration (the entity/enum ambiguity), and execCreateViewEntity converted it with convertDataType without asking what it names. The alias-to-attribute alignment skips association columns, so the declared attribute had no column and was compared against the next one", "file": "`mdl/executor/oql_view_associations.go` (`ValidateViewAttributeDeclarations` MDL080, `viewAttributeEntityTypeErrors`), `mdl/executor/cmd_entities.go` (`execCreateViewEntity`), `mdl/executor/validate.go`, `mdl/executor/validate_program.go`, `cmd/mxcli/lsp_diagnostics.go`", "insight": "**The TypeEnumeration/TypeEntity ambiguity has a consumer wherever a data type becomes a stored type, and view entity attributes were one nobody had listed.** Split the refusal by what it needs: an association column's alias and a three-part name are decidable from the script, so they belong in the no-project phase that exec's pre-check also runs; entity-vs-enum needs the project, so it goes in check -p AND the handler, because exec --no-check skips both phases. Verify the handler refusal by counting changed files, not by the error text", "fix": "Refuse in ValidateProgram/LSP (MDL080) and at the top of execCreateViewEntity before any backend call; report an attribute once"} diff --git a/.claude/skills/fix-issue/findings/other.jsonl b/.claude/skills/fix-issue/findings/other.jsonl index 70a928b841..3fa07fd258 100644 --- a/.claude/skills/fix-issue/findings/other.jsonl +++ b/.claude/skills/fix-issue/findings/other.jsonl @@ -14,3 +14,4 @@ {"area": "generated/metamodel", "date": "2026-08-19", "raw": "| A REST call's `body mapping Mod.EMM from $var` is written with the variable under a key Mendix does not read, an empty `ContentType`, and an action-level `RequestHandlingType` of `Custom` that contradicts its own `Microflows$MappingRequestHandling` sub-element. A form-data body is dropped entirely by DESCRIBE, so describe → edit → exec silently produces a call that posts nothing | `generated/metamodel` is decisive: `MicroflowsMappingRequestHandling` owns exactly three properties — `contentType` (enum Json\\|Xml), `mappingId`, `mappingVariableName`. mxcli wrote `ParameterVariable`, which the type does not own, and omitted `MappingVariableName`; its own READER had known the right key since #843 and the writer was never corrected. `RequestHandlingType` was hardcoded `\"Custom\"` in both engines regardless of handler. `FormDataRequestHandling` / `AdvancedRequestHandling` can be parsed and not written, so a rewrite dropped them | `mdl/backend/modelsdk/microflow_write.go` (`requestHandlingTypeOf`, the Mapping case) and `sdk/mpr/writer_microflow_actions.go` (`restRequestHandlingTypeOf`, same case); guard `mdl/executor/validate_rest_request_handling.go` wired from `cmd_microflows_create.go`; tests `mdl/backend/modelsdk/microflow_restbody_test.go`, `mdl/executor/validate_rest_request_handling_test.go` | **A reader that compensates for a writer hides the writer's bug** — the `MappingVariableName` fallback made DESCRIBE round-trip correctly while every document mxcli wrote carried the wrong key. When a reader has a \"the real key is X\" comment, check that the writer agrees. **An unknown property is worse than a wrong value**: mxbuild tolerates it and Studio Pro refuses to open the document, so a green build proves nothing (same rule as the overlay section in CLAUDE.md). **Ask for one Studio Pro example per variant** — four microflows covering Custom/Mapping/FormData/Binary settled the discriminator question that a single example had left as \"unverified, so leave it alone\"; guessing the other five enum values from one measured case would have been a coin flip. **Parse-but-cannot-write is a data-loss path, not a gap**: DESCRIBE omits what the writer cannot express, so the omission looks like faithful output — refuse the rewrite (ADR-0005), and make the guard's allow-list the writable set so it stops refusing the moment a type becomes expressible. Not fixed: the legacy engine encodes `MappingId` as binary where Studio Pro stores a qualified-name string |", "refs": ["#843"]} {"area": "model", "date": "2026-08-25", "raw": "| A multi-segment member in an **inline REST** mapping (`\"Title\" = \"fields/Title\"` inside `Response:`/`Body: MAPPING`) passes every gate — `mxcli check`, `exec`, `mx check`, `describe` — and the column is **empty at runtime** | The inline REST serializer is a SEPARATE code path from the mapping documents, and it appended the member text verbatim: `jsonPath + \"\\|\" + m.ExposedName` stores `(Object)\\|fields/Title`, one member whose NAME contains a slash, where Mendix stores `(Object)\\|fields\\|Title`. Nothing converted `/` to `\\|` on this path. Confirmed against four Studio Pro-authored inline response mappings in the demo apps, which all store full pipe paths (`(Object)\\|results\\|bindings\\|(Object)\\|caseId\\|value`) | `model/mapping_paths.go` (`InlineMappingPath`, `InlineMappingExposedName`), `sdk/mpr/writer_rest.go` (`serializeInlineMappingElement`), `mdl/backend/modelsdk/consumed_rest_write.go` (`restInlineMappingElementToGen`), `mdl/executor/cmd_rest_clients.go` (`inlineMemberName`) | **The document fixes do not reach this.** The mapping census (`PROPOSAL_mapping_coverage.md`) classifies mapping DOCUMENTS only and does not mention the inline form, so none of #248/#262–#268 improved it as a side effect — a reader who sees multi-segment paths documented as working will reasonably try one here. Store the pipe path and put the **last segment** in `ExposedName` (Studio Pro's own derivation uniquifies against siblings — `caseId\\|value` becomes `CaseId_Value` but `graphData\\|value` in the same document becomes plain `Value` — so it is not reproducible from the path and does not need to be; ExposedName is a label, JsonPath is what binds). **Fixing the path breaks DESCRIBE in the same motion**: the printer emitted `ExposedName`, which now holds only the last segment, so it produced `\"Title\" = \"Title\"` — output that parses, re-executes and binds the wrong member. Derive the member from the stored JsonPath relative to the enclosing element and drop the generated `(Object)`/`(Array)`/`(Wrapper)` markers. Both engines have their own copy of this serializer; patch both or they drift. Repro `mdl-examples/bug-tests/rest-inline-mapping-paths.mdl`, tests `sdk/mpr/writer_rest_inline_mapping_test.go`. mxcli-rest FINDINGS #36 |", "refs": ["#248", "#262", "#268", "#36"]} {"area": "web/dist", "date": "2026-08-30", "raw": "| After `mxcli test … --local`, an app another `mxcli run --local` is serving goes blank while still answering HTTP 200 (~1.7 KB, the Mendix SPA shell); the runtime log shows `Connector: 404 - file not found for file: dist%2Findex.js` and `deployment/web/dist` is gone | `cmd/mxcli/testrunner/localapp_options.go`, `cmd/mxcli/testrunner/runner_local.go` (`localTestDeployDir`), `cmd/mxcli/testrunner/runner.go` (`checkScratchDeploymentExists`) | A local test run already used its own ports and its own `_test` database — the code comment says why, verbatim — but shared the **deployment directory**, which is the one the *browser* reads. A headless test boot does not bundle the web client, so its build left the running app serving the shell over a 404: tests pass, run keeps running, app is blank, nothing reported at either end. **Detection was not the fix**: the two processes use different ports by design, so no port check can see it, and a lock file would only turn a silent blanking into a refusal. The test boot now builds into `/.mxcli/deployment-test/` — gitignored, already where the test runtime log lives — which makes the collision impossible. Note booting a runtime against the shared directory damages it even **without** a rebuild (the packaging step removes the bundle — FINDINGS §35, `ReportLostWebClientBundle`), so \"reuse the dev loop's tree read-only\" is not an alternative. Consequence to wire: `--skip-build` used to mean \"reuse deployment/\" and now has nothing until tests have run once, so it is refused with the reason rather than failing inside the runtime boot against a path the user never chose. Reported as mxcli-formula1 FINDINGS §62 |"} +{"area": ".claude/skills/mendix/record-narrated-demo", "date": "2026-09-13", "symptom": "In a narrated demo recorded with CSS `zoom` (take.js's fix for a fixed-width Mendix page), narrate.js's `point()` highlight ring is drawn around the wrong control or off the edge of the frame, and the caption plate is the wrong height and sits outside the film's caption band. Nothing in the take, the beat assertions or the contact sheet reports anything.", "cause": "Under `html{zoom:z}` Chromium reports `getBoundingClientRect()` in ZOOM-ADJUSTED (video) pixels but `getComputedStyle()` and `style.*` in CSS pixels. `point()` read a rect and assigned it straight to `style.left/top/width/height`, so the ring landed at position x z (measured at z=1.6842: a target at (168,202) ringed at (274,330)). The plate had the mirror-image problem: its geometry was declared in CSS pixels, so a 96px bar reached the file as 96 x z = 162 video px against a 184px caption band.", "file": ".claude/skills/mendix/record-narrated-demo/narrate.js (`point`, `css`, `checkOverlay`)", "insight": "The overlay lives in the page's coordinate space and the film is specified in the frame's, and `zoom` is the only conversion between them - so every overlay number is now stated in VIDEO pixels and divided by a zoom passed to `configure()`. The trap is that the conversion runs in opposite directions depending on which API you read it back with, which is why the fix came with `checkOverlay()`: it measures the installed plate against the band and refuses the take, with the control being one line (build the overlay without telling it the zoom -> 'caption plate is 310 video px tall, the band is 184'). A design rule that can be measured in the page should be a check that throws at record time, not a note in a skill - the same argument PRODUCTION.md sec 12 makes for compositions.", "refs": ["ako/mxcli-intro-video video-system/DESIGN-LANGUAGE.md"]} diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md index da574232d3..29562f62a0 100644 --- a/.claude/skills/mendix/create-page/reference/widgets.md +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -356,8 +356,9 @@ column colActions (caption: 'Actions') { |--------|-------------| | `datasource: database from Module.Entity` | Direct database query | | `datasource: $Variable` | Variable bound (requires DATAVIEW parent with entity) | -| `datasource: microflow Module.GetData` | Microflow datasource — no `()`, the name alone | -| `datasource: nanoflow Module.GetData` | Nanoflow datasource (client-side, no server roundtrip) — no `()` | +| `datasource: microflow Module.GetData` | Microflow datasource with **no parameters** — the name alone | +| `datasource: microflow Module.GetData($Param)` | Microflow datasource **with** parameters — one argument per parameter, required. Mendix does not auto-map an object in scope, so leaving it out is **CE1571** | +| `datasource: nanoflow Module.GetData` | Nanoflow datasource (client-side, no server roundtrip) — same rule: the name alone only when it takes no parameters | | `datasource: selection widgetName` | Listen to selection from another widget | | `datasource: association path` | Retrieve by association from context (ByAssociation) | | `datasource: $currentObject/Module.Assoc` | Sugar for `association` — same semantics, reads more naturally | diff --git a/.claude/skills/mendix/record-narrated-demo/SKILL.md b/.claude/skills/mendix/record-narrated-demo/SKILL.md index 7c56a13d38..b9b74a290f 100644 --- a/.claude/skills/mendix/record-narrated-demo/SKILL.md +++ b/.claude/skills/mendix/record-narrated-demo/SKILL.md @@ -69,8 +69,8 @@ part of recording, not a nicety before it. ## Recording mechanics -Four things that decide whether the video is watchable. Each has a reason; none -is a style preference. +The things that decide whether the video is watchable, and whether it looks like +the rest of the catalogue. Each has a reason; none is a style preference. ### Record at a human pace, not the harness's @@ -84,18 +84,33 @@ Two numbers, both from films that were re-cut for being too fast: Caption read time is roughly `words ÷ 3.5` seconds — which is what `narrate.js` computes. Screen read time is how long it takes a viewer to *find the thing that changed*, and it is always longer than it feels while authoring. `narrate.js` - knows only the caption; when the screen is the slower of the two, pass `holdMs`. + knows only the caption; when the screen is the slower of the two — or when a + narration line is longer than both — pass the measured duration as `holdMs`. - **About two events per ten seconds.** A click, then its result. Not a click, a scroll, a filter and a result — that is four things a viewer is asked to track in the time they can follow one. +And three the finished film is measured against, from the video system's +`TONE-AND-SPEED.md` — a product demonstration is **60–120s**, **55–65% voice +density**, **~135 wpm**. Density is the one worth checking early: it is the tail +budget stated as a ratio, and a walk that fills 80% of its runtime with talking +is not a slow film that needs trimming, it is a fast one wearing a slow pace. + ### Give the compositor something to draw during pauses Playwright's video captures only frames the compositor actually produces. A genuinely idle screen during a reading pause can collapse to almost no video, so a 5-second pause plays back in a blink and the narration desyncs. Keep something -continuously animating — a small pulsing indicator is enough — so idle time is -recorded *as* idle time. +continuously animating so idle time is recorded *as* idle time. + +`narrate.js` does this with a hairline segment sweeping the caption plate's top +edge, on a loop, for the whole take. It used to be a spinning ring, and the ring +had to go for a reason worth keeping in mind whenever this element is redesigned: +the design language has **no rounded corners**, so a ring can only be a spinning +square. The keep-alive has to be expressible in the system's own vocabulary — +here, 1px structure travelling — rather than bolted on beside it. What it must +not become is per-caption: a hold *between* captions still has to produce frames, +so the animation runs continuously and is never keyed to a reveal. ### Record the same walk at desktop and at a real mobile profile @@ -166,20 +181,80 @@ project: | | | |---|---| -| `say(page, text, step)` | caption, held for `max(2200, words * 280)` ms — a fixed hold rushes long lines and stalls on short ones | -| `point` / `unpoint` | pulsing outline around an element's rect, **drawn** — a real click ring would move the cursor and the page under it | +| `configure({ zoom, accent, ground, font })` | tokens and the **zoom take.js is using** — see below; call it before anything else | +| `say(page, text, step)` | caption, held for `max(2500, words * 280)` ms — a fixed hold rushes long lines and stalls on short ones. Refuses a caption containing a tofu glyph | +| `point` / `unpoint` | pulsing outline around an element's rect, **drawn** — a real click ring would move the cursor and the page under it. The film's one accent event | | `clickSlowly` | scroll in, mark, beat, click: a cursor that arrives and clicks in one frame reads as a glitch | | `typeSlowly` | per-key typing, then commit | | `bringIntoView` | includes **horizontal** scroll (`inline: 'center'`), for a grid whose action sits past a phone's right edge | +| `checkOverlay(page)` | the design rules that are measurable in the page, as a check that **throws**. Run by `install()`, so it costs nothing to remember | -The spinning ring in the caption bar is the compositor fix described above, not -decoration and not a loading indicator — it is what keeps a reading pause from -collapsing to no frames. Removing it silently breaks the pause *and* any audio -timed against it. +The sweeping hairline is the compositor fix described above, not decoration and +not a loading indicator — it is what keeps a reading pause from collapsing to no +frames. Removing it silently breaks the pause *and* any audio timed against it. What stays per-project is the walk itself: the persona, the steps and the selectors (`narrated-walkthrough.js`). Only the library is shared. +### The overlay is the film's furniture, so it is on the design system + +A capture is most of a Type A film's runtime, and the caption plate is the only +thing the film draws over it. Furniture that disagrees with the frames it cuts +against reads as two designs — so the overlay is built to +`video-system/DESIGN-LANGUAGE.md`, not to whatever looked reasonable in a +browser. What that changed, and why each one is a defect rather than a taste: + +- **One accent, and it is the pointer.** The plate carries none. Two accented + things competing is the fastest way a frame stops working, and the accent's + job here is *where to look*. +- **The plane is flat** — no shadow under the plate, no corner radius on the + plate or the pointer, no glow. Depth is the hairline at the plate's top edge + and the value step to the app's ground. The pointer pulses on **opacity**, + because the old expanding `box-shadow` was a glow. +- **No system font stack.** `'Segoe UI'` and friends do not exist on a clean + build machine and fall back to DejaVu Sans with nothing saying so, so the + plate now inherits the **app's own computed body font**. That is also the + seam-free choice: furniture set in a different face from the UI it wraps is + the one thing a viewer notices without being able to name it. +- **Ligatures off**, for the reason the whole system has them off: a code + ligature composes `!=` into one glyph, and on a caption quoting the app that is + a claim rather than a typographic choice. `say()` refuses `≠ → ✓ ✗` and the + rest of the tofu set outright — derive the real set from the font's cmap when + you know the file (`fontTools` recipe in `PRODUCTION.md` §3). +- **The plate fills the caption band**, exactly — the bottom 17% of 1080, + `y896–1080` — and the safe-area gutter puts its text at `x96`. That band is + reserved across the whole catalogue, kept clear even in films that carry no + captions, so a plate that is 96px tall or floated somewhere else is not a + smaller caption: it is the one film whose bottom edge does not line up. + +**Geometry is stated in video pixels and converted with the zoom, and the two +coordinate spaces do not agree.** This is the part worth reading twice, because +it is invisible until the capture is cut against a composed frame. `take.js` +reaches a fixed-width layout with CSS `zoom` on `html`, so a plate declared +`96px` tall lands at `96 × zoom` in the *file*. Measured at zoom 1.6842: the old +96px plate is **162 video px**, not the 184 the band wants — and it is worse than +a wrong number, because everything about it looks right in the browser. So pass +the zoom to `configure()` and let the overlay do the division. + +Then measuring it back has the opposite trap, also measured on Chromium 1194: + +| under `html{zoom:1.6842}` | reports | +|---|---| +| `getBoundingClientRect()` | **video px** — a 109.25px-tall plate measures 183.98 | +| `getComputedStyle()` | **CSS px** — the same plate reports `109.25px` | + +`checkOverlay()` compares the rect raw and multiplies the computed padding, and +it exists because getting that backwards produces a perfectly styled plate in the +wrong place. Its control is one line: build the overlay without telling it the +zoom, and it reports a 310px plate starting at y770. + +The same mismatch had already broken `point()`, silently, for as long as anything +has used `zoom`: it read a rect (video px) and assigned it straight to +`style.left` (CSS px, zoomed on the way out), so the highlight landed at +*position × zoom*. Measured at 1.6842, a target at (168, 202) was ringed at +(274, 330) — a ring around the wrong control, or off the screen, in a take that +otherwise looks fine. Anything that reads a rect and writes a style has to divide. + ### Spoken narration, if you add it `recordVideo` writes a **silent** track — voice is not a setting, it is a second @@ -187,34 +262,58 @@ pipeline you build and mux in. It has been produced ad hoc in a session before, which is the problem: re-improvised each time, it lands on a different voice, a different pace and different levels, so the demo's sound quality is luck. Pin it. -Neither dependency is guaranteed present — both were **absent** from a fresh web -container, and `apt-get install ffmpeg` failed there against a stale package index -(404s on superseded `libva`/`mesa` versions) until `apt-get update` ran first. -Check for them before promising audio; `pip install piper-tts` plus one voice -`.onnx` + `.onnx.json` is the rest. +**The voice is Kokoro `bm_george`, and it is not a per-film decision.** Every +film in the catalogue uses it; a demo that arrives in a different voice breaks +the family harder than any visual difference, because the viewer hears the change +before they can look for it. Pace varies by type, the voice does not. -Three things decide whether the result sounds professional. All are measured, not +```python +from kokoro import KPipeline +pipe = KPipeline(lang_code='b') # British English +audio = np.concatenate([c.audio.numpy() for c in pipe(line, voice='bm_george', speed=1.0)]) +sf.write(f"assets/voice/{n:02d}.wav", audio, 24000) # Kokoro emits 24 kHz mono +``` + +Where mxcli is named at all — in a Type A film that is the closing credit only — +**write it phonetically in the script**, `em ex see ell eye`, so the TTS spells +the five letters out instead of trying to pronounce it. On screen it stays +`mxcli`, lowercase. A copy pass will "correct" this back into a word if nobody +says why it is there. + +`ffmpeg` and `ffprobe` are not guaranteed present — both were **absent** from a +fresh web container, and `apt-get install ffmpeg` failed there against a stale +package index (404s on superseded `libva`/`mesa` versions) until `apt-get update` +ran first. Check for them before promising audio. + +Four things decide whether the result sounds professional. All are measured, not matters of taste: -1. **Normalize, or it clips.** Raw Piper output measured **-17.5 LUFS with a - +0.0 dBTP true peak** — at full scale, so it crunches audibly the moment it is - encoded to AAC for the video. Two-pass `loudnorm` (measure with - `print_format=json`, then feed the measured values back) to `I=-16:TP=-1.5` - brought the same clip to -16.2 LUFS / **-4.5 dBTP**. Resample to 48 kHz stereo - at the same time: Piper emits 22.05 kHz mono, which is not what a video - container wants. -2. **Set the pace explicitly and then verify it.** `--length-scale` controls - speaking rate, but only scales cleanly with `--sentence-silence 0` - (measured 0.5 → 2.26s, 1.0 → 3.39s, 2.0 → 5.41s on one sentence; an earlier - run varying the flag alone moved the duration by 6% across the same range). - Read each clip's real duration back with `ffprobe` rather than trusting the - flag. -3. **Time the video from the audio, not the reverse.** Synthesize first, measure - each clip, and hold the step for that long. This is also why the pulsing - indicator above is load-bearing rather than cosmetic: if an idle pause - collapses to almost no frames, a pre-rendered voice track drifts against the - picture no matter how good the synthesis is. Confirm the recorded file's - duration matches the script's wall-clock before adding audio at all. +1. **Normalize to the catalogue's level, or it clips *and* it stands out.** Raw + TTS output measured **-17.5 LUFS with a +0.0 dBTP true peak** — at full scale, + so it crunches audibly the moment it is encoded to AAC for the video. Two-pass + `loudnorm` (measure with `print_format=json`, then feed the measured values + back) to **`I=-18.6:TP=-1.5`**, which is where the rest of the films sit; an + earlier pass here targeted -16 and would have made the demo the loudest thing + in the catalogue by two and a half LU. Resample to 48 kHz stereo at the same + time — 24 kHz mono is not what a video container wants. +2. **A bed, not a track.** A product demonstration takes a warm sustained pad, + low and continuous, no percussion, mastered to **-30 LUFS** (`loudnorm=I=-30: + TP=-3:LRA=7`) so it sits well under the voice. Duck it to near-silence under + the beat that pays off. +3. **Verify per line, never by total duration.** TTS has failed two ways that + both pass a total check: 0 of 12 lines generated (a silent film, exit 0), and + 10 of 12 (a film with two silent beats). Assert each file exists, is longer + than 0.5s, and matches the duration recorded for it — and that the count + matches the script's line count. Read each clip's real duration back with + `ffprobe` rather than trusting any synthesis flag. +4. **Time the video from the audio, not the reverse.** Synthesize first, measure + each clip, and hold the step for `voice duration + tail` — not the bare voice + length, which is what every sync tool defaults to and which leaves zero + reading time. This is also why the keep-alive above is load-bearing rather + than cosmetic: if an idle pause collapses to almost no frames, a pre-rendered + voice track drifts against the picture no matter how good the synthesis is. + Confirm the recorded file's duration matches the script's wall-clock before + adding audio at all. ## The take has to be true, not only watchable @@ -319,20 +418,59 @@ present tense instead. This skill owns the **capture**. How a capture is framed, cut and scored into a finished film is the video system's — `video-system/` in `ako/mxcli-intro-video`, -which defines the product-demonstration type this skill feeds. +which defines the product-demonstration type this skill feeds. Read it before a +film, in its own order, and **treat it as normative over this page**: the numbers +here are copied from it and go stale when it moves. -Two boundaries worth keeping: the recording is **full-bleed** (no browser chrome, -no window frame, no laptop mockup — the capture *is* the frame), and the -narration plate stays `narrate.js`'s. **One caption system per film**; a second -one layered on in the edit reads as two designs. +| | | +|---|---| +| `DESIGN-LANGUAGE.md` | palette, type, grid, plane, motion, the closing lockup, the honesty rules — invariant across all three types | +| `TONE-AND-SPEED.md` | everything that varies, as numbers. The comparison table is the working document | +| `types/product-demonstration.md` | the type this skill feeds | +| `PRODUCTION.md` | the hazards, several of which are the ones on this page | + +Four boundaries worth keeping: + +- The recording is **full-bleed** — no browser chrome, no window frame, no laptop + mockup. The capture *is* the frame. +- The narration plate stays `narrate.js`'s. **One caption system per film**; a + HyperFrames caption layered on in the edit reads as two designs. A film may + also run the capture with no captions at all and carry the narration in voice + alone (`videos/sudoku-demo` does) — but then the caption band stays *clear*, + which is the same rule, not an exemption from it. +- **Nothing is animated on top of a capture in the edit** — no drifting scale, no + Ken Burns, no highlight rings added in post. The app's own transitions carry + those beats, and `point()` is allowed precisely because it is *in* the capture, + decided at record time with the app's state in front of you. +- The **closing lockup** is the film's, not the capture's, and it is identical in + every film in the catalogue. Do not build one into the walk. + +### The clock map is a consequence of one long take + +`take.js` records a whole walk in one browser context, which is why `cut-clips.js` +has to fit an offset *and* a ~1.065 clock scale before it can trust a mark. The +alternative — **one context per beat** — removes that problem entirely rather than +modelling it: each recording starts at its own zero, and there is nothing to map. +It costs the session, so each beat has to re-enter the app (carry the login as +Playwright `storageState`) and it cannot film a continuous interaction across a +cut. Take it when the walk is genuinely a set of independent scenes; keep the +single take when the continuity is the point. ## Checklist - [ ] `journeys/.journey.json` exists and the journey run is `PASS` - [ ] The positive-control run has shown every rung can go red — no `UNPROVEN` - [ ] The demo is a **separate script**; no PASS/FAIL verdict lives in it -- [ ] Pace is human, with real reading pauses +- [ ] Pace is human, with real reading pauses; the finished film is 60–120s at + 55–65% voice density - [ ] Something animates during pauses, so idle time survives into the video +- [ ] `configure()` was told the same `zoom` as `openTake`, and `checkOverlay` + passed — the plate fills the caption band, flat, accent-free, in the app's + own font +- [ ] Captions carry no glyph outside the shipped fonts (`say()` refuses the + known set; derive the rest from the font's cmap) +- [ ] Voice is Kokoro `bm_george`, mastered two-pass to `I=-18.6:TP=-1.5`, and + every line was verified individually — not by total duration - [ ] Recorded at **both** a desktop viewport and a real mobile device profile - [ ] The mobile pass runs the same steps, with nothing simplified - [ ] Narration mentions no database proof and no past bugs diff --git a/.claude/skills/mendix/record-narrated-demo/narrate.js b/.claude/skills/mendix/record-narrated-demo/narrate.js index f04cfdf842..20f5f34b55 100644 --- a/.claude/skills/mendix/record-narrated-demo/narrate.js +++ b/.claude/skills/mendix/record-narrated-demo/narrate.js @@ -1,20 +1,89 @@ // // The narration overlay. Injected into the page under test; asserts nothing. // -// Two jobs, and the second one is not decoration: +// Three jobs, and only the first is obvious: // // 1. Show a caption a viewer can read while the app does something. // 2. Keep something MOVING for the whole recording. +// 3. Look like the rest of the catalogue. // -// Playwright's video captures frames the compositor actually produces. A screen -// that is genuinely still during a reading pause can collapse to almost no -// video - the pause the viewer needed disappears, and the demo cuts from one +// (2) Playwright's video captures frames the compositor actually produces. A +// screen that is genuinely still during a reading pause can collapse to almost +// no video - the pause the viewer needed disappears, and the demo cuts from one // action straight into the next. A small continuously animating element means -// idle time is recorded as idle time. That is what the progress ring is for; it -// is not a spinner and it is not pretending anything is loading. +// idle time is recorded as idle time. That is what the sweeping hairline is +// for; it is not a spinner and it is not pretending anything is loading. // +// (3) The capture is most of a Type A film's runtime, so this overlay is the +// film's furniture, not a debug HUD. It is built to +// `video-system/DESIGN-LANGUAGE.md` in ako/mxcli-intro-video - the palette, the +// flat plane, the grid and the caption band - because furniture that disagrees +// with the frames it cuts against reads as two designs. `checkOverlay()` below +// enforces the parts of that which are measurable in the page. +// + +// The system palette (DESIGN-LANGUAGE.md §1). ONE accent, and it belongs to +// #demo-spot alone: the thing the viewer should be looking at. The caption +// plate is deliberately accent-free - two teal things competing is the single +// most common way a frame stops working. +// +// A product demonstration may swap `accent` (and `ground`) for the demonstrated +// app's own - see types/product-demonstration.md, "Type and accent". That is +// the one documented allowance; pass it through `configure()`, do not edit it +// here. +const SYSTEM = { + ground: '#0e1116', // full-bleed ground + line: '#262828', // 1px hairline - carries all structure + ink: '#e6edf3', // primary text, 16.0:1 + faint: '#6b7787', // chrome labels, 4.15:1 - floor for 24px+ mono only + accent: '#3fbdb8', // the only colour +}; + +// Geometry is stated in VIDEO pixels (the 1920x1080 frame), never in CSS +// pixels, and converted with `zoom`. This is not pedantry: take.js reaches a +// fixed-width layout with CSS `zoom`, so a plate declared as `96px` tall lands +// at 96*zoom in the file - at the zoom sudoku-demo used, 162 device px, which +// is neither the caption band nor anything else in the system. Tell the overlay +// the zoom and every number below is the number that reaches the video. +const FRAME = { width: 1920, height: 1080 }; +const BAND = 184; // bottom 17% of 1080 - the caption band (DESIGN-LANGUAGE.md §3) +const GUTTER = 96; // safe-area x - the caption's left edge aligns with the grid +const CAPTION_PX = 34; +const LABEL_PX = 26; // >= 24px, the floor for `faint` on mono + +let cfg = { + ...SYSTEM, + zoom: 1, + // Set to a family that is actually loaded in the page. Left null, the plate + // inherits the app's own computed body font, which is both the seam-free + // choice for a Type A film and the only one that cannot fall back: a plate + // asking for 'Segoe UI' renders in DejaVu Sans on a clean build machine and + // nothing says so. + font: null, + // checkOverlay() throws rather than warns. The pipeline's default failure + // mode is to keep going and hand you a plausible-looking film. + strict: true, +}; + +/** Override the tokens, the zoom, or the caption font. Call before install(). */ +function configure(opts = {}) { + cfg = { ...cfg, ...opts }; + return cfg; +} -const OVERLAY_CSS = ` +// Marks that are not in the shipped fonts and render as tofu. The system +// derives this set from the film font's cmap (PRODUCTION.md §3) - `·`, `—` and +// `–` ARE present in Recursive and are not banned; `≠`, `→`, `✓` and `✗` are +// not present anywhere in the catalogue's fonts. A caption is set in the APP's +// font, whose coverage is its own business, so this list is the floor rather +// than the whole check: when you know the file, derive the set from it. +const BANNED_GLYPHS = /[≠→←↑↓⇒⇐➔➡✓✗✔✘≤≥]/; + +const css = () => { + const z = cfg.zoom || 1; + const px = (v) => `${(v / z).toFixed(3)}px`; + const viewportW = FRAME.width / z; + return ` /* pointer-events: none for the same reason #demo-spot has it, and the reason is easy to miss here: the plate is a full-width bar pinned to the BOTTOM of the viewport, which is exactly where Mendix puts a page footer's buttons. @@ -26,67 +95,171 @@ const OVERLAY_CSS = ` so it gives up pointer events for free. */ #demo-narration { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483647; - pointer-events: none; - display: flex; align-items: center; gap: 14px; - padding: 16px 22px; - background: rgba(17, 24, 39, .94); - color: #fff; - font: 500 17px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - box-shadow: 0 -8px 24px rgba(0,0,0,.22); - transform: translateY(110%); + box-sizing: border-box; height: ${px(BAND)}; + pointer-events: none; overflow: hidden; + display: flex; align-items: center; gap: ${px(28)}; + padding: 0 ${px(GUTTER)}; + background: ${cfg.ground}; color: ${cfg.ink}; + /* Depth is a hairline and the value step to the app's own ground. NO + box-shadow: the flat plane is not a preference, it is what stops the + film reading as a template (DESIGN-LANGUAGE.md §4). */ + border-top: ${px(1)} solid ${cfg.line}; + font-size: ${px(CAPTION_PX)}; line-height: 1.35; font-weight: 500; + /* A code ligature composes "!=" into a single glyph: the bytes stay "!=" + and the picture shows a different character. On a caption quoting what + the app said, that is a claim rather than a typographic choice. */ + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "clig" 0, "calt" 0, "dlig" 0; + transform: translateY(100%); transition: transform .45s cubic-bezier(.16,.84,.44,1); } #demo-narration.up { transform: translateY(0); } - #demo-narration .ring { - flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; - border: 2.5px solid rgba(255,255,255,.28); border-top-color: #fff; - animation: demo-spin 1.15s linear infinite; + + /* The compositor keep-alive, and the reason it is a hairline rather than the + spinning ring this used to be: the system has no rounded corners, so a ring + could only be a spinning square. A segment travelling the plate's top edge + is the same guarantee in the system's own vocabulary — 1px structure, in + the "faint" role, never the accent — and it doubles as a read-progress cue. + Continuous, not per-caption: a hold between captions still has to produce + frames. */ + #demo-narration .sweep { + position: absolute; top: 0; left: 0; + width: ${px(180)}; height: ${px(2)}; + background: ${cfg.faint}; + animation: demo-sweep 2.6s linear infinite; } + @keyframes demo-sweep { + from { transform: translateX(${px(-180)}); } + to { transform: translateX(${viewportW.toFixed(2)}px); } + } + #demo-narration .text { flex: 1 1 auto; } #demo-narration .step { - flex: 0 0 auto; font-size: 12.5px; letter-spacing: .06em; - text-transform: uppercase; color: rgba(255,255,255,.55); + flex: 0 0 auto; font-family: ui-monospace, monospace; + font-size: ${px(LABEL_PX)}; letter-spacing: .14em; + text-transform: uppercase; color: ${cfg.faint}; } - @keyframes demo-spin { to { transform: rotate(360deg); } } - /* Where the viewer should be looking. Drawn, not clicked - a real click ring - would move the cursor and the page under it. */ + /* Where the viewer should be looking, and the film's one accent event. + Drawn, not clicked — a real click ring would move the cursor and the page + under it. Square, because the plane is flat; the pulse is opacity, because + a soft expanding glow is banned for the same reason the shadow is. */ #demo-spot { position: fixed; z-index: 2147483646; pointer-events: none; - border-radius: 10px; border: 2.5px solid #2563eb; - box-shadow: 0 0 0 4px rgba(37,99,235,.22); - transition: all .4s cubic-bezier(.16,.84,.44,1); + border: ${px(3)} solid ${cfg.accent}; + transition: left .4s cubic-bezier(.16,.84,.44,1), + top .4s cubic-bezier(.16,.84,.44,1), + opacity .4s linear; opacity: 0; } #demo-spot.on { opacity: 1; animation: demo-pulse 1.6s ease-in-out infinite; } + @keyframes demo-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .42; } } /* Reserve the plate's own height at the foot of the page. pointer-events above makes a covered control CLICKABLE; this makes it VISIBLE, and the film needs both — a click that lands under an opaque caption is a beat the viewer cannot see happen, which is the same dead beat by another route. - Applied once at install, before the take starts, so nothing shifts mid-shot. - --demo-plate-height is overridable for an unusually long caption. */ - :root { --demo-plate-height: 96px; } + Applied once at install, before the take starts, so nothing shifts mid-shot. */ + :root { --demo-plate-height: ${px(BAND)}; } body { padding-bottom: var(--demo-plate-height) !important; } - @keyframes demo-pulse { - 0%, 100% { box-shadow: 0 0 0 4px rgba(37,99,235,.22); } - 50% { box-shadow: 0 0 0 9px rgba(37,99,235,.10); } - } `; +}; /** Put the overlay on the page. Safe to call again after a navigation. */ -async function install(page) { - await page.addStyleTag({ content: OVERLAY_CSS }).catch(() => {}); - await page.evaluate(() => { - if (document.getElementById('demo-narration')) return; - const bar = document.createElement('div'); - bar.id = 'demo-narration'; - bar.innerHTML = '
'; - document.body.appendChild(bar); - const spot = document.createElement('div'); - spot.id = 'demo-spot'; - document.body.appendChild(spot); - }).catch(() => {}); +async function install(page, opts) { + if (opts) configure(opts); + await page.addStyleTag({ content: css() }).catch(() => {}); + await page.evaluate((font) => { + let bar = document.getElementById('demo-narration'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'demo-narration'; + bar.innerHTML = '
'; + document.body.appendChild(bar); + const spot = document.createElement('div'); + spot.id = 'demo-spot'; + document.body.appendChild(spot); + } + // Inherit the app's own typeface unless told otherwise. Furniture set in + // a different face from the UI it wraps reads as a seam, and a family + // this file names is a family that can be missing. + bar.style.fontFamily = font || getComputedStyle(document.body).fontFamily; + }, cfg.font).catch(() => {}); + return checkOverlay(page); +} + +/** + * The design rules that are measurable in the page, as a check that fails the + * take rather than the render. Everything here is a rule from + * DESIGN-LANGUAGE.md; the geometry ones exist because a wrong `zoom` puts a + * perfectly styled plate in the wrong place and nothing looks wrong until the + * capture is cut against a composed frame. + * + * Returns { ok, problems }. Throws in strict mode (the default). + */ +async function checkOverlay(page) { + const rgb = (hex) => { + const h = hex.replace('#', ''); + return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`; + }; + const problems = await page.evaluate(([z, want, band, frameH]) => { + const out = []; + const bar = document.getElementById('demo-narration'); + const spot = document.getElementById('demo-spot'); + if (!bar || !spot) return ['overlay is not installed']; + const s = getComputedStyle(bar); + + if (s.boxShadow !== 'none') out.push(`caption plate has a box-shadow (${s.boxShadow}) — the plane is flat`); + for (const c of ['borderTopLeftRadius', 'borderTopRightRadius']) { + if (parseFloat(s[c]) > 0.5) out.push(`caption plate has a corner radius (${s[c]}) — the plane is flat`); + } + if (parseFloat(getComputedStyle(spot).borderTopLeftRadius) > 0.5) { + out.push('the pointer has a corner radius — the plane is flat'); + } + if (s.backgroundColor !== want.ground) { + out.push(`caption plate ground is ${s.backgroundColor}, expected ${want.ground}`); + } + if (/system-ui|Segoe UI|-apple-system|BlinkMacSystem/.test(s.fontFamily)) { + out.push(`caption font resolves to a system stack (${s.fontFamily}) — it will not exist on a clean build machine`); + } + // Geometry, in VIDEO pixels. The two coordinate spaces differ and it + // is measured, not assumed: under `html{zoom}` Chromium reports + // getBoundingClientRect() ALREADY zoom-adjusted (a 109.25px-tall plate + // at zoom 1.6842 measures 183.98) while getComputedStyle() still + // reports CSS px (109.25). So the rect is compared raw and the computed + // padding is multiplied. Getting this backwards is how a perfectly + // styled plate ends up 310px tall in the file. + // Measure where the plate SITS, not where it happens to be sliding + // through: install() runs both before the first raise (parked a full + // height below the fold) and again mid-take, where the 0.45s entrance + // transition may still be in flight — which read as a 5px band error + // once. Neutralising the transform and restoring it inside one + // synchronous block never reaches a paint, so nothing flashes. + const t0 = bar.style.transform, n0 = bar.style.transition; + bar.style.transition = 'none'; + bar.style.transform = 'translateY(0)'; + const r = bar.getBoundingClientRect(); + bar.style.transform = t0; + bar.style.transition = n0; + const height = Math.round(r.height); + const top = Math.round(r.top); + if (Math.abs(height - band) > 3) out.push(`caption plate is ${height} video px tall, the band is ${band} — check \`zoom\``); + if (Math.abs(top - (frameH - band)) > 3) out.push(`caption plate top is y${top}, the band starts at y${frameH - band}`); + const pad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; + if (Math.round(pad * z) < band - 3) out.push(`body reserves only ${Math.round(pad * z)} video px for a ${band}px plate — the plate will cover a control`); + // One accent event: it belongs to the pointer, and to nothing else. + if (JSON.stringify([s.color, s.backgroundColor, s.borderTopColor]).includes(want.accent)) { + out.push('the caption plate carries the accent — one accent event per frame, and it is the pointer'); + } + return out; + }, [cfg.zoom || 1, { ground: rgb(cfg.ground), accent: rgb(cfg.accent) }, BAND, FRAME.height]).catch((e) => [`overlay check failed: ${e.message}`]); + + if (problems.length) { + const msg = `narrate.js: overlay does not conform:\n - ${problems.join('\n - ')}`; + if (cfg.strict) throw new Error(msg); + console.warn(msg); + } + return { ok: problems.length === 0, problems }; } /** @@ -94,9 +267,18 @@ async function install(page) { * * The hold is derived from the length of the sentence, not from a fixed number: * a demo that gives every caption the same 2 seconds either rushes the long ones - * or stalls on the short ones. + * or stalls on the short ones. The 2.5s floor is the product-demonstration + * type's, and the hold is a FLOOR in the other direction too — when the screen + * takes longer to read than the caption, or when a narration line is longer + * than both, pass the measured duration as `holdMs`. */ async function say(page, text, stepLabel, opts = {}) { + const bad = text.match(BANNED_GLYPHS); + if (bad) { + throw new Error( + `narrate.js: caption contains ${JSON.stringify(bad[0])}, which renders as tofu ` + + `in the catalogue's fonts. Use ASCII ("->", "!=") or an inline SVG mark.\n ${text}`); + } await install(page); await page.evaluate(([t, s]) => { const bar = document.getElementById('demo-narration'); @@ -107,7 +289,7 @@ async function say(page, text, stepLabel, opts = {}) { }, [text, stepLabel]); const words = text.split(/\s+/).length; - const readMs = opts.holdMs || Math.max(2200, Math.round(words * 280)); + const readMs = opts.holdMs || Math.max(2500, Math.round(words * 280)); await page.waitForTimeout(readMs); } @@ -130,21 +312,31 @@ async function bringIntoView(page, selector) { await page.waitForTimeout(900); } -/** Draw attention to an element without touching it. */ +/** + * Draw attention to an element without touching it. + * + * The two coordinate spaces meet here and they do NOT agree: under + * `html{zoom}` Chromium reports getBoundingClientRect() in VIDEO pixels, while + * a style value is read back as CSS pixels and zoomed on the way out. Assigning + * the rect straight across therefore multiplies the position by the zoom — at + * 1.6842 a target at (168,202) was ringed at (274,330), which is a highlight + * sitting on the wrong control, or off the screen entirely, with nothing in the + * take saying so. Divide, then pad in video pixels like every other number. + */ async function point(page, selector) { await install(page); - await page.evaluate((sel) => { + await page.evaluate(([sel, z]) => { const spot = document.getElementById('demo-spot'); const el = document.querySelector(sel); if (!spot || !el) return; const r = el.getBoundingClientRect(); - const pad = 6; - spot.style.left = (r.left - pad) + 'px'; - spot.style.top = (r.top - pad) + 'px'; - spot.style.width = (r.width + pad * 2) + 'px'; - spot.style.height = (r.height + pad * 2) + 'px'; + const pad = 10; // video px of air around the target + spot.style.left = ((r.left - pad) / z) + 'px'; + spot.style.top = ((r.top - pad) / z) + 'px'; + spot.style.width = ((r.width + pad * 2) / z) + 'px'; + spot.style.height = ((r.height + pad * 2) / z) + 'px'; spot.classList.add('on'); - }, selector).catch(() => {}); + }, [selector, cfg.zoom || 1]).catch(() => {}); await page.waitForTimeout(700); } @@ -183,4 +375,8 @@ async function typeSlowly(page, selector, value, perKeyMs = 140) { await unpoint(page); } -module.exports = { install, say, point, unpoint, bringIntoView, clickSlowly, typeSlowly }; +module.exports = { + configure, install, checkOverlay, say, point, unpoint, + bringIntoView, clickSlowly, typeSlowly, + SYSTEM, FRAME, BAND, BANNED_GLYPHS, +}; diff --git a/.claude/skills/mendix/record-narrated-demo/take.js b/.claude/skills/mendix/record-narrated-demo/take.js index a21f0edd2a..fb68d24a30 100644 --- a/.claude/skills/mendix/record-narrated-demo/take.js +++ b/.claude/skills/mendix/record-narrated-demo/take.js @@ -11,7 +11,11 @@ // Usage, from the per-project walkthrough script: // // const { openTake } = require('./take.js'); +// const narrate = require('./narrate.js'); // const take = await openTake(browser, { url: 'http://127.0.0.1:8080/', zoom: 1.68 }); +// narrate.configure({ zoom: 1.68 }); // THE SAME ZOOM — see narrate.js: +// // every overlay number is in video +// // pixels and divided by this one. // await take.goto(); // navigates, settles, starts the clock // take.mark('home'); // await take.click('.sd-key >> nth=0'); // paced, dialog-guarded diff --git a/.claude/skills/mendix/run-local/SKILL.md b/.claude/skills/mendix/run-local/SKILL.md index 9e05786c05..91cfba3d28 100644 --- a/.claude/skills/mendix/run-local/SKILL.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -57,8 +57,14 @@ association catalog only at startup; behavioural changes are hot-reloaded. - **`--ensure-db`** provisions it for a fresh session: starts local Postgres if the port is down and creates the role + database if missing. It uses a service manager, or a user-owned `initdb`/`pg_ctl` cluster under `~/.mxcli/postgres` - when no service becomes ready (e.g. Arch) — needing no `postgres` OS account or `sudo`. - Remote hosts are only checked, not provisioned. + when no service becomes ready (e.g. Arch) — the latter needing no `postgres` + OS account or `sudo`. Remote hosts are only checked, not provisioned. + In a **non-root devcontainer** the service start is elevated with `sudo -n` + (Debian's init script aborts on a permission denial before it reaches any + cluster), and the superuser is reached through root where the sudoers policy + permits only that target — the devcontainer default. Both are non-interactive, + so a run never blocks on a password prompt; where sudo is unavailable the + user-owned cluster still carries it. The user-owned cluster persists across sessions; its server log is `~/.mxcli/postgres/server.log`. Stop it with `pg_ctl -D "$HOME/.mxcli/postgres/data" stop`. To remove it, stop it first and diff --git a/.claude/skills/mendix/system-module/SKILL.md b/.claude/skills/mendix/system-module/SKILL.md index 7c4e97005c..cdc9e3da2a 100644 --- a/.claude/skills/mendix/system-module/SKILL.md +++ b/.claude/skills/mendix/system-module/SKILL.md @@ -117,7 +117,7 @@ The central user entity. All application users are instances of `System.User` or | Association | Target | Type | Description | |-------------|--------|------|-------------| -| User_UserRoles | System.UserRole | Many-to-Many | Roles assigned to this user | +| UserRoles | System.UserRole | Many-to-Many | Roles assigned to this user. In XPath it is `System.UserRoles` — **not** `System.User_UserRoles`, which fails the build with CE1613 "The selected association … no longer exists" | | User_Language | System.Language | Many-to-One | User's preferred language | | User_TimeZone | System.TimeZone | Many-to-One | User's timezone | diff --git a/.claude/skills/mendix/write-microflows/reference/control-flow.md b/.claude/skills/mendix/write-microflows/reference/control-flow.md index 72c39849f6..022bc5582d 100644 --- a/.claude/skills/mendix/write-microflows/reference/control-flow.md +++ b/.claude/skills/mendix/write-microflows/reference/control-flow.md @@ -384,3 +384,87 @@ begin end; / ``` + +### Where the Error Path Goes — `merge` / `join` + +`on error` has four forms, and they differ in **where the error path goes**, not +just in what it does. The difference is invisible in the MDL, so it is worth +knowing which one you are writing. + +| Form | Error path | +|------|-----------| +| `on error continue` | No error path at all | +| `on error [without rollback] { … return/throw }` | Its own path, its own terminator | +| `on error [without rollback] { }` | **Not a no-op** — falls through to whatever the *enclosing branch* does next | +| `on error [without rollback] { … join L; }` | Rejoins the normal path at the merge labelled `L` | + +The empty form is the one that surprises people. It means "on error, do whatever +the enclosing branch's continuation does" — which in a branch that returns +something else is a value nowhere in the text. Prefer `join` when you mean it. + +```mdl +create microflow Module.Post (Payload: String) returns String +begin + declare $Status String = 'sent'; + $r = call microflow Module.Send(Payload = $Payload) on error without rollback { + log warning node 'Module' 'send failed, degrading'; + set $Status = 'degraded'; + join recovered; + }; + join recovered; + + merge recovered; + return $Status; +end; +``` + +**`merge