Repository navigation
Build the re-evaluation lane, so the toolchain MVID can demote from invalidation unit to trigger #1976
Description
Follow-up to #1707 slice 1 (#1712 / #9d55ac5df). The extraction did what it said — MeshWeaver.Graph is no longer a full-MVID root — but measured, the identity now moves MORE often than it did before, not less.
What slice 1 was for
"Factor out minimalistic build tools … so
FrameworkBuildIdentity.FullMvidAssembliespins a low-churn assembly instead of the highest-churn one in the repo." — #1707, requirement 2
and its acceptance:
"A body-only edit anywhere in
MeshWeaver.Graphoutside the toolchain rebakes nothing."
The acceptance line holds. The requirement it was serving does not.
Measured
FullMvidAssemblies is no longer a list — it is a computed transitive closure over the roots' MeshWeaver.* AssemblyRefs (src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs:192-219):
internal static readonly ImmutableArray<string> ToolchainRoots =
["MeshWeaver.Compiler", "MeshWeaver.NuGet"];
private static readonly Lazy<ImmutableArray<string>> _fullMvidAssemblies = new(() =>
ComputeToolchainClosure(ToolchainRoots, ReferencedMeshWeaverAssembliesOf));Resolved against the built assemblies, that closure is 16 assemblies, because MeshWeaver.Compiler → MeshWeaver.Mesh.Contract → Layout / Messaging.Hub / Data.Contract, and ContentCollections → Markdown:
MeshWeaver.Compiler MeshWeaver.Layout MeshWeaver.Reflection
MeshWeaver.ContentCollections MeshWeaver.Markdown MeshWeaver.ServiceProvider
MeshWeaver.Data MeshWeaver.Mesh.Contract MeshWeaver.ShortGuid
MeshWeaver.Data.Contract MeshWeaver.Messaging.Contract MeshWeaver.Utils
MeshWeaver.Domain MeshWeaver.Messaging.Hub
MeshWeaver.Kernel MeshWeaver.NuGet
Commits on origin/main in the last 30 days, per member — the number that matters is how often anything in the set moves, because any one of them mints a new global identity and rebakes the world:
| assembly | commits/30d |
|---|---|
| MeshWeaver.Mesh.Contract | 190 |
| MeshWeaver.Messaging.Hub | 135 |
| MeshWeaver.Data | 59 |
| MeshWeaver.Layout | 40 |
| MeshWeaver.Messaging.Contract | 17 |
| MeshWeaver.Data.Contract | 15 |
| MeshWeaver.ContentCollections | 14 |
| MeshWeaver.Compiler | 12 |
| Markdown / Utils / NuGet / Kernel | 5 / 3 / 2 / 1 |
| Domain / Reflection / ServiceProvider / ShortGuid | 0 |
| union of the closure | 383 |
(for comparison) MeshWeaver.Graph, the OLD sole root |
311 |
before #1712: public static readonly ImmutableArray<string> FullMvidAssemblies = ["MeshWeaver.Graph"];
→ 311 commits/30d could mint a new identity
after #1712: computed 16-assembly closure
→ 383 commits/30d can mint a new identity
So the highest-churn single assembly left the boundary and four more of the top five walked in behind it. MeshWeaver.Mesh.Contract alone (190) is over half of Graph's whole rate, and it is the assembly nearly every change in this repo touches.
Why the closure is nonetheless the right shape
The comment at FrameworkBuildIdentity.cs:179-186 is not wrong:
"The set is COMPUTED from the roots' AssemblyRef metadata … rather than hand-listed, so a new toolchain dependency can never be silently outside the identity."
A hand-list is how NuGetDirectiveParser sat outside the boundary while shaping compile input — the hole #1707 opened by naming. Reverting to a hand-list would re-open it. The problem is not the walk; it is that the roots reach Mesh.Contract.
Options, none of them a one-line change
- Cut
MeshWeaver.Compiler's reference toMesh.Contract/ContentCollections. It needsIAssemblyStore,AtomicFileWrite,MeshNodeand the content-collection config shapes. AMeshWeaver.Compiler.Contractholding just those would let the walk terminate before Layout/Messaging.Hub. Biggest win, most work, and it must not fracture the surface manifest (fix(#1814): the bake published to an address no portal read — restore the portal's canonical surface + guard the equality #1847 pinned bake and portal to the SAME canonical set — a split has to move both). - Ship slice 4 and delete the full-MVID rule entirely. Compile cleanup: MeshWeaver.Compiler, dependency-scoped rebuilds, consume-prebuilt-everywhere (maintainer-directed) #1707 already names this as the end state: "a toolchain change invalidates exactly the types whose generated input actually changed, and the full-MVID rule can be deleted." Strictly better than any narrowing, and it makes this issue moot rather than solved.
- Do nothing and accept it, on the argument that per-type
CompiledDependencies(feat(#1707 slices 2+3): per-type dependency records + adopt-before-compile at install, push, and release #1719) already keeps a stale global identity from serving wrong bytes. That is true — but the identity is still what empties the share's key-space and forces the rebake, which is the cost NodeType assemblies are built in CI and LOADED at runtime — no runtime Roslyn, no warm-up window (maintainer-directed) #1660 exists to remove.
Recommendation: (2), on #1707's own sequencing. Filing this so the measurement is on the record either way — slice 1's stated acceptance passes while its purpose regressed, and that combination is exactly the kind that stays invisible.
How to re-measure
# the closure, resolved against built assemblies
dotnet run --project <a console referencing src/MeshWeaver.Compiler> \
# prints FrameworkBuildIdentity.FullMvidAssemblies
# churn of the union
git log --oneline --since=30.days --no-merges origin/main -- \
$(printf 'src/%s ' MeshWeaver.Compiler MeshWeaver.ContentCollections MeshWeaver.Data \
MeshWeaver.Data.Contract MeshWeaver.Domain MeshWeaver.Kernel MeshWeaver.Layout \
MeshWeaver.Markdown MeshWeaver.Mesh.Contract MeshWeaver.Messaging.Contract \
MeshWeaver.Messaging.Hub MeshWeaver.NuGet MeshWeaver.Reflection \
MeshWeaver.ServiceProvider MeshWeaver.ShortGuid MeshWeaver.Utils) | wc -lA regression test worth having regardless: assert the closure does NOT contain MeshWeaver.Layout / MeshWeaver.Messaging.Hub once (1) or (2) lands, so it cannot silently widen again. Today FrameworkBuildIdentityTest.CanonicalList_MatchesTheTesterClosure asserts membership of the roots and does not bound the set.
🤖 Generated with Claude Code
Activity
Merge pull request #1977 from Systemorph/test/1976-pin-toolchain-closure
[-]The full-MVID boundary got BIGGER, not smaller: the toolchain closure spans 16 assemblies and 383 commits/30d (was Graph alone, 311)[/-][+]Build the re-evaluation lane, so the toolchain MVID can demote from invalidation unit to trigger[/+]rbuergi commentedon Aug 22, 2026
Retargeted (maintainer decision, 2026-08-22). The original framing — the full-MVID boundary got bigger, shrink it — cannot be acted on, and slice 4 established why.
Why the closure cannot simply be deleted
FullMvidAssembliesis currently the only signal that a generator body change invalidates compiled bytes. Slice 4 built the generated-input content key (#1994, merged) and then deliberately did not delete the rule, because the key has no cheap live counterpart by construction: evaluating it means regenerating the compilation input, and every rebuild-or-not consumer is deliberately metadata-only.Trace a body-only toolchain commit that does change generated input (the #1802 global-usings fix and the
AnchorIncludePathfix are both this shape) throughHasStaleFrameworkBuild— the only automatic re-drive: framework version unchanged, per-assembly entries unchanged,!toolchainconstant,!inputskipped becauseFindMismatchhas no live value. Result false. Nothing recompiles, stale bytes are served, and it surfaces as aTypeLoadExceptioninside an ALC at activation with no diagnostic.That is pinned as a test —
DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain— rather than left as prose.What this issue is now
Build the re-evaluation lane: on a toolchain change, regenerate the input and compare. Equal ⇒ keep the bytes and restamp. Different ⇒ compile. The MVID then demotes from invalidation unit to cheap trigger, and the closure's size stops mattering — which resolves the original complaint properly rather than by narrowing a list.
Slice 4 is the precondition and it has landed. Note also that determinism was a silent precondition nobody had noticed was missing: before #1994's join-order fix the mesh compiled identical content into a different assembly on every process, so a re-evaluation lane built earlier would have regenerated a different key every time and recompiled every type forever.
#1977's pin stays
It guards a boundary we have now documented as un-deletable-for-now. It becomes removable when this lane exists, not before.
rbuergi commentedon Aug 27, 2026
Not covered by #2492 — measured, not assumed. But the lane is blocked on a store capability that does not exist.
Picked this up to build the lane. Two findings: the first clears #2492 out of the way, the second is
a scope call I am not making unilaterally.
1. #2492 renamed a state. It did not move the lane one inch.
Its own PR body says so — "Same NodeTypes rebuild at the same time — only the name and the
diagnostics change" — and the code confirms it independently rather than on the author's word:
FrameworkStaleandDependencyStaleare therefore behaviourally identical — bothNeedsBake,both
WasHealthy, and thereturnstill preempts the bytes-win rule. #2492 madeFrameworkStalereachable and recovered the discarded mismatch string. Valuable, and orthogonal: what recompiles,
and when, is byte-for-byte what it was. So "demote from invalid" does not partly already exist as
FrameworkStale.2. The precondition is stamped on every compile and read by nothing
This is the sharper finding.
CompiledDependencies.FindMismatchhas carried theliveContentKeyparameter since #1994:Every production call site passes the 3-arg overload, so that
continuefires 100% of the time:liveContentKeyNodeTypeBakeStatus.ClassifyDetailed—NodeTypeBakeStatus.cs:249NodeTypeCompilationHelpers.DependenciesValid(HasUsableBuild/HasStaleFrameworkBuild) —:1655NodeTypeCompilationHelpers.cs:736PrebuiltAssemblySeeder.Seedadoption gate —PrebuiltAssemblySeeder.cs:280The only 4-arg callers in the repo are two assertions inside
GeneratedInputIdentityTest.!inputis written on every compile and compared by nothing. That is the lane's missing half, exactly as
DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchainpredicts.3. 🚨 The blocker: "keep the bytes and restamp" has no read path
The issue's shape is "Equal ⇒ keep the bytes and restamp. Different ⇒ compile." The restamp half
is cheap. The keep the bytes half cannot be written today, and this is structural, not an
oversight:
IAssemblyStore.TryGetAssemblyPath(nodeTypePath, version)takes no tag and globs the live one.So after a framework roll a type's previous bytes are unaddressable, and there is no
fetch-by-
(Collection, ContentPath)either —MeshDataSource.cs:1725andMeshOperations.cs:2731use
LatestAssemblyCollectiononly to pick the store, then look up by(path, version), whichre-applies the live tag.
The bytes are still on the volume — per-type eviction deliberately never crosses the tag boundary
(
FileSystemAssemblyStore.cs:226-228) — so this is a missing read, not missing data. Adding it isone method. What makes it a scope call is the reason the boundary is there, stated in that same
comment:
Carrying a build across the tag boundary on the strength of the content key is precisely a
cross-generation load. The key is designed to make that sound — "two compiles whose generated input
hashes equal produce interchangeable bytes, whoever built them and whenever" — but it also carries
four deliberate coarsenings (
GeneratedInputIdentity: the BOM drop, the CRLF→LF fold, and the twonormalised wall-clock lines), each documented as a trade. Betting the 2026-06-20 failure mode on them
is a decision for the maintainer, not for an implementing session.
The design, ready to build once that call is made
CompiledDependencies.FindMismatchAfterReevaluation(record, liveIdOf, liveToolchainId, liveContentKey)—the demotion itself, pure and unit-testable like
Classify. When a live key is supplied and itmatches
!input, the!toolchainentry stops being decisive (MVID = trigger, not invalidationunit); every assembly
ref:/mvid:entry still decides, so a real surface or module drift isstill a mismatch. When
!inputdiffers it is decisive — which also closes theunder-invalidation hole the pinned test names (a body-only toolchain commit that does move
generated input). Kept separate from
FindMismatchso the four metadata-only call sites keeptheir exact current semantics.
NodeSetCompiler.ResolveInputs(documented "WITHOUT compiling") +GeneratedInputDigestOf(
NodeSetCompiler.cs:101,265). The runtime lane does not: the digest is taken inline threestatements before Roslyn at
MeshNodeCompilationService.cs:1698-1704. Extracting lines 1657-1704into a
RegenerateContentKeyreturningIObservable<string?>(async leaf bridged throughIIoPool, neverObservable.FromAsync) is the work item.frameworkStaleKickoffSub,NodeTypeCompilationHelpers.cs:646-767. It already has the right shape —Take(1), then abounded probe whose whole purpose is to avoid a rebuild (
:707-717). The lane is a secondpredicate after it: regenerate → compare → carry forward and restamp, else flip
Pending.DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchainbecomes the lane's positive testrather than a pin on its absence, and
NodeTypeCompilation.mdgains theBakeStatesection itcurrently lacks entirely.
What I need decided
Is a content-key-gated cross-generation assembly load acceptable, given 2026-06-20? If yes, the
lane is one PR along the five points above. If no, the alternative is to make the roll cheap at the
producer end instead (bundle coverage for the 273 uncovered types), which is a different issue and
leaves the MVID as the invalidation unit.
No files were touched for #1976.