Skip to content

Four formula1 findings: run --local port diagnostics, OData Filter/Sort restrictions, QUAL002 + System-module leak, unbounded external-entity associations - #123

Merged
ako merged 7 commits into
mainfrom
claude/mxbuild-diagnostics-spike-emta6h
Aug 9, 2026
Merged

Conversation

@ako

@ako ako commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Two independent fixes from ako/mxcli-formula1 FINDINGS.md. Unrelated to each other; sectioned so they can be reviewed apart.

  • suggested issue 8 — a killed run --local leaves a child holding the port, and the guard names a process you already killed
  • §48FilterRestrictions/SortRestrictions have two annotation shapes and only one was read (28 × CE6630 on one service)

Part 1 — suggested issue 8: name the process holding the port

A killed mxcli run leaves the mxbuild child holding the serve port — the next boot then refuses, correctly, on the previous run's corpse: "port 6643 is already in use". The guard is right; the diagnosis is misleading, because the process it names is one you already killed. Reap the child on exit, or print the offending pid so the fix is one command rather than three.

Why only the second remedy

The first one is already implemented. procgroup_unix.go puts each long-lived child — mxbuild's JVM, the runtime, the rollup bundler — in its own process group, and teardown signals the group, so a graceful stop reaps everything.

Which means reaching this error at all tells you something specific: the previous run never ran a handler. kill -9, a crash, or a reaped container. No in-process change can close that path — by the time the port is stuck, the code that would have freed it was never given a chance to run. So this is a diagnostic fix, deliberately, and not a claim to have stopped the leak.

What the guard said, and why it was a guess

port 6643 (mxbuild serve) is already in use — a previous 'mxcli run --local'
or a stray mxbuild --serve/runtime is likely still serving on it.
  Free the ports, then retry:
    pgrep -af 'mxbuild --serve|runtimelauncher|mxcli run'   # find them
    kill <pid>

"Likely" was doing real work there. An orphan of a previous run and a colleague's unrelated server on 8080 produced identical text and need opposite remedies. And pgrep -f 'mxcli run' matches the shell you type it into, so following the advice literally can kill your own session.

What it says now

The guard resolves the listener through /proc — inode from /proc/net/tcp{,6}, owner by scanning /proc/<pid>/fd for socket:[<inode>]. No lsof/ss: both are routinely absent from slim containers, and needing a subprocess to explain why a boot failed is its own failure mode.

Leftover of a previous run — safe to kill, so it offers the command:

