NodeType and LinkType have no retirement mechanism, and their dense values make a silent misread more likely than the flags case that got one.
What #399 fenced, and what it left open
The flags leg of #399 established that a vocabulary read across the readable window — not just at the current version — needs a way to retire an enumerator without a format bump. For NodeFlags that now exists in include/pup/index/format.hpp:
RECORDED_NODE_FLAGS_MASK — what this build names,
RETIRED_NODE_FLAGS_MASK (:287) — bits that were named once and must still be readable,
READABLE_NODE_FLAGS_MASK (:298) — the union actually enforced in FileEntry::from_raw,
- a
(RECORDED & RETIRED) == 0 disjointness static_assert (:290) so a future flag cannot silently take a retired bit's meaning,
- a value pin whose failure message (
:279) tells the maintainer to move the bit rather than delete it.
NodeType and LinkType are read by the same recovery path, across the same window, and have none of it. names_node_type (include/pup/core/types.hpp:170-187) is a bare exhaustive switch over eleven enumerators; names_link_type (:190-193) delegates to link_role. Retiring an enumerator from either means deleting its case, and nothing anywhere records that the value was ever spoken for.
Two failure modes, and the second is the one that matters
Over-rejection (loud). Delete a case and every legitimate record in the readable window carrying that byte fails names_node_type, returns IndexDamaged, and is announced as damage. This is the exact defect the flags leg found in read_prior_paths — a legitimate v9–v13 record becoming Kind::Lost — and it is why RETIRED exists. Bad, but it is loud, and loud is survivable.
Misinterpretation (silent), and it is likelier here than for flags. NodeType's enumerators are dense and sequential — File=0 through Phi=10. Retire one and the freed value is not a hole anyone must deliberately reach into; it is the next value the natural numbering hands to whoever adds an enumerator, particularly if the retired case was removed from the middle and later entries were not renumbered. At that point an old record's byte silently means the new type: it passes validation, constructs a FileEntry, and routes as something it never was.
That is precisely the argument that justified the flags disjointness assert. It applies with more force to a dense enum than to a bitmask, where a retired bit stays a hole until someone deliberately reuses it.
Why this is not just the flags fix again
There is nothing to put in a retired set today: no enumerator has ever been retired from NodeType, LinkType, NodeFlags, or CommandFlag across the readable window except the single NodeFlags::Inactive bit that #399 already handles. So this is not a latent live bug — it is an unfenced invariant, and the fence is cheap now and expensive after the first retirement.
The flags leg's comment at names_node_type states the obligation in prose and explicitly denies the mechanism ("would need the retired set NodeFlags has and this does not"). That was the correct thing to ship with a comment-only follow-up, and it is not a substitute for a compiler-checked device. Prose does not fail the build.
Suggested shape, not prescribed
The symmetric answer is a retired set per vocabulary plus the same disjointness assert, so that retiring an enumerator is a mechanical move rather than a deletion, and reusing a retired value is a build error. Whether both vocabularies need it, and whether the assert or the value pin is the right tripwire for a dense enum, is a design question — the flags shape should not be copied over by reflex.
Found by the builder while applying the #399 flags-leg deltas, and correctly not built there: it is a design change, out of scope for a comment-only follow-up.
NodeTypeandLinkTypehave no retirement mechanism, and their dense values make a silent misread more likely than the flags case that got one.What #399 fenced, and what it left open
The flags leg of #399 established that a vocabulary read across the readable window — not just at the current version — needs a way to retire an enumerator without a format bump. For
NodeFlagsthat now exists ininclude/pup/index/format.hpp:RECORDED_NODE_FLAGS_MASK— what this build names,RETIRED_NODE_FLAGS_MASK(:287) — bits that were named once and must still be readable,READABLE_NODE_FLAGS_MASK(:298) — the union actually enforced inFileEntry::from_raw,(RECORDED & RETIRED) == 0disjointnessstatic_assert(:290) so a future flag cannot silently take a retired bit's meaning,:279) tells the maintainer to move the bit rather than delete it.NodeTypeandLinkTypeare read by the same recovery path, across the same window, and have none of it.names_node_type(include/pup/core/types.hpp:170-187) is a bare exhaustive switch over eleven enumerators;names_link_type(:190-193) delegates tolink_role. Retiring an enumerator from either means deleting its case, and nothing anywhere records that the value was ever spoken for.Two failure modes, and the second is the one that matters
Over-rejection (loud). Delete a case and every legitimate record in the readable window carrying that byte fails
names_node_type, returnsIndexDamaged, and is announced as damage. This is the exact defect the flags leg found inread_prior_paths— a legitimate v9–v13 record becomingKind::Lost— and it is whyRETIREDexists. Bad, but it is loud, and loud is survivable.Misinterpretation (silent), and it is likelier here than for flags.
NodeType's enumerators are dense and sequential —File=0 throughPhi=10. Retire one and the freed value is not a hole anyone must deliberately reach into; it is the next value the natural numbering hands to whoever adds an enumerator, particularly if the retired case was removed from the middle and later entries were not renumbered. At that point an old record's byte silently means the new type: it passes validation, constructs aFileEntry, and routes as something it never was.That is precisely the argument that justified the flags disjointness assert. It applies with more force to a dense enum than to a bitmask, where a retired bit stays a hole until someone deliberately reuses it.
Why this is not just the flags fix again
There is nothing to put in a retired set today: no enumerator has ever been retired from
NodeType,LinkType,NodeFlags, orCommandFlagacross the readable window except the singleNodeFlags::Inactivebit that #399 already handles. So this is not a latent live bug — it is an unfenced invariant, and the fence is cheap now and expensive after the first retirement.The flags leg's comment at
names_node_typestates the obligation in prose and explicitly denies the mechanism ("would need the retired setNodeFlagshas and this does not"). That was the correct thing to ship with a comment-only follow-up, and it is not a substitute for a compiler-checked device. Prose does not fail the build.Suggested shape, not prescribed
The symmetric answer is a retired set per vocabulary plus the same disjointness assert, so that retiring an enumerator is a mechanical move rather than a deletion, and reusing a retired value is a build error. Whether both vocabularies need it, and whether the assert or the value pin is the right tripwire for a dense enum, is a design question — the flags shape should not be copied over by reflex.
Found by the builder while applying the #399 flags-leg deltas, and correctly not built there: it is a design change, out of scope for a comment-only follow-up.