Skip to content

fix: settings writes corrupted private constant overrides - #77

Merged
ako merged 5 commits into
mainfrom
claude/private-constant-values
Aug 1, 2026
Merged

fix: settings writes corrupted private constant overrides#77
ako merged 5 commits into
mainfrom
claude/private-constant-values

Conversation

@ako

@ako ako commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Problem

A constant override's value is stored one of two ways:

Stored type Meaning
Settings$SharedValue carries a Value — lives in the model, so in version control, and every developer gets it
Settings$PrivateValue a marker type with no properties at all — the value is on the developer's own workstation, deliberately kept out of the repository (the usual choice for development API tokens)

The overlay assumed every stored SharedOrPrivateValue was a SharedValue and assigned cv.Value into it. For a private override cv.Value is always "" — the value is not in the model — so any settings write produced:

{"$Type": "Settings$PrivateValue", "Value": ""}

a property that type does not define. Studio Pro resolves each stored property against the type's property list and throws on open:

System.InvalidOperationException: Sequence contains no matching element
  at ...MprProperty.cs:line 25

the same failure shape as mendixlabs#759. mx check passes on the corrupted document — mxbuild's deserializer tolerates unknown properties, so the build is not a safety net.

The blast radius is wider than a local mistake. Configurations are shared in version control, so one developer running any ALTER SETTINGS or CREATE CONFIGURATION corrupted every developer's private overrides and pushed the result.

Separately, the read type-asserted to *SharedValue, failed, and returned "" with no way to tell private from empty. So describe settings rendered the override as value '', and replaying describe's own output converted it into a shared empty override — publishing into git a value the developer chose to keep local.

Approach

The shared/private choice belongs to the constant; configurations must just respect it. mxcli now carries the distinction and preserves it without ever authoring it.

Surface Before After
settingsoverlay.constantValue assigned cv.Value into whatever node it found branches on $Type; a PrivateValue node is returned byte-identical
Both read paths type-asserted to *SharedValue, returned "" model.ConstantValue.IsPrivate carries the distinction
describe settings alter settings constant 'X' value '' a comment, not a re-executable statement
alter settings constant … value … silently converted private → shared refused, pointing at Studio Pro
alter settings drop constant … still allowed: it discards the whole override, which is what was asked for
show constant values blank cell — indistinguishable from an override set to "" (private)

The codec read matches on the child's type name rather than its Go type, since the gen registry may decode the marker to a bare element.Base.

Verification

On a real 11.12.2 project seeded with a Studio-Pro-shaped private override, an unrelated alter settings configuration 'Default' HttpPortNumber = 8099:

before fix:  {"$Type": "Settings$PrivateValue", "Value": ""}
after fix:   {"$Type": "Settings$PrivateValue"}
  • Identical on both write engines (codec and --engine legacy).
  • mxcli docker check → 0 errors.
  • All five guards mutation-checked: reverting each makes its test fail with the reported symptom.

New tests: mdl/settingsoverlay/private_value_test.go (3), mdl/executor/cmd_settings_private_test.go (4), plus one in cmd_constants_mock_test.go. Full ./mdl/... ./sdk/... ./cmd/... ./model/... suite green.

Documentation

CLAUDE.md gains the invariant this bug and mendixlabs#759 both come from, since it applies to any future overlay write and neither mx check nor the build catches a violation:

  1. Write only keys the document already carries — property names are version-specific (JavaVersion/JavaMajorVersion, Tracing/OpenTelemetry between 11.6 and 11.12).
  2. Dispatch a polymorphic child on $Type before assigning fields — variants can differ in arity, not just values.

Plus the sibling trap: validate enum-valued properties against generated/metamodel rather than passing a user string through.

User-facing docs updated on each surface the story touches: mxcli syntax domain-model.constant.create (on the constant topic, not the settings topic — the choice belongs to the constant), docs-site create-constant.md + show-settings.md, MDL_QUICK_REFERENCE.md, and skills/mendix/project-settings.md. create-constant.md already told readers to use an empty default "for secrets"; private values are the actual mechanism, so it now says so.

Symptom row appended to .claude/skills/fix-issue.md, and a manual-repro script added at mdl-examples/bug-tests/private-constant-values.mdl — the override itself cannot be scripted, since authoring it is exactly what MDL now refuses.

Notes for review

Two judgement calls worth a second opinion:

  • drop constant on a private override is allowed. It removes the whole override including the marker, which is what the user asked for — unlike setting a value, it publishes nothing. Refusing it too would be defensible if private overrides should be untouchable from MDL entirely.
  • alter settings constant … value refuses rather than warns. It would both push a deliberately-local value into git and break the developer's local binding, silently. This matches how cross-module grants are handled (reject, don't remap). A warn-and-proceed variant is a one-line change.

This bug was not from a filed issue — it surfaced from a description of how configurations and private values are used day to day.

Found alongside mendixlabs#759, but branched independently off main. Both touch settingsoverlay.go and settings_read.go, in different functions; whichever merges second should apply cleanly.


Generated by Claude Code

claude added 2 commits August 1, 2026 15:34
A constant override's value is stored one of two ways. Settings$SharedValue
carries a "Value" and lives in the model, so it is in version control and every
developer gets it. Settings$PrivateValue is a marker type with no properties at
all: the value is on the developer's own workstation, deliberately kept out of
the repository (the usual choice for development API tokens).

