Skip to content

fix(#3812): a hold nothing can recompute is history, not the current verdict - #3815

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3812-a-frozen-hold-is-history
Sep 9, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/3812-a-frozen-hold-is-history

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3812.

Two fields, two clocks, one apparent story

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 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:

heldAt          : 2026-09-07T22:27:17Z
heldTag         : 3.0.0-ci.8057
lastCheckedAt   : 2026-09-09T10:41:24Z          <- 36 h later
lastCheckVerdict: "updates are disabled on this install (Admin/UpdatePolicy = None);
                   the registry was not listed."

#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 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-build
inconsistency. Full correction: #3706 (comment).

The change

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:

asks true when
IsHeld(tag) is there a record HeldTag == tag
IsHoldOperative(tag) is it a live verdict …and a poller is running that would clear it
  • Updates tab — an operative hold renders as before; a frozen one renders as history ("This is
    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 still
    happening.
  • PlatformUpdateStatus.Derive — stops answering UpdateHeld off a note nothing can refresh. A
    Red 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 not
vacuous:

FrozenHoldIsHistoryTest.TheAboutSurface_DoesNotReportAFrozenHoldAsHeld        [FAIL]
FrozenHoldIsHistoryTest.WithCheckingOFF_TheHoldIsRecordedButNotOperative      [FAIL]
FrozenHoldIsHistoryTest.AFrozenHoldWithNoTimestamp_StillReadsAsHistory        [FAIL]
FrozenHoldIsHistoryTest.AFrozenHold_ReadsAsHistory_NeverAsTheCurrentVerdict   [FAIL]
Failed! - Failed: 4, Passed: 5

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_NotAsAvailable did not set
Policy, and Policy's default is None. Its premise — a poller is running — always mattered and
was 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.updateHoldHistorical and ui.updateHoldHistoricalUndated, added to both
strings.en.json and strings.de.json (1433 keys each, parity verified). Inserted beside
ui.updateHeldAt rather than re-serialising the catalogue.

🚨 The mirror does not see this. MeshWeaver.Plugins/clients/react/src/i18n/ compares against a
pinned core commit, not core's main, so adding keys here reddens nothing anywhere and leaves the
mirror 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 carry ui.updateHoldHistorical and ui.updateHoldHistoricalUndated (en + de) into clients/react/src/i18n/. The sync runs in that repository and cannot be done from here.

Observed, deliberately not changed

Under policy None the poller does not list the registry, so LatestAvailableTag is 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

solution build, Release -warnaserror ......... 0 Warning(s)  0 Error(s)
MeshWeaver.Documentation.Test ................  368 passed, 0 failed
MeshWeaver.Messaging.Hub.Test ................  384 passed, 0 failed
Memex.Portal.Shared.Test ..................... 1188 passed, 1 failed  (see below)

🚨 The one failure is PRE-EXISTING on main, not from this diff.
AnInstanceReportsWhatItRunsTest.TheReport_CarriesBothModuleShapesAndThePlatform_AndLandsWhereAReportedOneWould
fails here, and it fails 2 of 2 runs on a clean origin/main worktree with no part of this
change
— 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 on
DeploymentReportService'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

…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>
Copilot AI lite review requested due to automatic review settings September 9, 2026 11:06
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 9, 2026 11:08

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.

🟡 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.

Comment on lines +634 to +635
"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.",
@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

254 tests   254 ✅  2m 17s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

389 tests   389 ✅  52s ⏱️
  2 suites    0 💤
  2 files      0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

496 tests   496 ✅  40s ⏱️
  1 suites    0 💤
  1 files      0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 869 tests   1 869 ✅  2m 19s ⏱️
    4 suites      0 💤
    4 files        0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 142 tests   1 140 ✅  3m 34s ⏱️
    3 suites      2 💤
    3 files        0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    6 files      6 suites   4m 19s ⏱️
2 518 tests 2 326 ✅ 192 💤 0 ❌
2 519 runs  2 327 ✅ 192 💤 0 ❌

Results for commit d87e57f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   17 files     17 suites   14m 2s ⏱️
6 668 tests 6 474 ✅ 194 💤 0 ❌
6 669 runs  6 475 ✅ 194 💤 0 ❌

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>
@rbuergi

rbuergi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

CI red is #3827, not this diff: ReadDuringDisposalWindowTest.ReadThatFaultsOnTeardown_IsNackedShuttingDown_NotAnsweredAsAbsent, which fails on a clean origin/main worktree at 3ba04254c with no diff (run 1 of 2 red locally), and reddened #3797 earlier today.

It is not a plain flake: the test relies on "message intake stays open until DisposeHostedHubs… by construction", and #3506 closed exactly that window — QuiescingHubRefusesNewWorkTest describes the same sentence as the defect it fixed. What is left is an unordered race between the test's read and the ShutdownRequest that Dispose() posts from inside the DisposeRequest handler. Full analysis on #3827.

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 i18n catalog gate is green now: it reads the body from the pull_request event payload, so the earlier --failed re-run replayed the pre-edit body. A pushed commit was needed to carry the Mirror-sync: line — now documented in Doc/Architecture/Localization.)

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 9, 2026
@rbuergi

rbuergi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Merging. Required check Consolidate test results: success.

The one red is Bundle registry scripts (executed), which is not a required check and failed on infrastructure, not on this diff:

Traceback (most recent call last):
urllib.error.HTTPError: HTTP Error 500: Internal Server Error

GitHub was returning 500s in that window — my own git push to this repo took one at 11:51Z. Nothing in this diff (a settings-tab predicate, two catalogue keys, docs, tests) reaches bundle registry scripts.

The earlier test red was #3827, with a control on clean origin/main (1 in 4, no diff).

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.

A hold nothing can recompute still renders as the live reason — a frozen heldReason beside a fresh lastCheckedAt

2 participants