Skip to content

fix(adoption): the stale-prebuilt refusal was armed and INERT — make it reachable - #2947

Merged
rbuergi merged 3 commits into
mainfrom
fix/2813-adopted-source-fingerprint
Sep 1, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/2813-adopted-source-fingerprint

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

The refusal built by #2821 has never fired

#2821 shipped the three-way comparison that refuses a prebuilt assembly whose recorded source
disagrees with the source the mesh holds, and #2820 records the consequence in one line: "Today
every adoption reads AdoptedUnverified, because nothing writes a source fingerprint into a bundle
yet."

It was worse than that. Four links were missing between a bundle and that comparison, and every
one of them fails silently — a guard whose input never arrives is indistinguishable from a guard
that passes:

  1. PrebuiltAssemblySeeder.Seed had a seven-parameter convenience overload that hard-coded
    sourceFingerprint: null, and both production callers bound to it (PluginBundleClient,
    ShippedPrebuiltBundles). AdoptedSourceFingerprint was therefore never written and the first
    guard in ApplyAdoptedSourceStamp short-circuited.
  2. No producer wrote a fingerprint into a bundle at all.
  3. BundleReader had nowhere to carry one.
  4. The live half was computed only under a condition the incident's own ordering never
    satisfies.
    The owner computed CurrentSourceFingerprint only "when there is something to
    compare it against". But the owner publishes its snapshot first; the adoption's patch then
    lands carrying both the fingerprint and the stamp request, and the sources watcher does not
    re-run — its DistinctUntilChanged keys on the source queries, which did not change. The
    judgement then read an absent live value and took the inconclusive branch. So even with links
    1–3 closed, the refusal would still not have fired in the ordering that destroyed the documents.

All four are closed, and the refusal now fires end to end.

The load-bearing decision: what is hashed

Producer and consumer must hash identically, or every good bundle is refused — a self-inflicted
outage strictly worse than the staleness bug. The new NodeTypeSourceFingerprint hashes the
compile input: the NodeCompileShaping.CollectCompileSources fold — the same call both the
runtime compile path and the tree bake already make, so they cannot fork on which files count —
reduced to (node path, SHA-256 of the code text) and folded with PartitionSourceFingerprint
(which sorts by path, so enumeration order cannot reach the result).

It lives in MeshWeaver.Compiler because FrameworkBuildIdentity.FullMvidAssemblies is that
assembly's transitive closure, and adoption is already gated on framework identity — so a producer
and a consumer that can adopt across each other necessarily run the same implementation of the hash.

Hashing the source nodes' serialised content — what the live half did — cannot work across
processes, and each failure mode is a FALSE REFUSAL:

  • CodeConfiguration carries run bookkeeping (LastExecutedAt, LastExecutedBy,
    LastExecutedCodeHash, LastActivityPath) written when a reader presses Run on a code cell.
    The live hash would move with no source change, and no producer can know those values.
  • The consumer has a hub and therefore a TypeRegistry (polymorphic $type); the compiler-driven
    bake deliberately has neither — TreeNodeLoader materialises exactly the two content types a
    compile reads and leaves the rest null, precisely so a half-populated registry cannot degrade
    content to JsonElement. Two honest readers, two different JSON strings.
  • Node metadata (description, icon, order) is not compile input.

Also fixed

ApplyCompileSuccess never reset BuildProvenance, so a type refused as stale and then
successfully recompiled kept reading AdoptionRefused forever. One refusal in a node's history
would have marked it permanently — turning the operator signal the incident asked for into noise,
and making #2820's execute-time interlock refuse a type whose live source it had just compiled
itself. ApplyCompileFailure deliberately still does not reset it.

The obsolete overload, and why it is not deleted

AGENTS.md requires sweeping the live mesh before removing public framework surface. That sweep
could not be completed
: search_chunks answers "searched": false on both reachable deployments
(content indexing inactive) — a FAILED sweep, not a clean one. Both repos' node trees were swept by
hand and hold no caller.

