A tariff withheld from the front being served can be held from the place map. Driven through the endpoint with the quota out of the way, all three surfaces that compose their own lines accept it: a seat click, a relocate, and a reprice, each ending with the line naming the withheld tariff. The same tariff asked of RequestBooker is refused, which is the rule working exactly where it is written and nowhere else.
It takes a crafted request rather than the interface, since the payload's tariff list is filtered and the picker never offers a withheld tariff. What it needs is the booking permission, a CSRF token and a tariff id, and ids are small integers while the endpoint answers accepted or refused. What still bounds it is every rule that is already inside the engine: the tariff must belong to the slot's resource, it must price the seat's grade, an allotment must have a unit left, and a per-order allowance applies. So the reachable act is a booker holding a seat at a price the operator withheld from that front, and a withheld price is usually the cheaper one.
Why it happens
Every eligibility rule lives inside the engine: capacity, the constraint plugins, the group policies, place uniqueness, the grade a tariff prices. Every rule but this one. Whether the front being served offers this resource and this tariff is asked above the engine, in RequestBooker::assertOnOffer(), so it is the only rule a caller can bypass by not going through that class, and the three surfaces that compose their own lines bypass it. The filter that owns the rule says so in its own words: hiding is not enforcing, and the refusal at the hold is what stops a crafted request holding it anyway. That refusal exists. It is just not where the holds go.
The change
The rule moves inside the engine as a group policy, which is the mechanism that receives the order as well as the lines. It reads the front stamped on the order rather than whatever context happens to be current, because that is the reproducible source: a queued or retried hold has no meaningful ambient front, and an operator completing a booker's order is not on the booker's front. Where the order is new and carries no stamp, the front being served is authoritative, which is the one moment it is, and the order is about to be stamped with exactly that value.
RequestBooker::assertOnOffer() is deleted rather than left delegating, so the rule has one home. Nothing else changes: no signature moves, no call site moves, and every caller of the engine inherits it.
Why the entry points are not restructured
Worth recording, because the shape invites it. There is already one door: holdRequests() composes lines carrying placement_pending and delegates to holdInTransaction(), adding the retry that drops a guessed area when the party does not fit. Whether a destination is decided or waiting for the placement is already data rather than a choice of method. And the seam that keeps the engine from knowing what a seat is is already an interface: the provider is asked to place what is wanted and answers with lines, whose columns the engine spreads without reading them. So nothing has to be restructured for this rule to be enforceable. It only has to be in the layer every caller passes through.
How it is verified
A kernel test per surface, each seen to accept a withheld tariff before the change and to refuse it after, driven as the demonstration above was. The query budget tests are the performance gate and must come out identical rather than merely green, since a rule already running on one path should not move a statement on the others: taking a place, releasing one, the endpoint read, and the allotment budget.
Found while fixing #3618650: Count a house-given place once, and put a reprice through the engine, whose last section describes this rule as read-side only. That is wrong and will be corrected there: the rule is enforced, just not on the paths that compose their own lines.
AI-Generated: Yes (Claude Code was used to find the defect, to draft this summary and to write the change and its tests. The three vulnerable paths were demonstrated with a kernel probe before any code was written.)
Issue fork yoyaku-3618660
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #5
mably commented