SYSTEM DEBRIEF

Building an Author and Reviewer Entity Graph Across 744 WordPress Pages

Debrief: 6
How I wired named authors and reviewers into 744 WordPress pages as one schema.org @id entity graph in Rank Math and JetEngine, and the two Rank Math behaviours that only surfaced by watching live markup disappear.
The Ryzodus AI Raven in a landscape shot below Fitz Roy

First, why the Raven? Style.

SouthAmerica.travel has been in business since 1999. Two of its founders are still the people answering the hard questions about where to go in Patagonia in October. None of that existed in the site’s structured data. To Google, and to any LLM reading the page, the content was authored by nobody. Structured data application is often common on commercial pages… but would it have an impact across all pages.

This is the record of wiring that in: named authors and reviewers across 744 commercial pages, expressed as a single schema.org entity graph. The build ran 23–30 June 2026. It’s client work, and SAT has cleared me to name them.

The schema itself turned out to be the easy half. The half worth writing down is that Rank Math silently owns parts of the JSON-LD graph, and the only way I found the parts it owns was by watching my own markup disappear from the rendered page.

The stack

Naming versions explicitly, because the behavior below is version-specific and this post is written to be found by someone hitting the same wall. Note that this was the version at the time of this projects development; not implying anything else by these.

  • WordPress 7.0 on Kinsta
  • Rank Math + Rank Math Pro (schema layer)
  • JetEngine 3.8.11.1 (custom post types, meta, relations)
  • Elementor Pro theme-builder templates, selected by taxonomy term
  • A custom plugin we engineered for this site’s structured data, hooking rank_math/json_ld
  • Channel: SSH + WP-CLI on the managed host. No wp-admin clicking except one structural step.

One structural note that matters for anything you try to audit on this site: the page body is not in post_content. It renders from JetEngine custom fields through Elementor templates. post_content reads zero words on pages carrying 3,000+ words of live copy.

The architecture: one canonical Person, referenced everywhere

Everything hangs off one decision:

The canonical Person is the team-member CPT page, not the WordPress user. Its @id is https://…/about/team/{slug}/#person.

WordPress’s native author system is thin — a gravatar and a display name — and the author archive at /author/{nicename}/ is noindex on this site. Building an identity graph on top of that is building on sand. The team CPT, by contrast, already held real bios, real photos, job titles, LinkedIn URLs, and employment dates. The data was there; nobody had ever expressed it as schema.

So the team page owns the full Person node — name, jobTitle, image, worksFor pointing at the Organization @id, sameAs, knowsAbout, knowsLanguage, alumniOf. Every other page in the system references that same @id as author or reviewedBy.

Define once, reference everywhere. Inlining a full Person node on all 744 pages would be redundant and actually works against entity consolidation — you want search engines resolving many references to one node, not reconciling 744 near-duplicate descriptions of the same human.

Build notes: the parts that weren’t obvious

Rank Math’s per-post-type Person default is a stub that renders nothing

Rank Math lets you set a default schema type per post type. On this site, team-member was already set to person. It emitted nothing. The live team pages carried a BreadcrumbList and that was it. Meanwhile blog posts, whose default is article, rendered a complete Article graph without anyone touching them.

The tell was in the stored meta. I checked for per-post schema data and found essentially none on either post type — rank_math_schema_* existed on 0 of 24 team members, and on only 1 of 78 blog posts. So “missing per-post meta” couldn’t be the explanation; the blog posts didn’t have it either and they worked fine.

The actual behaviour, as of Rank Math Pro in mid-2026: Rank Math auto-generates Article-family schema from the post-type default with no per-post data, but the Person default is a stub that expects the per-post schema meta box to have been saved. A CPT roster imported in bulk never gets that. So the setting reads as configured in the UI and produces zero output on the page.

Decision: stop relying on Rank Math for the Person node and emit it ourselves on is_singular('team-member'). That also let us pull the rich fields Rank Math’s stock Person snippet ignores anyway.

Append keyed nodes — never overwrite the richSnippet

Our plugin already injected a Product node on destination pages by overwriting $data['richSnippet'] inside the rank_math/json_ld filter. If the authorship work had copied that pattern, each new node would have eaten the last one.

The rule that held for every node afterwards: add a new keyed entry to $data and reference other nodes by @id. Rank Math emits each key in $data as a node in the @graph, so an appended key simply shows up. Overwriting a shared key destroys whatever was already there.