So the seven-parameter overload is [Obsolete(error: true)] rather than deleted: the symbol stays,
nothing already compiled can break, and every source call site now fails loudly with the reason.
Passing null remains legitimate for a genuinely legacy bundle — but it must be written at the call
site, where a reviewer can see the claim being waived.

Verification — every new test was proven able to fail

Each inversion was applied, built, run, and restored:

inversion expected observed
consumer passes null (i.e. restore the exact defect) refusal + verified tests red, legacy green ✅ refusal test red at 90 s timeout; verified test red with AdoptedUnverified; legacy green
salt one side's hash (producer ≠ consumer) verified test red, equivalence test red ✅ verified test red with Compiled (refused → recompiled); BakeEquivalenceTest red, "Strings differ"
legacy branch claims AdoptedVerified legacy test red, alone ✅ legacy test red, 1 of 5

Green after restore, and:

  • MeshWeaver.Graph.Test — 1607/1607
  • AdoptedBuildSourceStampTest — 5/5, three of them new, each driving a real bundle through
    BundleWriter → BundleReader → ShippedPrebuiltBundles → Seed → the owner's stamp on a real mesh
  • BakeEquivalenceTest — bakes one content set both ways (through a real mesh, whose value is
    the consumer-side value, and through the mesh-free tree bake) and asserts the fingerprints are
    equal, non-empty, and different for different types. This is the equality proof; without it the
    fix would be an outage.
  • scripts/check-record-signatures.py and scripts/check-type-forwards.py both clean (the new
    fields are init properties, never primary-constructor parameters — adding one is a binary
    break across a mixed fleet).
  • Every touched project built -c Release -warnaserror, one per invocation, 0 Error(s).

Stated residue

An @@ include pulls a Code node no source query matches, so it is outside the fold and a change to
an included-only snippet does not move the fingerprint. This is not new — it is the same set
CompiledSources records, so IsDirty has always missed it too. Closing it means resolving
includes on the live side (a mesh read per include). It is named in the doc and in the code rather
than left implied, because a guard whose coverage is assumed is exactly how this mechanism went
inert the first time.

Docs

Doc/Architecture/NodeTypeCompilation documented the three-way as if it were live. It now states
what actually reaches the comparison, why the fingerprint shape is what it is, and the general
lesson: "the fix merged" is not "the fix runs." Plus a What's New entry (Category: Fix).

Closes #2813

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm

