Issue #3618709: Guard the shape rather than the vocabulary: the engine asks only about its own fields
The guard from [#3618663] listed four forbidden field names. It caught the two reach-ins it was written for, then let hasField('grade') through — the same defect in the same shape, missed because nobody added that word to the list.
This guards the shape instead: a hasField() on a literal is the engine admitting a column might not be its own, so literals are held to a list of the engine's own fields (tenant, allotment) and every other rule must take the name in a variable. Sixteen of the engine's eighteen hasField() calls already comply.
Seen to fail twice before passing: against the real grade leak, and against a planted hasField('place') in an unrelated engine class, which it named and located.
grade is listed as a known exception with its reason. The rule reading it isn't misplaced — a cell is a tariff class and a band, and the engine prices cells — so the fix is for bands to become the engine's own, not for a pricing rule to be exiled to whichever module draws them. Moving that constraint into the placement module was written, went green, and was discarded for exactly that reason.
Also found while widening it, deliberately not fixed here: four hardcoded lists of another module's field names — EntityTitle's scopes, ResourceFieldGroups' group mapping, BookingHooks' defaults and followed-fields lists, and the machine name widget's scoping. The defaults are the worst: one of them defaults a field to a plugin id the engine cannot provide. Those are vocabulary work, not guard work.
Test only, no production code. ModuleBoundariesTest 33 tests green, phpcs clean.