Skip to content

The localization guard only watched the side that never changes - #984

Merged
rbuergi merged 9 commits into
mainfrom
fix/964-localization-parity-trigger
Aug 9, 2026
Merged

rbuergi merged 9 commits into
mainfrom
fix/964-localization-parity-trigger

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #964.

The defect

.github/workflows/clients.yml fired only on clients/**. But several of the jobs it runs are
deliberately cross-tree — they read a file under src/ and assert the client copy matches it:

Server input Guard that reads it
src/MeshWeaver.Messaging.Hub/Localization/** clients/react/src/i18n/localize.test.ts — byte-identical catalog drift guard
src/MeshWeaver.Messaging.Contract/Localization/Locales.cs same test, scrapes Locales.Supported
src/MeshWeaver.Blazor/BlazorViewRegistry.cs clients/react/src/render/parity.test.ts + clients/react-native/src/parity.test.ts
src/MeshWeaver.Hosting.Grpc/Protos/** the canonical mesh.proto the python / grpc-web / typescript clients generate from

So every one of those guards was blind to the side that actually moves. A server-only PR adds a key
(or a control, or a proto field), the job never runs, CI is green, and the gap sits latent until an
unrelated PR happens to touch the client tree and inherits a red it did not cause.

The mechanism, proven rather than inferred

The two commits that introduced the currently-unmatched keys touched ZERO files under clients/:

c34bd01e6  A refused round explained itself with an HTTP header dump      clients/ files: 0
18b3e2ee3  A storage stall told a signed-in user their account did not exist   clients/ files: 0

Both merged green. This is the second occurrence within hours of PR #959 resyncing the previous 12
plugins.discovery.* keys — the guard gap regenerated the same defect immediately, which is what
distinguishes it from a one-off sync miss.

Remaining gaps the widened guard catches on current main — 5 keys

Verified by running the real test, not by construction. npx vitest run src/i18n/localize.test.ts
on unmodified main fails 2 of its tests (en and de), naming 5 keys present server-side with no
client counterpart:

  • chat.modelProviderError
  • chat.modelRateLimited
  • chat.modelSubstitutionNote
  • error.identityUnavailable
  • error.identityUnavailableHint

Fixed here — all five copied verbatim into clients/react/src/i18n/strings.{en,de}.json, in the
server's key order, values byte-identical.

Correction (see "Post-merge re-verification" below). An earlier revision of this description
claimed these were user-visible as raw codes — "a React-client user hitting a rate-limited model
saw the literal string chat.modelRateLimited". That is wrong, and the corrected claim is
narrower.
chat.modelRateLimited and chat.modelProviderError are resolved server-side, by
LocalizationCatalog.Get(key, userAccessContext?.Locale, …) in ThreadExecution.cs (~line 2618),
off the server catalog — which always had them. What reaches the client is finished prose, not
a key. No client resolves any of these five: a repo-wide search finds no reference to them outside
the catalog JSON, no dynamic key construction (`chat.${…}`) in any client, and no
server-sends-a-key mechanism. A stale client copy therefore could not have produced a raw code for
them.

The real defect is duller and still worth fixing: the clients' bundled copy of the server's text
had silently fallen out of date, and the check that exists to catch exactly that was gated on the
one tree that does not change when the fault is introduced. The What's New entry was rewritten to
match this narrower, accurate claim.

Do the other ratchets share the blind spot?

Checked each rather than assuming:

  • clients/react/src/render/parity.test.ts and clients/react-native/src/parity.test.ts —
    yes, same blind spot. Both scrape src/MeshWeaver.Blazor/BlazorViewRegistry.cs yet were
    reachable only through a clients/**-filtered job. Both happen to pass on main today
    (react: green; RN: 9/9 green), so there is no latent control gap right now — but that is luck, not
    guarding: 993282677 modified BlazorViewRegistry.cs with 0 files under clients/. Fixed by the
    same trigger widening.
  • test/MeshWeaver.Maui.Abstractions.Test/MauiControlCoverageTest.cs — the third parity ratchet.
    Does NOT share the flaw, and is deliberately left alone. It is a .NET test in dotnet-test.yml,
    which has no paths: filter at all, and it reflects over UiControl subclasses rather than
    scraping a file. It already runs unconditionally on every PR.

How this relates to the server-side LocalizationTest

They guard different invariants and cannot disagree:

  • LocalizationTest (test/MeshWeaver.Messaging.Hub.Test/) is intra-catalogue: every English key
    has a de translation, no orphan de keys, plural pairs complete. It never looks at the clients.
    It runs on every PR via dotnet-test.yml.
  • localize.test.ts is cross-tree: the client copy is byte-identical to the server catalogue.

So the server test says "the server catalogue is internally complete" and the client test says "the
client copy equals the server catalogue". Neither constrains the other's subject; widening the
trigger adds no second opinion about what "in sync" means. Notably LocalizationTest was green
throughout
— it is not the mechanism that could have caught these 5 keys, which is precisely why
the client-side guard needed to run.

Preventing recurrence

Widening a hand-maintained list fixes today's holes and nothing more — the list duplicates knowledge
that lives in the test files, so it will drift again. clients/react/src/ciTrigger.test.ts closes
that loop: it walks every client package for cross-tree reads (resolving each reference against both
the file's directory and the package root, keeping only those that land on a real file under src/)
and fails if the workflow's paths: filter does not cover one. It also asserts the pull_request
and push lists stay identical, since the Actions parser cannot share them through a YAML anchor.

Adding a new cross-tree read is itself a change under clients/**, so the job that catches it is
reached by definition — the guard-the-guard has no blind spot of its own.

Verification

  • Scrape output matches the hand analysis exactly — 5 server inputs found, all now covered.
  • Negative test: removing src/MeshWeaver.Blazor/BlazorViewRegistry.cs from the pull_request
    list makes ciTrigger.test.ts fail with both assertions, naming the exact missing path. Restored
    and re-verified green.
  • clients/react: npm run typecheck clean, npm test 34 files / 253 tests passed (after the
    merge with origin/main).
  • clients/react-native: npx vitest run src/parity.test.ts 9/9 passed.
  • Catalog re-checked after merging origin/main: server 797 / client 797, no missing, no extra, no
    value drift, both languages. (Superseded — main moved again; see "Post-merge re-verification".)
  • Workflow YAML parses; both paths: lists identical; all 8 jobs intact.
  • No .cs/.csproj touched, so no dotnet build -c Release -warnaserror was applicable.

Note on the committed node_modules symlink — RESOLVED by #983

clients/react/node_modules and clients/react-native/node_modules were committed symlinks to
absolute paths
(/Users/roland/code/MeshWeaver/..., added in 69edd029d). They dangled anywhere
but that one machine — and on that machine they were self-referential: the target path is the
same committed symlink, so resolving it gave Too many levels of symbolic links. On CI the target
simply did not exist and npm install replaced the dead link, which is why it never went red.

#983 has since merged and deletes both (delete mode 120000 clients/react/node_modules) — the
right home for it, since that PR is the /Users/roland/... scrub. Nothing left to do here.

The catalogs themselves are NOT symlinks, and that is the whole point of the guard. Worth
stating explicitly, because "copy" vs "link" changes what drift even means here:

$ git ls-files -s clients/react/src/i18n/ clients/react/node_modules
100644 … clients/react/src/i18n/strings.de.json     ← regular file (real copy)
100644 … clients/react/src/i18n/strings.en.json     ← regular file (real copy)
120000 … clients/react/node_modules                 ← symlink (mode 120000)

strings.{en,de}.json are mode 100644 regular files — genuine byte copies of the server catalog,
duplicated so the clients can resolve text synchronously without a round trip. Nothing keeps them in
step automatically. If they were links, drift would be impossible and localize.test.ts would be
pinning nothing; because they are copies, drift is not only possible but is what happened, twice.
The only symlink in the area is node_modules, which is dependency-install state and unrelated to
the catalogs.

Post-merge re-verification (20b9f1981)

The parity figures above were measured before origin/main moved. Re-checking rather than
assuming was the right call: merging main re-opened the very gap this PR exists to close.

#985 and #992 landed 21 further server-side keys with no file under clients/, plus a reworded
about.intro the copy never received — i.e. the defect regenerated a third time, while this PR
sat open, which is the strongest possible argument for the trigger change:

measured at branch-cut after merging origin/main now (20b9f1981)
keys missing from client 5 21 0
value drift 0 1 (about.intro) 0
server / client key count 797 / 797 818 / 797 818 / 818

Re-synced in 622e72e92; the client catalogs are byte-identical to the server's again (diff on
both files is empty, both languages). The 21: about.updateStatus / upToDate / updateAvailable,
menu.modelTiers + menu.modelTiersTooltip, and the 16-key aiCatalog.tab.* / aiCatalog.search.*
family. Both languages complete — no key ships English-only.

Guards re-proven by mutation at this HEAD, not assumed:

Mutation Result
drop src/MeshWeaver.Messaging.Hub/Localization/** from both paths: lists ciTrigger.test.ts ✗ — names the exact uncovered path
drop src/MeshWeaver.Blazor/BlazorViewRegistry.cs from both lists ciTrigger.test.ts ✗ — names the exact uncovered path
drop the localization path from the push list only ciTrigger.test.ts ✗ — "keeps the pull_request and push filters identical"
delete menu.modelTiers from the client en catalog localize.test.ts ✗ — 817 vs 818 keys

All restored; both files green afterwards (19/19). The meta-guard is pinning something real.

Trigger ownership vs #983 — #983 has dropped its incidental clients.yml edit (its file list no
longer contains the workflow), so #984 owns it uniquely. Verified the result is coherent, not
duplicated: exactly two paths: blocks, each with the identical 6 entries, no repeats. #983 still
edits the same two catalog files, so whichever lands second will conflict there — the resolution is
mechanical, since the server catalog is the single source of truth and the correct client content is
a verbatim copy of it.

Full local run at this HEAD: clients/react npm test 34 files / 253 tests green, npm run typecheck clean, npm run build clean; LocalizationTest 33/33; DocumentationLinkIntegrityTest
1/1; MeshWeaver.Documentation builds clean under -c Release -warnaserror (0 warnings, 0 errors).

After merging #983 — the catalog change is gone, and that is the correct outcome

#983 merged mid-review and re-synced the client catalogs itself (54c0ffe75, "The client string
catalogs had fallen 8 keys behind the server"). origin/main therefore already has them
byte-identical to the server, so the merge dropped the catalog files out of this PR's diff:

.github/workflows/clients.yml            | 36 ++++++
clients/react/src/ciTrigger.test.ts      | 138 +++++++++++++++
…client-strings-no-longer-lag-the-server.md | 27 ++++
3 files changed, 201 insertions(+)

Both PRs converged on identical content without a conflict, which is what you would expect: the
correct client catalog is definitionally a verbatim copy of the server's, so there is exactly one
right answer to converge on. Re-verified post-merge: 818 / 818 keys, both languages, zero missing,
zero extra, zero value drift
, and diff against the server catalog is empty for both files (so no
duplicated keys or reordering slipped in — JSON.parse would have silently hidden either). React
suite re-run on the merged tree: 34 files / 253 tests green.

What remains is exactly this PR's own substance — the trigger widening, the meta-guard, and the
note. The What's New was updated to stop claiming the sync.

Trigger ownership confirmed after the merge: #983's incidental workflow edit (77f2955ec) is
NOT in origin/main's clients.yml — it was dropped before merge, as coordinated. #984 owns the
trigger uniquely, and the merged result is coherent: exactly two paths: blocks, six identical
entries, no duplication.

🤖 Generated with Claude Code

Counterfactual: replaying the three commits that slipped through

Applying both filters to each commit's actual file list — the direct answer to "would the widened
guard have caught this?":

Commit What it added Old filter fires New filter fires
c34bd01e6 chat.model* keys (8 files) ❌ no ✅ yes — .../Localization/strings.{en,de}.json
18b3e2ee3 error.identityUnavailable* keys (8 files) ❌ no ✅ yes — .../Localization/strings.{en,de}.json
993282677 changed BlazorViewRegistry.cs (11 files) ❌ no ✅ yes — src/MeshWeaver.Blazor/BlazorViewRegistry.cs

All three are server-only diffs with zero files under clients/. Under the old filter none of them
ran the guard; under the new one all three do — on the PR that created the change.

rbuergi added 3 commits August 9, 2026 08:39
The client-side catalog drift guard and both Blazor-parity ratchets read files
under src/, but .github/workflows/clients.yml only fired on "clients/**". So the
guards were blind to the exact side that moves: a server-only PR adds a key, the
job never runs, CI is green, and the gap sits latent until an unrelated PR
happens to touch the client tree and inherits a red it did not cause.

Not hypothetical. c34bd01 and 18b3e2e each added localization keys and
touched ZERO files under clients/. Both merged green, and the 5 orphan keys they
left survived on main until this commit — the second recurrence within hours of
PR #959 resyncing the previous 12.

The trigger now lists every input these jobs actually read: the localization
catalog, Locales.cs, BlazorViewRegistry.cs, and the canonical mesh.proto. That
puts the cost back on the PR that creates it.

Widening a hand-maintained list once fixes today's holes, so ciTrigger.test.ts
scrapes the client tree for cross-tree reads and fails if the filter misses one
(or if the pull_request and push lists drift — Actions cannot share them via a
YAML anchor). Adding such a read is itself a clients/** change, so the guard
that catches it is reached by definition.

Fixes #964
Copilot AI lite review requested due to automatic review settings August 9, 2026 06:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes a CI blind spot where the “Clients” workflow only triggered on clients/** even though multiple client-side parity/drift tests intentionally read canonical inputs from src/. It also resyncs missing localization keys so the React client no longer renders raw localization keys for several user-visible error states.

Changes:

  • Widen .github/workflows/clients.yml paths: filters (for both pull_request and push) to include server-side inputs that client parity/drift jobs read.
  • Add a “guard-the-guard” Vitest test (clients/react/src/ciTrigger.test.ts) that scrapes cross-tree reads and asserts the workflow trigger covers them.
  • Sync 5 missing localization keys into clients/react/src/i18n/strings.{en,de}.json and add a matching What’s New entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-client-strings-no-longer-lag-the-server.md Documents the localization parity/trigger fix and the user-visible string corrections.
clients/react/src/i18n/strings.en.json Adds the 5 missing English localization strings used by the React client.
clients/react/src/i18n/strings.de.json Adds the 5 missing German localization strings used by the React client.
clients/react/src/ciTrigger.test.ts Adds a test that enforces the workflow trigger covers any src/ files the client packages read at runtime.
.github/workflows/clients.yml Expands trigger paths to include server-side canonical inputs used by client parity/drift checks.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/react/src/ciTrigger.test.ts Outdated
Comment thread clients/react/src/ciTrigger.test.ts
@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests   685 ✅  5m 8s ⏱️
 10 suites    0 💤
 10 files      0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 887 tests   1 885 ✅  6m 1s ⏱️
   11 suites      2 💤
   11 files        0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   5m 19s ⏱️
1 935 tests 1 830 ✅ 105 💤 0 ❌
2 063 runs  1 958 ✅ 105 💤 0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 216 tests   1 208 ✅  7m 19s ⏱️
   11 suites      8 💤
   11 files        0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 717 tests   1 526 ✅  6m 0s ⏱️
   11 suites    191 💤
   11 files        0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 188 tests   1 188 ✅  7m 11s ⏱️
   11 suites      0 💤
   11 files        0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files     65 suites   37m 0s ⏱️
8 628 tests 8 322 ✅ 306 💤 0 ❌
8 756 runs  8 450 ✅ 306 💤 0 ❌

Results for commit 89f84da.

♻️ This comment has been updated with latest results.

…X form

statSync follows links, so the node_modules skip was by name only — an lstat +
isSymbolicLink check makes the code match what the comment claimed and keeps the
walk from leaving the repo. And resolve() yields \ on Windows, where the /-based
startsWith would have matched nothing and reported every input as uncovered.
@rbuergi

rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

CI note: shard 1 is red on a leak guard this PR cannot have caused

MeshWeaver.Hosting.Monolith.Test.MeshHubDisposalLeakTest.MeshHub_IsCollected_AfterMeshAndServiceProviderDisposal failed on run 31300373896. Recording why it is unrelated, with the evidence, rather than re-running to get a green — per AGENTS.md a re-run to "see if it was a flake" hides the bug.

Why not this PR: the diff is 5 files — a workflow YAML, two client JSON catalogues, a client .ts test, and one markdown doc. No C#, no test code, nothing that can create a GC root. The one .NET-visible delta is the What's New doc (embedded via Data\**\*.*), and MeshHubDisposalLeakTest.ConfigureMesh only calls .AddGraph() — it never loads the documentation partition.

Why it is pre-existing: main at 906fc8827 — the exact commit this branch is merged with, 0 commits behind — ran shard 1 and passed this test in run 31298376804 (06:12Z), 68 minutes before it failed here (07:20Z). Note that main's newer green (31301200550) does not contradict that: its Build solution (once) is skipped, i.e. green-tree reuse, so it re-tested nothing.

What the guard actually found — it is not vacuous; ClrMD named the whole chain:

ROOT[StrongHandle] System.Object[]
  → System.Threading.TimerQueue[] → TimerQueue → TimerQueueTimer
  → System.Reactive.Concurrency.ConcurrencyAbstractionLayerImpl+Timer
  → UserWorkItem<ValueTuple<ObservableImpl.Timer+Single+_, …>>
  → System.Reactive.Linq.ObservableImpl.Timer+Single+_
  → System.Reactive.AnonymousSafeObserver<Int64> → Action<Int64>
  → MeshWeaver.Mesh.ActivityControlPlaneExtensions+<>c__DisplayClass2_1<MeshNode>
  → System.Action
  → MeshWeaver.Mesh.ActivityControlPlaneExtensions+<>c__DisplayClass2_0<MeshNode>
  → System.Func<IObservable<MeshNode>>
  → MeshWeaver.Graph.Configuration.NodeTypeCompilationHelpers+<>c__DisplayClass5_0
  → MeshWeaver.Messaging.MessageHub  [RunLevel=6] [Address=AccessAssignment]

A pending single-shot Observable.Timer closure in ActivityControlPlaneExtensions (reached from NodeTypeCompilationHelpers) sits on the process-wide TimerQueue and pins the AccessAssignment hub past Mesh.Dispose(). That is a real leak with a named culprit; it is intermittent only in whether the timer is still pending when the probe looks. Both files are untouched by this PR.

Filed separately so the chain is not lost when these logs expire — this PR should not be the place it gets fixed.

rbuergi added 5 commits August 9, 2026 11:46
… again

The parity sweep this PR shipped was measured before #985 and #992 landed. Merging
main re-opened the exact gap the PR exists to close: 21 keys authored server-side
with no file under clients/ (about.updateStatus/upToDate/updateAvailable,
menu.modelTiers*, and the aiCatalog.tab/search family), plus a value drift on
about.intro where the server sentence grew a clause the client copy never got.

Both catalogs are byte-identical to the server's again, which is what
localize.test.ts asserts. A stale sync is worse than none — it reads as done.

Once this PR's trigger is on main, this catch-up stops being a recurring chore:
the workflow fires on src/MeshWeaver.Messaging.Hub/Localization/**, so the PR that
authors the key is the one that reds.
Two corrections to the note.

It claimed five error messages "showed up as raw codes in the React client". They
did not. chat.modelRateLimited and its neighbours are resolved SERVER-side, by
LocalizationCatalog.Get(key, accessContext.Locale, …) in ThreadExecution — off the
server catalog, which always had them. The client's copy being stale could not have
produced a raw code for those keys, because no client resolves them. The real
defect is narrower and duller: the bundled copy was out of date.

And "five" is already wrong — merging main took the gap to twenty-six. Any fixed
number here is only true until the next server-side string lands, so the note now
describes the mechanism, which stays true, instead of a count that does not.

What is worth saying to a reader is the shape of the failure: the check that would
have caught this was gated on the one tree that does not change when the fault is
introduced.
#983 merged while this was open and re-synced the client catalogs itself, so
origin/main already has them byte-identical to the server — my re-sync commit is
now a no-op against main and the catalog files have dropped out of this PR's diff
entirely. Both PRs converged on the same content, which is what you would expect:
the correct client catalog is definitionally a verbatim copy of the server's, so
there is only one right answer to converge on.

What is left in this PR is its actual substance — the trigger and the meta-guard —
so the note now describes that and stops taking credit for the copy being current.

Kept the sharper fact instead: this regenerated three times. That is the argument
for the trigger, and it is the part a reader should take away.
@rbuergi
rbuergi merged commit 757b362 into main Aug 9, 2026
24 checks passed
rbuergi added a commit that referenced this pull request Aug 9, 2026
The client catalogs are verbatim copies of the server's, and I added five keys server-
side without syncing them — plus four that arrived from main the same way. 840 keys
against 849 is exactly the drift the guard exists to catch, and it caught mine.

I had also mis-attributed this failure to main. It was not: #984 widened the Clients
workflow triggers to include the server Localization folder precisely so that touching
the catalog runs this check, instead of the drift piling up until it ambushed whoever
next touched clients/. The workflow ran on this PR because the change is mine.

Copied verbatim — a mirror has exactly one right answer, and the guard compares values
as well as keys, in both locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sierragolflima added a commit that referenced this pull request Aug 18, 2026
The React client keeps a BYTE-IDENTICAL copy of the server catalog, asserted by
`localize.test.ts`'s "catalog drift guard" (key sets and every value). Adding
`about.lastDeployed` server-side only broke it:

    AssertionError: expected [ 'about.buildCommit', …(966) ]
                 to deeply equal [ 'about.buildCommit', …(967) ]
    -   "about.lastDeployed"

Both values are taken FROM the server file rather than retyped, because the guard compares values
as well as keys — a hand-copied translation that drifted by a character would fail identically.

This is the guard #984 repaired so it actually fires on `src/**` changes rather than only on
`clients/**`. It caught this exactly as intended.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi
rbuergi deleted the fix/964-localization-parity-trigger branch August 19, 2026 10:32
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.

Localization parity guard is path-filtered to clients/** — server-only key additions ship a latent gap and red the next innocent PR

2 participants