The already-owned rows in that claim wizard were genuinely unclickable. Not a link switched off with CSS, but a dimmed <div> carrying a small lock hint reading CLAIMED, and what decided dimmed or not was a single closure reading two metas at once: an owner id greater than zero, or a claimed flag set to 1. From the point of view of anyone using the form, those profiles simply were not available.
On 3 July 2026 I copied the slug of one of those dimmed rows into the wizard URL and opened it directly, never touching the search box. The CONFIRM step rendered exactly as usual, form included. That dimming in the search list turned out to be the only check anywhere in the flow, and the submit handler stored the claim with no ownership check at all.
// Search results: a row is dimmed when either of these metas says so.
$is_taken = ( (int) get_post_meta( $listing_id, '_dir_owner_id', true ) > 0 )
|| ( get_post_meta( $listing_id, '_dir_claimed', true ) === '1' );
if ( $is_taken ) {
echo render_locked_row( $listing_id ); // dimmed <div> + "CLAIMED" lock
} else {
echo render_selectable_row( $listing_id ); // a clickable <a>
}The handler receiving the submit had no counterpart to any of that:
// Nothing derived from $is_taken lives here. The claim is stored on the spot.
$request_id = wp_insert_post( [
'post_type' => 'dir_request',
'post_status' => 'pending',
] );
update_post_meta( $request_id, '_req_listing_id', $listing_id );
update_post_meta( $request_id, '_req_owner_id', get_current_user_id() );I ran a claim on a profile someone else already owned all the way to the end: submit, email confirmation, admin approval. Every stage let it through. The only thing that stopped the ownership transfer was the last-line one-owner guard. Ownership never actually moved, and the dimming turned out to be cosmetic only.
Why it stayed hidden
All prior QA walked the happy path through the UI, and on that path the dimming made the route look unreachable. "The UI prevents it" felt like coverage, and it never was coverage. The UI is one client. The handler serves every client: direct URLs, stale CDN copies of the list, curl, and re-posted forms.
That stale copy is not a made-up scenario. A one-year edge cache rule served the search list in its pre-claim state, so a visitor could receive a list whose rows still looked selectable while the database said otherwise.
The dimming predicate itself was not a reflection of ownership either. Twelve of the rows that looked claimed were only carrying a demo seed flag, with _dir_claimed set by the seeding script to demonstrate the three rendering states, while the owner id was empty on every one of them. Not a single one had ever really been claimed by anybody. What made those rows dim was a flag, not an owner.
Same class, same session
In that same flow, _req_owner_id was stamped from whatever session happened to be logged in. Which means an admin merely testing the form was silently recorded as the claimant, and the project notes record the consequence that follows: approval would grant ownership to that admin account.
The fix removed any need to infer the claimant from the execution context. One single resolver is now shared by the metabox and the approval process, so both answer the question of who the requester is from the same source, plus a guard so staff sessions are never the claimant.
The fix, in three layers
The first layer sits in the submit handler. It re-derives the same predicate from the same data, and if the listing is already owned, the request is turned away with a taken=1 notice. The second layer sits in the CONFIRM step, which now renders an "Already claimed." state with no form at all. The third layer is the one-owner guard inside the ownership linking function, which refuses to overwrite a different owner and releases that user's previous profile at the same time.
// Layer 1: same predicate, same data, re-run on the server.
if ( dir_listing_is_taken( $listing_id ) ) {
wp_safe_redirect( add_query_arg( 'taken', '1', $wizard_url ) );
exit;
}
// Layer 3: the linker refuses to overwrite a different owner.
function dir_link_user_listing( $user_id, $listing_id ) {
$owner = (int) get_post_meta( $listing_id, '_dir_owner_id', true );
if ( $owner && $owner !== (int) $user_id ) {
return false; // no silent transfer
}
// ... release the user's old profile, then attach the new ownership
}The third layer was proven on the live site, and the first two were tested with a slug-swapped POST. A hostile second link returns false with the owner unchanged, while a re-link by the same owner still returns true, so the guard is idempotent rather than a blind refusal. The search list itself was verified rendering 13 rows in the claimed state and 12 selectable rows, which is 25 rows in total, mirroring the states used in the directory.
The collision case had been verified the day before, on 2 July. A second user's claim on an owned profile may still be approved, but the result is only a conflict marker on that claim, with no transfer, the owner unchanged, and a warning for staff rendered in the metabox.
Two things were deliberately left less informative than they could be. The public post-submit state stays account-blind, because the moment it distinguishes an existing account from a missing one it becomes an enumeration oracle. And the claim tracker on the account page deliberately excludes claims stamped to other users as well as claims on profiles that already have an owner, while approved-but-conflicting claims keep rendering as still in review rather than quietly disappearing.
What changed about testing
That 3 July session contained four live gaps I found, all of them root-caused, fixed, adversarially reviewed, then deployed across three commits. The adversarial review before deploy produced eight confirmed findings, all fixed first, plus one finding that was refuted.
The exact request shape that used to get through is what I used to prove the patch: a POST with a swapped slug, carrying a genuinely valid nonce taken from a legitimate page.
# The nonce is valid. What is not valid is ownership of the target listing.
curl -s -i -X POST https://client-site.test/ownership/ \
-d 'listing=a-profile-someone-else-owns' \
-d '_wpnonce=<nonce from a legitimate page>' \
-d 'email=tester@example.test' | grep -i '^location:'
# Now: Location: /ownership/?taken=1The re-QA afterwards I ran through UI clicks, from the search box to clicking a result to filling the form, rather than firing direct URLs, so that what gets exercised is the path people actually walk. The whole run passed, and all three verdict emails were confirmed as received. One practical detail when sequencing tests: claim submits are rate limited to 5 per 1800 seconds per IP, so the average allowance is 1800 divided by 5, which is 360 seconds, six minutes apart.
These five adversarial paths now run on every flow, not just the happy ones:
- Direct-URL every gated state and skip the wizard.
- Swap hidden field, slug, or ID values through devtools before submitting.
- Submit with someone else's email or another account's resource ID.
- Replay from a stale cached page, with an old nonce and old list state.
- Re-click a consumed one-time email link.
Six weeks later
The client reported a side effect of the second layer. Every CTA on the pricing page points at the claim route, and the claim template shows "Already claimed." with no form and no exit toward the owner. So the pricing page sends an existing member straight into a dead end.
That is not a reason to pull the guard out. It is a good reminder that a layer which is correct in authorization terms still has a product side, and a refusal state needs an exit the same way a success state does.
The lesson
The rule I took away from this fits in one sentence: every UI affordance that says "can't" must have a server-side twin in the handler, with the same predicate and the same data. A dimmed row means the handler re-checks ownership. A disabled button means the handler re-checks state. A filtered list means the handler re-validates the selection. If the handler-side check is missing, the feature is unshipped, no matter how convincing the UI looks.
It is also why a valid nonce is never enough. A nonce proves the request came from our own page and has not expired, not that the sender has any right to the row they named. Ownership is a row-level property, and the only place it may be decided is on the server, re-derived from the data, right before the write happens.