…it reachable (#2813)

#2821 built the three-way comparison that refuses a prebuilt assembly whose recorded
source disagrees with the source the mesh holds. It has never fired. Four links were
missing between a bundle and that comparison, and every one of them failed silently:

1. `PrebuiltAssemblySeeder.Seed` had a seven-parameter convenience overload that
   hard-coded `sourceFingerprint: null`, and BOTH production callers bound to it
   (`PluginBundleClient`, `ShippedPrebuiltBundles`). `AdoptedSourceFingerprint` was
   therefore never written and the first guard in `ApplyAdoptedSourceStamp`
   short-circuited: every adoption on every mesh returned `AdoptedUnverified`.
2. No producer wrote a fingerprint into a bundle at all.
3. `BundleReader` had nowhere to carry one.
4. The owner computed `CurrentSourceFingerprint` only "when there is something to
   compare it against" — a condition the incident's own ordering never satisfies. The
   owner publishes its snapshot first; the adoption's patch then lands carrying the
   fingerprint and the stamp request, and the sources watcher does not re-run because
   its `DistinctUntilChanged` keys on the source QUERIES, which did not change. The
   judgement read an absent live value and took the inconclusive branch.

All four are closed.

**The fingerprint shape is the load-bearing decision.** Producer and consumer must hash
identically or every good bundle is refused — an outage strictly worse than the bug. The
new `NodeTypeSourceFingerprint` (in `MeshWeaver.Compiler`, inside the framework-identity
boundary, so a producer and consumer that can adopt across each other necessarily run the
same implementation) hashes the COMPILE INPUT: the `NodeCompileShaping.CollectCompileSources`
fold — the same call both the runtime and the bake already make — reduced to
`(node path, SHA-256 of the code text)`, folded with `PartitionSourceFingerprint`.

Hashing the source nodes' serialised content, which is what the live half did, cannot work
across processes and each failure mode is a FALSE REFUSAL: `CodeConfiguration` carries run
bookkeeping (`LastExecutedAt`, …) that moves with a Run button and no producer can know;
the consumer has a TypeRegistry and the compiler-driven bake deliberately has none, so the
two serialise the same node differently by design; and node metadata is not compile input.

Also fixed: `ApplyCompileSuccess` never reset `BuildProvenance`, so a type refused as stale
and then successfully recompiled kept reading `AdoptionRefused` forever.

The seven-parameter overload is `[Obsolete(error: true)]` rather than deleted: AGENTS.md
requires sweeping the live mesh before removing public framework surface, and that sweep
cannot be completed — `search_chunks` answers `"searched": false` on both reachable
deployments, which is a FAILED sweep. Both repos' node trees were swept by hand and hold no
caller, so the symbol stays and nothing already compiled can break, while every source call
site now fails loudly with the reason.

Verified — each new test was proven able to fail by inverting one condition and restoring:
- restoring the defect (consumer passes null): refusal test times out red, verified test
  reports `AdoptedUnverified`, legacy test still green;
- salting one side's hash: verified test red, `BakeEquivalenceTest` red ("Strings differ");
- legacy branch claiming verified: legacy test red, alone.

Closes #2813

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
The worst outcome of the fingerprint comparison is not a missed staleness — it is
refusing bundles that are fine, which would make every portal recompile everything.
That failure was invisible to CI by construction: `Seed` returns true, the
adopted/declared counts still balance (the owner refuses afterwards, when it stamps),
the type flips to Pending, recompiles locally, and every per-type verdict is green.

bake-then-gate.sh stages the bake and the gate from the SAME tree, so an
"ADOPTION REFUSED" line in that run can only mean the compiler-driven producer and the
runtime hash the same content differently. assert-bake-consumption.sh now fails on the
phrase, with its own message rather than the fallback one (a reader sent to look for a
declined bundle would find a perfectly adopted one).

Proven able to fail: clean logs pass, an injected refusal line exits 1 naming #2813.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
@rbuergi

rbuergi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Added a second, stronger pin for the equality claim — and it is the one that runs on the real doc and samples trees rather than a fixture.

bake-then-gate.sh stages the bake and the gate from the same tree. So an ADOPTION REFUSED line in that run cannot mean staleness; it can only mean the compiler-driven producer and the runtime hash the same content differently — a false refusal, which is the worst outcome this change could have (it would make every portal recompile everything, and on a RequirePrebuilt mesh it would park every type).

That failure was invisible to CI by construction, in exactly the way the original defect was: Seed returns true, the adopted/declared counts still balance because the owner refuses afterwards when it stamps, the type flips to Pending, recompiles locally, and every per-type verdict is green. Only the log says anything — and only because the refusal logs at Error, above the gate's default level.

assert-bake-consumption.sh now fails on the phrase, with its own message rather than the fallback one (a reader sent to look for a declined bundle would otherwise find a perfectly adopted one).

Proven able to fail before shipping: clean logs exit 0; an injected ADOPTION REFUSED line exits 1 naming #2813.

Also filed #2948 for the stated residue (@@ includes are outside the fold — pre-existing, shared with CompiledSources/IsDirty).

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 makes the “stale prebuilt adoption refusal” path (#2813 / #2821) actually reachable end-to-end by ensuring a producer records a source-content fingerprint into bundles, the reader preserves it, the consumer passes it into adoption, and the owner computes its live fingerprint unconditionally so the three-way comparison can fire in the real event ordering.

Changes:

  • Add NodeTypeSourceFingerprint and record per-assembly SourceFingerprint in bundle manifests, carrying it through BundleWriter → BundleReader → PrebuiltAssemblySeeder.
  • Update adoption/seeding call sites to pass the fingerprint explicitly (with null only when intentionally claiming a legacy bundle), and make the 7-arg Seed overload [Obsolete(error: true)].
  • Adjust owner-side compilation/source watchers to compute and persist CurrentSourceFingerprint reliably and reset BuildProvenance to Compiled on successful local compilation; add end-to-end tests and documentation/What’s New entry.

Reviewed changes

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

Show a summary per file
File Description
tools/MeshWeaver.PluginTester/TreeBake.cs Record SourceFingerprint when emitting baked assembly entries.
tools/MeshWeaver.PluginTester/CascadeBuild.cs Record SourceFingerprint for cascaded package builds.
tools/MeshWeaver.PluginTester/BakeOutput.cs Compute and attach live source fingerprints for mesh-driven bake outputs.
test/MeshWeaver.PluginTester.Test/BakeEquivalenceTest.cs Assert mesh-driven vs compiler-driven bakes produce equal, non-empty, discriminating fingerprints.
test/MeshWeaver.PluginCatalog.Test/ShippedPrebuiltBundlesTest.cs Update cref to new Seed signature.
test/MeshWeaver.PluginCatalog.Test/AdoptedBuildSourceStampTest.cs Add end-to-end tests covering verified/unverified/refused adoption paths via real bundles.
src/MeshWeaver.PluginCatalog/PluginBundleClient.cs Pass bundle SourceFingerprint into PrebuiltAssemblySeeder.Seed.
src/MeshWeaver.Plugin.Packaging/BundleWriter.cs Add AssemblyEntry.SourceFingerprint and write it into the manifest.
src/MeshWeaver.Plugin.Packaging/BundleReader.cs Carry manifest fingerprint into payloads for consumers.
src/MeshWeaver.Hosting/ShippedPrebuiltBundles.cs Thread SourceFingerprint through shipped bundle seeding.
src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs Make the old overload obsolete(error) and add required fingerprint parameter to the new overload.
src/MeshWeaver.Graph/Configuration/NodeTypeDefinition.cs Update docs/crefs describing adopted source fingerprint behavior.
src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs Compute/persist live fingerprint unconditionally; wait for live fingerprint before consuming one-shot stamp requests; reset BuildProvenance on compile success.
src/MeshWeaver.Graph/Configuration/NodeTypeAdoptionRegistry.cs Update cref to new Seed signature.
src/MeshWeaver.Compiler/NodeTypeSourceFingerprint.cs New shared fingerprint implementation over compile inputs (paths + code text SHA).
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-prebuilt-assembly-is-now-checked-against-the-source-you-actually-have.md New user-facing “Fix” entry describing verified/refused/unverified adoption.
src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md Document what is hashed, how the fingerprint travels, and why the earlier implementation was inert.

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

Comment thread tools/MeshWeaver.PluginTester/BakeOutput.cs Outdated
Comment on lines +234 to +242
var access = workspace.Hub.ServiceProvider.GetService<AccessService>();
return Observable
.Using(
() => access?.ImpersonateAsSystem()
?? System.Reactive.Disposables.Disposable.Empty,
_ => NodeSources.GetSources(workspace, def, typePath))
.Take(1)
.Timeout(ReadBudget)
.Select(sources => NodeTypeSourceFingerprint.Compute(sources, typePath));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and it would have gone red in CI — thank you. ImpersonationScopeSiteRatchetGuard scans tools/, and BakeOutput.cs is not on its allow list, so this was a NEW SITE.

Fixed in b4ee197 with access.RunAsSystem(...). Verified both directions rather than assumed: the guard passes with RunAsSystem, and putting the old shape back reproduces NEW SITE tools/MeshWeaver.PluginTester/BakeOutput.cs (1) exactly as you predicted. RunAsSystem also subsumes the null-access fallback, so the Disposable.Empty branch is gone too.

BakeEquivalenceTest is still green, so the producer/consumer fingerprint equality is unaffected by the reshaped read.

…ble.Using

Copilot review, both findings real:

1. The new source-fingerprint read used
   `Observable.Using(() => access.ImpersonateAsSystem(), …)` — the #1790 identity-latch
   shape. Rx runs Using's resource factory on the SUBSCRIBING thread and disposes it when
   the inner observable TERMINATES, which for this cross-hub read is a different thread, so
   the subscriber is left running as System. `ImpersonationScopeSiteRatchetGuard` scans
   `tools/` and BakeOutput.cs is not on its allow list, so this would have gone RED in CI.
   Verified both ways: the guard passes with RunAsSystem and reports
   `NEW SITE tools/MeshWeaver.PluginTester/BakeOutput.cs (1)` when the old shape is put back.

   RunAsSystem also subsumes the null-access fallback, so the Disposable.Empty branch goes.

2. `using MeshWeaver.Mesh.Security;` was dead — AccessService and RunAsSystem both live in
   `MeshWeaver.Messaging`, already imported. (CS8019 is not on in this build, so the
   -warnaserror build was green either way; removed as genuine dead code, not as a fix.)

BakeEquivalenceTest still green, so the producer/consumer fingerprint equality is unchanged
by the reshaped read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kwar1ZGt5FMEpHiXmkzKCm
@rbuergi

rbuergi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Both Copilot findings were real and are fixed in b4ee197.

1. Observable.Using(() => access.ImpersonateAsSystem(), …) — this would have gone RED in CI. ImpersonationScopeSiteRatchetGuard scans tools/, and BakeOutput.cs is not on its allow list, so my new read was a NEW SITE. Replaced with access.RunAsSystem(...), which seals the scope inside the one Subscribe instead of storing on the subscribing thread and restoring on the terminating one (#1790) — and subsumes the null-access fallback, so the Disposable.Empty branch goes too.

Verified in both directions rather than assumed:

  • with RunAsSystem: guard green (3/3);
  • with the old shape put back: NEW SITE tools/MeshWeaver.PluginTester/BakeOutput.cs (1), 1 failed.

2. using MeshWeaver.Mesh.Security; was dead. AccessService and RunAsSystem both live in MeshWeaver.Messaging, already imported. One correction to the reasoning: CS8019 is not enabled in this build, so the -warnaserror build was green either way — I removed it as genuine dead code, not as a build fix. Worth noting so nobody later concludes the warning set is stricter than it is.

BakeEquivalenceTest re-run green after the reshape, so the producer/consumer fingerprint equality — the one thing that must not move — is unaffected.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

793 tests   789 ✅  3m 17s ⏱️
  7 suites    4 💤
  7 files      0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 138 tests   1 138 ✅  3m 56s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    8 files      8 suites   4m 24s ⏱️
1 494 tests 1 493 ✅ 1 💤 0 ❌
2 199 runs  2 198 ✅ 1 💤 0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 168 tests   1 168 ✅  3m 59s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

1 153 tests   1 153 ✅  7m 38s ⏱️
    7 suites      0 💤
    7 files        0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 030 tests   2 838 ✅  6m 41s ⏱️
    8 suites    192 💤
    8 files        0 ❌

Results for commit b4ee197.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Test Results

   44 files     44 suites   29m 58s ⏱️
8 776 tests 8 579 ✅ 197 💤 0 ❌
9 481 runs  9 284 ✅ 197 💤 0 ❌

Results for commit b4ee197.

@rbuergi
rbuergi added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit d499bb7 Sep 1, 2026
25 checks passed
@rbuergi
rbuergi deleted the fix/2813-adopted-source-fingerprint branch September 13, 2026 10:33
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.

GitSync adopts a stale prebuilt assembly over the source it just synced — silently running last week's code, and it destroyed client data

2 participants