Repository navigation
fix(#3812): a hold nothing can recompute is history, not the current verdict - #3815
Conversation
…verdict HeldTag/HeldReason/HeldAt are written ONLY by RecordHold, which runs only when a candidate is actually evaluated. LastCheckedAt is written by RecordCheck on EVERY check. While updates are on those move together and nothing distinguishes them. Switch updates off and they come apart: the check keeps recording "updates are disabled on this install", the last real verdict sits underneath unchanged, and the fresh timestamp beside it makes it read as current. Measured on memex 2026-09-09 10:42Z: heldAt 2026-09-07T22:27:17Z lastCheckedAt 2026-09-09T10:41:24Z <- 36 h later lastCheckVerdict "updates are disabled on this install (…= None)" #3795, merged this morning, sharpened it: under policy None the BuildCompletion / ModuleSetProposed triggers are now filtered out before they check, so nothing revisits the hold at all. It has already cost a day. #3706 was filed on that exact frozen record, arguing three retired packages hold every self-update "forever". Those lines were computed at 22:27Z on 09-07; #3648 made floors advisory at 00:22Z and #3651 reduced the hold to measured unloadability at 01:10Z the next morning. The evidence predated its own fix by two hours and the record could not say so. IsHeld(tag) answers "is there a record". Both readers used it to answer "is this why the install is not moving right now". Those are now different questions, so they are different methods — IsHoldOperative adds "and a poller is running that would clear it". - the Updates tab renders a frozen hold as history, naming when it was written; the "(held <date>)" suffix goes with the live framing, because that phrasing describes something still happening; - PlatformUpdateStatus.Derive stops answering UpdateHeld off a note nothing can refresh. A Red combo verification keeps holding: that is a recorded FACT about the build, not a note about the poller — the line the surrounding comment already drew, extended to the case it did not anticipate. 🚨 The record is deliberately NOT cleared when updates are switched off. The last real evaluation is the only diagnostic an operator has before turning updates back on; destroying it to avoid showing something stale trades a misleading answer for no answer. Only the framing changes. Two existing tests set Policy explicitly now. Their premise — a poller is running — always mattered and was never written down, and Policy's default is None, which made them indistinguishable from the frozen case. Negative control: run against the pre-fix predicate (IsHoldOperative ≡ IsHeld), 4 of the 9 new assertions fail. The 5 that pass are the operative-path cases and the Red-verdict control, correctly unaffected. i18n: ui.updateHoldHistorical{,Undated} added to BOTH catalogs (1433 keys each, parity verified), inserted beside ui.updateHeldAt rather than re-serialising the file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new i18n keys require an explicit Mirror-sync: declaration in the PR body to satisfy the repo’s mirror-handover gate.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a self-update UX/status bug where a stale hold record (HeldAt/HeldReason) could be misread as the current reason an install isn’t updating, because LastCheckedAt continued to advance even when policy disables evaluation. It introduces an explicit “operative vs historical hold” distinction and updates both the Updates tab and the About surface to reflect it.
Changes:
- Add
IsHoldOperative(tag)to differentiate a live hold from a frozen record when updates are disabled. - Update Updates-tab markdown and About-surface availability derivation to use the operative predicate and render frozen holds as history.
- Add i18n keys + tests and document the behavior in “What’s New” and the self-update architecture page.
File summaries
| File | Description |
|---|---|
| test/Memex.Portal.Shared.Test/UpdateAdvisoriesOnTheTabTest.cs | Makes the test’s policy explicit so it’s unambiguously testing the “operative hold” path. |
| test/Memex.Portal.Shared.Test/PlatformUpdateStatusTest.cs | Makes the test’s policy explicit to avoid conflating with the “frozen record” case. |
| test/Memex.Portal.Shared.Test/FrozenHoldIsHistoryTest.cs | New negative/positive controls for frozen-hold-as-history (tab) and non-held (About), plus red-verdict control. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.en.json | Adds the two new UI strings for historical holds. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.de.json | Adds the two German translations for historical holds. |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-09-a-hold-that-nothing-is-checking-now-says-so.md | Release note describing the user-visible change and rationale. |
| src/MeshWeaver.Documentation/Data/Architecture/SelfUpdateTargetSelection.md | Architecture doc update explaining the two clocks and the new distinction. |
| memex/Memex.Portal.Shared/Settings/UpdatePolicySettingsTab.cs | Renders a non-operative hold as historical (different key + drops “held at” suffix). |
| memex/Memex.Portal.Shared/SelfUpdate/UpdatePolicyNodeType.cs | Introduces IsHoldOperative(tag) and documents the semantic split from IsHeld(tag). |
| memex/Memex.Portal.Shared/SelfUpdate/PlatformUpdateStatus.cs | Stops reporting UpdateHeld on the About surface for frozen hold records (except Red combo verdict). |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| "ui.updateHoldHistorical": "🗄️ **This is a record, not a current verdict.** Automatic updates are switched off on this install, so nothing has re-checked `{0}` since {1} — what follows is the last evaluation that ran, and it may name problems that have since been fixed. Switch updates on to have it re-checked.", | ||
| "ui.updateHoldHistoricalUndated": "🗄️ **This is a record, not a current verdict.** Automatic updates are switched off on this install, so nothing has re-checked `{0}` — what follows is the last evaluation that ran, and it may name problems that have since been fixed. Switch updates on to have it re-checked.", |
Test Results (shard 0)254 tests 254 ✅ 2m 17s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results (shard 3)389 tests 389 ✅ 52s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)496 tests 496 ✅ 40s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 869 tests 1 869 ✅ 2m 19s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 142 tests 1 140 ✅ 3m 34s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results (shard 5) 6 files 6 suites 4m 19s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
Test Results 17 files 17 suites 14m 2s ⏱️ Results for commit d87e57f. ♻️ This comment has been updated with latest results. |
…the body does not unblock the run
Measured on this pull request. The `i18n catalog (mirror sync handed
over)` gate takes the body as
PR_BODY: ${{ github.event.pull_request.body }}
which is frozen when the run is created. `gh run rerun --failed` replays
that same payload, so the job re-read the pre-edit body and failed
identically while the pull request on screen plainly carried the
`Mirror-sync:` line — its log echoed the old text. The remedy is a fresh
`pull_request` event: push a commit, or reopen.
The same holds for every body-declared gate (`Pairs-with:`,
`Implementers:`, `Satellite-pins:`) — they read the body the same way,
and for the same reason: a body fetched at run time is
attacker-controlled text that can change after review.
Recorded in Doc/Architecture/Localization beside the Mirror-sync rule,
where someone hitting the gate is already reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
CI red is #3827, not this diff: It is not a plain flake: the test relies on "message intake stays open until This branch changes a settings-tab predicate, two catalogue keys and docs — nothing on the hub teardown path. Re-running the failed jobs on that evidence. (The |
|
Merging. Required check The one red is GitHub was returning 500s in that window — my own The earlier test red was #3827, with a control on clean |
Closes #3812.
Two fields, two clocks, one apparent story
HeldTag/HeldReason/HeldAtare written only byRecordHold, which runs only when acandidate is actually evaluated.
LastCheckedAtis written byRecordCheckon every check.While updates are switched on those move together and nothing distinguishes them. Switch updates
off and they come apart: the check keeps recording "updates are disabled on this install", the
last real verdict sits underneath unchanged — and the fresh timestamp beside it makes it read as
current.
Measured on
memex,get @Admin/UpdatePolicy, 2026-09-09 10:42Z:#3795, merged this morning, sharpened it: under policy
NonetheBuildCompletion/ModuleSetProposedtriggers are now filtered out before they check, so nothing revisits the holdat all.
It already cost a day
#3706 was filed on that exact frozen record, arguing three retired packages "hold every self-update
on memex forever". Those lines were computed at 22:27Z on 09-07; #3648 made floors advisory at
00:22Z and #3651 reduced the hold to measured unloadability at 01:10Z the next morning. The
evidence predated its own fix by two hours and the record could not say so. Of the eleven lines in
that
heldReason, ten are advisories under today's rules; the one real blocker is #3732's two-buildinconsistency. Full correction: #3706 (comment).
The change
IsHeld(tag)answers "is there a record". Both readers used it to answer "is this why the installis not moving right now". Those are now different questions, so they are different methods:
IsHeld(tag)HeldTag == tagIsHoldOperative(tag)a record, not a current verdict … nothing has re-checked
{tag}since «date»"). The(held «date»)suffix goes with the live framing, because that phrasing describes something stillhappening.
PlatformUpdateStatus.Derive— stops answeringUpdateHeldoff a note nothing can refresh. ARed combo verification keeps holding regardless: that is a recorded fact about the build, not a
note about the poller. The surrounding comment already drew exactly that line; this extends it to
the case it did not anticipate, and there is a positive control pinning it.
🚨 The record is deliberately NOT cleared when updates are switched off. The last real evaluation
is the only diagnostic an operator has before turning updates back on. Destroying it to avoid
showing something stale trades a misleading answer for no answer. Only the framing changes — the
reason is still quoted in full.
The negative control
The new suite was run against the pre-fix predicate (
IsHoldOperative≡IsHeld) to prove it is notvacuous:
The five that still passed are the operative-path assertions and the Red-verdict control — correctly
unaffected.
One existing test changed, and why
PlatformUpdateStatusTest.ANewerTagTheAvailabilityGateHolds_ReadsAsHeld_NotAsAvailabledid not setPolicy, andPolicy's default isNone. Its premise — a poller is running — always mattered andwas never written down; it is now stated. That makes it a different case from the frozen record
rather than an accidental duplicate of it.
i18n
Two keys,
ui.updateHoldHistoricalandui.updateHoldHistoricalUndated, added to bothstrings.en.jsonandstrings.de.json(1433 keys each, parity verified). Inserted besideui.updateHeldAtrather than re-serialising the catalogue.🚨 The mirror does not see this.
MeshWeaver.Plugins/clients/react/src/i18n/compares against apinned core commit, not core's
main, so adding keys here reddens nothing anywhere and leaves themirror silently stale — which is why the handover is declared rather than assumed:
Mirror-sync: run
npm run sync:i18n -- --ref <this PR's merge sha>in MeshWeaver.Plugins after this merges, to carryui.updateHoldHistoricalandui.updateHoldHistoricalUndated(en + de) intoclients/react/src/i18n/. The sync runs in that repository and cannot be done from here.Observed, deliberately not changed
Under policy
Nonethe poller does not list the registry, soLatestAvailableTagis also frozen— the About surface can therefore say "update available" about a tag that is no longer the newest.
That predates this PR and is not what it is about; the Updates tab dates the tag (
ui.updateCheckedAt)and the new wording states outright that nothing has re-checked. Flagging it rather than folding an
unrelated behaviour change into this diff.
Verified locally
🚨 The one failure is PRE-EXISTING on
main, not from this diff.AnInstanceReportsWhatItRunsTest.TheReport_CarriesBothModuleShapesAndThePlatform_AndLandsWhereAReportedOneWouldfails here, and it fails 2 of 2 runs on a clean
origin/mainworktree with no part of thischange — so it is deterministic on the baseline rather than a flake this PR provoked. It passes in
isolation (
--filter), so it is parallelism-dependent. Filed separately; nothing in this diff is onDeploymentReportService's path (three pure functions and two catalogue keys).Pairs-with: none — additions only; no public type or member is removed.
🤖 Generated with Claude Code