Who Owns a NodeType Member

A NodeType node's content is a NodeTypeDefinition, and it has two owners at once. The repository owns the authored definition — Configuration, Sources, Description, Dependencies. The mesh owns the compile bookkeeping the framework writes as the type builds — the verdict, the timestamps, the assembly coordinates, the source-version maps, the release triggers. One record, two owners, and three seams where a node crosses between them:

Seam Rule Where
Export strips the mesh-owned members NodeTypeOperationalContent.StripOperational
Import / upsert preserves the LIVE node's values, absent when live has none PreserveLiveOperational, applied by the owner inside the upsert merge
Change detection ignores them — a node differing only in bookkeeping has not changed PartitionSourceFingerprint

All three read NodeTypeOperationalContent.MemberNames — the two strip rules over the union with StrippedButNotPreserved (see below), the preserve rule over MemberNames alone. Getting a member into the right list is the whole of the ownership decision; leaving one out is silent in every direction.

Why it matters is on Node Type Compilation and in ShippedNodeTypeStateTest: a node that adopts a compile verdict it did not earn on THIS deployment is unreachable by every automatic compile path, and parks forever on a foreign machine's answer.

The gap: a set pinned in one direction (#4480)

The list was pinned to the record by one assertion:

foreach (var member in NodeTypeOperationalContent.MemberNames)
    Assert.True(properties.Contains(member), "…the list drifted from the record.");

That is MemberNames ⊆ the record. It catches a mask entry that names no property — a typo, a rename — and it is structurally unable to see a runtime-state property that is MISSING from the mask. That is the direction that loses data, and it stayed green through four omissions:

Member What it decides What an authored value does
LatestAssemblyMvid the IDENTITY of the bytes the build produced; bind time compares it against the bytes actually served forges a MATCH and turns off the stale-build detector (the state in #2471: a portal serving stale compiled code while reporting Ok), or forges a MISMATCH and refuses a correct bind
CompiledModulesHash HasUsableBuild invalidates a build stamped with a different non-null hash than the live module set a hash matching the importing deployment declares a FOREIGN build usable and suppresses the recompile a module update requires
CompiledDependencies the per-type dependency record; HasUsableBuild, the bake probe's Classify and IsAlreadyAdopted all validate it against the live environment a record matching the bundle makes a FRESH install read as already-adopted — the bytes are never seeded and the type parks on a stamp nobody earned
DispatchedBuildInputs what the compile IN FLIGHT was dispatched for; a request resolving to the same token is CONSUMED rather than queued a token matching a live request absorbs that request against a compile nobody dispatched, and the release it asked for is lost

The first three leaked in both directions: export left them in the repo file (and in the change token, which then moved when only the runtime state had changed), and import let a file overwrite a measurement taken on this mesh. LatestAssemblyMvid was incoherent on its own terms — the file named bytes by an identity that exists nowhere on the importing mesh, while carrying no path to them.

Why a naming convention cannot be the guard

ShippedNodeTypeStateTest bans runtime state from committed files by a naming conventionCompilation*, Compiled*, LastCompil*, LatestAssembly*, RequestedRelease*, Failed* and so on. It is fail-closed by name, which is exactly right for what it does: a member added tomorrow is banned the day it is added, with nobody maintaining a list.

It is a sufficient condition and never a necessary one. DispatchedBuildInputs is compile-pipeline state spelled outside it, and so are BuildProvenance, ReleaseNotes, RequestedSourceStampAt and the whole Adopted* family. A reverse assertion built on the convention alone would have found three of the four and been blind to the fourth — the same defect, one name away.

So the guards are three, and each covers what the others cannot:

  1. MemberNames ⊆ the record — a mask entry that names no property.
  2. convention ⊆ MemberNames — a member SPELLED as runtime state that nobody masked.
  3. the partition — every serialised member of the record is classified as repo-authored, mesh-owned-and-masked, or stripped-but-not-preserved. Exactly one bucket, no member unclassified. This one does not depend on what a member is called, so a new property cannot be missed however it is spelled — and its denominator is the JsonIgnoreCondition, never the presence of a [JsonIgnore] attribute: IncludeGlobalTypes carries one with Condition = Never, which means it is ALWAYS written.

The convention lives in one place (NodeTypeMemberOwnership in the Graph test suite) and every guard reads it from there. Two lists that both approximate the same set drift pairwise; that is the failure this page describes, one layer up.

The third bucket: stripped from every file, never preserved on import

The three seam rules are not one switch. A member can belong to the mesh at the export and change-token seams and still have to lose at the import seam — and PendingRetirement is exactly that, so it lives in NodeTypeOperationalContent.StrippedButNotPreserved rather than in MemberNames:

Mask it and a re-shipped type stays marked retired forever — and the bake gate reads a stamped type's compile failure as Retired, i.e. as a verdict that must not hold a rollout.

But it is still runtime state, so a file must never carry it either. Leaving it out of the strip as well would let the live stamp ride an export straight into the repository — GitSync's SerializeOne exports through StripOperational — and a file that carries it can then forge a retirement the mesh never measured. So the strip paths (export, the typed import reset, and the change token) use the union of the two sets, and only the preserve path uses MemberNames alone. That asymmetry is the third bucket, and it is what the entry's reason has to justify.

What to do when you add a member to NodeTypeDefinition

Decide the owner, and write the reason down next to the entry:

The partition guard fails until one of the three is true, and names the member.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.