Skip to content

fix(dynamic): keep groupmembership stereotype wrappers when normalizing rulesets - #192

Merged
2000game merged 3 commits into
mainfrom
fix/keep-groupmembership-stereotype
Oct 1, 2026
Merged

2000game merged 3 commits into
mainfrom
fix/keep-groupmembership-stereotype

Conversation

@2000game

@2000game 2000game commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Problem

stripCosmeticLabels (src/engine/dynamic.ts) treated every dterm: [label, expr] wrapper as cosmetic. A label with stereotype: ["groupmembership"] is not cosmetic: it makes ChurchTools evaluate the wrapped group condition per person ("is / is not a member of group X") instead of per membership row.

apply PUTs the normalized ruleset, so applying one silently removed these wrappers. For a negated group condition that is fatal: !(member of X) stays true for anyone who has any other membership row, so it excludes no one.

Evidence (read-only, POST /churchquery/debug/export, CT 3.137)

  • A newsletter rule with a negated "unsubscribed" membership: stripped, it included 45 unsubscribed people; wrapped, it matched exactly the intended set.
  • 72 live rulesets, each evaluated three ways: stored vs only-stereotype-labels-kept identical in 72/72; stored vs fully stripped: 18 differ.

Change

  • isCosmeticLabel is an allowlist: string and { title }-only labels are stripped; anything else (notably stereotype) is kept verbatim, its body normalized recursively. Idempotent.
  • coerceScalars leaves a kept label untouched (a title "2024" stays a string); labels are copied, not shared.
  • Upgrade guard: ct plan warns when the declared ruleset has fewer stereotype wrappers than the live one (applying would remove N "stereotype" dterm wrapper(s)). Rulesets adopted before this fix lack them; without the warning, the first apply after upgrading would strip every wrapper still live.
  • q.memberOf(node) emits the wrapper from the typed builder.
  • Handbook dynamic-groups.md: normalization section, authoring example with a portable group ref, and recovery for groups an earlier apply already stripped live (re-adopt can't bring those back). Re-signed.

Review

/code-review high: 10 findings, 8 fixed here (upgrade-path guard, recovery doc, DSL helper, numeric-title coercion, allowlist predicate, org names in docs/tests, vacuous fixture assertion, shared label reference). Refuted: "kept title diffs forever" — a declared title that differs from live is a real diff in a declarative config.

Ran the build against dev (ct plan --env dev, read-only) with a consumer config whose rulesets carry the wrappers: 44 updates, every one differing only by restoring wrappers that earlier applies had stripped; no removal warnings.

Follow-up (consumers)

Re-adopt rulesets/*.json (or add the wrappers) before applying with this version; the new plan warning lists every group affected.

…ng rulesets

stripCosmeticLabels dropped every dterm label, including
{ stereotype: ["groupmembership"] }. That label is part of the query:
it makes ChurchTools evaluate a group condition per person instead of
per membership row. Because apply PUTs the normalized ruleset, applying
a ruleset with a negated group condition silently turned
!(member of X) into a no-op (MHfsH in eqrm/ct-structure kept adding
unsubscribed people).

Verified read-only on prod (CT 3.137) via POST /churchquery/debug/export
over all 72 active rulesets: keeping only stereotype labels reproduces
every live result; stripping them changes 18.
…ls verbatim

Review follow-ups on keeping groupmembership wrappers:

- plan warns when the declared ruleset has fewer non-cosmetic dterm
  wrappers than the live one. Without it, upgrading turned a silent
  no-op into a PUT that strips every wrapper a pre-fix adopt missed.
- isCosmeticLabel is an allowlist (string or title-only label); any
  other label key is kept rather than assumed decorative.
- coerceScalars leaves a kept label verbatim, so a numeric-looking
  title is not retyped to a number on write-back; labels are copied,
  not shared.
- q.memberOf emits the wrapper from the typed builder.
- Handbook: neutral example with a portable group ref, and the
  recovery path for groups an earlier apply already stripped live
  (re-adopt cannot bring those back). Re-signed.
- Fixture test asserts real wrapper counts instead of a vacuous every().
@2000game
2000game merged commit 3ff4997 into main Oct 1, 2026
3 checks passed
@2000game
2000game deleted the fix/keep-groupmembership-stereotype branch October 1, 2026 11:22
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.

1 participant