Re-anchoring blog bylines takes two edits, not one

Out of the box, Rank Math’s blog schema emits a standalone Person node whose @id is the author archive URL, and an Article node whose author property references that same archive @id. Two places, one identity.

To point both at the real Person entity I first populated a bridge — a user_select_team_member user-meta field mapping each WordPress user to their team-member post, for all 17 blog-authoring accounts. I matched them on exact display name and then verified each one rather than trusting the match.

Then, in a callback on is_singular('post'), rewrite the standalone Person node’s @id, url and image to the team entity and rewrite the Article’s author.@id to match. Miss either one and you ship two competing “Juergen Keller” entities that never merge — which is worse than having none, because now you’re actively teaching Google that there are two of him.

The reviewer is a JetEngine relation, populated by rule

Reviewer assignment needed to be visible and editable in wp-admin, not buried in code, so it lives as three JetEngine relations — one per content type: team-member to tours, to travel-info content, and to destinations, all one-to-many. Two things worth recording:

  • A relation created with db_table = false stores its rows in the shared wp_jet_rel_default table rather than a dedicated wp_jet_rel_{id} table. No new table appears, and the schema filter reads it with a plain SELECT parent_object_id FROM wp_jet_rel_default WHERE rel_id = %d AND child_object_id = %d.
  • Populate through the API, not raw inserts: jet_engine()->relations->get_active_relations($rel_id)->update($parent_id, $child_id). I ran a single connection first and confirmed the row landed before letting it loose on 744.

Nobody hand-assigned 744 pages. The rule reads data the site already had: destinations by post ID, tours by parent_destination_slug plus geo-includes taxonomy terms, and travel-info pages by parent_destination plus dce_geo_includes_selection — which is a PHP-serialized array of term IDs stored in meta, so matching it in SQL means a LIKE against the serialized fragment and accepting that as the price of doing business.

Result: 308 pages to one principal, 436 to the other, every page with exactly one reviewer, and new content inheriting the right one automatically.

reviewedBy needs a page node, and destination pages didn’t have one

reviewedBy is a property of WebPage. Tour and travel-info pages already emitted a WebPage or ItemPage node, so the filter attaches to that. The destination pages emitted only BreadcrumbList plus the injected Product — no page node at all — so the plugin creates a minimal WebPage node to carry the property. Detect by @type, create only when absent.

The one that cost me the most time

Separate task, same site, same week. An existing Product node on the destination pages had a schema.org validity error, and no image. The error was easy: priceCount is not a property of AggregateOffer. Renaming it to offerCount stuck on the first try.

Adding an image did not. I set $data['richSnippet']['image'], deployed, fetched the live page — and the key was simply gone. Not malformed. Absent. From the same array where offerCount, set three lines earlier, rendered perfectly.

I’ll own the two wasted rounds. My first instinct was that the featured image must be missing, so I went and confirmed all 17 pages have thumbnails. They do. My second was that a bare string image needed to be a proper ImageObject, so I built one and deployed it. Also stripped. Both were guesses dressed up as diagnosis.

What actually settled it was the dumbest possible test: set image to a static string, unconditionally, and see if that survives. It didn’t. That one probe killed both theories at once, because it proved the problem was never the value — the key itself was being removed by something running after my callback at priority 99, while offers and aggregateRating in the same array were left alone.

The fix is to re-assert the image from a separate callback at priority 99999, after Rank Math’s own image pass has run:

add_filter( 'rank_math/json_ld', 'prefix_force_product_image', 99999, 2 );

function prefix_force_product_image( $data, $jsonld ) {
    if ( ! is_singular( 'destinations' ) || empty( $data['richSnippet'] ) ) {
        return $data;
    }
    $img = get_the_post_thumbnail_url( get_the_ID(), 'full' );
    if ( $img ) {
        $data['richSnippet']['image'] = array(
            '@type'      => 'ImageObject',
            'url'        => esc_url( $img ),
            'contentUrl' => esc_url( $img ),
        );
    }
    return $data;
}

The generalisable lesson, and the reason this section exists: when markup you set disappears rather than errors, stop reasoning about the value and prove whether the key survives at all. A static probe answers in one deploy what speculation won’t answer in five. The common advice “just hook rank_math/json_ld” is incomplete — priority is load-bearing whenever you’re contesting a field the plugin believes it owns.

A note on pronouns for the rest of this post: I use “we” where the debugging was genuinely side-by-side between me and the engineering harness I run these sessions in. The probe above was cheap enough to be worth trying only because the harness could deploy, cache-bust, fetch and diff the live JSON-LD in a single pass. Neither of us would have got there as fast alone.

How to actually validate this

The client checked a page in Google’s Rich Results Test and reported that the reviewer schema wasn’t showing up under “Detected.” Nothing was broken. Two things trip up everyone here, so they’re worth stating plainly:

  • Google’s Rich Results Test only reports types eligible for a visual rich result — Breadcrumb, Product, FAQ and friends. Person, Organization and reviewedBy are entity-graph data with no rich result attached, so the tool will not list them even when it has parsed them perfectly. Use the Schema Markup Validator at validator.schema.org, which reports every type.
  • reviewedBy is nested inside the WebPage node, not a top-level item — expand that node to see it. And on a content page it appears as a bare reference, an @id pointing at the team page, because the full Person lives there. That is the @id pattern working as designed, not a missing node.

One more, because it cost a step: you cannot test these callbacks in wp eval. is_singular() is false under WP-CLI, so anything gated on it returns early. And calling the filter directly fatals:

PHP Fatal error: Uncaught Error: Call to a member function can_add_global_entities() on null
in .../seo-by-rank-math/includes/modules/local-seo/class-local-seo.php:98

Rank Math’s Local SEO module expects a real request context. Validate by fetching the live URL with a cache-busting query string instead. On Kinsta, note that flushing the object cache alone does not clear the full-page cache — that needs wp kinsta cache purge --all.

Where it landed

All verified against live rendered output: 24 team pages emit an enriched Person; the Organization node carries founder and foundingDate; blog bylines resolve to the team @id; all 744 commercial pages carry reviewedBy pointing at the correct reviewer, spot-checked 17 of 17 destination pages against the relation table; the Product node validates clean. Every change is additive and reversible, and none of it touches site content.

What this will and won’t do

Authorship and reviewer markup is not a direct Google ranking factor, and I told the client not to expect commercial rankings to move because of it. Google infers E-E-A-T mostly from content quality, links and real-world reputation. Markup clarifies entities; it does not manufacture authority. Anyone selling schema as a ranking lever is selling something.

Where I think it does earn its keep: AI search surfaces — AI Overviews, ChatGPT, Perplexity — lean on author and entity signals when deciding what to trust and cite, and that is the higher-leverage bet as of August 2026. The visible bylines are also a genuine human trust signal, and the whole thing closes a specific credibility gap that specialist competitors in this niche already had.

I checked Search Console roughly seven weeks after deployment. There is no measurable movement I would attribute to this work, which is exactly what I predicted, and I am not going to dress that up. Attribution would have been impossible even if the numbers had been good — the same window contains a crawl and internal-linking fix, a thin-content pass, and two separate content sweeps, and this market is strongly seasonal on top of that. One deployment, many variables, no control group.

The measurement that would actually mean something here is AI-surface citation — whether these people and pages get cited and attributed in generated answers — and standard rank tracking simply does not see it. That is an open problem I do not have a clean answer to yet.

Its one puzzle piece among many to rebuild this client’s website.

Reusable takeaways

  1. If your real bios live in a CPT, make the CPT the canonical entity, not wp_users. The user record is plumbing.
  2. Define the entity once, reference it by @id everywhere else.
  3. When markup vanishes instead of erroring, probe whether the key survives using a static value. Do not debate the value.
  4. Filter priority is a tool. Set fields Rank Math does not manage at 99; re-assert fields it does — like image — after its own pass.
  5. Verify structured data from the rendered page, never the settings screen, and know which validator hides what.
  6. JetEngine relations with db_table=false live in wp_jet_rel_default; populate via the relation object’s update method and read with a direct SELECT.

Debriefs are drafted from live working-session context by the engineering harness that did the work, then reviewed, verified, and published by me.

Observed: Jun 23, 2026
Verified: Aug 18, 2026
Debrief: 6
Status: Current
Type: Implementation
Systems:
WordPress
GSC
Tech Stack:
Elementor
JetEngine
Claude Code
WP-CLI
Rank Math
Kinsta
Problem Domains:
SEO
Content Systems