port 8080 (app) is already in use.
  A stale process is silently adopted otherwise, so edits appear to do nothing (looks like a stale cache — it isn't).
  Held by pid 11893: /root/.mxcli/mxbuild/11.13.0/modeler/mxbuild --serve …
  That is a leftover from an earlier run that did not shut down cleanly (a kill -9 or a reaped container skips mxcli's own teardown).
    kill 11893
    # confirm it is gone: curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080   (want 000)
  Or run on different ports with --app-port (and --admin-port/--serve-port).

Foreign listener — not safe to kill, so it does not offer to:

  Held by pid 4820: python3 -m http.server 8080
  That is not a process mxcli started, so it is not a leftover run — pick another port rather than killing it.

Both blocks above are real output, captured from the built code against live processes.

Unresolvable (not Linux, or the listener belongs to another user so /proc/<pid>/fd is unreadable) falls back to a generic hint — minus the pgrep pattern that matches the caller's own shell. The guard stays detection-only: reaping someone else's process remains the user's call.

Applied to both port guards, checkTargetPortsFree (run --local) and checkLocalAppPortsFree (localapp.go).

Three details the kernel forces

Detail Why it is not optional
Read /proc/net/tcp6, not just tcp A JVM binding "localhost" lands on [::1] or dual-stack [::] as often as 127.0.0.1 — the exact process this exists to name. Its local_address is a 128-bit hex blob, not the 32-bit one.
Filter on state 0A (TCP_LISTEN) An ESTABLISHED socket on that port is a client. Without the filter the advice can tell you to kill your own browser.
Lowest pid when a socket is shared Repeated runs then name the same process, rather than rotating through a pre-forking server's workers.

Testing, including two controls that did not fail at first

The strongest test here is also the cheapest: bind a port in the test process and demand listenerOnPort returns os.Getpid(). That cannot pass by accident.

Three traps worth recording, because the first two would have shipped as coverage that proved nothing:

  1. The live-IPv6 test only ever skipped — no IPv6 in this container — so the tcp6 path was effectively untested. This is the same shape as docker build (and therefore docker run / mxcli test) silently converts MPRv2 projects to MPRv1 via mx update-widgets, deleting mprcontents — same root cause as #763, different call site mendixlabs/mxcli#808. Replaced with assertions against captured kernel output.
  2. A parser test calling parseListeningInodes directly does not prove listeningInodes opens both files. Deleting /proc/net/tcp6 from the list left every test green.
  3. Making the list a var so a test can point it at fixtures then stops the test covering the shipped default. Same control, still green. Only after asserting the default's contents did it finally fail:
--- FAIL: TestListeningInodes_ReadsBothKernelTables
    procNetTCPFiles does not include /proc/net/tcp6, so that family is never read: [/proc/net/tcp]

The TCP_LISTEN filter and the advice wiring have controls that reproduce too. One control turned out to be a no-op rather than a gap — LastIndex vs Index in hexPort — because a /proc local_address has exactly one colon; that line is defensive, not load-bearing, and is not claimed as tested.

Docs

run-local.md (skill + docs-site) previously taught the pgrep hunt, including the pattern that matches your own shell. Both now show the real message, explain the leftover-vs-foreign split, and state that a graceful stop already reaps the process group.


Part 2 — §48: the sibling of the capabilities bug just fixed

Reported against 715bac5 while verifying the §42 fix: 27ea1da taught the parser that the OData capabilities vocabulary has two annotation shapes, fixed TopSupported/SkipSupported, and stopped there. FilterRestrictions/SortRestrictions have the same problem.

applyCapabilityAnnotations pulled NonFilterableProperties out of the record and ignored the record's own Bool property value. Filterable: !nonFilterable[p.Name] was then true for every property of a set that had declared nothing filterable:

28 × CE6630 — "'message' is marked Sortable=False in the OData service, but True in the app"
              (20 on Sortable, 8 on Filterable)

Same signature as §42: publisher right, contract right, generated consumer wrong.

Mendix picks the shape by arithmetic, not preference

It lists NonFilterableProperties when some attributes are filterable, and emits a bare boolean when none are — because then there is no list to write. Both appear in one document, on different entity sets:

<!-- DriverForm: some are. This shape already parsed. -->
<Annotation Term="Org.OData.Capabilities.V1.FilterRestrictions"><Record>
  <PropertyValue Bool="true" Property="Filterable"/>
  <PropertyValue Property="NonFilterableProperties"><Collection>
    <PropertyPath>raceName</PropertyPath> …

<!-- Predictions: none are. This shape was ignored. -->
<Annotation Term="Org.OData.Capabilities.V1.FilterRestrictions"><Record>
  <PropertyValue Bool="false" Property="Filterable"/>
</Record></Annotation>

An entity exposing only a KEY reliably produces the second form, which is why a fixture built from the first shape alone tests the half that already worked.

Both shapes behind one accessor

Rather than adding two more fields for the caller to remember to AND together — which is precisely how this arose — the restriction is now one question on the type that owns it:

Filterable: entitySet.AttrFilterable(p.Name),
Sortable:   entitySet.AttrSortable(p.Name),

AttrFilterable/AttrSortable are nil-safe, so the entitySet != nil dance and the two lookup maps disappear from the call site. Absent still means true — OData's default is allowed, and defaulting the other way would invert CE6630 for every unannotated service.

Both controls reproduce: making the parser ignore the record's Bool fails the whole-set test with the CE6630 message it describes, and dropping the check inside AttrFilterable fails it the same way. A control test pins the list shape so the half that worked keeps working.

Also verified in §48, no change needed

The report confirms five other fixes landed — the OData action (ededab1 3509f2a), custom auth (109a55c), AllowedModuleRoles (dc780ec), menu icons (10ba2e1 9364d43), and the §42 Top/Skip fix itself. Worth surfacing one honest caveat they raise about MDL-ODATA03: their read microflows hand the request to a Java action, so the rule stops at a call it cannot read rather than guessing. That is the designed behaviour — silence over a guess — and it does mean the rule is trusting that app rather than checking it.


go test ./mdl/... ./cmd/mxcli/... green; go vet and gofmt clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4

claude added 2 commits August 9, 2026 19:00
`run --local` refused to boot with "a previous 'mxcli run --local' … is
likely still serving on it" and told the user to go hunting with pgrep.
The guess was wrong as often as right, and one of the suggested
patterns — `pgrep -f 'mxcli run'` — matches the shell it is typed into.

The guard now resolves the listener through /proc (inode from
/proc/net/tcp{,6}, owner from /proc/<pid>/fd) and prints its pid and
command line. No lsof/ss: both are routinely missing from slim
containers.

It also separates two cases that had shared one message and need
opposite remedies. A leftover of a previous run — which can only happen
after a kill -9, a crash, or a reaped container, since a graceful stop
already reaps the whole process group — gets a ready-to-paste
`kill <pid>`. A foreign listener gets told it is foreign and pointed at
--app-port, with no kill offered.

Detection-only is unchanged: reaping someone else's process stays the
user's call.

mxcli-formula1 suggested issue 8
FilterRestrictions and SortRestrictions have two shapes and only one was
read. The parser pulled NonFilterableProperties out of the record and
ignored the record's own Bool property, so `Filterable:
!nonFilterable[p.Name]` was true for every property of a set that had
declared nothing filterable — 28 × CE6630 on one service:

    'message' is marked Sortable=False in the OData service,
    but True in the app.

Mendix picks the shape by arithmetic, not preference: it lists
NonFilterableProperties when SOME attributes are filterable, and emits a
bare `Bool="false" Property="Filterable"` when NONE are, because then
there is no list to write. Both appear in one document, on different
entity sets — an entity exposing only a KEY produces the whole-set form.

Both shapes now sit behind EdmEntitySet.AttrFilterable/AttrSortable, so
a caller cannot consult one and forget the other, which is how this
arose. The accessors are nil-safe, which also removes the entitySet !=
nil dance at the call site. Absent still means true: OData's default is
allowed.

This is the sibling of the Top/Skip fix in 27ea1da — same vocabulary,
same two-shapes cause, found because a consuming app failed to build.

mxcli-formula1 §48
@ako ako changed the title run --local: name the process holding the port instead of guessing run --local: name the process holding the port; OData Filter/Sort whole-set restrictions Aug 9, 2026
claude added 5 commits August 9, 2026 20:32
A generated project can have documentation nowhere and nothing says so:
`mxcli check` and the build both pass, because documentation is never
load-bearing. QUAL002 was the reminder, but it reached only entities and
microflows.

Java actions were not reachable from Starlark at all, and their
parameters were not reachable from anywhere — the catalog kept a
parameter count and discarded each parameter's Description. That is the
field Studio Pro shows to whoever wires up the call, where an
undocumented parameter is a blank next to a name like `pInput` at
exactly the moment a caller has to decide what to pass.

- catalog: java_action_parameters table + view, registered in Tables()
- linter: JavaActions() carries its parameters, so a rule naming a
  parameter can name its action without re-joining; Marketplace and
  System modules excluded as every sibling iterator does
- starlark: java_actions() builtin, parameters nested on each action
- QUAL002: Java actions, their parameters, and (off by default, on
  request) entity attributes — every target switchable via get_option

CatalogSchemaVersion 8 -> 9. Without the bump an existing cache keeps
its old schema, the query fails, the error is swallowed, and the rule
reports zero parameters — silent under-reporting that looks exactly like
a documented project.
Extends the previous commit from four targets to all of them. Nineteen
document types are now swept by one table-driven projection rather than
nineteen bespoke builtins, so covering a new Mendix document type is two
rows: one in documentableSources naming the catalog table and its
documentation column, one in _DOC_KINDS giving the option and suggestion.

On by default, one option each: Module, Entity, Page, Snippet, Building
block, Layout, Enumeration, JavaScript action, Image collection, Data
transformer, Workflow, Business event service, REST client, Published
REST service, Constant, JSON structure, Import mapping, Export mapping.
Off by default: attributes and associations — a domain model has hundreds
and the same check there is a wall of text rather than a signal. Java
action parameters stay on: an action has a handful, and Studio Pro shows
each description to whoever wires up the call.

Also fixes a leak the sweep made impossible to ignore. modules.Source
carries "Marketplace ..." for downloaded modules and is empty for System
exactly as it is for the user's own, so the usual
`WHERE COALESCE(m.Source,'') = ''` excludes Marketplace and lets all of
System through. On a blank 9.24 project that was 52 findings, of which 47
were FileDocument, HttpRequest and friends. Filtering additionally on the
sentinel module id leaves the 5 that are the user's. Around ten other
LintContext iterators still carry the Source-only filter and leak System
into their own rules; those are left alone here rather than silently
changing every rule's output in a documentation commit.

The documentation column is not uniform — Mendix says Documentation for
Java actions, REST and mappings, Description for the rest — and a revert
control confirms assuming one spelling silently drops five kinds.

Verified end to end, not only in unit tests: exec'd MDL against a real
.mpr creating documented and undocumented elements, rebuilt the catalog
and ran the CLI, which reports the undocumented ones and stays silent on
the documented ones. Unit fixtures insert catalog rows directly and so
cannot see a builder that never populates a column.
Follow-up to fbb1609, which fixed the leak only in the iterators it
introduced. The same Source-only filter appeared in eleven more places,
so every rule that walks entities, pages, microflows, enumerations,
constants, snippets, widgets or database connections — plus all three
FindUnused kinds — reported platform elements the user cannot change.

On a blank Mendix 9.24 project the whole run goes from 60 findings to 8.
Removed: CONV001 asking to rename System booleans (User.Active ->
IsActive), SEC001 demanding access rules on 38 System entities, DESIGN001
splitting QueuedTask, SEC006 on System.User, MPR003 splitting the System
module itself. Verified by diffing full lint output before and after: 52
findings removed, every one of them System, and the ADDED set empty —
the predicate only ever narrows, so it cannot invent a finding.

TestIterators_ExcludePlatformModules drives all twelve iterators against
a catalog holding System, Marketplace and user rows, and asserts each
returns the user's element and neither platform one. The third assertion
matters most: an iterator returning nothing would satisfy the first two.

Also fixes drift in setupModuleFilterDB, whose hand-rolled modules table
lacked the Id column the sentinel check reads. Because these iterators
swallow query errors and return no rows, that surfaced as four "expected
ModA entities to be yielded" failures rather than "no such column".
Same schema drift as setupModuleFilterDB, in two more hand-rolled
fixtures. I ran ./mdl/linter/ before committing 4ccf8e5 but not
./mdl/linter/rules/, so four tests in that package were left red.

The failure text is misleading in the same way: because the iterators
swallow query errors, "no such column: m.Id" reaches the test as
"expected 1 violation, got 0".
An iterator whose query failed returned no rows and said nothing:
`if err != nil { return }` inside an iter.Seq[T], which has no error
channel. For a linter that is the worst shape of failure — the entire
output is "here is what I found", so a dead query is indistinguishable
from a clean project, and CI goes green on a run that checked nothing.
This is how three fixtures' missing modules.Id column surfaced as
"expected 1 violation, got 0" rather than "no such column".

LintContext now collects QueryErrors. Iterators still degrade to "no
rows" so one broken query cannot take down the run, but the failure is
recorded, and `mxcli lint` prints each one and exits 1.

All 34 sites: Query+return, Query+continue, bare and inline rows.Scan,
the `return unused` in FindUnused that a bare-return sweep misses, and
the reader-backed ListScheduledEvents. Errors dedupe on iterator+cause,
since several rules iterate the same accessor.

Verified end to end by dropping and recreating a view in a real cached
catalog: the run names the iterator and the cause, exits 1, and the
remedy it prints — delete .mxcli/catalog.db — was run and does clear it.
The healthy path is unchanged: no output, exit 0, same findings.

ako commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Part 3 — QUAL002 covers every document type; the System module stops leaking into every rule

Five commits pushed to this branch after the description above was written (5a7780fa, fbb1609b, 4ccf8e5c, 7a37cde8, c9245e87). A third independent concern — see the scope note at the end.

Started from a mxcli-formula1 observation: a generated project has no documentation anywhere and nothing says so. mxcli check and the build both pass, because documentation is never load-bearing. QUAL002 is the rule that is supposed to notice, and it only looked at entities and microflows.

3a — the rule now sweeps all 21 document types

Java actions were not reachable from Starlark at all, and their parameters were not reachable from anywhere: the catalog stored a parameter count and discarded each parameter's Description. That is the field Studio Pro renders in the dialog where someone wires up the call, so an undocumented parameter is a blank next to a name like pInput at the exact moment a caller has to decide what to pass.

Rather than nineteen bespoke builtins, one table-driven projection. Adding a Mendix document type is two rows — one in documentableSources naming the catalog table and its doc column, one in _DOC_KINDS giving the option and suggestion text:

On by default Module, Entity, Page, Snippet, Building block, Layout, Enumeration, Microflow, Java action, Java action parameter, JavaScript action, Workflow, Constant, Image collection, Data transformer, Business event service, REST client, Published REST service, JSON structure, Import mapping, Export mapping
Off by default check_attributes, check_associations

Attributes and associations are off for volume, not importance: a domain model has hundreds and the same check there is a wall of text. Java action parameters stay on because an action has a handful and each one is read at a call site.

Two things the codebase enforced that are easy to miss:

  • CatalogSchemaVersion 8 → 9. Without the bump every existing .mxcli/catalog.db keeps its old schema, the parameter query fails, and the rule reports zero parameters. Silent under-reporting from a stale cache is indistinguishable from a well-documented project.
  • catalog_test.go caught an omission I would have shipped: "catalog view java_action_parameters is not listed in Tables() — add CATALOG.JAVA_ACTION_PARAMETERS so SHOW CATALOG TABLES includes it." Now registered, so select * from CATALOG.JAVA_ACTION_PARAMETERS where Description = '' works too.

The documentation column is not uniform — Mendix says Documentation for Java actions, REST and mappings, Description for the rest. A control confirms that assuming one spelling silently drops five kinds.

3b — System was leaking into every rule, not just this one

Widening the sweep made a much older bug impossible to ignore.

modules.Source does not identify the System module. It holds "Marketplace …" for downloaded modules and is empty for System exactly as it is for your own modules. So the near-universal WHERE COALESCE(m.Source,'') = '' excludes Marketplace and lets all of System through. Twelve iterators carried it.

On a blank Mendix 9.24 project, the whole lint run:

Rule Before After What it was reporting
SEC001 40 2 access rules missing on 38 System entities
CONV001 8 0 rename User.ActiveIsActive, FileDocument.DeleteAfterDownload → …
DESIGN001 4 0 split QueuedTask, ProcessedQueueTask, WorkflowActivity
MPR003 1 0 "Split module 'System' into smaller modules"
SEC006 1 0 PII on System.User
QUAL002 4 4
Total 60 8

Filtering additionally on the sentinel module id (00000000-…-0001) leaves the 8 that are the user's own.

Verified by diffing the full finding text, not counts: 52 removed, every one a System.* element, and the added set empty. The predicate only ever narrows a result set, so it structurally cannot invent a finding — and the diff confirms it did not lose a real one.

3c — failed catalog queries are no longer swallowed

Chasing 3b surfaced the underlying defect. An iterator whose query failed did this:

rows, err := ctx.db.Query(...)
if err != nil {
    return          // iter.Seq[T] has no error channel
}

No error, no warning, no log line. For a linter that is the worst available shape of failure: the entire output is "here is what I found", so a dead query is indistinguishable from a clean project and CI goes green on a run that checked nothing. This is exactly how three fixtures' missing modules.Id column reached the tests as "expected 1 violation, got 0" rather than "no such column".

LintContext now collects QueryError{Iterator, Err}. Iterators still degrade to "no rows" — one broken query must not take down the run — but the failure is recorded, and mxcli lint prints it and exits 1:

Error: lint could not read the catalog for Pages: SQL logic error: no such column: p.QualifiedName (1)
Results are INCOMPLETE (2 failed queries). The cached catalog is usually the cause;
delete /path/to/proj/.mxcli/catalog.db and re-run.

All 34 sites, across five distinct shapes: Query+return, Query+continue, bare and inline rows.Scan, the return unused in FindUnused that a bare-return sweep misses, and the reader-backed ListScheduledEvents. Errors dedupe on iterator + cause — several rules iterate the same accessor, so one broken view was otherwise reported once per rule; keying on iterator alone would instead hide a second distinct failure.

Signatures stay iter.Seq[T]. Moving to iter.Seq2[T, error] would churn every rule and Starlark builtin for no gain — no rule wants to handle a query error individually.

Testing

Nineteen new tests. Controls that reproduce, per claim: the generic sweep, each kind list, the doc-column split, Marketplace (both join shapes), System, the no-op recorder (every iterator goes silent), and a single reverted iterator (only that one).

Three notes worth recording:

  1. A unit test that INSERTs catalog rows directly cannot detect a builder that never populates a column — the rule would flood with false positives and the suite would stay green. So the real proof is end-to-end: exec MDL against a real .mpr creating documented and undocumented elements, rebuild the catalog, run the CLI. Documented silent, undocumented reported.
  2. TestQueryError_AllIteratorsReport found the FindUnused gap, not review — my regex sweep required a bare return and skipped return unused. That is the case for asserting across every accessor instead of spot-checking one.
  3. A revert control passed and proved nothing at first: the perl pattern used tabs against a space-indented .star file, so the substitution never applied. Re-ran it correctly.

One self-correction: the error message first read Try: mxcli lint --refresh. That flag does not exist. The shipped message names .mxcli/catalog.db, and deleting it was actually run and confirmed to clear the error and restore exit 0.

Scope

This is a third concern on a branch that already carried two. By the repo's own checklist ("if the description needs and between unrelated items, split it") these belong in separate PRs; they landed together because the branch was already open. Parts 1–3 touch disjoint files, so they review independently — happy to split 3 out onto its own branch off main if preferred.

go test ./mdl/... ./sdk/... ./cmd/... green; go vet and gofmt clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4


Generated by Claude Code

@ako ako changed the title run --local: name the process holding the port; OData Filter/Sort whole-set restrictions run --local port diagnostics; OData Filter/Sort whole-set restrictions; QUAL002 all document types + System-module leak Aug 9, 2026
@ako
ako merged commit b872086 into main Aug 9, 2026
5 checks passed

ako commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Part 4 — create external entities from duplicated associations on every run, unbounded

One more commit on this branch (16450830), from mxcli-formula1 §50, still-open item #1. A fourth independent concern; same scope caveat as Part 3.

The report

Re-running create external entities from added two associations every time and never stopped:

one generation      season  season_2  season_3        (correct: 6 in F1Cached)
+1 re-run           …       season_4  season_5
+2 re-runs          …       season_6  season_7

That project had reached season_15 before anyone noticed — twelve spurious associations, committed. mx check clean, every test passing. The only symptom is duplicate links in Studio Pro's domain model, which nothing in the toolchain looks at.

Root cause, one layer below the report

The reported cause — "the dedup compares association names and the generator computes a fresh suffix before it looks" — is the visible half. It does not explain why the dedup has a nav-property index that was supposed to handle exactly this.

Association names are unique per module, so three entities each exposing a season navigation property give season, season_2, season_3. A suffixed association can never match its own nav property by name. The dedup knows that, and keys a second index on RemoteParentNavigationProperty — the field that records which OData nav property an association was generated from.

That index was always empty. The modelsdk reader never read the OData association source back. The write path sets the field (domainmodel_write.go:649), the gen type carries it, and the legacy parser reads it — only assocFromGen on the default engine dropped it. So the field survived one save and vanished on the next load, silently degrading the dedup to the name match that cannot work.

A field the write path sets and the read path drops is invisible to a grep for the identifier: it appears in three places and is missing from a fourth.

The fix

assocFromGen now reads Rest$ODataRemoteAssociationSource back — both nav properties, Navigability2, and the four Creatable/Updatable flags. The dedup index moved into indexExistingAssociations so it is directly testable.

Explicitly not fixed by stripping a trailing _<n> from association names. That heuristic also matches a user's genuine season_2, and the model already records the true origin — the correct key exists, it just was not being read.

Testing

mdl/backend/modelsdk/association_odata_source_test.go

  • Round-trip of an association deliberately named season_2 with RemoteParentNavigationProperty: "season" — the exact shape the re-import failed to recognise. Control (reverting the read) fails on all four fields.
  • A plain association must not acquire an OData source: the read has to branch on the stored type, not stamp every association.

mdl/executor/cmd_contract_reimport_test.go

  • The three-season domain model, asserting all three parents are recognised as already imported.
  • The assertion that matters: the two suffixed ones must match via the nav index and not the name index. Without that, the first assertion could pass for the wrong reason.
  • Nav-absent fallback (legacy and Studio Pro-authored associations) and an unresolvable-parent guard.

Control: dropping the nav index fails with "DriverStandings.season is not recognised as already imported — a re-import will create a duplicate with a fresh suffix".

Not addressed here

Existing damage. A project that ran the import N times carries 2N spurious associations, and they cannot simply be deleted — §50 found that external-entity access rules reference them, so removing one leaves CE1613 "The selected association no longer exists". That needs a repair path, which is its own change.

§50's other half — sub-element id churn (still-open #2). Reproduced exactly here on a 9.24 project:

run1 32759e5c9ff14f36  243 bytes
run2 2eaf9e097dbfb4b1  243 bytes
run3 b2231045a8b7f4e0  243 bytes
→ 16 bytes differ, one contiguous run at offset 150, right after `Type` — one UUID

Mechanism: assignID already no-ops on a non-empty id; the churn is that the write path builds fresh gen elements from the semantic model, so they arrive with empty ids and get minted. 162 call sites.

One correction to how §50 frames it, and it matters for prioritisation. §50 files this as the cosmetic half, separate from "the one that is not cosmetic". That is not reliably true. 06a9face fixed this same shape for entity attributes, where a fresh id made Mendix's DB synchronizer read "attribute departed + new attribute added" and drop and re-add the column — 11 feeds and 98 articles blanked, mx check clean throughout. So sub-element id churn is cosmetic for some element types and data-destroying for others; the remaining types need judging individually rather than as one bucket.

The precedent for the fix is per-type identity reuse (name→id map from the existing document, reuse for retained elements), done in the executor rather than as a global mechanism. Across ~630 ids in a domain model and ~858 in a database connection that is a multi-commit piece of work where a mis-matched id is model corruption, so it is deliberately not started here.

go test ./mdl/... ./sdk/... green; go vet and gofmt clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4


Generated by Claude Code

@ako ako changed the title run --local port diagnostics; OData Filter/Sort whole-set restrictions; QUAL002 all document types + System-module leak Four formula1 findings: run --local port diagnostics, OData Filter/Sort restrictions, QUAL002 + System-module leak, unbounded external-entity associations Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants