Skip to content

A control plane must refuse to EXECUTE against a type whose build provenance is not verified — refusing to load is the second line of defence #2820

Closed

Description

@rbuergi

The interlock that would actually have saved the documents is at EXECUTE time, not load time

From the session that owns the #2813 incident, and it is a better framing than the one I was working
to:

The damage needed TWO ingredients — stale bytes, and something armed to run them. Stale bytes
sitting unloaded harm nobody. Refusing to execute matters more than refusing to load. A type
that renders read-only pages from unverified bytes is a degraded system; a type that WRITES from
them is the incident.

That is the correct ordering. #2813 makes an adopted build's provenance visible and refuses a
provably-stale adoption at load time — but a Crm/Migration-shaped node that fires on type
registration will still fire, against exactly the assembly whose provenance could not be verified.
Load-time refusal is the second line of defence; this is the first.

What exists after #2813

NodeTypeDefinition.BuildProvenance — Compiled | AdoptedVerified | AdoptedUnverified |
AdoptionRefused — readable on the NodeType's own node and mirrored onto the compile-state
satellite at {type}/_Activity/compile-state, so a control-plane watcher can read it through
GetMeshNodeStream(path) without new plumbing. It is deliberately operational (stripped on export,
preserved from the live node on import) so an authored value cannot forge it.

Today every adoption reads AdoptedUnverified, because nothing writes a source fingerprint into
a bundle yet. That is the honest state, and it is also what makes this issue urgent rather than
theoretical: the predicate a control plane needs is already answerable, and its answer right now is
"unverified" for every adopted type on every mesh.

What is missing

A control plane that refuses to act on a type whose build provenance is not
Compiled or AdoptedVerified — specifically anything that would WRITE. The read path can stay
degraded-but-serving; the write path must not run.

Open questions the design has to answer, none of which I want to guess at:

  1. Where the interlock sits. The activity control plane is the obvious home, but "a type fires on
    registration" spans more than one trigger surface, and a check in only some of them is worse than
    none — it would make the gap look closed.
  2. Read vs write. Distinguishing "renders a page" from "writes to the mesh" is the whole value of
    the interlock and is not currently a property of a type.
  3. The refusal's shape. It must be visible and actionable, not a silent no-op — and it must not
    report as a timeout, which is the mistake Force-release is silently discarded: the compile watcher re-adopts the prebuilt bundle instead of compiling, so there is no verb that forces a build of the live source #2818 documents costing an hour.
  4. What an operator does about it. Until Force-release is silently discarded: the compile watcher re-adopts the prebuilt bundle instead of compiling, so there is no verb that forces a build of the live source #2818 lands there is no verb that forces a rebuild while
    a bundle resolves, so an interlock could refuse to run something the operator cannot fix.
    Sequencing matters.

Why it is filed rather than folded into #2813

Same standard as #2818: the change is not the hard part, the regression is. Proving "an armed node
does NOT fire against an unverified type, and DOES against a verified one" needs a control-plane
trigger driven end to end on a real mesh, plus a type whose provenance is staged both ways. Shipping
an execute-time gate without that test would put a guard on the write path whose failure mode is
silent — and a guard that quietly stops guarding is the failure this repo keeps paying for.

Related: #2813 (provenance, the load-time half), #2818 (the missing recovery verb — an operator who
sees AdoptedUnverified currently cannot force a rebuild while the bundle resolves).

Activity

  1. rbuergi commented on Aug 30, 2026

    @rbuergi
    ContributorAuthor

    Re-measured on current main: the premise HOLDS, and the blocker is now a one-line reachability fact

    I have been re-running issues' own measurements before acting tonight, because several had drifted. This one has not. #2813/#2821 landed the provenance machinery since this issue was filed, so it would be reasonable to assume the ground had moved. It has not, and here is the exact reason — worth recording so nobody re-derives it.

    The three-way comparison exists and CAN produce AdoptedVerified

    NodeTypeCompilationHelpers (~1018–1050) implements it properly, with the two inconclusive branches carefully distinguished from the refusing one:

    if (def.AdoptedSourceFingerprint is not { Length: > 0 } adopted)
        return stamped with { BuildProvenance = AdoptedUnverified };   // legacy bundle — UNKNOWN
    if (def.CurrentSourceFingerprint is not { Length: > 0 } live)
        return stamped with { BuildProvenance = AdoptedUnverified };   // not yet computed — UNKNOWN
    if (string.Equals(adopted, live, StringComparison.Ordinal))
        return stamped with { BuildProvenance = AdoptedVerified };     // ← reachable in principle

    The AdoptedUnverified branches even cite #890's lesson explicitly — "a probe must not answer its scariest branch on its own inability to run" — which is the right instinct and the same one this issue's gate will need.

    But that branch is unreachable in production, and it is one line

    PrebuiltAssemblySeeder has two overloads. The 8-arg one takes sourceFingerprint and is the one that could make it reachable. Its only caller in the entire tree is the 7-arg overload everything actually uses:

    public static IObservable<bool> Seed(hub, nodeTypePath, assemblyBytes, pdbBytes, frameworkMvid,
                                         logger = null, dependencies = null)
        => Seed(hub, nodeTypePath, assemblyBytes, pdbBytes, frameworkMvid, logger, dependencies,
            sourceFingerprint: null);      // ← line 257, the only call site
    $ grep -rn "sourceFingerprint:" src/ test/
    src/MeshWeaver.Graph/Configuration/PrebuiltAssemblySeeder.cs:257:  sourceFingerprint: null);
    

    And nothing in MeshWeaver.PluginCatalog or MeshWeaver.Plugin.Packaging writes a source fingerprint into a bundle at all.

    So every adoption takes the line-1022 legacy branch, and AdoptedVerified is dead code today — exactly as this issue states. The machinery is built, tested and inert on the branch that matters, which is the same shape as TestTimeouts (built by #2708, zero adopters until today) and InstanceComboVerifier (#2274 — a verifier nothing calls).

    What that means for the gate this issue asks for

    The predicate cannot be "refuse to execute when provenance is not AdoptedVerified" until a producer writes the fingerprint, because today that refuses everything. That is not a reason to defer the design — it is the acceptance criterion for it:

    1. The bake writes a source fingerprint into the bundle and the seeder passes it through (delete the null at line 257 and thread it from the manifest). Until this exists, nothing downstream can distinguish verified from unverified.
    2. Only then can the control plane refuse to EXECUTE on a non-verified type — and it must handle AdoptedUnverified as its own outcome, never folded into either Verified or AdoptionRefused. The three-verdict discipline ComboVerification already documents, and the same NotVerifiable ≠ Green trap The combo gate exists and is never called — nothing verifies an image against the modules an instance actually runs #2274 is about.

    Suggest ordering this issue explicitly behind step 1, and stating that a gate shipped before it would either refuse every install or be silently disabled — which is how a safety mechanism becomes decoration. The null at line 257 is a good place to leave a pointer to this issue, so the next person to touch it knows what it gates.

  2. added a commit that references this issue on Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions