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 convention —
Compilation*, 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:
MemberNames ⊆ the record— a mask entry that names no property.- convention ⊆
MemberNames— a member SPELLED as runtime state that nobody masked. - 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:IncludeGlobalTypescarries one withCondition = 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:
- it is stamped by a repository-driven import when the repo retired a type that still has live
instances, through the probe's own
stream.Update— never through an upsert, so masking would not protect the write; - nothing in
src/ever writes null back to it. The one thing that clears it is the repo shipping the type AGAIN: an upsert replaces the node's content wholesale, so a member the live node holds and the file does not simply goes away.
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 repo owns it → add it to
NodeTypeMemberOwnership.Authored. Nothing else to do; imports honour it and the change token sees it. - The mesh owns it → add it to
NodeTypeOperationalContent.MemberNames, with a comment saying what an authored value would forge — every entry there carries one. It must also be added toNodeTypeCompileState, which carries exactly the masked set (pinned byNodeTypeCompileStateTest), or the compile-state satellite silently drops it. - The mesh writes it but the import must NOT preserve it →
NodeTypeOperationalContent.StrippedButNotPreserved, with the reason why dropping it on re-import is the point. It is still stripped from every file shape, so nothing extra is needed to keep it out of the repository.
The partition guard fails until one of the three is true, and names the member.
Related
- Node Type Compilation — the compile control plane that writes this state
- CQRS and Content Access — why the live node, not a query, is the authority
- Static Repo Import — the seam where a repo file becomes a node