Constraint policies¶
A booking constraint is an invariant of the model: the slot is over, the place is taken, the window has closed. Nobody switches those off. A constraint policy is a rule a site chooses and configures, attached where it should reach. Nothing runs unless attached.
- Attribute:
#[\Drupal\yoyaku\Attribute\ConstraintPolicy(id: '…', label: …, requires: […])] - Interface:
\Drupal\yoyaku\ConstraintPolicyInterface - Base class:
\Drupal\yoyaku\ConstraintPolicyBase - Plugins live in
Plugin/ConstraintPolicy/ - Manager service:
yoyaku.constraint_policy_manager
One method, asked at every checkpoint:
public function check(PolicyContext $context, PolicyScope $scope): ?string;
$context is what this policy applies to: the bookings it reaches, the
transaction if there is one, the checkpoint, and the requirements it declared. $scope
is how far it reaches: the host, the resource, and the tariff when there is one.
Return NULL to accept, or a booker-facing reason to reject.
What a policy is asked, and when¶
A policy answers accept or reject from the data it applies to and its own settings. That is the whole of its job: what it does internally to reach the answer is its business, and nothing in the framework encodes it.
It cannot answer before the data exists, so it declares the requirements it needs and is asked wherever they hold. Requirements rather than moments, because the rules a booking engine wants do not sort into a couple of fixed times: a minimum quantity needs an order nobody is still adding to, a maximum spend needs resolved prices, a per-booker limit needs a named booker.
| Requirement | Holds when | Answered by |
|---|---|---|
bookings |
always; it is the baseline | the engine |
transaction |
a saved order exists for the bookings to belong to | the engine |
sealed_transaction |
the order is not being added to right now | the engine |
booker |
something on the order names who is booking | yoyaku_order |
prices |
the order's prices can be worked out | yoyaku_payment |
The vocabulary is open: a requirement is a string plus a service tagged
yoyaku.policy_requirement naming the requirement it answers, in the module that owns it.
Nothing central lists them, which is why booker and prices can live outside
the engine at all. A policy declaring a requirement nothing resolves is refused and
logged, not skipped: no checkpoint will ever satisfy it, and dropping the rule
would behave as though the operator had configured none.
Checkpoints are where an evaluation is asked from:
| Checkpoint | Where | The order's state there |
|---|---|---|
Hold |
as units are asked for, before any is held | empty or pending |
Registration |
the booking form's submit | pending, still editable |
Lock |
before the order is locked: for a payment, on its page and again on the click | pending on the page, locked on the click |
Confirm |
an operator, a workflow or the confirm form | placed and beyond |
Registration does not seal the order: an order leaves pending only when it
is locked, and nothing about a form submission stops the booker
returning to the cart. So sealed_transaction is read from the order's state
and from nothing else.
At the hold, a policy speaks only about what is being asked for. One that reaches nothing in the request is not asked at all, because everything it does reach is already held and it was only reached because those bookings share a basket. Without that, a single rule broken anywhere in a cart vetoed every later hold in it: a visitor holding one refuge night too many could not book a concert seat, and was told they could only book one refuge at a time. Only at the hold, though. Every later checkpoint judges the transaction as it stands, and there a rule broken by bookings nobody is adding to is exactly what has to be reported.
A rule needing no more than its bookings is asked at the hold, and should be:
refusing the click beats refusing the checkout. A rule counting across a booker's
other orders is asked there too, as soon as the order names one, which on a site
that requires an account is the first click and on a form is the registration
step. Those live in yoyaku_order, the module that adds a booker, and extend
BookerPolicyBase.
Wherever a rule is asked at the hold, the units being asked for count. The group is judged before any of it is written, so a rule that counts by querying what is stored has to add the request to what it finds; one that adds up the bookings it is handed already has it. The alternative is a basket allowed past the allowance and refused at the checkout, holding capacity out of the offer in meantime for a booker who cannot complete.
Where a policy attaches¶
A policy, here and on the screens, is one configured placement: a plugin id plus the settings chosen for it, sitting on a host. That is what an operator creates on a Policies tab, and it is the unit everything on this page is about. The same plugin can be attached more than once, on different hosts or even on one, and each of those is resolved, scoped and asked separately with settings of its own, so what a policy reaches is never a property of its plugin. Each one can also be switched off without being removed, which is covered under Enforcement and overrides.
Four widths of host, from the general to the particular, each with that tab:
| Host | Reach |
|---|---|
| resource type | every resource of that kind, say every concert |
| resource | one resource, everything it offers |
| tariff | that tariff, in every slot offering it |
| slot tariff | that tariff, on one slot |
They merge, they do not override. Every policy reaching a booking is asked and any of them may refuse, so two ceilings need no precedence rule: the tighter one refuses first. The engine settles overlapping availability bounds the same way, asking every provider and taking the lowest.
A host reaches its own resources and no others. A resource covers the bookings made against that resource; a type covers those of every resource of that kind, and nothing outside it. That sounds like it goes without saying, and it did not: the manager gathers the policies of every ask in a group and asks each of them about the group, so a scope that answered "yes" to any of them at all was handed the bookings of every other resource in the same basket. A limit on one concert counted another concert's units against its own number, and a rule on one kind of resource was applied to every other kind.
A word on the examples, because one reading of them is wrong. A resource is
what is booked, and a venue is the physical place a resource may run in: the
hall is the venue, and the season of concerts playing in it is a resource each.
So "attached to a concert" here never means attached to a room. A venue is not a
policy host at all; it belongs to yoyaku_placement, which reuses one place map
across every resource running there.
Reach is read from the host rather than from the resource a policy was reached through, because policies are deduplicated by host: a type has one scope for the whole request, and it stands for every resource of that type rather than for whichever one the manager happened to walk first.
A policy is handed the bookings it reaches, narrowed by the manager rather than by the policy. So where it hangs decides what it covers, and a slot booked without any tariff is covered by the two wide hosts like anything else. One narrowing, applied for every policy, is what makes that hold: a policy working out its own reach is how a booking naming no tariff becomes invisible to it, and how a limit attached to a resource offering plain units ends up doing nothing at all, silently, while still listed on the tab.
It also means a limit on a resource offering three tariffs caps their sum, not each tariff separately: "at most 4" is four across the three, not twelve.
One kind of policy needs more than its own bookings: one whose pool is
declared wider than its host, as the cross-resource limit's shared_with
name is. Such a policy reads $context->getEvaluationBookings() and narrows
by the pool it declares, because what it is handed is only a part of what it
counts.
What an ask is made of¶
An ask being proposed carries its slot, its tariff, a quantity, and two arrays that look alike and are not:
| On the ask | What it is | Where it ends up |
|---|---|---|
fields |
what the ask records: a place reference, a section, whatever a module adds | spread into the booking, so every key is a field on it |
flags |
what the producer says about the ask: how it decided, what it had to settle for | read while the group is judged, then gone |
The engine interprets neither. The difference is the destination, not the lifetime: both live on the ask until the group is judged, and then one is written down and the other is not.
That distinction is what keeps a requirement about a decision off the busiest table
in the engine. The seating strategy flags place_last_resort on an ask it
reached only after every arrangement the venue prefers had failed, and the orphan
rule reads it to tell an arrangement nobody had an alternative to from one the
seating chose badly. Asking a booking about it later would be asking the wrong
thing: a booking says what was booked, not how the engine arrived at it, and the
answer stops being true of anything the moment the group is judged.
So a producer that has to tell a rule something flags it. Making it a field instead puts a column on every booking ever written for a requirement nobody will ask a booking for.
When a policy's plugin has gone¶
A policy on a host stores a plugin id. If nothing defines that id any more,
the plugin was renamed or the module providing it was uninstalled, the row still
says an operator configured a rule, and the rule most often configured is a cap.
So it is not skipped: it is answered by a stand-in
(broken) that refuses, and the manager logs the host carrying it and the id
that is missing.
Refusing is the safe direction. A refusal is visible, reversible and reported; skipping the row behaves as though nothing were configured, which overbooks silently while the policy is still listed on the host's tab. The message the visitor sees says only that the offer is unavailable, since the cause is a misconfiguration they can neither see nor fix.
The fix belongs to the site, not to a booking: on the host's Policies tab, point the policy at a plugin that exists, or delete it. Renaming a policy plugin id is therefore a data migration, not just a code change, and pre 1.0 that migration is done by hand.
The stand-in declares no hosts, so it is never offered as something to attach. It declares that it needs nothing but its bookings, which always hold, and that is load-bearing: a policy is asked only where its requirements hold, so a stand-in asking for anything more would be passed over at the very checkpoints it exists to refuse at, and the stale policy would go back to failing open.
When a policy cannot be evaluated¶
Not the same thing. Here the plugin exists and the policy is sound; the order simply does not carry what the rule needs yet, which before the last checkpoint is a wait rather than a problem.
It still has to be visible. A policy that was never asked returns nothing, exactly as a policy that accepted does, so an operator reading only the violations cannot tell a clean order from one whose limit nobody could check. Every evaluation therefore reports two lists, and the confirm form and the Orchestra review step show the second under These policies could not be checked, with what each is waiting for. At the last checkpoint it is logged as well, because there is no later one to catch it.
It does not section. An order an operator typed in that names no booker has always been confirmable, and still is.
What this reports is the framework's gate, and that is the honest limit of it.
A policy can also decline for a reason of its own: a per-booker rule whose
configured Count by attribute has no value on this order counts nothing and
returns nothing, exactly as one that approved would. The booker requirement holds as
soon as any booker attribute is filled in, so it cannot see that the one
particular attribute a given rule counts by is still empty. Closing that would
mean every policy reporting its own reasons for standing down, which is a larger
change than the requirement gate.
Storage is on the host: the resource type keeps its policies in
third_party_settings, under the yoyaku key, and the three content hosts in a
policies field; ConstraintPolicyResolver reads and merges them.
The engine owns that key because it owns the policy system, and one consequence
matters to anybody shipping a policy. The engine describes the attachment
itself, but it resolves the settings schema from the attached plugin id, so
a module shipping a policy declares
yoyaku.constraint_policy.<plugin_id> in its own schema file, extending
yoyaku.constraint_policy_settings and naming only the settings it adds. A
policy whose module forgets makes every resource type it is attached to carry
configuration nothing describes. There is no per-plugin schema for the content
hosts, which keep their attachments in a field rather than in config.
A type carrying policies gains no dependency on the module providing them, which is deliberate: a config entity is deleted along with a module it depends on, and a resource type is not something to lose because a policy module was uninstalled. Uninstalling the module that ships an attached policy leaves the attachment naming a plugin nothing defines, which the section above covers.
One consequence worth knowing when a refusal turns up that is nowhere in the
code. A policy's message setting holds the id of a booker message, and
that message's text is authored configuration in its own right, translated
through Config Translation on its own translate form, so it lands in a
language.<langcode>.… override rather than in the module's .po file.
getViolationMessage() reads the text as the config system hands it over, already
in the active language, so a French refusal can exist with no t() anywhere
near it. Only the plugin's built-in sentence, used when nothing is chosen, comes
from the interface translation. See Booker messages.
The shipped policies¶
| Policy | Module | Needs | Settings | Counts | Rule |
|---|---|---|---|---|---|
| Limit per transaction | core | bookings |
max |
units (quantities added up) | At most max in one transaction, across everything it reaches. |
| Per-booker limit | yoyaku_order |
bookings, booker |
max, count_across, count_from |
units (quantities added up) | At most max per booker, counted over each slot, each resource, or the whole reach, and from a moment it chooses. |
| Per-booker limit across several resources | yoyaku_order |
bookings, booker |
shared_with, max, count_from |
distinct slots | At most max active slots per booker across every resource sharing the shared_with name. |
| Minimum booker age | yoyaku_orchestra_example |
bookings, booker |
min_age, field |
nothing; it reads a date | Rejects when the date in field puts the booker under min_age on the day they book, not on the day of the slot. |
| Places a booking may orphan | yoyaku_placement |
bookings |
minimum, allow_chosen, allow_last_resort |
places left available side by side | Rejects a booking that would leave fewer than minimum available places side by side in a row it takes from, unless nothing legal was left to propose. |
| Acknowledge places that are apart | yoyaku_placement |
- | ask_chosen |
whether a booker who chose the places is asked too | Refuses nothing. Asks the booker to accept, on the cart page, that one slot's places are not all next to each other. |
What a limit counts is part of its rule, which is why the column exists: "at most 4" is four units in the first two policies, so one booking of four is already at the limit, and four dates in the third, where a party of four sharing one night is one. A case wanting to count something else is a new plugin rather than a setting, since the number and what it counts cannot be configured apart from each other without an operator having to guess which they picked.
What a limit counts is not the same question as what it counts over, and
only the first is fixed. The second policy offers both readings in
count_across precisely because both count units: the number on the form means
one thing either way, so choosing the axis cannot change what "at most 10" is
worth. An axis that also changed the unit would be the setting this paragraph
warns against.
The first policy counts within one transaction and so needs no identity, which is why it can refuse the click. The others count across a booker's transactions, or read something the booker gave, and wait until one is named.
The second is the only one whose axis is configurable, and its three readings
are genuinely different rules rather than narrower versions of one. The
count_across setting chooses how wide the sum is:
count_across |
On a resource | On a resource type |
|---|---|---|
slot |
each of its slots on its own | each slot of every resource of the kind, on its own |
resource |
all its slots added up | all of one resource's slots added up, granted to each of the kind |
reach |
all its slots added up | every resource of the kind pooled into one allowance |
So "one per person per evening" over a season is slot, "ten per person for
this concert's whole season" is resource, and "ten per person across every
concert we run" is reach. The per-slot reading is the default, and was for a
while the whole of the policy, under the id slot_per_booker_limit.
count_from then decides how far back the counting goes, which is a separate
question from how wide it is. Both per-booker policies carry it, since both
answer by querying what the booker already holds:
count_from |
Counts |
|---|---|
upcoming (default) |
only slots that are not over yet |
date |
everything from a day the operator names, slots already over included |
all |
every booking, however old |
The cutoff is judged on when a slot ends, not when it starts: a refuge the booker is in tonight began this afternoon and has to keep counting, or a second one could be taken mid-stay.
It cannot be left to the booking states, which is why the setting exists at all.
A booking leaves held or confirmed only when something completes it or marks
it unused, and the worker that does so after a slot is never enqueued for a
resource on manual settlement. Without a cutoff the allowance on such a resource
would be spent for good, and a booker who came last season could never book
again.
The two readings answer different rules and neither is a default for the other.
"One refuge at a time" wants upcoming, since a stay that is over must release
its allowance. "Ten concerts for the season" wants date, since a concert
already attended in September has to keep counting in October, or the allowance
quietly resets every night. The season's day is moved on when the next one
opens.
The middle width exists because a type attachment could otherwise only be the
narrowest or the widest. "Ten per person per concert, the same rule for every
concert" is the setting most operators reach for, and without resource it
could only be said by attaching to each concert by hand and remembering to do it
again for the next one. On a resource resource and reach are the same total,
which is why the choice only becomes visible one level up.
The reach is the host, not the request. A season allowance counts the
dates the booker is not touching in this click as much as the one they are;
reading it from the bookings would quietly turn it into a per-basket limit,
which is the first policy. Nothing else is needed to know that, so this axis
reads the ones it is handed like everything else and does not reach for
getEvaluationBookings().
The third counts slots, which is neither of the two things easier to count.
Not booking rows: several rows can share one slot, since a placed venue writes
one booking per seat, so a party of four is four rows and one occasion. Not
distinct resources either: two nights at one refuge is one resource, and a
booker who may hold one refuge at a time may not hold it on every date. Where a
resource is taken whole, as a refuge is, every booking is one unit and the two
readings agree; they diverge on anything offered by quantity, and there this
counts occasions rather than how much of them was taken. So "one refuge at a
time" is that policy with shared_with = refuges and max = 1 attached to
the refuge type, and it means one refuge on one date; a refuge bookable
alongside the others simply carries no name, or a different one.
The name is an arbitrary token, not a resource type: resources of different types can share one, and resources of one type can carry different ones or none. Where the policy hangs is a separate axis from what it spans.
It also counts across a booker's other orders, which raises a question the first two never face: when the booker holds more than the allowance, which of their orders is the one at fault? An order is judged against what the booker already held when that order took them, so the answer is the newest. A confirmed booking does not become a violation because a second date was started and abandoned in a basket afterwards, which is what an operator recording attendance would otherwise be warned about, on a paid booking, until the abandoned hold happened to lapse.
At a time is what count_from makes true of the third, and by default only the
slots that are not over yet are counted, so a stay that has finished releases
its share of the pool. That cannot be left to the booking states: the worker
that settles a booking after its slot is never enqueued for a resource on
manual settlement, so without the cutoff last season's stay would hold the
booker's one share for good.
Nothing is loosened at the hold: a request being held is newer than everything already there, so it still counts the whole pool and is still refused under the slot locks. Only the retrospective reading of an order that is already written is narrowed that way.
Both of these count across a booker's orders by querying what is stored, and at the hold nothing of the request is, so each adds the request to what it finds: the units asked for on each slot in the second, and the slots asked for anywhere in the pool in the third. The third reads them from the whole request rather than from its own bookings, because the pool it spans is wider than the resource it hangs on and one request can take a slot of several of them at once.
A per-booker rule can first refuse where the booker is named, which for a visitor filling in a form is the registration step and not the click. Until then it is reported as one of the policies that could not be checked, and it is worth knowing that a basket assembled anonymously can therefore exceed the allowance until the identity arrives.
The last is in an example module, and is there to be read rather than
enabled: it shows a site adding a rule of its own over an administrator-defined
order field, with no engine change. field names that field, so the rule works
on whatever the site called it.
The stand-in (broken) is a policy too, but declares no hosts, so it is never
offered on a Policies tab and can only arrive by a policy outliving its
plugin. See When a policy's plugin has gone.
Places a booking may orphan¶
The one shipped policy that is not a limit on how much somebody may take: it reads what a booking leaves behind. Two, the default, refuses a booking that would leave one place available on its own, between other bookings or at the end of a row; three refuses one that would leave a pair. The whole rule, and what the seating strategy does to avoid proposing such a booking in the first place, is in Seating a party.
What counts as empty is not this policy's own opinion: it reads PlacesInPlay,
the same picture the seating weighs the rule in, so a take cannot be accepted here
and refused there. A place an allotment holds for somebody else is occupied in it,
and a place priced for another grade is not.
One setting decides who the rule answers to. Left off, it refuses a booking wherever the place came from, which is the reading that holds it everywhere. Ticked, a place somebody named themselves is accepted and the rule becomes a guide for the seating this venue does on a booker's behalf: what the engine chooses is still judged, because a proposal that strands a place is a fault of the seating rather than a choice anybody made. The booking says which it is, since the placement provider stamps what it places and the map stamps what was clicked, so nothing has to be configured per surface. A request made only of chosen places is then answered without reading the slot's places at all.
A second setting says what happens when the venue has nowhere left to put somebody. Left on, which is the default, the rule never costs a booking: where every arrangement the seating would prefer has failed, the party is seated on what remains and the place left on its own is the price of the booking rather than the reason to refuse it. Refusing there would keep that place available and turn the party away as well, which is two places lost to save one, and the end of a slot is exactly where it bites: an area down to a pair and a booker asking for one place. Switched off, the booking is refused instead, which is a trade a house can choose deliberately.
The flag is on the ask, like the one above it: the strategy says a proposal was its last resort, the provider stamps that on what it builds, and the rule reads it. So the setting excuses only an arrangement that had nothing legal to compete with. A proposal that strands a place while a legal one existed is still refused, because that one is a fault of the seating.
It offers only the two widest hosts, a resource and its type, and this is a property of the rule rather than a simplification. An attachment is handed the bookings its host reaches, and a tariff-level host reaches only its own tariff's: a family holding one row at two prices would be measured as though it were taking half of what it takes, and refused for a hole its other half fills. A guard that answers about a row nobody booked is worse than no guard, so those hosts are not offered at all.
Acknowledge places that are apart¶
The one shipped policy that refuses nothing at all. A party the house cannot seat side by side is placed rather than turned away, because a worse seat is a disappointment and only the booker can judge whether to take it; they are told how close it managed on the page that placed them. That message is easy to miss, and the plan shows the places only to somebody who looks at it and can read seats drawn on it. A venue attaching this asks the booker to say out loud that they accept it, before the basket is committed.
It publishes the question through BookerQuestionInterface instead of
answering check(), because a question needs somebody to answer it and there is
no moment under a slot lock at which a person can. The cart
page shows what it hands back
and holds its Checkout button until the box is ticked. Attaching it therefore
never turns a booking away; switching the attachment off stops the asking, and
that is the only switch there is. Being asked and being enforced are one setting.
It is asked per slot. The attachment reached the order through one resource, so the question appears in that slot's own card, and a booker seated apart at two houses ticks two boxes.
One setting decides who is asked. Left on, everybody whose places ended up apart says so. Switched off, only a booker the house placed is asked: somebody who clicked two seats at opposite ends of the hall has already said what they want. An order mixing the two counts as placed, because a booker cannot be held to have chosen what they were given. The sentence follows the same requirement, so a chooser who is asked reads "the places I chose" rather than being told the house did it.
Apart means what the seating means by it: PlaceGeometry::getPartyRuns(), the
same grouping the strategies use, so the message on the picker and the question
on the cart cannot disagree. A booking holding no place is not measured, nor
is a place with no row or position, an area offered by the quantity being
somewhere to stand rather than somewhere to sit. Slots are measured one at a
time, because a row is a different row on every slot: seat 1 on Monday and
seat 2 on Tuesday would otherwise read as a run of two.
Like the orphan rule it offers only the two widest hosts, for the same reason: a party spread over two prices is one party, and a rule handed only its own tariff's bookings would call half of it together.
Telling a surface the number¶
A policy that caps a count implements CeilingProviderInterface, and
ConstraintPolicyManager::getSlotTariffCeiling() returns the lowest ceiling any
policy puts on a tariff. A quantity control sets its maximum from it and says
so, because a control that silently refuses to go further reads as broken.
Both booking surfaces read it. The booking page asks for it as it builds each offer card; the seat map cannot, being drawn in a browser from a document every cache may keep, so the allowance travels in that document per tariff and the browser subtracts what the basket it is already holding contains. A remaining count could not travel there at all: the document is shared, and one booker's remainder in it is one booker's basket handed to the next. See What a per-order rule does on the plan.
The figure is advisory. The refusal on hold decides, and it re-checks under the slot lock, so a page drawn a moment ago being out of date is expected rather than a bug. A surface reading it should therefore be looser than the engine where the two can disagree, never tighter: an attachment reaching several tariffs caps them together, and a guard that subtracted per tariff and then refused would refuse a click the engine would have taken.
Asking the booker something¶
Most policies decide by themselves: they read the order and accept or refuse it. Some cannot, because the answer is not theirs to give. A party the house could not seat together is placed rather than refused, since only the booker can judge whether a worse seat is acceptable, and the rule that cares still has to hear them say so.
A policy with something to put to the booker implements
BookerQuestionInterface and returns render elements from
buildQuestions($order, $slot). The surface committing the order shows them
immediately above the control that commits, which on the shipped stack is the
cart page. Nothing outside the
policy learns what the question is about.
Three properties follow, and they are the point of doing it this way:
- Being asked and being enforced are one switch. An operator attaches the policy and the question appears; they switch the attachment off and it stops, along with everything else that policy does. There is no second place to configure and no second thing to forget.
- Nothing names a plugin.
AttachedPoliciesmatches a resource's attachments against the interface using the cached plugin definitions, and instantiates only what qualifies, so a resource whose policies are all about quantities builds no plugin at all. It is the same reader the seating uses forPlaceSpacingInterface, which is the older half of this pattern. - It is asked per slot. An attachment reaches an order through one resource, so the question belongs beside the slot it is about rather than at the foot of the basket, and a booker asked about two slots answers twice.
OrderAcknowledgements::buildElement() builds the common case: a sentence and a
required box. A policy may return any element the Form API supports, but a
question shaped like every other question is one an operator learns once.
Three properties of that control are accessibility requirements rather than styling, and a policy contributing its own element owes the same three:
- The requirement is said in the visible label, in words, and the control
carries
#required. So a sighted booker reads it, a screen reader is told by the attribute, and nothing depends on a marker drawn in CSS, which has nothing behind it to announce. The theme's red asterisk is decoration on top and needs no legend, because the word is beside it. - The refusal must stand on its own. A form error is also collected into the page's list of errors, away from the control, so it has to name what was not accepted and which booking it was about: "you have to accept this" identifies nothing there, and a basket asked twice would show one sentence twice. That is why the refusal is the policy's to word, not the builder's - only the policy knows the slot. See WCAG 3.3.1.
- The button is never disabled. A control that will not press reads as a
broken page, and a booker who has missed one of two boxes learns more from
being told which. So the submit is refused instead: the browser stops it
natively from
required, and the server refuses it again if that is bypassed.
Order attributes (the fields a policy counts by)¶
A limit is always "at most N per something". That something is not fixed to the booker: each policy carries a Count by setting naming one or more order attributes, the dimensions two orders must share to count together. The default is the booker email.
An order attribute is simply a field on the order (yoyaku_transaction)
entity. The order ships two, booker_email and booker_name, and an
administrator adds more from Manage fields on the order (a phone, a service,
a birth date, whatever the site needs), each of any field type. Because they are
real fields, they are also columns in Views with no extra code. A matcher is
derived once per attribute field, so a field appears as a Count-by
dimension the moment it exists, with nothing to write.
- Attribute:
#[\Drupal\yoyaku_order\Attribute\OrderAttributeMatcher(id: '…', deriver: …)] - Interface:
\Drupal\yoyaku_order\OrderAttributeMatcherInterface - Deriver:
\Drupal\yoyaku_order\Plugin\Derivative\OrderFieldMatcherDeriver - Manager service:
yoyaku_order.attribute_matcher_manager
A matcher answers, from the order's TransactionContext (the order plus what the
tagged context builders resolve from it, its payment and so on):
public function value(TransactionContext $context): ?string;
public function match(QueryInterface $query, TransactionContext $context): void;
public function capture(TransactionContext $context, string $value): void;
value() is the order's value on the dimension, or NULL when it is unknown
(no email captured yet, no card stored). A NULL on any configured matcher
means the identity is incomplete, so the policy is skipped rather than
counting every order together. When every value is known, each matcher's
match() narrows the count query, and a policy counting by several dimensions
counts two orders together only when they match on all of them (AND). To
express "same email or same card", attach two policies. capture() is the
symmetric write, used by the booking form (see below).
Shipped matchers:
| Matcher | Backed by | Counts orders sharing |
|---|---|---|
order_field:booker_email |
the booker_email field |
the booker email |
order_field:booker_name |
the booker_name field |
the booker name |
order_field:field_* |
any field an administrator adds | that field's value |
card_token |
the stored payment (yoyaku_payment) |
the stored payment card |
The card_token matcher resists a booker re-registering under a fresh email to
slip past a per-booker limit: the two orders still share the stored card. It is
captured at the payment step, not the form, so it is match-only.
Capturing attributes from the booking form¶
The booking form step maps submission elements onto order attributes: on the
handler you choose, per element, which attribute it feeds. On submit the
attribute's matcher capture()s the value onto the order, through the field
API, so a shipped field and an administrator's field behave identically. When
the handler's enforce option is on, the policies are then validated over every
captured dimension, blocking a submission that could never be confirmed.
The order is the one the form's step sees in Orchestra. A branch of the workflow can carry an order of its own, and then the form works on that one. The form changes the order only when the submit completes its step. A re-edit after the step moved on, or a submit by someone who may not complete the step, saves the answers but changes no order. A submit the policies or the form refuse changes none either: the policies are asked with the answers on the order, and that write is undone once they have answered.
A composite element is expanded into one mapping source per sub-element,
keyed <element>__<sub> (webform's own composite key convention; a dot is not
used because config keys may not contain dots), so its parts feed separate order
fields, for example address__city onto a city field and address__postal_code
onto a postcode field.
Enforcement and overrides¶
The policy model above only bites if the policies are run. Every policy attached
to an order's resources (and their types) is evaluated through
ConstraintPolicyManager::evaluateOutcome(), which the OrderManager service wraps:
getViolationsAt($order, $checkpoint) returns the violation reasons for a named
moment, validateOrder() is that with the confirmation named, and
confirmOrder() refuses to confirm, throwing an
OrderConstraintViolationException, when any policy is violated. Validation
always counts within the order's tenant.
The checkpoint is always the caller's to name, never implied by which method was called: a caller that cannot say which checkpoint it is at ends up asking for the wrong one.
The engine evaluates separately at Hold, once per hold() before any capacity
is taken and again under the anchor-row locks, so two concurrent requests cannot
each see room for the last unit. The second pass
resolves its requirements afresh, which is the point of it.
An evaluation is remembered for the rest of the request, per order and per checkpoint, because the confirm form makes two passes over one submission and would otherwise run every policy twice. Writing a booking or the order forgets it, so waiving enforcement and validating again in the same request sees the waiver.
The Registration checkpoint is reached from one place:
- The booking form. The webform booking handler has an Enforce constraints option. With it on, the captured order is validated on submit, so a booking that already breaks a policy is refused at the form rather than accepted and blocked later. Asked as the registration and not as the confirmation, because the confirmation is still to come: a policy that cannot answer here will be asked again, and reporting it as one nothing would ever ask again told operators a rule had been passed over while it was merely early.
The Confirm checkpoint is reached from two:
- The order confirmation. The order's confirm transition form lists any violations and will not confirm the order until they are resolved, or the operator ticks Confirm anyway.
- The workflow. In an automated Orchestra booking workflow the Confirm
order action validates and raises
OrderConstraintViolationExceptionon a violation instead of confirming silently, and the Order review interaction surfaces the violations for a human step to act on.
The checkout¶
The confirmation is too late to be the only gate, because the money moves before it. An order can comply when the booker registers and stop complying before they pay: a per-booker limit cannot be counted until the order names a booker, so every basket the same booker fills afterwards lands in that gap. The booker was charged and refused afterwards, and a workflow canceling the order behind them had taken money for a booking the rules never allowed.
So the payment step asks too, at the Lock checkpoint.
OrderManager::getViolationsAt() runs the same policies at that checkpoint, and
CheckoutPolicySubscriber puts each violation on Orchestra's
CheckoutEvents::CHECKING event as a sentence. Orchestra renders them and learns
nothing about bookings.
The booker sees it twice over, both times before any money moves:
- On the payment page. The reasons are listed above the amount and Go to payment is drawn but inert, rather than a button whose only outcome is the same page with an apology on it.
- On the click. Orchestra asks again straight after locking the order, so the answer is about an order nothing can add to, and before the amount is worked out, so a refused order reaches no gateway and no payment row is created for it. The lock is released and the booker lands back on the page with the reasons.
A booking that owes nothing is asked on the same terms. Such an instance normally skips the payment page entirely and a flow carries it onward; a refused one stops on that page instead, because otherwise a free slot would escape every rule a paid one obeys.
Only violations reach the booker there, never the policies that could not be checked. A deferral is not a refusal, and "this limit could not be checked" is an operator's concern: the Order review step shows it and the payment page does not.
Note which policies can answer where. A rule needing no more than its bookings
is already asked at the hold, so an order that breaks one cannot be assembled
in the first place, and reaching the checkout in a broken state is what
needing a booker causes. A rule that also needs sealed_transaction can only
answer on the click, because the order is an editable basket while the page is
drawn: such a click can be refused for a reason the page never showed, and the
booker reads it on the page they land back on. No shipped policy declares that
requirement today.
Ticking Confirm anyway sets a persistent disable_constraint_policies flag on the
transaction. Every checkpoint but Hold honors it first and reports no violations
for that transaction from then on, at every point above. The hold deliberately does
not: the waiver says "this order, as it stands, may be confirmed", which is a
judgement about a finished order, and reading it as units are still being asked
for would turn it into a license to keep adding past the cap. The waiver is
per order and permanent, not a one-time bypass: once an operator overrides the
policies for an order, the form, a later confirm and the workflow all treat
that order as exempt.
Switching one policy off¶
A policy has an Enforce this policy checkbox on its settings form, and the Policies tab states for each one whether it is enforced. Unchecking it leaves the policy where it is, with the numbers it was tuned with, and stops it being asked. That is the way to lift a rule for one concert and put it back afterwards: removing the policy instead loses its settings, and on a resource type it loses more, because the stored list is a sequence whose translated messages are keyed by position, so taking one entry out moves the ones below it.
A policy that is off is skipped at every checkpoint, the hold included, which is where it differs from the waiver above: the waiver says one finished order may be confirmed as it stands, while a policy that is off is not a rule at all for as long as it is off. It publishes no ceiling either, so a quantity control is not capped by a number nothing enforces. And it is skipped before the missing-plugin check, so one that is off and names a plugin that has gone is neither logged nor answered by the stand-in; there is nothing to correct until somebody switches it back on.
Being off is what is stored, and being enforced is the absence of it: a policy
nobody has switched off carries nothing extra, and a missing flag reads as the
rule still biting. ConstraintPolicyResolver::isEnabled() is the one reader of
it, used by the manager, which is the single gate every evaluation and every
published ceiling passes through, and by the cross-resource limit's pool
expansion, which does not.
Booker messages¶
When a policy rejects a booking it returns a booker-facing reason.
- The sentence the policy ships. Each policy carries a plain-language,
plural-aware one (built with
formatPlural(), so "1 booking" and "3 bookings" both read correctly), translated through the module's translation files (translations/yoyaku.fr.pocarries the French). This is what is shown unless an operator says otherwise. - A sentence the venue writes. A policy's settings form offers Message
shown to the booker, a choice between the booker messages written for
that policy. Each policy declares what it fills in, so which placeholders a
message may use is decided by where it is used:
@limiton any of the limits,@tariffon the per-transaction one,@shared_withon the cross-resource one,@placesand@minimumon the orphaned-places rule,@ageon the minimum-age one. Leave the choice on the shipped wording to keep it.
The sentence lives on the message rather than in the attachment, which is what lets every host use one: a resource type keeps its attachments in configuration, but a resource, a resource tariff and a slot tariff keep theirs in a serialized field, where no translation system can reach inside. Pointing at a message by id puts one translated sentence behind all four. Booker messages covers writing them.