Closed Type Set

A portal normally serves two kinds of NodeType. Code-registered types ship in the image: a module's MeshNodeProviderAttribute (or MeshBuilder.AddMeshNodes) contributes a MeshNode whose HubConfiguration delegate is the type. In-mesh types are rows: a NodeType node whose configuration is C# inside a JSON string, compiled by the portal at runtime or adopted from a baked bundle (see Node Type Compilation).

A closed type set is a process that serves only the first kind. It exists for the fleet's control instance, whose readiness must depend on nothing but its image: on 2026-09-25 one abandoned in-mesh type in a user partition refused readiness on every pod of the instance that runs every governed repair, for about seven hours. The control-instance design states the requirement as two properties — P1: every NodeType the instance can activate is defined by the image; P2: readiness reads nothing but the image and the database schema — and this switch is the platform half of P1.

The switch

Configuration Mesh:ClosedTypeSet=true (environment: Mesh__ClosedTypeSet=true)
Code builder.WithClosedTypeSet() — wins over configuration
Read serviceProvider.IsClosedTypeSet()
Default open. An absent or unparsable value is open: closing the set withdraws types, so it is never inferred.

It is a property of the image, not of a deployment record: the control image sets it as a container environment variable, next to the module that registers its types.

Where it acts

  1. Activation — NodeTypeEnrichmentHelpers.EnrichWithNodeType. The code-registered fast path runs first, unchanged: a static node whose Path equals the instance's nodeType and that carries a HubConfiguration is applied directly, with no database read and no compile — which is already true on an open mesh. On a closed mesh a type that misses it is refused at that point, before the existence probe, the row's stream subscription or any compile or adoption. The instance activates onto an overlay (copy localized at render time) whose typed requests are NACKed with ErrorType.Rejected and the reason — never CompilationFailed, since nothing was compiled, and never Unavailable, since retrying cannot change the answer. No self-heal watches for a row to appear, because a row appearing can never change the answer.

  2. The pre-warm sweep, the bake probe and the boot seeders — DynamicTypePreWarmer.WarmDynamicTypes / ProbeDynamicTypes, and the hosted service's SeedAll / SeedPublishedRoot ahead of them. All normally enumerate every NodeType row in every partition. On a closed mesh they enumerate nothing (not "everything, then filter"): the rows are exactly the input a closed mesh must not read, and a broken one must not be able to occupy, slow or fail the pass the readiness gate reads. The probe and the optional sweep still publish their empty bake reports to the process registry. /health can then show a measured zero instead of degrading with "NO bake report"; the empty verdict comes from the closed-set policy, not from a database enumeration.

  3. Adoption — PrebuiltAssemblySeeder.SeedDetailed, the one write every bundle adoption goes through (boot seeders, published root, on demand). A closed mesh answers NotSeeded. The gate is evaluated at SUBSCRIBE time and only after the leaving check (#3129): a hub whose teardown has finished has disposed the provider the gate reads, so asking it first threw ObjectDisposedException out of the seed instead of answering NotSeeded (#6060).

  4. Compile dispatch — the compile watcher, beside Modules:RequirePrebuilt. A compile asked of a row itself (its Compile button, a release request, a self-heal kick) skips on-demand adoption and is parked at Error with the closed-set reason instead of reaching Roslyn — and, unlike the prebuilt gate, is never held on a build the row already carries.

  5. Registry bundle adoption — PluginBundleClient.Adopt, the install-time fetch from the plugin registry. A closed mesh fetches nothing and records the attempt as NotApplicable (not a miss, and not NothingToAdopt, which claims a read bundle declared no NodeTypes), naming the switch. It used to download each bundle, have every assembly answered NotSeeded by the seeder (3, above), and record "adopted 0/N" — a miss — although nothing compiles in its place. On the control instance that held /health's bundle_adoption at Degraded for good (measured 2026-10-09: 27 attempts, 0 adopted, 14 misses, every one a NOT SEEDED: Mesh:ClosedTypeSet=true line). Pinned by ClosedTypeSetBundleAdoptionIsNotAMissTest, with OpenTypeSetBundleAdoptionControlTest as the open-mesh control that does fetch and does record the miss.

  6. Package installs — PackageInstaller (every lane: full node-repo install, incremental update, content and code packages) and the boot repair pass (InstalledPackageRepairService), through ClosedTypeSetPackageRows (#6627). A closed mesh writes no NodeType definition row (nodeType: NodeType) a package ships, and removes the ones an earlier install on this host left. Everything else installs exactly as on an open mesh: the package root, its instances (an instance typed by a withheld type is validated as in-package, as before), Source/ nodes, access satellites, the install record — and the module assemblies installed beside it, which are not package rows at all. Only the row is removed, never its subtree: a type's path prefixes its own Source/ and, for a leaf-shaped type, its instances. The row goes through the framework delete — a DeleteNodeRequest with KeepDescendants, which removes the node at the path alone: the delete validators run, the change feed and the stream cache hear it, and a live activation at the path is disposed, so nothing keeps serving the removed row. Never a direct storage write. The request carries the version the classifying read saw (ExpectedVersion), so a row claimed after that read answers Conflict and is kept. A claimed row (SyncBehavior other than Include) is the user's, so it is named at Warning and kept. The install record carries the withheld files as PackageManifest.WithheldFiles (every lane, content packages included), which the completeness sweep and — on a closed host — the update lane's restore widening subtract; otherwise every withheld row would read as an absent node and be handed to the heal, which would reinstall the package to restore a row it never writes. The boot repair pass runs the same removal over every install record before the sweep — all of them, not one per partition, because two packages installed into the same target partition each declare their own rows — so a host whose packages never update converges too. A code package's NodeType row is synthesized by the install ({install root}/{id}) and maps to no installed file, so the pass derives it from the record (PackageInstaller.SynthesizedNodeTypePathOf) rather than from the declared files (#6751).

    Three consequences of a row being absent, each handled where it bites. Paths: the removal reads candidates through the install's OWN file→node mapping (PackageInstaller.InstalledNodePathOf): a content or code package strips its SourceFolder and rebases under its target, so Library/Widget.json with TargetPartition: Installed is Installed/Widget — reading it as Library/Widget would delete, as System, an unrelated type in the source partition. Creates: the per-node create checks a type against the store, where a withheld row is absent by design, so a NEW instance typed by one of the package's withheld types (a delta adding it, a content instance) is created through the bulk lane exactly as the full install creates every new instance; updates that keep a type are not type-checked. The record's WithheldFiles is what tells a later delta which types are the package's own. Releases: a delta derives release targets from written paths (the owner of a changed Source/ file), which on a closed host name the removed row, so no release is requested there at all — a flip would be a point write to an absent node.

    KeepDescendants is refused unless the stored target is a NodeType definition, refused for a partition root even when it is one (a top-level, namespace-less type — the partition-drop handler matches the shape, not the type), and refused together with Recursive, each with its own localized message and before any side effect: on any other node the post-deletion handlers would still run (a partition root's would drop the store the request asked to keep) and an ordinary parent's children would be orphaned. A row-only delete enumerates nothing under its path — neither for the plan nor for the drain's verification, which asks only whether the root is still in writable storage (#6751).

    On an open host the same pass CLEARS a withheld set an earlier closed run recorded: there the rows are owed again, and a stale set would keep the sweep from ever restoring them.

    Why: a row on a closed mesh can only ever settle at the refusal below and read as Error in the pre-prod sweep (nodeType:NodeType content.compilationStatus:Error partitions:all). On the control instance, once it mounted the public registry and took package content, that was 73 of 126 NodeTypes (measured 2026-10-09), none of them a compile defect — so the sweep could never read zero there.

Every surface reports one sentence, ClosedTypeSet.RefusalFor(type, instance), and ClosedTypeSet.IsRefusal(error) recognises it. The failed-verdict re-drive's older-platform trigger (a failure formed on an older platform build gets one retry on a newer build, #6349) skips a recorded closed-set refusal on a closed process: the refusal records this process's switch, not the type's source, so no newer build can answer it differently — before, every refused row went Pending once per platform build only to be refused again. An ordinary compile failure on a closed process, and the same refusal text met on an open process, keep their retry.

What it deliberately does not change

Moving a type from the mesh into the image

The code registration must look like what the dynamic compile emits for the row (DynamicMeshNodeAttributeGenerator.GenerateAttributeSource): a MeshNode at the type's path with NodeType = "NodeType", a NodeTypeDefinition carrying no Configuration or Sources strings (otherwise the sweep would count it as compilable), and HubConfiguration = c => userLambda(c.AddMeshDataSource()). Register the content types on the mesh hub's TypeRegistry too — WithContentType<T>() registers only on an activated instance hub, and ContentDiscriminatorValidator refuses a create whose $type the mesh registry cannot resolve.

A row left at the same path as a code registration is shadowed for activation but can still surface in a nodeType:NodeType query (the merge de-duplicates by path, first to arrive), so a mesh that moves a type into its image should not keep the row. The control instance no longer starts from an empty database and stays that way — it takes package content from the registry — so the package installer removes such rows itself (6, above); a row written any other way still has to be removed by whoever wrote it.