ctx.index entries and their path_to_id registration are produced at nine
sites in src/cli/cmd_build.cpp, and seven of them must each remember to
register. There is no enforcement point: Index::add_file does not take the
map, and nothing stops a tenth producer appearing without a registration. The
invariant is conventional, not structural.
This is not hypothetical. #386 was exactly one arm having forgotten — the ghost
serialization arm wrote a non-empty path and never registered it — and it stayed
forgotten until a defect in a different subsystem exposed it.
The inventory (line refs as of the #391 merge)
| # |
Producer |
.path written |
Registers |
| 1 |
get_or_create_dir arm A :479-480 |
non-empty |
yes :480 |
| 2 |
get_or_create_dir arm B :500-501 |
non-empty |
yes :501 |
| 3 |
create_implicit_file :553-554 |
rel_path |
yes :554 |
| 4 |
serialize_graph_nodes File/Generated, empty-path arm :599 |
StringId::Empty, deliberately |
no |
| 5 |
serialize_graph_nodes File/Generated :673-674 |
intern(node_path) |
yes :674 |
| 6 |
serialize_graph_nodes Directory/GeneratedDir :694-696 |
intern(node_path) |
yes :696, guarded on !node_path.empty() |
| 7 |
serialize_graph_nodes Ghost :732-747 |
intern(node_path) |
yes :747, guarded on !node_path.empty() (#386) |
| 8 |
serialize_graph_nodes Variable/Group/Root :766 |
StringId::Empty, deliberately |
no |
| 9 |
merge_out_of_scope_commands' resolve_file :1325-1337 |
old_file->path |
yes :1337 |
Producers 4 and 8 keep a slot so ids stay dense; they are safe precisely because
they write an empty path.
The hinge
Producers 6 and 7 guard registration on node_path.empty() while writing the
field unconditionally as intern(node_path). Those two predicates agree only
because interning the empty string yields StringId::Empty — documented beside
is_empty and pinned by a unit test as of #391. Had intern("") minted a fresh
handle, the root directory entry would have a non-empty path handle and no
registration on every build.
A funnel folds that guard into one site and retires the hinge's load-bearing
role for registration entirely.
Why Index::add_file is the wrong enforcement point
The read side legitimately builds Index instances with no path_to_id and
must never register: read_index and read_prior_paths reconstruct the old
record, which is not the new build's namespace. A signature demanding the map
would make every read-side construction carry a lie.
The right shape is a writer-side funnel in the serialization TU: one
function through which every producer of a ctx.index entry routes — add the
entry, register iff the path is non-empty — so a producer cannot add without the
registration decision being made, at count=1.
Priority: low, and honestly so
Since #382 a record naming one path twice is refused on read, and since #391 the
fallback that used to absorb a forgotten registration in silence is gone. So a
violation today is already machine-caught: the first lookup that misses mints a
duplicate, and the next read reports the record unreadable. This fence buys
impossible over loud-one-build-later, which is a real improvement — the
current signal names unreadability rather than the producer that forgot — but it
is not closing an open hole.
Acceptance
- Every
ctx.index producer routes through one add-and-register funnel; the
direct add_file calls in the writer TU are gone.
- The
is_empty guard exists at one site rather than being repeated per arm.
- The funnel closes by construction the case a duplicate could otherwise evade
the reader: a minted entry whose type falls outside the three lists
prior_paths scans. That case is unreachable through today's merge, whose
resolve_file only handles File/Generated/Ghost entries, so it is a
fence-design requirement rather than a live defect.
- The read side is untouched and still constructs
Index without a map.
ctx.indexentries and theirpath_to_idregistration are produced at ninesites in
src/cli/cmd_build.cpp, and seven of them must each remember toregister. There is no enforcement point:
Index::add_filedoes not take themap, and nothing stops a tenth producer appearing without a registration. The
invariant is conventional, not structural.
This is not hypothetical. #386 was exactly one arm having forgotten — the ghost
serialization arm wrote a non-empty path and never registered it — and it stayed
forgotten until a defect in a different subsystem exposed it.
The inventory (line refs as of the #391 merge)
.pathwrittenget_or_create_dirarm A:479-480:480get_or_create_dirarm B:500-501:501create_implicit_file:553-554rel_path:554serialize_graph_nodesFile/Generated, empty-path arm:599StringId::Empty, deliberatelyserialize_graph_nodesFile/Generated:673-674intern(node_path):674serialize_graph_nodesDirectory/GeneratedDir:694-696intern(node_path):696, guarded on!node_path.empty()serialize_graph_nodesGhost:732-747intern(node_path):747, guarded on!node_path.empty()(#386)serialize_graph_nodesVariable/Group/Root:766StringId::Empty, deliberatelymerge_out_of_scope_commands'resolve_file:1325-1337old_file->path:1337Producers 4 and 8 keep a slot so ids stay dense; they are safe precisely because
they write an empty path.
The hinge
Producers 6 and 7 guard registration on
node_path.empty()while writing thefield unconditionally as
intern(node_path). Those two predicates agree onlybecause interning the empty string yields
StringId::Empty— documented besideis_emptyand pinned by a unit test as of #391. Hadintern("")minted a freshhandle, the root directory entry would have a non-empty path handle and no
registration on every build.
A funnel folds that guard into one site and retires the hinge's load-bearing
role for registration entirely.
Why
Index::add_fileis the wrong enforcement pointThe read side legitimately builds
Indexinstances with nopath_to_idandmust never register:
read_indexandread_prior_pathsreconstruct the oldrecord, which is not the new build's namespace. A signature demanding the map
would make every read-side construction carry a lie.
The right shape is a writer-side funnel in the serialization TU: one
function through which every producer of a
ctx.indexentry routes — add theentry, register iff the path is non-empty — so a producer cannot add without the
registration decision being made, at count=1.
Priority: low, and honestly so
Since #382 a record naming one path twice is refused on read, and since #391 the
fallback that used to absorb a forgotten registration in silence is gone. So a
violation today is already machine-caught: the first lookup that misses mints a
duplicate, and the next read reports the record unreadable. This fence buys
impossible over loud-one-build-later, which is a real improvement — the
current signal names unreadability rather than the producer that forgot — but it
is not closing an open hole.
Acceptance
ctx.indexproducer routes through one add-and-register funnel; thedirect
add_filecalls in the writer TU are gone.is_emptyguard exists at one site rather than being repeated per arm.the reader: a minted entry whose type falls outside the three lists
prior_pathsscans. That case is unreachable through today's merge, whoseresolve_fileonly handles File/Generated/Ghost entries, so it is afence-design requirement rather than a live defect.
Indexwithout a map.