Skip to content

fix(#4259): an UPDATE restores a declared node that is absent from the mesh - #4277

Merged
meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4259-incremental-repair
Sep 14, 2026
Merged

meshweaver-cloud[bot] merged 2 commits into
mainfrom
fix/4259-incremental-repair

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

An UPDATE never looked at the mesh — so a lost node survived every update, each reporting success

Closes the last open half of #4259.

The defect

CatalogLayoutAreas.InstallOrUpdate has three exits. Two of them observe the mesh; the third did not.

exit taken when looked at the mesh?
skip / heal module hash EQUAL yes — #3485's InstallCompleteness.Observe + SkipOrHeal
full install no record, or no file map yes — DecideAndWrite writes whenever current is null
incremental update module hash MOVED and the record has a file map no — fetched exactly newManifest.DiffFrom(record.InstalledFiles)

The incremental fetch set was a comparison of two declarations: the candidate's manifest.lock
against the record the installer itself stamped. A node lost after a previous install has an
unchanged file hash, so it never entered delta.AddedOrChangedFiles, was never fetched, and never
reached DecideAndWrite — the one place that would have restored it. The presence-awareness sat one
layer below a set the absent file could not enter.

🚨 And it is the exit an update always takes. #3485's heal lives on the module-hash-EQUAL exit,
which an update never takes by definition — and every new publication moves the hash again. So a
package that loses a node stays short across arbitrarily many updates, each one green.

The measured case — Plugins/Hosting on memex.meshweaver.cloud

Read 2026-09-14T06:0xZ, read-only (get / search); portal at
3.0.0+c84c6c05503228860df03c4a8b596e497e6d218c.

The files were there and they compiled. Hosting/TriageStatus/Release/20260913081235-bylQeIj_
(created on this portal 2026-09-13T08:12:35.446Z, status: Succeeded) names its inputs in
sourceVersions: Hosting/Deployment/Source/TriageIntake,
Hosting/TriageStatus/Source/TriageStatusContent, …/TriageStatusLayoutAreas (ticks =
2026-09-13T08:12:26Z), plus two Test/ nodes. A release records the source nodes a successful
compile actually read.

The update wrote only what had changed. Record stamped 2026-09-13T22:03:03.221Z,
installedNodeCount: 224. Exactly two nodes carry that install's timestamps:

node version written
Hosting/Deployment/Source/AksOpsResult 1 (new file) 22:02:54.601Z
Hosting/Deployment/Source/PlatformBuildInboxWatcher 16 (changed file) 22:02:57.255Z

Eleven declared files have no node. Anchored probes, each with a same-anchor positive control:

probe result
namespace:Hosting/Deployment/Source scope:subtree nodeType:Code 14, truncated: false — no TriageIntake; the 14 are the control
namespace:Hosting/Deployment/Test scope:subtree nodeType:Code 3 — no TriageIntakeTests
namespace:Hosting/TriageStatus scope:subtree 1 — the Release node alone
namespace:Hosting/TriageItem scope:subtree 1 — the Release node alone
namespace:Hosting scope:children nodeType:NodeType 19, truncated: false — the denominator
namespace:Hosting scope:children nodeType:NodeType content.compilationStatus:Error 8 of 19

All eleven are in the record's own installedFiles map. Control instance, same probes:
0 of 19 at Error, and its Hosting/Deployment/Source holds 15 including TriageIntake —
same package, same declaration, one instance short.

The fix

IncrementalUpdate asks the mesh before it fetches:

  1. Derive the candidate manifest's declared node paths through PackageInstaller.NodePathForFile —
    the installer's own file→node rule, not a second implementation of part of it.
  2. InstallCompleteness.ObservePresent — one batched IStorageAdapter.ReadMany over that
    bounded, known set. Never a query (a stale negative would re-fetch a present file) and never N
    point reads of possibly-absent paths (which opens a storm breaker on the owning hub).
  3. InstallCompleteness.FilesToRestore — pure, so every arm is pinnable offline — returns the
    declared files the delta is not already fetching whose node is absent.
  4. Fetch delta.AddedOrChangedFiles ∪ restore.

One batched read per incremental update — the same read the post-install verification already pays
on this exit, moved to where it can still change the outcome instead of only reporting it.

Three answers, never two. ObservePresent returns null when the mesh was NOT read (no
adapter, or a faulted batched read) and never an empty set — an empty set would say "the mesh holds
none of them", which on a Postgres read that faulted (42P01, not "absent") would re-fetch every
file the package ships, every time. That null yields an empty restore set and the caller says so
at Warning, spelled so it cannot be read as a pass.

A restored SHARED Source//Test/ file falls back to a full install, by the same rule the
existing changed-file guard applies and for the same reason: those are compile inputs for every type
in the package, so the delta's owner-derivation would recompile only the nominal owner.

And the other silent hole in the same method. IPackageSource.FetchPackageFiles(…, paths)
filters, and its contract is explicit that "paths absent from the package simply don't appear in the
result"
— so a requested path the source does not serve vanished with no exception and no line,
after which the record was stamped with the full declared map anyway. The returned set is now
compared against the requested one and the shortfall named at Error. It reports only; it never fails
the update.

Verification

  • Deterministic repro, and it is not vacuous.
    AnIncrementalUpdateOverAMissingNode_RestoresIt installs a package, deletes one node, then runs an
    update in which the module hash moves and the deleted node's own file hash does not.
    • reverting only the CatalogLayoutAreas.cs change and rebuilding: FAILED in 2 m (the
      WaitForNode(present: true) timeout — the node stays gone)
    • with the change: PASSED in 3 s
  • Memex.Portal.Shared.Test.InstallCompletenessTest — 21 of 21 passed, 27 s. The class held 18
    [Fact]s at the branch base and holds 21 here: the behavioural repro plus two arms pinning
    FilesToRestore (every branch, offline) and ObservePresent (live read, absence, and the
    no-adapter null that must never be an empty set).
  • MeshWeaver.Documentation.Test link integrity / topic map / conflict markers — 6 of 6 passed.
  • dotnet build -c Release -warnaserror, one project per invocation: MeshWeaver.PluginCatalog
    0 Error(s) 0 Warning(s), MeshWeaver.Documentation 0/0, Memex.Portal.Shared.Test 0/0.

Work products conserved

  • Declared Is Not Landed —
    the three exits, the measured case, the batched presence read, and the sweep hazard:
    Hosting/TriageItem reads compilationStatus: Ok while all six of its compiledSources are
    absent, because it has not been re-driven since its last successful compile. So
    content.compilationStatus:Error is a sweep for types that attempted a compile and failed —
    "8 of 19 report Error" understates the damage, and the cross-check that sees it is
    compiledSources (or a release node's sourceVersions) against the mesh.
  • NodeTypeCompilation.md gains the shared=@… decision that Two sample NodeTypes fail on every boot of memex-cloud because of the samples MIRROR, not their source — a pre-rename Type/article leftover and Product's unrebased shared=@Northwind refs #4174 deliberately left open:
    @ is absolute and never rebases (CodeQueryResolver.Expand), because the dominant use is a
    genuinely cross-partition include; the consequence is that a tree carrying an absolute
    cross-reference is not importable under a prefix, and the remedy is at the install boundary
    (install the package at the root its sources were authored for), not in the resolver.
  • Topic-map entry + a Category: Fix What's New entry.

What this does NOT do

Refs #4259, #4257, #3485, #4208, #4174

🤖 Generated with Claude Code

…e mesh

`CatalogLayoutAreas.InstallOrUpdate` has three exits and only two observed the
mesh. The incremental one fetched exactly
`newManifest.DiffFrom(record.InstalledFiles)` — a comparison of two
DECLARATIONS, the candidate's lock against the record the installer itself
stamped. A node lost AFTER a previous install has an unchanged file hash, so it
never entered the delta, was never fetched, and never reached `DecideAndWrite`
— the one place that would have restored it, because it writes whenever
`current is null`. The presence-awareness sat one layer below a set the absent
file could not enter.

And it is the exit an update always takes. #3485's heal lives on the
module-hash-EQUAL exit, which an update never takes by definition, and every new
publication moves the hash again — so a package that loses a node stays short
across arbitrarily many updates, each one green.

Measured on memex.meshweaver.cloud 2026-09-14 (read-only):
`Hosting/TriageStatus/Release/20260913081235-bylQeIj_`, created on that portal at
08:12:35.446Z with `status: Succeeded`, names all three of
`Hosting/Deployment/Source/TriageIntake` and both
`Hosting/TriageStatus/Source/*` in its `sourceVersions` — so they landed and
compiled. The 22:03:03Z update then wrote exactly the two files whose content had
moved (`AksOpsResult` v1 22:02:54.601Z, `PlatformBuildInboxWatcher` v16
22:02:57.255Z) and left eleven declared-and-absent nodes absent, while stamping a
224-file record that asserts every one of them.

The fix: derive the candidate manifest's declared node paths through the
installer's own file->node rule, read them in ONE batched
`IStorageAdapter.ReadMany`, and add back the declared files whose node is absent.
`InstallCompleteness.FilesToRestore` is pure so every arm pins offline;
`ObservePresent` answers null — never an empty set — when the mesh was not read,
and the caller says so at Warning rather than proceeding as if it had checked. A
restored SHARED Source/Test file falls back to a full install, by the same rule
the existing changed-file guard applies. And a fetch that returns fewer files
than it asked for is now named at Error instead of vanishing.

Verified by a deterministic repro that is not vacuous: reverting only the
`CatalogLayoutAreas.cs` change and rebuilding fails
`AnIncrementalUpdateOverAMissingNode_RestoresIt` in 2 m on the
`WaitForNode(present: true)` timeout; with the change it passes in 3 s.
`InstallCompletenessTest` 21/21, `MeshWeaver.Documentation.Test` 6/6, and every
touched project builds `-c Release -warnaserror` at 0 Error(s) 0 Warning(s).

Work products: `Doc/Architecture/DeclaredIsNotLanded` carries the three exits,
the measured case and the sweep hazard — `Hosting/TriageItem` reads
`compilationStatus: Ok` while all six of its `compiledSources` are gone, so
`content.compilationStatus:Error` counts only types that ATTEMPTED a compile and
failed. `NodeTypeCompilation.md` gains the `shared=@...` decision #4174 left
open: `@` is absolute and never rebases, so a tree carrying an absolute
cross-reference is not importable under a prefix, and the remedy is at the
install boundary.

Refs #4259, #4257, #3485, #4208, #4174

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

0 tests   - 1 844   0 ✅  - 1 652   0s ⏱️ - 4m 53s
0 suites  -     4   0 💤  -   192 
0 files    -     4   0 ❌ ±    0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

362 tests   - 1 087   362 ✅  - 1 087   45s ⏱️ - 1m 26s
  1 suites  -     1     0 💤 ±    0 
  1 files    -     1     0 ❌ ±    0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

498 tests   - 406   498 ✅  - 406   22s ⏱️ - 3m 5s
  1 suites  -   1     0 💤 ±  0 
  1 files    -   1     0 ❌ ±  0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

262 tests  ±0   262 ✅ ±0   1m 51s ⏱️ -29s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    2 files   -   4      2 suites   - 4   1m 52s ⏱️ - 2m 54s
1 405 tests  - 724  1 405 ✅  - 724  0 💤 ±0  0 ❌ ±0 
1 405 runs   - 728  1 405 ✅  - 728  0 💤 ±0  0 ❌ ±0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

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

A critical baseline-stamping issue and several moderate review findings remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR repairs incremental plugin updates by detecting and restoring declared mesh nodes that went missing.

Changes:

  • Adds batched presence checks and restoration logic.
  • Reports package-source fetch shortfalls.
  • Adds regression tests and installation documentation.
File summaries
File Review findings
test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs Moderate (1 vote): the test does not prove the incremental narrow fetch path; capture and assert requested paths.
src/MeshWeaver.PluginCatalog/InstallCompleteness.cs Moderate (2 votes): synchronous ReadMany failures can bypass Catch; wrap the call in Observable.Defer.
src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs Critical (1 vote): fetch shortfalls can still advance the install baseline and preserve stale content. Moderate findings cover duplicated full-manifest reads, missing incremental shortfall coverage, and untested shared Source/Test fallback. Nits cover file/node count units and content-asset diagnostics.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-14-an-update-restores-content-that-went-missing.md No review comments.
src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md No review comments.
src/MeshWeaver.Documentation/Data/Architecture/DeclaredIsNotLanded.md Nit (1 vote): 2026-09-14T06:0xZ is not a valid ISO-8601 timestamp.
src/MeshWeaver.Documentation/Data/Architecture.md No review comments.
Review details

Suppressed comments (6)

src/MeshWeaver.Documentation/Data/Architecture/DeclaredIsNotLanded.md:44

  • 2026-09-14T06:0xZ is not a valid ISO-8601 timestamp, so readers cannot tell whether the x is an intentional redaction or a typo. Use a valid timestamp, or state the date/time was intentionally omitted.
`Plugins/Hosting` on **memex.meshweaver.cloud**, read 2026-09-14T06:0xZ. All reads through `get` /
`search`; nothing was mutated.

src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs:1422

  • The existing shortfall test exercises a full install, not this new incremental FetchPackageFiles(..., wanted) branch. A regression that removes or miscomputes ReportFetchShortfall could therefore still pass the suite; add an incremental source that omits one requested path and assert that the Error log names that path.
                                : source.FetchPackageFiles(pkg, sourceRef, wanted))
                            .Do(fetched => ReportFetchShortfall(pkg.Id, wanted, fetched, logger))

src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs:1408

  • restore.Count counts manifest files, while declaredNodePaths.Count counts distinct node paths and the message labels both as nodes. NodeFileMapper deliberately maps both A.json and A/index.json to the same node, so a missing node can produce a misleading line such as “2 of 1 declared node(s)”. Report these as files (or compute a distinct restored-node count) so the warning's numerator and denominator have the same units.
                                "Updating {Id} incrementally: {Restore} of {Declared} declared "
                                + "node(s) are ABSENT from the mesh although their file hash has "
                                + "not moved, so the diff alone would have skipped them. Re-fetching "
                                + "them: [{Files}] (MeshWeaver#4259).",

src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs:1463

  • wanted contains every manifest file, including content/** assets and other non-node files, while NodePathForFile explicitly excludes those. If a content asset is omitted by the source, this new error says its node cannot be written even though no node is expected, which misdiagnoses the operational failure. Make the message type-neutral or report node and non-node shortfalls separately.
            "Updating {Id} incrementally: the source returned {Fetched} of the {Wanted} file(s) "
            + "asked for — [{Missing}] did NOT travel, so their nodes cannot be written, and the "
            + "install record will still declare them (MeshWeaver#4259).",

src/MeshWeaver.PluginCatalog/CatalogLayoutAreas.cs:1394

  • The new missing-node path deliberately falls back to a full install when an absent file is under the package-level Source/ or Test/ prefix, because an incremental owner release would leave sibling NodeTypes stale. The added tests cover ordinary missing-node restoration and the pure helper arms, but none exercises this branch or verifies that a sibling is recompiled; a future change could remove this safety rule while the current regression test still passes.
                        if (restore.Any(p => sharedPrefixes.Any(
                                s => p.StartsWith(s, StringComparison.Ordinal))))
                            throw new InvalidOperationException(
                                $"Package '{pkg.Id}' is missing shared Source/Test node(s) "
                                + $"([{string.Join(", ", restore.Take(InstallCompleteness.MaxNamedInALine))}]); "
                                + "full install required.");

test/Memex.Portal.Shared.Test/InstallCompletenessTest.cs:1318

  • Written.Should().BeGreaterThan(0) does not prove that the incremental path or its narrow fetch set was used: a regression that falls back to Full() can produce the same result, and IncrSource does not record the requested paths. Since this test claims to protect hash-based fetch efficiency, make the source capture the second paths argument and assert that the update requested only the changed file plus the missing Guide.md, not the whole package.
        second.Written.Should().BeGreaterThan(0,
            "the update changed a file, so it must have written something — a zero here would mean "
            + "the incremental path never ran and this test is measuring the wrong exit");
  • Files reviewed: 7/7 changed files
  • Comments generated: 3
  • 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 +1421 to +1423
: source.FetchPackageFiles(pkg, sourceRef, wanted))
.Do(fetched => ReportFetchShortfall(pkg.Id, wanted, fetched, logger))
.SelectMany(changedFiles => PackageInstaller.InstallNodeRepoDelta(
Comment on lines +1374 to +1377
return InstallCompleteness
.ObservePresent(
hub.ServiceProvider.GetService<IStorageAdapter>(),
hub.JsonSerializerOptions, declaredNodePaths)
if (nodePaths.Count == 0)
return Observable.Return<IReadOnlySet<string>?>(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.Ordinal));
return persistence.ReadMany(nodePaths, options)
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    1 files   -   2      1 suites   - 2   4m 47s ⏱️ - 1m 30s
1 506 tests +445  1 506 ✅ +447  0 💤  - 2  0 ❌ ±0 
1 510 runs  +449  1 510 ✅ +451  0 💤  - 2  0 ❌ ±0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    6 files   -    12      6 suites   - 12   9m 39s ⏱️ - 14m 17s
4 033 tests  - 3 616  4 033 ✅  - 3 422  0 💤  - 194  0 ❌ ±0 
4 037 runs   - 3 616  4 037 ✅  - 3 422  0 💤  - 194  0 ❌ ±0 

Results for commit 2c3e46a. ± Comparison against base commit 0a45bcc.

♻️ This comment has been updated with latest results.

@rbuergi
rbuergi marked this pull request as draft September 14, 2026 07:04
auto-merge was automatically disabled September 14, 2026 07:04

Pull request was converted to draft

@rbuergi

rbuergi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Held as draft — author terminated, CI red, three review findings standing

I have converted this to draft, which disabled auto-merge (auto_merge: false verified after the conversion). Draft alone is the correct hold here because the PR was not admitted to the queue — mergeable_state: blocked on failing checks, so there was no queue entry to retract. (Had it been admitted, draft would not have held it: an admitted entry merges regardless of draft state, and the hold would have been GraphQL dequeuePullRequest first, then draft.)

Why it is held. This PR was armed with --auto and carries three unaddressed Copilot findings, one of which is a correctness defect. That combination — a change set set to merge on checks alone while a valid review finding stands — is precisely the gap #4269 exercised. Its author was terminated mid-work by an API rate limit that resets 2026-09-17 13:00 Europe/Zurich, so nobody was going to answer them.

1. CI red — one failing test, and it is a rule this repo enforces deliberately

MeshWeaver.Documentation.Test.TestTimeoutLiteralRatchetGuard.TheHandWrittenTimeoutCountOnlyEverGoesDown
(shard 5, run 34813326232; 1 ❌ of 4 022, classified TESTFAIL — ordinary failing tests, not a host death).

This diff added hand-written TimeSpan literal(s) in tests. The ratchet only goes down: the fix is TestTimeouts.*, never raising the guard's count. Small and mechanical, but it must be done rather than worked around.

2. Three Copilot findings, none answered

  • 🚨 Correctness — the install baseline advances through a fetch shortfall. InstallNodeRepoDelta unconditionally stamps newManifest as the next InstalledFiles baseline. If a requested changed file is omitted while its old node is still present, VerifyLanded sees the node present and reports completeness — so the record claims the new hash while the old content remains, and the next same-module update skips it permanently. That is durable stale content, not a transient miss. Do not advance the baseline when missing is non-empty.
  • Cost — Verified(...) still calls VerifyLanded after InstallNodeRepoDelta, so every incremental update now performs two full-manifest ReadMany probes, not the one the PR description claims. Either account for that explicitly or redesign so the second read is not needed.
  • A synchronous throw bypasses the Catch — ReadMany is invoked before the Rx chain exists, so an adapter that throws synchronously skips the documented null/Warning path and falls back to a full package install. Observable.Defer gives synchronous and asynchronous failures the same conservative outcome.

The first one is worth restating: it is the same shape as the defect this PR exists to fix (#4259 — "three declared source nodes never landed and the install logged nothing"). Shipping it as-is would replace a silent miss with a silent miss that is also durable.

Resuming

The branch is fix/4259-incremental-repair, pushed and intact. Work is: fix the timeout literal, answer the three findings, undraft. Do not undraft it before the findings are answered — undrafting re-arms auto-arm.yml, and the checks will pass long before a reviewer does.


🤖 Generated with Claude Code

…false record

Answers the three review findings on #4277 and the ratchet red.

1. 🚨 A fetch shortfall now FAILS the incremental update (Copilot). InstallNodeRepoDelta
   stamps newManifest as the next InstalledFiles baseline unconditionally, so a file
   that never travelled — while its OLD node is still present, the ordinary case —
   left VerifyLanded seeing a present node and reporting completeness. The record then
   claimed the NEW hash over OLD content and every later update of that module diffed
   clean and skipped it permanently. ReportFetchShortfall only logged; it is now
   EnsureFetchComplete and throws "full install required", the same mechanism the
   absent-shared-source arm already uses, so the caller falls back to a full install
   and the user still gets the package. The earlier "reports only, an install that ran
   is a fact" reasoning is corrected in the XML doc: the failure was never "landed
   short", it was "landed short and then lied about it".

   The shortfall is also no longer computed only when a logger exists — that made the
   guard vanish exactly where diagnostics are off.

2. ObservePresent's ReadMany is wrapped in Observable.Defer (Copilot). It was a method
   CALL evaluated before the Rx chain existed, so an adapter throwing SYNCHRONOUSLY
   escaped past the null/Warning path and forced a full re-fetch on every such fault.

3. The second full-manifest probe is documented at the call site rather than removed
   (Copilot). The preflight and VerifyLanded answer different questions on either side
   of the write; the added cost is one ReadMany over declaredNodePaths.

Ratchet: the one 30.Seconds() literal this branch added is now TestTimeouts.Convergence.
TestTimeoutLiteralRatchetGuard passes (3/3). The guard is deliberately narrow — it
catches only the copied 30 s convention — so the 60/120/180 s bounds here are untouched.

EnsureFetchComplete is internal (InternalsVisibleTo already covers this test assembly)
so the guard is reachable: three tests pin throw-on-shortfall, no-throw-when-complete,
and detection-without-a-logger. Negative control run: with the throw disabled, 2 of the
3 fail and the no-throw control still passes; restored, 3/3 green.

Builds: MeshWeaver.PluginCatalog and Memex.Portal.Shared.Test, Release -warnaserror,
0 Error(s) each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

All three findings answered, ratchet fixed, undrafting

Picked this up after its author was terminated by the rate limit. 2c3e46a4cb.

1. The install baseline advancing through a fetch shortfall — fixed, and you were right

This was the real one. ReportFetchShortfall only logged, and InstallNodeRepoDelta then stamped newManifest as the next InstalledFiles baseline regardless. With the old node still present — the ordinary case — VerifyLanded sees a present node and reports completeness, so the record claims the new hash over old content and every later update of that module diffs clean and skips it permanently.

It is now EnsureFetchComplete and it throws "full install required" — the same mechanism the absent-shared-source arm a few lines above already uses, so the caller falls back to a full install and the user still gets the package.

I also corrected the XML doc, which argued the opposite: "Reports only; it never fails the update. The install that did run is a fact, and collapsing 'landed short' into 'failed' would make a working update a new way to break." That reasoning does not survive what happens next — the failure was never "landed short", it was "landed short and then lied about it", and the fallback means nobody loses an update.

Second defect found while there: the shortfall was computed only if (logger is not null), so the guard disappeared exactly where diagnostics are off. Detection no longer depends on anyone listening.

2. ReadMany bypassing the Catch on a synchronous throw — fixed

Wrapped in Observable.Defer, with the reason in a comment so it is not "tidied" away later: it is a method call evaluated before the chain exists, so a synchronous throw escaped the null/Warning path and forced a full re-fetch on every such fault.

3. The second full-manifest probe — accounted for explicitly, not removed

Taking the first of the two options you offered. The reads are not redundant and neither can go: the preflight asks which declared nodes are ABSENT so the delta must re-fetch them, before the write — that is the whole of #4259 — and VerifyLanded asks did what we just wrote land, after it. Collapsing them gives either a re-fetch decision made on a pre-install verdict, or a lost node discovered only on the next update. The added cost is one ReadMany over declaredNodePaths, bounded by the manifest rather than by mesh size, and paid only on the incremental path. That is now stated at the call site.

The CI red

TestTimeoutLiteralRatchetGuard.TheHandWrittenTimeoutCountOnlyEverGoesDown — this branch added one 30.Seconds(), now TestTimeouts.Convergence. The guard is deliberately narrow (only the copied 30 s convention), so the reasoned 60/120/180 s bounds in this file are untouched. Guard passes 3/3.

Verification

EnsureFetchComplete is internal (InternalsVisibleTo already covers Memex.Portal.Shared.Test) so the guard is reachable at all — three tests pin throw-on-shortfall, no-throw-when-complete, and detection-with-no-logger.

Negative control run, not assumed: with the throw disabled, 2 of 3 fail and the no-throw control still passes; restored, 3/3 green. Builds: MeshWeaver.PluginCatalog and Memex.Portal.Shared.Test, Release -warnaserror, 0 Error(s) each.


🤖 Generated with Claude Code

@rbuergi
rbuergi marked this pull request as ready for review September 14, 2026 07:12
@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit f4d9a5c Sep 14, 2026
37 checks passed
@rbuergi
rbuergi deleted the fix/4259-incremental-repair branch October 10, 2026 13:10
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