The overlay assumed every stored SharedOrPrivateValue was a SharedValue and
assigned cv.Value into it. For a private override cv.Value is always "" — the
value is not in the model — so any settings write produced:

  {"$Type": "Settings$PrivateValue", "Value": ""}

a property that type does not define. Studio Pro resolves each stored property
against the type's property list and throws on open:

  System.InvalidOperationException: Sequence contains no matching element
    at ...MprProperty.cs:line 25

the same failure shape as mendixlabs#759. mxbuild does not catch it — `mx check` passes on
the corrupted document. The blast radius is wider than a local mistake:
configurations are shared in version control, so one developer running any
ALTER SETTINGS or CREATE CONFIGURATION corrupted every developer's private
overrides and pushed the result.

Separately, the read type-asserted to *SharedValue, failed, and returned "" with
no way to tell private from empty — so `describe settings` rendered the override
as `value ''`, and replaying describe's own output converted it into a *shared*
empty override, publishing into git a value the developer chose to keep local.

The shared/private choice belongs to the constant; configurations must just
respect it. So mxcli now carries the distinction and preserves it without ever
authoring it:

- model.ConstantValue.IsPrivate, set by both read paths (codec matches on the
  child's type name rather than its Go type, since the registry may decode the
  marker to a bare element.Base).
- The overlay leaves a PrivateValue node byte-identical.
- `describe settings` emits a comment instead of a re-executable statement.
- `alter settings constant ... value ...` on a private override is refused, with
  a pointer to change it in Studio Pro first. `drop constant` is still allowed —
  it discards the whole override, which is what was asked for.

Verified on a real 11.12.2 project with a Studio-Pro-shaped private override:
an unrelated port change leaves the node as {"$Type": "Settings$PrivateValue"}
with no Value key, on both write engines, and `mx check` passes. All four guards
were mutation-checked — reverting each makes its test fail with the reported
symptom.
Follows the private-override fix with the reporting and documentation half.

SHOW CONSTANT VALUES rendered a private override as an empty cell, which is
indistinguishable from an override deliberately set to the empty string. Since
the value genuinely is not in the project, report "(private)" instead.

Documentation, covering each surface the constant/configuration story touches:

- mxcli syntax domain-model.constant.create — a "Shared vs private values"
  section stating the rule and what each statement does with a private override.
  It lives on the constant topic, not the settings topic, because the choice
  belongs to the constant.
- docs-site create-constant.md — the same, as a table. This page already told
  readers to use an empty default "for secrets"; private values are the actual
  mechanism, so it now says so.
- docs-site show-settings.md — what the (private) marker means.
- MDL_QUICK_REFERENCE.md — a note under Constants.
- skills/mendix/project-settings.md — the SHOW CONSTANT VALUES behaviour
  alongside the describe/alter/drop rules.

CLAUDE.md gains the invariant both this bug and mendixlabs#759 come from, since it applies
to any future overlay write and neither `mx check` nor the build catches a
violation: write only keys the document already carries (property names are
version-specific — JavaVersion/JavaMajorVersion, Tracing/OpenTelemetry), and
dispatch a polymorphic child on $Type before assigning fields, because variants
can differ in arity (SharedValue carries a value, PrivateValue is a bare
marker). Plus the sibling trap: validate enum-valued properties against
generated/metamodel rather than passing a user string through.

The (private) marker is mutation-checked.
…nt-values

# Conflicts:
#	.claude/skills/fix-issue.md
ako pushed a commit that referenced this pull request Aug 1, 2026
…driver

Every bug fix appends a row to the symptom table in .claude/skills/fix-issue.md,
so two concurrent fixes always collide on the same line — five resolution rounds
in one week. Moving the insertion point from the top of the table to the bottom
did not help: both sides still append to the same place, so the collision simply
moved with it (PRs #76, #77 and #78 all hit it).

git's built-in "union" merge driver keeps BOTH sides of a conflicting hunk
instead of raising a conflict. That is exactly right here: the file is only ever
appended to, and it is looked up by matching a symptom rather than read in order,
so row order carries no meaning.

Verified: two branches that each append a row now merge with no conflict and
both rows present.

Caveat: union applies to the whole file, so two branches editing the same *prose*
line would silently keep both instead of conflicting. The failure is a visible
duplicated line, not corruption. Splitting the table into its own file — so union
covers only append-only content — is the follow-up, deferred because that
restructure would conflict with the three open PRs that all touch this table.
claude added 2 commits August 1, 2026 17:04
…nt-values

# Conflicts:
#	.claude/skills/fix-issue.md
Every bug fix appends a row to the symptom table, so two concurrent fixes always
collide on the same line — and moving rows from the top of the table to the
bottom did not help, because both sides still append to the same place. git's
built-in union driver keeps both sides instead of conflicting, which is correct
for a file that is only appended to and looked up by matching a symptom.

Appended to the existing rules, not replacing them.
@ako
ako merged commit 3e6c740 into main Aug 1, 2026
5 checks passed
ako pushed a commit that referenced this pull request Aug 1, 2026
The rationale in fix-issue.md and CLAUDE.md claimed that appending at the end of
the table avoids the conflict two concurrent fixes cause. It does not — both
sides still append to the same line, which is why #76, #77 and #78 each hit it
again after that convention was adopted.

Merging is handled by the merge=union driver in .gitattributes (added in #77).
Appending at the end is kept for readable diffs and rough chronology, which is a
human reason, not a git one.
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