feat(dpm): Dynamic Pari-Mutuel (Pool) market mode - #23
Merged
Conversation
Captures the agreed design for a self-funded Dynamic Pari-Mutuel (DPM) market mode alongside the CLOB: lifecycle (lobby/locked via openTime/ closeTime), DPM-shares pricing, entry-time transaction fee (neutral, reuses existing fee snapshot), separate DpmFacet.createDpmMarket reusing base creation services, no DPM-specific resolve (reads existing oracle outcome at claim), and support for binary/market-group/price markets. https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
…cing math Updates the DPM plan to specify exactly DPM I (Pennock 2004 §4 — losing money redistributed) as the canonical mechanism. Adds an optional pre- openTime "intent pool" phase with 1:1 refundable stake (no pricing math), which transitions lazily into DPM at par on the first post-openTime interaction. Removes the lobby exit (and the prior LMSR cost-function detour) — Pennock §3.1 itself has only a buy-side MM, so v1 matches the paper exactly. Adds the par-until-contested boundary rule (replaces the "operator seed wager" Pennock suggests, preserving the never-loses-capital property) and the no-contest refund rule for resolution to an outcome with zero backing. Adds docs/dpm-pricing-math.md with the canonical Pennock equations (eq. 6 / eq. 7), the inverse share function n(m), the DPM I payoff, the self-funding identity proof, the par boundary, the intent transition, the no-contest case, fixed-point implementation notes (rounding direction per call-site, domain checks, identity assertions), and a worked binary walkthrough. https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
First implementation increment for DPM markets per docs/dpm-markets-plan.md. Purely additive — no existing files touched, no behavior change. - Types.sol: append DpmMarket struct (outcomeCount, openTime, closeTime, poolInitialized). outcomeCount == 0 means "not a DPM market" — same isX sentinel pattern PriceMarket uses. - LibDpmStorage: namespace oddmaki.storage.dpm. Flat mappings for per-outcome aggregates (M, N, intent totals) and per-user state (intent stake, shares, paid, claimed, transitioned flags) so N-outcome groups need no array allocs. Helper accessors getPool / getOtherShares / getOtherCollateral for the Pennock price function's denominators. - LibDpmValidator: mode guards (requireIsDpmMarket / requireNotDpmMarket for the cross-mode protection planned for CLOB entry points / requireDpmMarketNotExists), creation-time input guards, and lifecycle guards (requireIntentPhase / requireOpenPhase / requireClosed) keyed off openTime/closeTime. Implementation continues with LibDpmPricingService (Pennock 2004 §4 eq. 6/7). https://claude.ai/code/session_01LQLFuR75STtmzwQthTivnq
Add LibDpmPricingService implementing the DPM I price math:
- sharesForCollateral(M_i, N_other, m) = N_other * ln(1 + m/M_i), rounded
down (protocol-favorable: fewer shares can only raise per-share payout).
- collateralForShares(M_i, N_other, n) = M_i * (exp(n/N_other) - 1), rounded
up; the forward used by tests for round-trip and identity checks.
- Par-until-contested boundary: N_other == 0 OR M_i == 0 => $1/share (n == m).
- exp-domain overflow guard (DpmExpInputTooLarge) for whale-sized single buys.
Uses PRBMath UD60x18 (pinned submodule v4.1.1) for vetted exp/ln. Internal
math is dimensionless in 1e18 fixed point; M/N/m/n stay in native collateral
(6-dec USDC) scale, so the 1e18 factors cancel and par stays exact 1:1.
Tests (test/LibDpmPricingService.t.sol): par branches, protocol-favorable
round-trip, self-funding identity (Σ M_i == deposits) over fuzzed interleaved
buys, the worked binary example from docs/dpm-pricing-math.md (payouts sum to
pool within floor dust), formula-level payout identity, and the exp-domain
revert. forge build + full forge test green (506 tests).
Add LibDpmAggregate, the mutation-only writes for the DPM overlay: - initPool / recordIntentStake / releaseIntentStake (intent phase), - seedMarketFromIntent (par seed M_i = N_i = intentTotals_i), - transitionUserFromIntent (par fold of a user's intent into shares/paid), - recordDpmEntry (dynamic buy: M_i += net m, N_i += n), - markClaimed. Add LibDpmValidator.requireSufficientIntentStake (+ InsufficientIntentStake) for a friendly exitIntent underflow guard. Intent transition is gross par reallocation (no fee), which keeps the self-funding sums exact (Σ userPaid == M_i, Σ userShares == N_i) with no M/N mutation during the claim phase. The plan's "charge a fee at transition" is rejected as insolvent/drift-prone; fees are charged only on dynamic enter (see the LibDpmService commit). forge build + test green.
Add LibDpmService, the internal sagas the DpmFacet will wrap:
- initPool: validate no overlay exists, then write it.
- enterIntent / exitIntent: 1:1 refundable intent (no fee, no pricing). exit
has no pause/access gate so a refundable stake is always retrievable.
- enter: lazy par seed + per-user transition (fee-free), charge the entry fee
on the deposited amount via the shared fee distributor, deposit only the net
into the pool, price shares via the Pennock inverse, record the entry, and
update shared volume/last-trade-tick stats.
- claim: read the winner from the CTF payout numerators (one non-zero = winner,
else invalid/split); refund userPaid + un-transitioned intent on invalid or
no-contest (N_w == 0); otherwise pay userPaid + floor(M_other·userShares/N_w).
Effects-before-interaction: marks claimed before withdrawing.
Add LibDpmValidator.requireResolved (+ NotResolved): claim reads the shared
registry status set by the UMA/Pyth/group resolution paths (DPM has no resolve
function of its own). forge build + full forge test green (506).
… cut
Add DpmFacet, the diamond entry surface for DPM markets (v1 binary, UMA):
- createDpmMarket: mirrors MarketsFacet.createMarket (venue/access/collateral
guards, creation fee + UMA reward collection) with a nominal 1e16 tick, then
initializes the DPM overlay with the resolved open/close window (openTime 0 =>
immediate, no intent phase).
- enterIntent / exitIntent / enter / claim: nonReentrant wrappers over
LibDpmService that emit indexing events.
- Views: isDpmMarket, getDpmMarket, getMarketCollateral, getMarketShares,
getIntentStake, getUserShares, getUserPaid, and quoteEntryShares (advisory,
net-of-fee, simulates the lazy intent seed).
Register the 13 selectors in both DeployOddMaki.s.sol and the test DiamondSetup
(facet index 21, array 21 -> 22). forge build + full forge test green (506); no
selector clashes.
Add LibDpmValidator.requireNotDpmMarket(marketId) as the first statement of every CLOB trading entry point, so a DPM market (which carries a base MarketTradingData row) can never be traded through the order book. One line each: - MarketOrdersFacet.placeMarketBuy, placeMarketSell - LimitOrdersFacet.placeOrder, expireOrders - BatchOrdersFacet.batchPlaceOrders, cancelAndReplace - MatchingFacet.matchOrders Cancel-only paths (cancelOrder, batchCancelOrders, cancelOrdersOnResolvedMarket) take no marketId and can't reference a DPM market (placeOrder is guarded, so no DPM order can exist), so they need no guard. MarketGroupFacet has no per-market trading entry point and DPM v1 markets are never grouped. forge build + full forge test green (506).
Add test/DpmMarket.t.sol driving the full lifecycle through the diamond:
- intent enter/exit (1:1 refund), exit-overdraw and post-open reverts,
- lazy intent -> DPM transition at par (per-market seed + per-user fold),
- par-until-contested then dynamic open-phase pricing,
- the worked binary example resolving to exactly $50 (payouts within floor dust),
- no-contest (winning side empty) and invalid ([1,1]) full refunds, including
gross refund of un-transitioned intent,
- double-claim / claim-before-resolved reverts,
- entry-fee path with the Σ M_i == custody solvency check (fees enabled),
- cross-mode guard: all seven CLOB entry points reject a DPM market.
Extend MockCTF with the real-CTF payoutNumerators(conditionId, index) read
(populated in reportPayouts via the oracle/questionId/slot-count conditionId),
which the DPM claim path uses to read the winner. forge build + full forge test
green (519 tests, +13).
Replace the (rejected) "fee only on dynamic enter" model: free intent let
everyone route deposits through the pre-openTime phase to dodge fees. The fee is
now charged at the intent->DPM transition, kept exactly solvent by construction.
Mechanism (see LibDpmService doc):
- Seed once (first post-open enter or first claim): charge the fee on the whole
intent pool. Per outcome M_i = intentTotals_i - floor(intentTotals_i*feeBps)
(collateral net), N_i = intentTotals_i (shares GROSS — never fee-reduced).
Distribute the summed fee.
- Per-user transition: pure allocation, never touches M/N — userShares += stake
(gross), userPaid += stake - ceil(stake*feeBps) (net).
- Dynamic enter: fee on amount as before.
- Operator slice folded into the protocol bucket (DPM has no keeper) via _splitFee.
Why solvent: gross shares freeze N_i; taking the fee once at seed (not lazily per
claim) freezes M_i/N_i before the first payout, so sequential winner claims can't
drift (M_other/N_w)*shares. seed-floor paired with per-user-ceil keeps
Sigma userPaid <= M_i exactly (sub-unit dust stays locked — the safe direction).
Winners always pay (fee taken at seed regardless of when they claim) -> no free ride.
LibDpmAggregate: seedMarketFromIntent/transitionUserFromIntent replaced by
seedOutcome + markPoolInitialized + recordIntentTransition + markTransitioned.
Tests: intent fee charged at seed (winner pays, operator->protocol exact),
no-free-ride + solvency with mixed intent/dynamic, and a 256-run fuzz proving
payouts never exceed deposits across random intent splits. forge build + full
forge test green (522).
DPM was binary-UMA only at creation; add a Pyth-resolved price-market path.
claim() needed no change — it reads the winner from the CTF payout numerators,
and Pyth resolution (resolvePriceMarketPyth) + invalidation (markPriceMarketInvalid)
already funnel through the same LibResolutionService.resolveMarket() as UMA, writing
the same numerators + Resolved status. So DPM claim is resolution-source-agnostic.
- Extract the shared price-market creation (feed-exponent read, base market with no
UMA reward escrowed, oracle.reward = 0 footgun, price overlay write) into
LibPriceMarketService.createPriceMarketBase, and refactor PythResolutionFacet.
createPriceMarketPyth to call it (behavior-preserving; the reward-zeroing lives in
one place now). 59 PriceMarket tests still green.
- Add DpmFacet.createDpmPriceMarket (Up/Down or explicit strike): venue/access/
collateral guards + creation fee (no reward escrow), shared base creation with the
nominal DPM tick, then initPool. DPM open/close double as the Pyth observation window.
- Register the 14th selector in DiamondSetup + DeployOddMaki.
Test: strike DPM price market entered on both sides, resolved via the Pyth mock,
winner paid from the pool exactly as in the UMA path. forge build + full forge test
green (523).
Note: in the test's Pyth VAA build, the uint64 timestamp casts are hoisted into
locals — nesting uint64(closeTime - 1) in the call args trips a via_ir codegen quirk.
Address the high private-helper count in LibDpmService (11 — far more than any
other service) by moving two cohesive concerns into their own services:
- LibDpmFeeService: feeBps() + distribute() (the exact operator->protocol fee
split). Reused by the intent seed, dynamic enter, and future market types.
- LibDpmResolutionService: winningOutcome() (CTF payout-numerator read +
winner/invalid classification). Isolates the oracle-read dependency and is
where the group (neg-risk) winner read will branch.
LibDpmService's remaining private helpers are all multi-use (_seedIfNeeded,
_transitionIfNeeded, _requireTradingAllowed, _collateralToken, _refundAll) except
_impliedTick (a guarded cosmetic stat kept for hot-path readability).
Also tighten enter() to strict effects-before-interaction: record the DPM entry
and stats first, then route the entry fee out as the final external call.
Behavior unchanged; forge build + full forge test green (523).
DpmFacet was 19,794 bytes — 80% of the 24,576 EVM code-size limit — with the
N-outcome group variant still to add. Split it along its natural seam:
- DpmMarketFacet: createDpmMarket, createDpmPriceMarket, + 8 read views (14,511
bytes; group creation lands here).
- DpmTradingFacet: enterIntent, exitIntent, enter, claim (7,728 bytes).
Both now sit far under the limit (10KB / 16.8KB headroom). Register them as two
diamond cuts in DeployOddMaki + DiamondSetup (array 22 -> 23). quoteEntryShares
now reads the fee rate via LibDpmFeeService.feeBps (0 when fees are disabled),
matching enter() instead of the raw snapshot.
All four trading mutators remain nonReentrant. forge build + full forge test
green (523).
The createPriceMarketBase extraction left PriceMarketCreatedPyth behind in PythResolutionFacet, so createDpmPriceMarket created a Pyth overlay WITHOUT emitting the event. The subgraph's handlePriceMarketCreatedPyth is what sets isPriceMarket, builds the PriceMarket overlay entity, and orders the series by closeTime — so a DPM price market would have been mis-indexed and its PriceMarketResolvedPyth would have had no overlay to update. Fix it the way the codebase already handles MarketCreated (owned by LibMarketCreationService): move the PriceMarketCreatedPyth declaration + emission INTO LibPriceMarketService.createPriceMarketBase, so BOTH createPriceMarketPyth and createDpmPriceMarket emit it identically. Verified the event still appears in both facets' ABIs (library events propagate to the emitting contract's ABI, as MarketCreated already does) with an unchanged topic0 (0xccc6017e...), so the combined OddMaki.json ABI and the subgraph are unaffected. Remove the now-duplicate declaration/emission from PythResolutionFacet. New test asserts createDpmPriceMarket emits the event carrying the feed id. 59 PriceMarket tests still green; full suite green (524).
…ed indexer
The subgraph reconstructs state from event params (it only calls views for static
config), and CLOB trade events carry resulting state. DPM had two gaps: the lazy
intent->pool seed (a market-level M/N initialization + fee charge) emitted nothing,
and DpmEntered carried only the trade delta, so probabilities weren't a clean
projection.
- Add DpmPoolSeeded(marketId, collateral[], shares[]): emitted once at the seed
with the initialized per-outcome M_i (net) / N_i (gross). Arrays so it covers
the N-outcome group variant too.
- Enrich DpmEntered with (newCollateral, newShares): the traded outcome's pool
state after the fill, so the indexer projects M_i/pool without recomputing the
fee or replaying Pennock pricing.
Move all four trade events into LibDpmService (service-owned, like MarketCreated /
PriceMarketCreatedPyth) so they carry post-state and DpmTradingFacet becomes a pure
nonReentrant wrapper. Events still appear in the facet ABI (verified) since it emits
them. Per-user intent transition stays indexer-derivable from the intent events +
the snapshotted fee bps (trivial ceil, not Pennock). New test asserts the seed event
fires and DpmEntered carries live M/N. forge build + full forge test green (525).
…ntation Remove speculative "N-outcome group variant still to come / will branch / future market types" notes and "v1" version framing from the new DPM facets and services (DpmMarketFacet, DpmTradingFacet, LibDpmFeeService, LibDpmResolutionService, LibDpmStorage). Comments now describe only current behavior. No code change.
… DPM [2] stats
Prep for N-outcome DPM. The oracle/condition layer already sizes from
outcomes.length; the only binary-specific creation step is the CTF outcome-token
vault registration (positionIds), which DPM never uses.
- LibMarketCreationService.createMarket: relax `outcomes.length != 2` to `< 2`,
and register vault positions only for binary markets (length == 2). N-outcome
markets carry empty positionIds. CLOB/binary creation is unchanged (still
registers); only N > 2 (DPM-only) skips it.
- LibDpmService.enter: stop writing the CLOB MarketTradingData volume/lastTradeTick
— those are uint256[2] arrays and would be out of bounds for outcome >= 2. The
DpmEntered event already carries volume + resulting pool state for the indexer.
Remove the now-unused _impliedTick helper + LibMarketTradingAggregate import.
Binary DPM still gets a MarketTradingData row (active flag + collateralToken) via
createMarket, so _collateralToken / requireActiveMarket are unaffected. forge build
+ full forge test green (525).
The DPM engine was already N-outcome (pricing/seed/transition/claim sum over all
other outcomes; winningOutcome counts non-zero CTF numerators). Wire creation up:
- createDpmMarket now accepts N outcomes (2..MAX_OUTCOMES): validates the count,
passes outcomes.length to the condition (N CTF slots) and to initPool. Vault
registration is skipped for N > 2 (Stage 1).
- LibDpmValidator.MAX_OUTCOMES = 64 (bounds the O(N) per-op loops; well under the
CTF protocol limit of 256).
- createDpmPriceMarket stays binary (price resolves to a 2-slot payout) — rejects
non-2 outcome sets.
Resolution is unchanged and already N-ready: UMA asserts ONE outcome string, the
DVM verifies it true/false (binary per assertion), computePayouts maps the string
to the slot index, claim reads the N numerators. Tests: a 3-candidate election
(create -> trade -> resolve -> claim), pro-rata split among winners, outcome-count
bounds, and binary-price rejection. forge build + full forge test green.
Support adding an outcome to a live DPM market — e.g. a candidate who enters the
race after the market opened. DPM holds no CTF outcome tokens, so this is pure
bookkeeping + a fresh CTF condition (nothing to split/migrate).
- LibDpmOutcomeService.addOutcome: prepare an (N+1)-slot CTF condition, re-point
the oracle at the new conditionId, append the outcome string, bump outcomeCount.
The questionId is stable so UMA assertions are unaffected; the new outcome starts
at M=N=0 (par-until-contested). Guards: before closeTime, non-empty + unique
label, <= MAX_OUTCOMES. Emits DpmOutcomeAdded (carries the NEW conditionId — it
changes — so the indexer can re-point the market).
- DpmMarketFacet.addDpmOutcome: venue-operator-only wrapper; registered as the
11th DpmMarketFacet selector.
- LibMarketOracleAggregate.appendOutcome + LibDpmAggregate.setOutcomeCount.
At resolution the asserter asserts the late candidate by name; computePayouts maps
it to the new slot and claim reads the (N+1) numerators. Tests: add Dave mid-market
-> bet -> resolve to Dave -> claim the full pool; plus guards (non-operator,
duplicate, empty, post-close). forge build + full forge test green.
Relaxing createMarket's outcome check for DPM had removed the guard that kept regular binary markets at exactly 2 outcomes — MarketsFacet.createMarket and the price path never validated length themselves, they relied on createMarket's old `!= 2`. A CLOB/price market created with 3+ outcomes would have been a broken, vault-less market. Add an explicit `allowMultiOutcome` flag to LibMarketCreationService.createMarket: binary callers pass false (require exactly 2), only DpmMarketFacet.createDpmMarket passes true (2..MAX, validated in the facet). Vault registration stays gated on length == 2, so binary behavior is byte-for-byte unchanged. Regression test asserts the CLOB createMarket rejects 3 outcomes. forge build + full forge test green.
…serting markets
Audit findings before testnet:
- enter() had NO slippage protection: the Pennock price moves with flow, so a buy
could be sandwiched into far fewer shares. Add minSharesOut (revert if
sharesOut < minSharesOut; 0 opts out). Signature: enter(marketId, outcome,
amount, minSharesOut).
- addDpmOutcome on a price (Pyth) market would corrupt resolution: Pyth always
writes a fixed 2-slot payout, so growing the outcome set points claim at a
mismatched condition. Block it (PriceMarketOutcomesAreFixed).
- addDpmOutcome while an outcome assertion is already open could change the set
out from under the asserter. Block it (AssertionInFlight).
Tests added: slippage revert; addDpmOutcome blocked on price + while asserting;
N-outcome no-contest (winner unbacked -> refund) and invalid ([1,1,1] -> refund);
late outcome added during the intent phase seeds + resolves; and an N-outcome
"never drains" fuzz with fees (payouts <= deposits over random bets). forge build
+ full forge test green.
script/upgrades/20260530_DPM_MarketMode.s.sol — modeled on the prior upgrade
scripts (SAFE_MODE calldata print + EOA broadcast). One atomic diamondCut:
ADD DpmMarketFacet, DpmTradingFacet
REPLACE MarketsFacet (allowMultiOutcome), MarketOrdersFacet / LimitOrdersFacet /
BatchOrdersFacet / MatchingFacet (cross-mode guard), PythResolutionFacet
(PriceMarketCreatedPyth now library-owned)
Replaced selectors are unchanged — only the bytecode (inlined libraries) is new.
Storage migration: none (DPM uses a fresh namespace). The cut is atomic so the
cross-mode guard is live before any DPM market can be created. Compiles clean.
…solvency) Audit (adversarial) found a real insolvency: the no-contest branch calls _seedIfNeeded, which charges the intent fee and sends it OUT of custody, but then _refundAll paid back the GROSS intentStake. Shortfall == the seed fee, so the last claimant's withdraw reverted (TransferFailed) and the market was insolvent. Same in the invalid branch when an open-phase enter had already seeded the pool. The existing void tests missed it because they were either fee-free or had no intent. Fix: refund the NET basis whenever the pool is seeded — fold the claimer's intent into their net userPaid (transition) before _refundAll. An unseeded market never paid a fee, so it still refunds gross. Regression tests: pure-intent no-contest with fees (both backers refunded net 98.7, neither reverts) and seeded-then-invalid with fees (un-transitioned intent refunded net). Σ refunds <= deposits in both. forge build + full forge test green.
…mulDiv claim
Deep adversarial audit (5 parallel reviewers). Beyond the critical void-refund
solvency fix, three more items, all verified and fixed:
- VaultFacet.splitPosition/mergePositions had no cross-mode guard. A binary DPM
market has a prepared CTF condition + positionIds, so split/merge executed on
it. The adversarial PoC confirmed it is NET-ZERO (pool custody stays intact; the
split collateral round-trips against the CTF's own balance, separate from the
Diamond-held DPM pool) — not a drain — but DPM markets must not expose a CTF
token side. Add requireNotDpmMarket to both. (VaultFacet joins the upgrade
REPLACE set.)
- enter() could mint 0 shares for a non-zero deposit on a deep/imbalanced book
(floored Pennock inverse), silently donating the deposit. Revert ZeroShares.
- claim slice (mOther*userShares)/nW was a plain checked multiply; overflow only
at ~2^128 units (unreachable for 6-dec USDC) but switch to 512-bit mulDiv so
18-decimal collateral can never lock a payout.
Audit confirmed SOUND: solvency identity, access control, reentrancy (global
slot-0 guard + CEI), fee math, fixed-point scaling for 6/18-dec, exp/ln domains,
dust direction, conditionId/outcomeCount consistency across late outcomes + UMA
dispute/re-assert. Tests: Vault guard reverts, zero-share reverts. forge build +
full forge test green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a self-funded Dynamic Pari-Mutuel trading mode (Pennock 2004 §4, "DPM I")
alongside the existing CLOB. Pools are pure redistribution: losers fund winners
plus an outcome-independent entry fee, so the operator never holds risk. The
load-bearing invariant is
Σ M_i == collateral in custodyat all times.Scope
with
addDpmOutcometo append a late outcome.entry (slippage-guarded) → claim. The entry fee is charged once, at the
intent→pool transition.
hold NO CTF outcome tokens and pay out from their own accounting.
Surface
DpmTradingFacet (enterIntent / exitIntent / enter / claim).
oddmaki.storage.dpm; no migration to existing structs.fixed-point pricing in LibDpmPricingService (prb-math).
split/merge reject DPM markets, applied atomically with the new facets.
Deploy
20260530_DPM_MarketMode.s.sol(2 Add + 7 Replace).Replace selectors are unchanged; only bytecode changes (inlined libraries).
Tests
542 passed, 0 failed, 1 skipped. Covers par-until-contested, pro-rata payout,
intent/transition, invalid + no-contest refunds, N-outcome, Pyth price, and
the worked $50 example.
No storage migration. No changes to existing CLOB selectors or behavior beyond
the cross-mode guards.