Author: Joey McCarey (with Claude)
RFCs: referral-program (Program 1), api-broker-program (Program 2), commercial-program-accruals (the producer that turns Program 1 and 2 policy into accrual rows), program-payouts (one candidate payer, owned outside this workstream), partner-rebate-payouts (the requirements the producer holds a payer to), omnibus-broker-program (Program 3, scoping only).
Tracking: A-4419 (master) → A-4420 Referral · A-4421 API Broker · A-4422 Payouts · A-4423 Omnibus. Each PR below has a ticket under its track: PRs 1–7 → A-4424…A-4430; PRs 8–11 → A-4431…A-4434 and PR 14 → A-4435; PR 13′ → A-4437. A-4436 closes as superseded by A-4492's module work. PR 12′ needs a ticket under A-4422 — A-4505 covers the same adapter but sits under A-4492, so it moves to us or closes as a duplicate. Do not leave the adapter owned twice.
Three commercial partner-economics programs on staggered timelines. This SoW sequences them into independently landable PRs and names what gates what.
Program 1 (Referral) leads because it builds the shared machinery. Program 2 (API Broker) is needed live for a Q4 marketing activation, with Hummingbot as launch partner. Program 3 (Omnibus) is blocked on Master Accounts and Subaccounts.
The workstream is bracketed by one shared platform, and half of it is owned elsewhere. The producer side — compute what is owed — is ours (commercial-program-accruals). The payer side is not: it is whichever exactly-once payer ships, integrated against that RFC's D9 acceptance criteria. What stays exclusively ours is what a dollar of referred flow is worth, and who it belongs to.
Programs 1 and 2 terminate at an approved accrual plus a CSV statement. That pair is the settlement artifact until Track C lands.
AX has none of this: no partner entity, no referral field, no Broker
ID, no rebate ledger, no per-partner aggregation. The one mechanism that
ever stamped a code on a user (users.invite_code) was
deleted at the Clerk cutover, and its write path lost attribution on
failure.
Reused rather than rebuilt: the dynamic fee engine
(rs/accrual-engine/src/fee.rs) and its trailing-30d
ClickHouse volume query (query_account_volumes_windowed,
schema.rs:1191-1258); the fee_schedules
versioned-immutable-config pattern
(db/postgres/1.sql:640-697); the MMLP subsystem as the
structural analogue for a partner registry plus daily evaluation plus
statements plus admin GUI; AX_FEE_ACCOUNT_ID and the
POST /deposit ledger path.
Verified absent: trading_accounts has no
fee_tier, fee_schedule_id or
fee_mode; api_keys has no owner or label
column; no ClickHouse table carries a broker identifier.
Every question that gated PR 6 is answered. What remains gates Track B and Track C. Owners are named because most are not engineering calls.
| Question | Owner | Holds up |
|---|---|---|
Who opens the upstream Hummingbot PR — an
Architect engineer, petioptrv, or a bounty? |
Eng lead + BD | PR 8, and Q4 go-live |
| Must a human authorize any sweep at all, regardless of size? If yes, the $1k auto-approval threshold drops to zero. No structural change, so this gates policy, not build | Legal + Compliance | PR 7 threshold value |
| Confirm the progressive-daily ladder rate (referral O12): a promotion applies forward day by day rather than repricing the whole month, and a day's own volume counts toward its rung. The fold is one function either way | Commercial | First partner statement |
Three questions are now shared payout-module gates
(A-4508): currency (USDfiat vs USDC), the
hold-window constant, and tax and withholding treatment. They are
answered once for every producer and gate PR 12′ rather
than anything in Track A or B. Same owners — Finance, Legal, Compliance
— so chase them once.
The sweep-authorization question above is not one of them. It sets referral's auto-approval threshold, which is producer-side approval policy gating PR 7, an admin surface in Track A. Folding it into the payout module's gate list would leave it dormant on another track's clock while silently blocking ours.
Two more items are operational rather than open:
broker_id field name before PR 8
opens. The upstream connector hard-codes it; re-opening the
choice costs a Hummingbot release cycle.a-4097 trades account
skip-indexes exist in production. They were a consumed
migration file. This affects PR 6's query cost, not its
correctness.Non-blocking questions live in each RFC's Open questions section.
Over-engineering is the main risk to this workstream, not under-building it (Commercial, 2026-07-30). AX is under pressure from existing market makers to grow the participant base, and the value of these programs is being live. Where this SoW names a deferral — second-level machinery, omnibus, capped campaigns, partner-facing surfaces — the deferral is the decision, not a placeholder to revisit before launch.
Settled across the four RFCs; full records in their Decisions sections.
Sequencing and attribution
ubo_user_id.retired_at soft-retire — and neither the referrer's status
nor its enrollment is read at registration. Attribution and accrual both
run whatever state the referrer is in. partners.status is
read at payout, the only place it can stop anything, and an accrual that
cannot be paid is held rather than forfeited (payouts RFC P8). An
attribution recorded under a referrer who turns out to be ineligible
costs nothing; one never recorded is unrecoverable, because first touch
is permanent.provision_user's existing
transaction (rs/api-gateway/src/auth.rs:210-262),
on the first-touch branch of POST /login/clerk only. One
transaction, because a split write can consume the code and lose the
attribution.The rebate base
Eligibility is read live and snapshotted, not
event-sourced (referral D4, D4d′). tier_index
resolves from live billed rates against the schedule in force at each
daily run and is snapshotted per account onto the accrual row.
Off-ladder accounts are excluded. A mid-period change rescores the open
period on the next run; approval freezes what it catches.
The rebate base is the fee on the attributed side of each
fill — maker_fee when the attributed account was
the maker, taker_fee when it was the taker, never both legs
of one fill. This makes the referral and broker aggregations the same
shape.
Only positive fees earn, floored per fill-side
(GREATEST(fee, 0) inline). The floor never fires on the
current ladder: taker fees cannot be negative by validator, and the only
maker-negative rung (>$100M) is the Tier 4 carve-out.
The floor is arithmetic insurance against a future ladder change, and
Σ paid ≤ Σ collected is the hard invariant that fires if
one lands. The margin is one rung: the lowest eligible maker rate is
rung 2's 0.00005, directly above the negative one. Referral
RFC D1b, D1c and §H.
Tier 4 (>$100M) is the only rung carved
out, and only from earnings (Business 2026-07-30; Finance and
Business 2026-08-03). Two consequences:
tier_index < 3.
Tier 3 (10–100M) earns. Treat any document saying "Tier 3
or better is excluded" as stale. Commercial's three-tier framing
(0–10M / 10–100M / >100M)
excludes exactly the same population, so the framing is presentational
and the fee schedule is not restructured. Merging the first two rungs
would re-price every client between $1M and $10M, which is a billing
change owned outside this programme.pricing_eligible for the
ladder, fee_eligible for the base. Off-ladder accounts
contribute to neither.Referral RFC D4b.
The ladder starts at $0; there is no no-rebate state. Every volume clears Bronze, so an earner's eligible fees always earn at least 30%. Referral RFC §D and O1.
No trade-kind carve-outs: block trades and liquidation
fills both earn (Finance, 2026-08-03). This leaves the
accrual's trade filter identical to the fee engine's.
Final-settlement fills remain excluded, which is why
block trades earn except the settlement ones
(is_final_settlement implies is_block_trade).
Referral RFC D1f.
Goodwill credits and fee waivers do not reduce a rebate
in v1 (Finance + Eng, 2026-08-03). Neither a fee-reversal
transaction kind nor an admin accrual adjustment is built. Trade
corrections still self-heal through the ReplacingMergeTree
read. The exposure cannot be measured, because a goodwill credit is an
ordinary POST /deposit with no reason code, so the
statement says it is gross of them. Referral RFC D1e.
Fees are already computed at trade emission and written
to the trades tape — verified in
trade-engine2/src/state_machine.rs:369-392 and
btnl-trade-engine/src/state_machine.rs:610. Feeing does not
need to move out of the risk engine; it is already in the trade engine.
The residual risk is the fallback-to-zero on a missing rate, handled by
referral D1d.
Two-level earning
partners.grandchild_rate: no rate, no grandchild arm, which
is every ordinary user in the tree. Exposure per account is
30% + 20% for a Bronze child and 50% + 20% for
a Gold one; Σ paid ≤ Σ collected holds at both.partners.override_rate, which prices the earner's
direct book only. A level could not express
20% in any case, since the rungs are 30/40/50. The second level is
priced by its own partners.grandchild_rate, so the two
levels may carry different rates.Approval and payout
payout_account_id resolution,
Σ paid ≤ Σ collected, and the held-balance gauge.claim_window_periods, so if holding drags in a
partner-facing surface or a balance-sheet-liability requirement, the
reversal is a constant. Tripwires are in P8.Scope and schema
rebate_schedules is deferred and the
value-typed accrual row is the audit story.
excluded_accounts is deferred, because self-dealing is
structural through ubo_user_id, judgment exclusions wait
for their first case, and withheld approval is the interim control. The
ledger uses its natural key with no surrogate id.
approval_threshold_usd lands with PR 7's auto-approval
task. Every deferral has a one-PR additive path back.broker_id field, not
tag. cid is a u64, so a
client-order-id prefix is impossible, and tag is unenforced
— users already stamp HBOT through it./referrals. Changing or retiring a code stays with support
on the admin surface./partners to
AIEX_SECTIONS.partner_programs schemaThe ledger third merged as #3703:
referral_codes, the append-only
referral_attributions user → user ledger, and the
partners payout registry keyed on user_id.
The remainder rides the accrual PR (A-4429) rather than a standalone schema PR, because nothing in it is read by anything but the accrual. It carries:
partner_programs.rebate_accruals, PK
(user_id, program_kind, period) with no surrogate id,
value-typed per referral D8b, with rate_source and the
freeze-on-approval trigger. program_kind accepts
api_broker from day one so Track B is purely additive.partners.override_rate and
partners.grandchild_rate. The override prices the direct
book; the grandchild rate gates and prices the second level.Cut or deferred (each recorded in the referral RFC):
enrollments — one program, and being a partner is a
registry row; rebate_schedules — the ladder is a constant
in the pure core, pinned by tests, and the table returns additively the
first time Commercial wants a rate change faster than a deploy;
excluded_accounts — self-dealing is structural through
ubo_user_id, and the judgment-call valve waits for its
first real case; rebate_accruals.approval_threshold_usd —
lands with PR 7's auto-approval task as an additive ALTER;
account_fee_rate_changes — eligibility is read live and
snapshotted (referral D4).
Immutability on referral_attributions is enforced by
grants (the app role holds SELECT, INSERT only) plus one
strictly-after ordering trigger, not by freeze triggers. The app role
and its grants land as a deploy step or an a-*.sql file;
1.sql carries no roles today.
PR 1 does not declare or touch the accrual_engine
schema. That schema belongs to the liquidity program (#3470), and a
duplicate declaration is something git merges cleanly and Atlas
rejects.
No separate account-pricing deliverable is part of the referral track.
Do not build an account_fee_rate_changes
log. tier_index is resolved live at each accrual
run and snapshotted onto the accrual row (referral D4, D4d′), so there
is no log, no fee-engine writer, no self-seeding, and no grants deploy
step. The cost — a mid-period re-tier rescores the whole open period at
the state the daily run sees — is one provisional period, bounded by
daily recompute and by approval freezing the row.
The two predicates the log fed live in PR 6 (referral §C):
pricing_eligible requires a non-NULL
tier_index; fee_eligible adds
tier_index < 3, which is where the Tier 4 carve-out is
enforced. This also removes the rollout wait for a period of log history
before the first accrual.
referral_code on the /login/clerk request
(rs/sdk/src/protocol/api_gateway.rs:114-118), threaded
through clerk_login →
resolve_or_materialize_clerk_user →
provision_user, writing the attribution row in the users
transaction.
Pass it only down the miss branch
(auth.rs:180); the WHERE origin = 'signup'
partial unique index is the backstop. An unknown or malformed code is a
warn and a commit without attribution — never a
failed registration — and the resolve runs against a savepoint
so a database error rolls back to it rather than poisoning the
users transaction. Resolution asks only whether the code
exists.
Needs PR 1.
?ref= from URL and storage through
ClerkSignupPage → exchangeClerkSession →
loginWithClerkToken. Capture happens once per app load in
AuthProvider, not on the signup screen, so a referral link
pointing at any app URL is persisted: the parameter is gone from the URL
by the time the visitor reaches signup.
The captured code is shown in the signup form, pre-filled and editable, on all four signup surfaces (AX web, aiex, native, california). Edits write through to storage so a correction survives the navigations before the exchange.
Pairs with PR 4.
Companion, separate repo
(architect-xyz/landing-page): capture
?ref= on the marketing site, persist it, and carry it to
the app on the shared apex domain. Needed only for links that point at
the marketing site first, so the two ship in either order.
All five commercial questions that gated this are answered. What remains is engineering.
Build it as a pure, I/O-free core plus a host task, the shape MMLP shipped:
rs/sdk-internal/src/referral_program.rs
— a pure policy module beside fee_program.rs: ownership and
the split rule (referral D7′), the two eligibility predicates, the
partner ladder and its progressive daily fold
(prefix-summed per-day volume → each ET day's rung → that day's fee base
priced at that day's rate), and the rebate computation. No I/O,
unit-testable without a database. A separate crate earns its place only
when a second program needs it.
The ladder's rungs are constants here, pinned by tests. A ladder change is a PR; the row's D8b snapshot keeps history defensible.
An rs/accrual-engine host task
(rebates.rs, beside fee.rs): attribution sets
→ live eligibility snapshot plus preflight gate → per-account-per-day
fee and volume aggregation from ClickHouse trades behind
one seam (referral D4c) → per-fill-side ownership with the broker arm
stubbed, so every fill-side resolves referral-only → progressive ladder
fold → upsert rebate_accruals for
program_kind = 'referral'. AX-only: watch()
refuses it on aiex like the fee-rates task.
The grandchild arm is in scope and is deliberately
small. It runs only for an earner carrying a
grandchild_rate and pays that flat rate on the grandchild
book, funded from AX's retained cut. The direct book is priced
separately, by the ladder or by a flat override_rate where
negotiated. It writes no extra row: both arms land on
that earner's single rebate_accruals row, the grandchild
arm in its own columns, with the per-account split in
detail_json. Keep it that way: no per-user rate tiers, no
depth parameter, no stored upline, no level column.
Two predicates, not one (referral §C, D4b). The fee
base sums over fee_eligible accounts; the ladder sums
volume over the wider pricing_eligible set, which includes
Tier-4 accounts that earn nothing. The schema already has both columns —
eligible_fees_usd and attributed_volume_usd —
so this needs no DDL. Collapsing them into one predicate is the likely
bug and restores demotion-by-success.
Build a per-account-per-ET-day sibling on
ChTradeRow::query_account_tier_metrics's scan.
Factor its maker/taker ARRAY JOIN fan-out and
final-settlement filter so both queries share one
trades FINAL scan; group the new one by account and ET day,
floor each fill-side at zero, count zero-fee fills, and exclude wash
pairs. Keep the (values, …, missing_multipliers) contract:
ensure! on missing multipliers and refuse the whole
run.
Recover
assert_ladder_supports_carve_out — the
maker-negative and rung-count tripwire — from #3382's parent commit
rather than rewriting it, and move its bound to
tier_index < 3. At < 2 it no
longer guards rung 2, which is both fee-eligible and adjacent to the
negative rung.
Also count zero-fee fills with non-zero notional on
eligible accounts and refuse above a threshold (referral D1d): both
trade engines book a zero fee when an account is missing from
account_fee_rates, which is indistinguishable on the tape
from a genuinely free fill.
Daily cadence over a monthly period. Make
compute_referral_rebates_once() pub so
integration tests drive it directly. Carries its own
schema (the PR 1 remainder); needs nothing else in-track beyond
the merged ledger.
The payee stays optional and the hold stays ours (commercial-program-accruals D3). A partner with no trading account accrues, is owed, and is held producer-side: not enqueued, not forfeited, counted. PR 6 owns that decision directly.
rs/api-gateway/src/admin_partner_routes.rs at
/admin/partners, following
admin_mmlp_routes.rs: partner registry and code CRUD, the
attribution exception path, the accrual list and approve endpoint, and
the CSV statement from detail_json. Clerk step-up on
approve and on exceptions.
Carries the auto-approval task (referral D5a): runs
after period close plus a settling delay, approves earners whose
cross-program period total is under the configured threshold, stamps
'system:auto-approve' and the threshold in force, and
escalates anything hitting an anomaly trigger to the manual queue.
This PR adds
rebate_accruals.approval_threshold_usd and its
iff-approved CHECK as an additive ALTER, since it is the
first thing that reads them.
The accrual list must separate pending-manual from auto-approved. The latter is not review work.
Admin GUI under gui/apps/admin/src/pages/partners/.
Two surfaces are out of scope: rebate-schedule publish (the ladder is
a code constant) and exclusion CRUD (excluded_accounts is
deferred).
Needs PRs 1 and 6.
BROKER_ID = "HBOT" in constants,
"bid": CONSTANTS.BROKER_ID in _place_order.
Five lines.
Runs on Hummingbot's release cadence, not ours, and is the Q4
critical path. Open it as soon as the field name is frozen; it
depends on no AX-side PR landing. Leave
client_order_id_prefix raising
NotImplementedError, which is correct for AX's numeric
cid.
broker_id column (operator step)broker_id LowCardinality(Nullable(String)) on
order_log and historical_orders, in
init.sql and in a one-off
db/clickhouse/a-<ID>-broker-id.sql.
Drop, re-add and re-materialize proj_ts_user and
proj_ts_account. Their SELECT *
column lists are frozen at creation, so a plain ADD COLUMN
leaves broker_id reading empty on every projection-served
query. These are heavy background mutations: use a controlled window and
watch system.mutations.is_done.
Apply before PR 10's binary. A struct field with no column errors the insert, while a column with no field is safe.
Also adds a skip index on order_log.order_id.
BrokerId type and order-path plumbingrs/sdk/src/types/broker_id.rs newtype with
#[serde(try_from = "String")] (trim, uppercase,
^[A-Z0-9]{3,10}$); PlaceOrderRequest.broker_id
as "bid"; mirror tag through
into_pending_order, RedisPendingOrderMeta,
restart recovery, both ClickHouse row constructors, cancel-replace, and
OrderDetails.
Verify the WebSocket path rejects a malformed bid as an
order reject, not a disconnect. Adds the missing ClickHouse schema-drift
tests for ChOrderLog and
ChHistoricalOrder.
Needs PR 9 applied.
partner_programs.broker_ids table;
ChTradeRow::query_broker_volumes_windowed
(FINAL on trades, argMax on the
order side, COALESCE across both order tables,
unjoinable-fill accounting); the broker ownership arm and the shared
fill-side split (referral D7′), including its hard per-shared-fill-side
invariant; broker-id CRUD on /admin/partners.
Needs PRs 1, 6 and 10.
api_keys.partner_id (independent)Turns a self-asserted Broker ID into a claim anchored to the key that placed the order, and makes "one rebate-bearing primary OMS" enforceable in data. Needs a key-labeling UX, which is why it is not blocking. Independently valuable.
The ledger, the settlement step, the sweep, the pacing work and the claim-window state machine are all built once, generically, by the payout module (A-4492). See program-payouts. What remains here is the producer side: telling the payer what is owed, and checking afterward that it paid the right amount.
Track C is payer-neutral. Two exactly-once cash paths are being built concurrently by other tracks. This SoW does not pick between them; it integrates with whichever ships, against the nine properties in commercial-program-accruals D9. That is affordable because Tracks A and B terminate at an approved accrual plus a CSV statement.
Checked against the program-payout schema as it stands, everything PR
12′ depends on is present: program_kind accepts
'referral', funding_account_id and
currency are columns, enqueue_ceiling is
nullable, and hold_policy accepts
'hold_indefinitely'. requests.account_id stays
NOT NULL, so the held payee-less accrual stays
producer-side as designed. The module names its tables
payout_engine.programs / requests /
attempts rather than the payout_program_config
/ program_payout_requests / payout_attempts
this Track still calls them; the contract is the same. Two knobs this
SoW once treated as open questions became config columns:
currency ('USDfiat' | 'USDC') and the claim
window (hold_policy plus
hold_claim_window_periods), so Finance and Compliance set a
value instead of blocking a schema.
Not yet built: the sweep itself. PR 6's host task makes the enqueue call directly.
One payout-program config row for
program_kind = 'referral':
funding_account_id = AX_FEE_ACCOUNT_ID, monthly at period
close, hold_policy = 'hold_indefinitely',
enqueue_ceiling null. Confirmed with the module's owner
(2026-08-05): enqueue_ceiling is a module-side backstop,
not the approval mechanism, and leaving it null makes our gate
authoritative.
The enqueue is ours to call, not ours to design. PR 6's host task makes the call against the payer's contract. This PR is the config row plus the producer knowledge the enqueue needs:
account_id is
partners.payout_account_id — the partner's choice
among its own accounts, defaulting to its only account. Not the partner
id.idempotency_key must carry its
program. The module's key is a global primary key with
program_kind as a separate column, so referral and
api_broker rows for the same partner and period collide unless the key
disambiguates. Use
referral://<period>/<partner_slug>. A collision
is silent — an idempotent enqueue reads it as "already enqueued" — so it
is a missing payment rather than an error. The module enforces this
structurally:
CHECK (idempotency_key LIKE program_kind || '://%') turns
the cross-program collision into a write error.trading_accounts row is never
enqueued. The module's account_id is
NOT NULL REFERENCES trading_accounts(id), so an
account-less affiliate cannot be represented in its queue even as
HELD. Such accruals stay approved in
rebate_accruals and are enqueued by a later run once an
account exists. payout_account() returns
Option<AccountId>, and the host task declines a
None row, counts it, and leaves it approved. What this PR
owes is the payout_account_id resolution that decides
whether the answer is Some at all.Needs PRs 1 and 7, and the module's schema and sweep landed.
Independently deployable behind the module's enabled flag,
which defaults false.
The checks the module structurally cannot perform, because it has no view of what was collected:
Σ paid ≤ Σ collected per closed period
(referral §H): every settled dollar traces to fees actually collected on
attributed flow. Implemented as the
referral-rebates-within-fees recon check over the previous
and open periods, as an alert rather than an accrual-engine assertion,
so an override promotion may legitimately exceed a period's intake.
Snooze the check for the promotion's duration. The module's own
Σ paid ≤ Σ approved is a weaker, different check and does
not substitute.Σ approved − Σ paid over rebate_accruals for
partners with no payout_account_id, by partner and oldest
period, computed from our tables since these rows never reach the
module's. Surfaced as an admin "accrued but unpayable" filter.
Nothing partner-facing. Reconcile its partner count
against the enqueue pass's held count for the same period: the two are
derived independently, so a disagreement means a row was dropped rather
than held.current_balances replication, and the transaction engine's
eager ClickHouse insert before a rolled-back Postgres commit leaves an
orphan row that the transaction_event_id join catches.Reads rebate_accruals and fee intake, so it does not
depend on the module and can land any time after PR 6. Needs PR 6.
No PRs. Blocked on Account families and on owner-level fee pooling, which the dynamic-fee SoW deferred. See omnibus-broker-program.
#3703 ledger (merged) ──┬── PR 4 ── PR 5 (+ landing) capture; landed as #3704
├── PR 6 ──┬── PR 7 ── PR 12′ ◄── A-4492 module (external)
│ └── PR 13′ (recon; no module dependency)
└── PR 11 ◄── PR 10 ◄── PR 9
PR 6 carries the PR 1 remainder and the PR 2 endpoint. PR 3 is cut.
PR 8 (Hummingbot, external) no AX dependency; open first
PR 14 independent
Independently deployable: 6, 8, 14. Everything else has the prerequisite named in its section.
One PR is coupled to another team's clock. PR 12′ is the tail: it cannot land before the payout module (#3609) does. PR 6 is standalone and waits on nothing outside this track.
?ref= link
produces exactly one origin = 'signup' row, and that a
repeat login produces none.eligible_fees_usd against net fee intake for the
attributed accounts over the same window, and check the unjoinable share
is near zero before any number reaches a partner. The two should tie,
because every eligible fee on the current ladder is positive (referral
D1c). Investigate any gap.proj_ts_account returns real
broker_id values, not empties. This is the failure mode
most likely to silently zero the money column.referral and
api_broker in one period gets two distinct rows, not one;
and an account-less partner produces no row at all while staying
approved and payable later. The first two are silent
failure modes.grandchild_rate. No stored upline, closure column,
recursive split, or per-user rate tier. Referral D2d and O8.max_uses caps on campaign codes. A
count-then-insert cap is a TOCTOU race.broker_id never reaches EP3; it is an AX-side
annotation.rebate_schedules table. The ladder
is a code constant and the value-typed accrual row is the audit story.
It returns additively when Commercial needs a rate change faster than a
deploy, or a per-partner ladder.excluded_accounts. Self-dealing is
structural through ubo_user_id. The judgment-call valve
returns as one table plus one NOT IN at the first real
Compliance case, with withheld approval as the interim control.