Custom Lists: Design and UserMeta Persistence
Overview
CustomList and CustomListValue provide organisation-scoped lookup values that events can attach to each EventParticipant in up to three configurable slots (custom_1_id, custom_2_id, custom_3_id). Typical uses are Club, Team, School, and any other organisation-specific classification the operator wants to capture at registration.
This document covers:
-
the two entities and their relationships,
-
how an
Eventconfigures which slots are used and whichCustomListpopulates each, -
how a selection made during registration is both stored on the
EventParticipantand persisted to the Person viawp_usermeta, so the value can be carried forward automatically to the rider’s next event.
|
Throughout this document Person and User refer to the same natural person. The current backing table is |
Entities
CustomList
Organisation-scoped container of lookup values.
| Field | Purpose |
|---|---|
|
Primary key |
|
Unique internal name of the list (e.g. "WP Clubs 2026") |
|
Label shown on-screen or in reports (e.g. "Club") |
|
|
|
Key into |
|
Optional pointer to the prior generation of this list, set when a list is cloned or declared as this season’s successor. Drives the season-preparation UI; not read by lookup. See List generations and value lineage. |
|
Owning |
CustomList implements OrganizationalSecured — access is scoped by organisation.
CustomListValue
An entry within a single list.
| Field | Purpose |
|---|---|
|
Primary key |
|
Authoritative human-readable name. What the rider sees in a picker. Not what is persisted — see UserMeta Persistence. |
|
Optional short code (e.g. "WPCC"). Used as a filter or in condensed displays when |
|
Optional pointer to one value on a prior generation of this list. Many new values may point at the same old value. This is the pointer carry-forward reads. See List generations and value lineage. |
|
Parent |
CustomListValue is not directly organisation-secured — it inherits its access scope from its parent CustomList.
Event configuration
Event exposes three slots. Each slot has three config attributes:
| Slot | Column | Meaning |
|---|---|---|
1 |
|
Pointer to the |
1 |
|
Display label for this slot on forms and reports (may differ from the list’s own |
1 |
|
|
2 |
|
Pointer for slot 2 |
2 |
|
Display label for slot 2 |
2 |
|
Required flag for slot 2 |
3 |
|
Pointer for slot 3 |
3 |
|
Display label for slot 3 |
3 |
|
Required flag for slot 3 |
If custom_list_{n}_id is NULL, slot n is unused on that event. An event can use any subset of the three slots; the slot index is just a positional identifier, not a stable meaning across events.
EventParticipant storage
For each slot the Event has configured, the rider’s selection is stored as a foreign key on the EventParticipant:
-
custom_1_id→CustomListValue.id -
custom_2_id→CustomListValue.id -
custom_3_id→CustomListValue.id
Each of these is optional; NULL is valid when the corresponding slot is unused or not required.
|
The event’s configured list is a recommendation, not a constraint.
This happens in production by design. Event 143’s slot 1 is configured with list 5 ("school-2026"), while EP 46795 points at a value on list 6 ("school-adhoc-2026") — the overflow list where that rider’s school lives. Earlier revisions of this page described the relationship as a "registration-flow invariant". That was wrong, and the error propagated into a later defect analysis that took this page at its word. Nothing enforces it and nothing should. |
UserMeta Persistence
CustomList.meta_key is the linchpin that lets a rider’s current club/team/school/etc. persist across events and get pre-populated on the next registration without the rider having to re-enter it.
The contract
-
On registration or EP creation (including EP import), for every slot the Event has configured with a
custom_list_{n}_id:-
Resolve the
CustomListand read itsmeta_key. -
Upsert
wp_usermeta(user_id = person.id, meta_key = CustomList.meta_key, meta_value = <the CLV’s id>). -
If the slot was cleared rather than set, upsert an empty
meta_value. A cleared selection must not leave the previous id stored, or the next event would resolve it and re-select what the rider removed. -
The upsert is "latest wins" — a new selection, or a clearing, overwrites any earlier value stored under the same
meta_keyfor the same person.
-
-
On EP creation for a subsequent event that also uses a slot configured with a
CustomList:-
Read the
CustomList.meta_keyfor each configured slot. -
Pull the current
wp_usermeta.meta_valuefor(user_id, meta_key). -
Resolve it per Resolving a stored value.
-
If resolved, pre-populate
EventParticipant.custom_{n}_idwith that CLV’s id. -
If no UserMeta row exists, or nothing resolves, leave
custom_{n}_id = NULL.
-
|
Only the slots the caller actually modified are synced. A slot absent from a Explicitly clearing a slot to null is a modification, and does sync — by blanking the stored value, not by leaving the previous one in place. The rider has said they no longer have a club; if the Blanking rather than deleting the row keeps the read side unchanged: resolution already treats a missing row and an empty value identically, so a cleared slot simply resolves to nothing and the rider selects afresh. |
What meta_value holds
The CustomListValue.id, always. There is no per-list choice.
An earlier design stored the CLV name, reasoning that names survive the per-season list swap while primary keys do not. That reasoning did not survive contact with the data — see List generations and value lineage — and a short-lived value_convention column that let each list choose has been removed. Storing the id gives lookup an exact handle: a name cannot say which generation it came from, so a name cannot use lineage at all.
wp_usermeta is owned exclusively by this system. No legacy plugin or WordPress site reads or writes these keys, which is what makes a single convention safe.
meta_key is organisation-prefixed. Two organisations previously used the same bare key (school_id) with opposite conventions, which is how an organisation-unscoped data migration came to reclassify a second customer’s data. The prefix makes the key self-scoping.
List generations and value lineage
An organisation commonly creates a new list per season — "WP Clubs 2025" → "WP Clubs 2026", school-2025 → school-2026 — to keep the roster clean. Several generations share one meta_key; that is what lets a value written against last season’s list be read while registering against this season’s.
Carrying a rider’s selection across that boundary is a convenience, not a guarantee. The load-bearing usability win is within a season: one list is reused across every event of that season, so a rider selects once and it carries for the rest of the year. Re-selecting a club or school once at the start of a new season is acceptable. The design is deliberately best-effort and deliberately small.
Why neither a name nor a bare id carries across
Measured on production, 2026-09-19. Only 8 of 134 school-2025 values appear by name in school-2026, and 2 of 337 from school-2024. The 2026 list was re-authored, not cloned:
school-2025 |
school-2026 |
|---|---|
|
|
|
|
|
|
Three transformations at once — rename (abbreviations expanded), merge (the school-level split baked into the 2025 names factored out into the code column), and ordinary add/delete — none of them recorded anywhere. A name match misses almost everything. A bare id is a primary key on one specific generation and cannot be resolved against another.
Lineage records the transformation instead of trying to infer it.
The pointers
-
CustomList.derived_from— which prior list this generation came from. Drives the season-preparation UI; not read by lookup. -
CustomListValue.derived_from— new value → one old value. Many new values may point at the same old value. This is the pointer lookup reads.
The value pointer deliberately points backwards. A list may be cloned more than once — organisation 9 already has both school-2026 and school-adhoc-2026 — so a forward "superseded by" pointer on the old value could only ever name one descendant, and would mean writing into a historical list that is still in use. Pointing back is free at clone time and leaves prior generations immutable.
Lineage never crosses an organisation boundary.
Where lineage is authored
At series or season preparation — after the old list stops being used and before the new one is needed. The operator chooses to reuse an existing list, clone one and modify it, or start fresh.
-
Clone seeds
derived_from1:1 on every copied value automatically, so an unmodified clone is fully mapped for free. The operator then adds, removes and renames, adjusting the pointer only where it matters. -
Start fresh still allows mapping a value back to a single entry on a prior list, which is what lets lookup look back one generation.
-
Values appended by a rider (the auto-create path) carry no lineage — they have no ancestor. An administrator may set
derived_fromlater if the value is subsequently curated.
Resolving a stored value
Given a stored meta_value and the event’s configured slot list:
-
Parse it as a
CustomListValueid. If it does not parse, that is a miss. -
If the value’s parent list is the configured list, that is a match.
-
Otherwise take one hop: find the value on the configured list whose
derived_fromis the stored value. Exactly one match counts; zero or several is a miss. -
Anything else is a miss.
The single hop is deliberate. Going forward, a rider who registered last season is exactly one generation back; two or more means they skipped a season, and chasing them is not worth the complexity. Historical backlog is a one-time data correction, not something the lookup should carry.
Step 3’s "several" case is a split — one old value became two or more. There is no basis for choosing between them, so the rider chooses. A merge — several old values collapsed into one — is only partly covered, because a single pointer records one ancestor; riders behind the other old value miss and re-select. Both outcomes are accepted.
Unmatched lookups
If nothing resolves, EventParticipant.custom_{n}_id is left NULL. The field is usually required, so in practice this means the dropdown is simply not defaulted and the rider picks from the current list — which they can always do anyway, since lineage only ever supplies a default. Deliberate choices:
-
No auto-creation of CLV rows. The list is curated; introducing a row because a stored value did not resolve could quietly propagate mis-spellings or obsolete clubs.
-
No error. A registration flow that blocks on an unresolvable historical value would be hostile.
-
Observable. The EP import response surfaces an unset-but-expected slot in its issues list so an operator can triage.
|
Pre-populate resolves only to a value on the event’s configured list. That is a user-interface choice, not an integrity rule — a default that is not among the dropdown’s options would be a poor default. It is not a constraint on what the slot may hold; see the note under EventParticipant storage. |
Stages where the contract applies
The contract fires wherever an EventParticipant row is created or has its custom_{n}_id set:
-
The registration flow (
EventFormController.onDone()and equivalents). -
The EP import path (
EventParticipantImportXLS.readStream()). -
The admin EP edit screen, when a slot value is changed.
The save on EventParticipant and the upsert on wp_usermeta are in the same transaction so the two writes cannot diverge.
One-directional flow
The contract applies forward only:
-
EP change → UserMeta upsert: yes.
-
UserMeta edit (from an admin UI or elsewhere) → propagate back into existing EPs: no.
An admin who wants to correct a rider’s club on an already-created EP edits the EP directly. This keeps the flow predictable and avoids "hidden" changes to historical events when a rider updates their profile.
Worked example — within a season
WPCA 2026 Autumn Road League:
-
Organisation: WP Cycling.
-
Two lists configured across the series:
-
CustomList id 1504— "WP Clubs 2026",meta_key = "wpca_club". -
CustomList id 1505— "WP Teams 2026",meta_key = "wpca_team".
-
-
Each Road League
Eventhascustom_list_1_id = 1504("Club") andcustom_list_2_id = 1505("Team"), both optional.
Rider Jane Doe registers for event 119 (Duynefontein) and picks "WPCC" (CLV id 2210) and "Gravel Devils" (CLV id 2415):
-
EventParticipant.custom_1_id = 2210,custom_2_id = 2415. -
wp_usermetafor Jane is upserted with(wpca_club, "2210")and(wpca_team, "2415")— the ids.
Two weeks later Jane registers for event 120 (Redhill), configured identically. Both stored ids resolve directly: their parent lists are the configured lists, so both slots pre-populate with no lookup beyond step 2. This is the case the feature exists for — one list, many events, one selection.
If Jane changes her club to "BMT" (CLV id 2230) at event 120, the registration flow:
-
writes
custom_1_id = 2230on the new EP, -
overwrites the
wpca_clubUserMeta row with"2230"(latest wins), -
leaves her
wpca_teamrow alone — she did not touch that slot, -
does not touch her existing EP for event 119, which still shows "WPCC" (the record of how she raced that day).
Worked example — across a season boundary
WCSC school lists:
-
CustomList id 5— "school-2026",meta_key = "wcsc_school",derived_from = 3. -
CustomList id 3— "school-2025", samemeta_key— the prior generation, still in the database.
Between the two, "Beaumont PS" (id 1505, on list 3) was renamed to "Beaumont Primary School" (id 1850, on list 5). At season preparation the operator cloned list 3 and edited it, so CustomListValue 1850 carries derived_from = 1505.
Rider A — carried forward. Thabo’s wp_usermeta holds (wcsc_school, "1505"), from last season. Registering against an event configured with list 5:
-
Step 1 parses
1505. Step 2 fails — its parent list is 3, not 5. -
Step 3 takes one hop: exactly one value on list 5 has
derived_from = 1505, namely1850. Match —custom_1_idpre-populates with1850, and the rider sees "Beaumont Primary School" already selected. -
The EP save then upserts
(wcsc_school, "1850"), moving Thabo onto the current generation. The next hop is no longer needed.
Rider B — school withdrew. Lerato’s stored value points at a school that is not in the 2026 list and has no successor, so nothing on list 5 carries that derived_from. Step 3 finds zero. custom_1_id stays NULL, the dropdown is simply not defaulted, and Lerato selects from the current list. No error.
Rider C — two seasons stale. Sipho last registered in 2024 and his stored value belongs to list 1. One hop from list 1 reaches list 3, not list 5, so there is no direct hit. Miss — he re-selects. This is the deliberate limit of the single hop; the alternative is chasing an arbitrary chain for a rider who skipped a season.
Rider D — a merge. "Bishops PS" and "Bishops HS" collapsed into one "Bishops" on list 5, which can record only one ancestor. Riders behind the recorded one carry forward; the rest re-select. The operator chooses which ancestor to record, so they can point it at whichever had more riders behind it.
Related documentation
-
Common Entities — entity reference for
CustomListandCustomListValue. -
Form System Architecture — how registration-flow
FormFieldclasses present and validate CustomList selections. -
Event Participant Import — how the EP import path resolves CustomList columns and triggers the UserMeta contract.