Repository navigation
feat(#2820): a build PROVEN stale is never armed — the execute-time interlock - #2982
Conversation
…nterlock Closes #2820. The other half of #2813, whose prerequisite (#2947) landed today: with a producer now writing a source fingerprint into every bundle, `BuildProvenance` can finally read `AdoptedVerified` / `AdoptionRefused` rather than `AdoptedUnverified` for everything — so an execute-time predicate is answerable for the first time. ## The predicate — three values, never two `NodeTypeExecutionGate.Evaluate(NodeTypeDefinition?)`: | provenance | verdict | |---|---| | `AdoptionRefused` | **Refused** — the bundle NAMED its sources and they are not this mesh's | | `AdoptedUnverified` | **Permitted** — a legacy bundle compared nothing; unknown ≠ proven stale | | `AdoptedVerified` / `Compiled` | Permitted | | definition unreadable | **Inconclusive** — no verdict was reached | 🚨 The row that matters most is the one that PERMITS. Refusing `AdoptedUnverified` would park every type adopted from every bundle published to date, and on a `Modules:RequirePrebuilt` mesh a local compile is refused by design — the outage "refuse every unproven bundle" was rejected to avoid, arriving through a different door. `Inconclusive` is its own member for the reason `ErrorType.Unavailable` is: a probe must not answer its scariest branch — or its friendliest — on its own inability to run. Refusal is opt-in one provenance at a time, so a member appended later cannot silently become a refusal. ## Two enforcement sites, chosen from a census `ApplyStreamResult` sits above EVERY branch that resolves an assembly (hot path, pinned release, legacy fallbacks), so one check covers all of them — a check inside only some would make the gap look closed, which the issue calls worse than none. The kernel cell-surface join is architecturally separate (it loads straight through `NodeAssemblyLoadContext`, no enrichment, and from then on every submission can call the pack by bare name with full write access), so `CellSurfaceAssemblyProvider.ResolveOne` carries its own check. The transient-probe surfaces (`$Model` render, `SchemaReference`, agent schema validation) stay open deliberately: `AsTransientNodeProbe` hubs have no control plane, no persistence sampler and no node identity, and they are what lets a refused type's OWN pages keep rendering so an operator can diagnose it. **Read vs write is not the available cut, and the doc says why**: every surface — including the read-only probes — already runs `Activator.CreateInstance` on the assembly's `MeshNodeProvider` types inside `CompileResultFromAssembly`, and a rendered layout area is not write-free (`WithClickAction`, the `Edit` macro, a `WithInitialization` watcher). The honest cut is durable-arming vs transient-probe. ## The refusal is a verdict, not a timeout New `ErrorType.ExecutionRefused` — deliberately not `CompilationFailed` (which sends an author to edit source Roslyn never rejected, #641) and not `Unavailable` (which reads as "retry" when a verdict WAS reached, #2818's hour). It rides an `UnhandledMessageNack` naming the type, a `LogCritical` / `LogError` naming both fingerprints, and a page whose copy is localized at render time off the viewer's `AccessContext` (`ui.executionRefusedIntro` / `ui.executionRefusedGuidance` / `ui.noCodeChangeNeeded`, en + de). Recovery verb: a FORCED release (#2824), or a rebake where no local compile is possible. The overlay self-heal makes it clear itself, because `ApplyCompileSuccess` resets the provenance to `Compiled`. ## Verification Builds `-c Release -warnaserror`, one project per invocation, `0 Error(s)`: Messaging.Contract, Messaging.Hub, Graph, Documentation, Graph.Test, Documentation.Test, Messaging.Hub.Test, Hosting.Monolith.Test. Tests: `NodeTypeExecutionGateTest` + `ExecuteTimeInterlockTest` 14/14 (the latter drives the real `EnrichWithNodeType` on a real Monolith mesh, two nodes differing in exactly one field), `CellSurfaceScriptingSeamTest` 6/6 (the new case proves the same bare-name call runs before the provenance flip and stops resolving after it), `DocumentationLinkIntegrityTest` 1/1, `LocalizationTest` 58/58. Mutation-checked — each control was inverted once and observed RED, then restored: - refuse `AdoptedUnverified` too → both anti-outage assertions fail (2 red) - disable the arming-site gate → the refused type activates with `CompilationFailed` (1 red) - disable the cell-surface gate → the refused pack stays callable by bare name (1 red) - a bogus link on the new doc page → `DocumentationLinkIntegrityTest` names the page (1 red) Nothing public was deleted or renamed, so no in-mesh caller can break; `ErrorType` gained an appended member only. Docs: new `Doc/Architecture/ExecuteTimeInterlock` (the surface census, the read-vs-write argument, where this is reachable, and what it deliberately does NOT do), linked from `NodeTypeCompilation` and the Architecture index. What's New: `Category: Fix`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements the execute-time “build provenance interlock” so NodeTypes with proven-stale adopted bytes (BuildProvenance.AdoptionRefused) are not armed to run: instances serve a refusal overlay (and NACK typed requests with ErrorType.ExecutionRefused), and such types are also excluded from kernel cell-surface joins. Adds tests, localization copy, and documentation to make the refusal visible and actionable.
Changes:
- Add a pure
NodeTypeExecutionGateand enforce it at (1) NodeType enrichment/arming and (2) cell-surface assembly resolution. - Introduce
ErrorType.ExecutionRefusedplus localized refusal-page copy (ui.executionRefused*). - Add end-to-end and unit tests plus new/updated architecture + “What’s New” documentation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/MeshWeaver.Hosting.Monolith.Test/CellSurfaceScriptingSeamTest.cs | Adds regression proving a proven-stale pack no longer joins the cell surface (bare-name call stops resolving). |
| test/MeshWeaver.Graph.Test/NodeTypeExecutionGateTest.cs | Adds pure verdict-table tests (including “anti-outage” permitted row + localization-key presence checks). |
| test/MeshWeaver.Graph.Test/ExecuteTimeInterlockTest.cs | Adds Monolith-mesh-driven tests proving arming-site refusal vs permitted provenances. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.en.json | Adds English UI copy for the refusal overlay. |
| src/MeshWeaver.Messaging.Hub/Localization/strings.de.json | Adds German UI copy for the refusal overlay. |
| src/MeshWeaver.Messaging.Contract/Events.cs | Adds ErrorType.ExecutionRefused with explanatory documentation. |
| src/MeshWeaver.Graph/Configuration/NodeTypeExecutionGate.cs | Introduces the pure gate (Permitted/Refused/Inconclusive) plus refusal summary + recovery verb text. |
| src/MeshWeaver.Graph/Configuration/NodeTypeEnrichmentHelpers.cs | Enforces the gate before assembly-resolving branches; adds refusal overlay builder + localization keys. |
| src/MeshWeaver.Graph/Configuration/CellSurfaceAssemblyProvider.cs | Enforces the gate before joining an assembly into a kernel’s cell surface (logs + skip). |
| src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-01-a-type-built-from-the-wrong-source-is-no-longer-allowed-to-run.md | Adds user-facing “What’s New” entry describing the new refusal behavior and recovery. |
| src/MeshWeaver.Documentation/Data/Architecture/NodeTypeCompilation.md | Links to and summarizes the execute-time interlock behavior in the compilation doc. |
| src/MeshWeaver.Documentation/Data/Architecture/ExecuteTimeInterlock.md | Adds new architecture page documenting trigger surfaces, rationale, visibility, and recovery. |
| src/MeshWeaver.Documentation/Data/Architecture.md | Adds the new page to the architecture index. |
Suppressed comments (1)
src/MeshWeaver.Graph/Configuration/NodeTypeExecutionGate.cs:125
RecoveryVerbis a hard-coded English paragraph that is appended to theUnhandledMessageNack.Reason. Since this text can surface to end users (delivery failures are shown in UI surfaces), it should be localized or moved into the already-localized UI guidance copy rather than emitted as an English literal.
public const string RecoveryVerb =
"Recompile this type (the Recompile button, or the `compile` verb over MCP — a FORCED "
+ "release compiles the live source instead of re-adopting the bundle, #2818). On a "
+ "`Modules:RequirePrebuilt` mesh no local compile is possible: rebake and republish the "
+ "package, then request a release.";
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… cell-surface regression main landed two moves under this change set's feet, both handled deliberately rather than by taking a side: - **`CellSurfaceAssemblyProvider` moved to the new `MeshWeaver.Compiler.Pipeline`** (the #1707 layering split). The rename carried the interlock's second enforcement site with it. Its predicate, `NodeTypeExecutionGate`, moves to that assembly too — NOT to `MeshWeaver.Graph.Contract` where `NodeTypeDefinition` now lives, because Graph.Contract sits inside `MeshWeaver.Compiler`'s reference closure, which is a full-MVID toolchain root: a body-only change there re-bakes every NodeType on every mesh. The pipeline is surface-hashed and is referenced by both enforcement sites, so it carries the predicate at no rebake cost. Noted on the doc page. - **`CellSurfaceScriptingSeamTest` was DELETED on main** — the whole `MeshWeaver.Hosting.Monolith.Test` project went to MeshWeaver.Plugins with 16 sibling suites. Silently accepting that deletion would have taken the only regression on enforcement site 2 with it, in a repo where half-finished cross-repo moves are the live defect class. The code under test is still core's, so the regression stays core's: `CellSurfaceRefusedBuildTest` in `MeshWeaver.Compiler.Pipeline.Test` now proves the same property one layer lower and faster — it resolves `ICellSurfaceAssemblyProvider` directly instead of round-tripping a kernel cell. Same controlled shape as the version it replaces: the pack is compiled, asserted PRESENT in the resolved cell surface, then the NodeType node is changed in exactly one field (`BuildProvenance = AdoptionRefused`, same bytes, same coordinates, same `CompilationStatus.Ok`) and asserted ABSENT. Mutation-checked at the new site: with the gate disabled it reads "expected false … but found True". - `Architecture.md` conflict resolved by keeping BOTH entries — main's `GraphCompilerLayering` row and this change set's `ExecuteTimeInterlock` row. Verification after the merge, `-c Release -warnaserror`, `0 Error(s)` each: Compiler.Pipeline, Graph, Documentation, Graph.Test, Compiler.Pipeline.Test, Documentation.Test. Tests: Graph.Test gate + interlock + enrichment/overlay/provenance 53/53, Compiler.Pipeline.Test 569/569 (whole project, including the new regression), DocumentationLinkIntegrityTest 1/1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Synced with main — two moves landed under this change set, both handled deliberately
1. 2. Same controlled shape as the version it replaces: compile the pack → assert it is in the resolved cell surface → change the NodeType node in exactly one field (
Re-verified after the merge
The four mutation checks in the description all still stand; the cell-surface one was re-run against the moved file. |
… stays on the log and the NACK
Copilot review, and it was right: RefusalSummary was passed to the overlay as its error message,
so a German viewer read an English sentence. The three facts a refusal has to name — the node
path and the two source fingerprints — are a wire identifier and two opaque hashes, so they are
verbatim in every language; only the sentence around them is translated. The page now reads
'ui.executionRefusedSummary' with those three as {0}/{1}/{2}, in en + de.
The English sentence is NOT deleted: it stays on the log line (Loki is English by house rule) and
on the delivery NACK (read by developers and tools, never rendered to a viewer). The split is what
lets an operator grep one language while a viewer reads their own.
🚨 The guard is the WIRING, not the catalog. 'BuildExecutionRefusedMarkdown' takes its localizer as
a Func — the UserPreferencesLocalizationTest pattern — so with an ECHO localizer every string the
page supplies comes back as its own dotted key, and an English literal stands out as prose among
keys. Asserting the catalog alone would pass while nothing read it. Mutation-checked: hard-coding
the headline again fails 'EveryStringTheRefusalPageSupplies_ComesFromTheCatalog'.
Also lifts the emergency-card CSS to a const shared with the compile-error page, so the two
overlays cannot drift apart visually.
Verified: Graph, Compiler.Pipeline, Messaging.Hub, Graph.Test -c Release -warnaserror, 0 Error(s);
gate + interlock + overlay + compile-error-page suites 60/60; LocalizationTest 58/58.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…venance-interlock # Conflicts: # src/MeshWeaver.Documentation/Data/Architecture.md
Test Results (shard 0)216 tests 216 ✅ 2m 15s ⏱️ Results for commit 4cc4e08. |
Test Results (shard 3)586 tests 586 ✅ 53s ⏱️ Results for commit 4cc4e08. |
Test Results (shard 5)1 128 tests 936 ✅ 1m 1s ⏱️ Results for commit 4cc4e08. |
Test Results (shard 1)419 tests 419 ✅ 1m 21s ⏱️ Results for commit 4cc4e08. |
Test Results (shard 2)507 tests 507 ✅ 1m 28s ⏱️ Results for commit 4cc4e08. |
Test Results (shard 4)1 668 tests 1 668 ✅ 1m 48s ⏱️ Results for commit 4cc4e08. ♻️ This comment has been updated with latest results. |
Test Results4 524 tests 4 332 ✅ 8m 49s ⏱️ Results for commit 4cc4e08. ♻️ This comment has been updated with latest results. |
The shard-4 red is attributed and filed — #2986 — and it is not this diffOne test failed in run 33523142249:
That is the signature The suite is one day old (added by #2944 at ~13:00Z today); Re-running the failed jobs only (the run's head is still this branch's tip, so nothing live is superseded). If it comes back red on the same test I will stop and hand it over rather than keep pushing. |
Closes #2820. The other half of #2813, whose prerequisite (#2947,
d499bb781) landed today: withBundleWriterwriting asourceFingerprintandPrebuiltAssemblySeederthreading it through,BuildProvenancecan finally readAdoptedVerified/AdoptionRefusedinstead ofAdoptedUnverifiedfor everything — so an execute-time predicate is answerable for the first time. Verified on this branch: the 7-arg overload is[Obsolete(error: true)], andApplyCompileSuccessstill resets the provenance toCompiled, which is what makes the signal trustworthy.The three questions the issue asked, answered
1. Trigger-surface coverage
A census, because a check in only some surfaces is worse than none — it makes the gap look closed.
NodeTypeEnrichmentHelpershot activation pathHubConfigurationonto a per-instance node;MonolithRoutingService/MessageHubGrainbuild the real hub from itNodeTypeContractHandlerlegacy contractCellSurfaceAssemblyProvider— kernel cell-surface joinNodeAssemblyLoadContext; every submission can then call the pack by bare name with full write access$Modelprobe,SchemaReferencehandler, agent schema validationAsTransientNodeProbe— no control plane, no persistence sampler, no node identity, disposed immediatelyNodeTypeBatchBake/DynamicTypePreWarmerCompiledprovenance by construction1–3 all funnel through
ApplyStreamResult, so one check above every assembly-resolving branch covers them. 4 never enriches and never builds aHubConfiguration, so it gets its own — the same shared pure predicate, not a second opinion.2. Read vs write — the split asked for is not available at the type level
MeshNodeCompilationService.CompileResultFromAssemblyloads the assembly and callsActivator.CreateInstanceon everyMeshNodeProviderAttributetype before any caller decides whether to use the result. A "read-only render" already runs the assembly's constructors.WithClickAction, theEditmacro, aWithInitializationwatcher installed by the same configuration.IObservablehops, which this repo forbids andAsyncLocaldoes not survive.So the cut is durable-arming vs transient-probe, a line the framework already draws for its own reasons (
AsTransientNodeProbe: "a probe hub must never be used to WRITE"). Net effect: the refused type's own pages keep rendering — Overview, data model, schema, compile diagnostics, i.e. exactly what an operator needs — while its instances serve the refusal card and its assembly never joins a kernel session.3. Refusal visibility and the recovery verb
New
ErrorType.ExecutionRefused, deliberately notCompilationFailed(which sends an author to edit source Roslyn never rejected — #641) and notUnavailable(which reads as "retry" when a verdict was reached — #2818's lost hour). It appears four ways carrying the same sentence: anUnhandledMessageNacknaming the type, a page whose copy is localized at render time off the viewer'sAccessContext, aLogCritical(arming) /LogError(cell surface) naming both fingerprints, andBuildProvenanceon the node plus its compile-state satellite.Recovery verb: Recompile / the
compileMCP verb — since #2824 a forced release skips re-adoption and builds the live source. On aModules:RequirePrebuiltmesh: rebake, republish, release; the log says so in those words. A successful compile stampsCompiledand the overlay's self-heal recycles every stuck instance.The dominant risk, and what holds it
🚨
AdoptedUnverifiedis PERMITTED. A legacy bundle carries no fingerprint, so nothing was compared: unknown ≠ proven stale. Refusing it would park every type adopted from every bundle published to date, and onRequirePrebuiltthere is no local compile to recover with — the outage "refuse every unproven bundle" was rejected to avoid. This is asserted twice (pure + on a real mesh) and is the assertion most worth protecting.🚨
Inconclusiveis its own member, never folded. It arises only where the definition could not be read at all — where every arming site already binds no assembly — so the gate names the state rather than inventing a refusal for it. Refusal is opt-in one provenance at a time (_ => Permitted), so a member appended toBuildProvenancelater cannot silently become a refusal.Where this is actually reachable: on a mesh that can compile locally, a refusal already clears the assembly coordinates, so
HasUsableBuildis false and the ordinary branches take over — the gate adds no refusals there. It catches the two states the load-time refusal deliberately leaves serving: aModules:RequirePrebuiltmesh, and the window betweenSeedstamping coordinates and the owner judging them.Verification
dotnet build <one.csproj> -c Release -warnaserror,0 Error(s)each: Messaging.Contract, Messaging.Hub, Graph, Documentation, Graph.Test, Documentation.Test, Messaging.Hub.Test, Hosting.Monolith.Test.Tests (local, Release,
.trxwritten):NodeTypeExecutionGateTest+ExecuteTimeInterlockTest— 14/14. The latter drives the realEnrichWithNodeTypeon a real Monolith mesh against two persisted NodeType nodes differing in exactly one field, and reads the verdict off theUnhandledMessageNackthe produced configuration installs.CellSurfaceScriptingSeamTest— 6/6, including the new case: the same bare-name call runs before the provenance flip and stops resolving (CS0103) after it, with the bytes unchanged.DocumentationLinkIntegrityTest1/1 ·LocalizationTest58/58.Mutation-checked — each control inverted once, observed RED, restored:
AdoptedUnverifiedtooCompilationFailedIn-mesh safety: nothing public was deleted or renamed;
ErrorTypegained an appended member. Nosearch_chunkssweep was needed (and it would have answered"searched": falseanyway — #2741).Docs
New page
Doc/Architecture/ExecuteTimeInterlock— the surface census, the read-vs-write argument with its evidence, where this is reachable, and an explicit what this deliberately does NOT do (the probe surfaces' residual constructor execution is named rather than assumed away). Linked fromNodeTypeCompilation's "refusing to LOAD is the second line of defence" note and from the Architecture index. What's New:Category: Fix.Follow-up, not in this PR
The three new
ui.*keys have a second home inMeshWeaver.Plugins/clients/react/src/i18n/. That mirror compares against a pinned core commit (catalog-source.json), which is already 9 keys behind, so this does not redden Plugins today; the keys travel with whoever bumps the pin.🤖 Generated with Claude Code