Key takeaways
  • On 2026-07-26, measuring captured SERPs directly — never prose — surfaced five sibling pairs in /bench and /crawlers sharing 50–78% of their top-10 results under two different, legitimately-ledgered facts each; the uniqueness gate had passed all ten rows cleanly because it only checks the fact, never the query. cannibalgate.mjs landed two minutes after the merges, to keep the corpus fixed.
  • A follow-up merge caught a subtler defect the SERP comparison itself surfaced: three rows owned two numbers, rearranged under different key strings, so the fact-uniqueness gate saw three distinct keys and missed that two of them were the same measurement stated twice.
  • A later engine upgrade (commit 541b6f6, 2026-08-17) measured the first gate's blind spot directly: over 844 live-and-captured /apps rows, 0 pairs collided within a section but 29 collided across sections — the exact shape neither gate's filter could see, because r.section === section was baked into the first one's design.
  • On 2026-08-31, the resolver's own tie-break order silently absorbed two of the site's strongest-performing page templates into a live page that hadn't yet been given the facts it absorbed — caught only because someone ran, by hand, the one assertion that is wired to run automatically on every new page and on no already-published one. The underlying rule-order hazard is fixed for those three rows and is still on record as unresolved for the next one.

01 A page can be true and still be a mistake

Two sibling pieces on this journal are about a pipeline that publishes programmatic pages twice a day with no human approving any individual one, and about the mechanisms that keep that unsupervised: hard gates that refuse to publish an unverifiable fact, and a rule that a live page can be withheld from but never deleted by an automated run. Both of those pieces are about a page being wrong. This one is about a failure mode that has nothing to do with being wrong at all: a page can state a fact correctly, cite a real measurement, pass every truth gate — and still be a mistake, because a sibling page already owns the exact search result it's trying to rank for.

This repository is private, so what follows are receipts you cannot click — file paths, commit hashes, and overlap percentages, quoted verbatim, that you're welcome to ask us to walk through live. Every fact below is checkable against this repo as of 2026-09-02, including the parts I re-ran myself while writing this.

02 What the uniqueness ledger cannot see

The pipeline's uniqueness ledger (scripts/pseo/uniqueasset.mjs) enforces one rule: no two pages in a section may claim the same underlying fact, keyed as <entity-handle>:<fact-slug>. It is a genuinely effective gate at the thing it checks. It has nothing to say about the thing it doesn't: two rows can own two entirely distinct, legitimately-ledgered facts and still be racing each other for the identical search result.

The queue held exactly that shape in /bench: "shopify speed optimization" was ledgered against bench-run:median-performance-score-34, and "shopify page speed optimization" against bench-run:median-7804400-total-bytes. Two different facts, two different keys, assertUniqueAssets passes both without a flicker — and their captured top-10 results shared 6 of 9 URLs. Google was answering one query. Publishing both pages meant splitting one ranking opportunity across two URLs that would only ever compete with each other. The queue held five such pairs before anyone measured it directly.

03 The gate, and the data correction it landed after

The chronology here is worth stating precisely, because it's a little backwards from how you'd guess. On 2026-07-26, at 15:38 and 15:43 local time, two commits — 8306c5c and b5fb603 — merged five cannibalizing keyword pairs in /bench and /crawlers, measured directly against each pair's own captured search results. The gate that would enforce this going forward, scripts/pseo/cannibalgate.mjs, landed two minutes later at 15:45, in commit 19554b3. Its own commit message is explicit about the order: the merges were "the data correction it will assert against" — a human fixing the corpus first, with the check arriving right behind to keep it fixed.

The first pass (8306c5c) merged three pairs on measured overlap:

OverlapMerged intoFrom
67%shopify speed optimizationshopify page speed optimization
78%shopify apps that slow down your storehow many apps slow down shopify
78%shopify block ai crawlersdoes shopify block ai crawlers

Which row survived which pair, and why, is recorded rather than assumed: the 170/mo keyword over its 40/mo variant (also the higher-CPC one), the head phrasing over the question form when both sat below the volume floor, the EASY-rated SERP capture over the MEDIUM one. And no measurement was thrown away — each dropped row keeps its unique_data intact as the record of what was measured; only its unique_asset key is cleared, which is the mechanism that lets the surviving page carry the freed fact as supporting material without tripping the foreign-fact gate. /bench went from 13 queued rows to 11; /crawlers from 18 to 17.

A second pass five minutes later (b5fb603) merged two more /bench pairs and surfaced something sharper: a flat top-10 count had missed that three rows owned two numbers, not three. "why is my shopify store slow" carried a 15,256ms lab LCP figure; "shopify slow loading" carried a 1,953ms field p75 figure; a third row juxtaposed both numbers as its own asset. assertUniqueAssets passed all three, because the three key strings were different — while the measurements underneath were the same two numbers, rearranged. After this pass, 15,256 is owned by exactly one live row and 1,953 by none, released to that same page as supporting material. The gap-between-lab-and-field story is deliberately not carried onto the app-bloat page too, because doing that would just rebuild the same collision one hop over.

Same day, at 18:59, a merge commit (e5ba2cb) registered the four /apps sections with the new gate and found 11 more real pairs — mostly clone apps listed under near-identical names ("notify me! back in stock alert" vs. "notify me, back in stock"). Because /apps rows carry no fact ledger by design — two rows on one App Store listing draw from the same record, so there's no second fact to point at — these can't be resolved with an exemption; the queue prunes one side instead, in a fixed order: EASY-rated SERP over MEDIUM, then higher App Store review count, then volume, then keyword order, for a resolution that's reproducible rather than arbitrary. Resolving one row often resolved the pair on its own — 11 pairs cost 10 rows, not 11.

04 Two pairs that look identical and are not

Not every high-overlap pair is a mistake. Two /crawlers pairs — "block applebot shopify" / "block ccbot shopify" at 70%, and "block gptbot shopify" / "block ccbot shopify" at 60% — share a top-10 because the same handful of general "block AI crawlers" guides rank for every bot's name. The answer is different per bot: each page turns on its own user-agent token and its own measured rate (Applebot's page is about the oldest vendor IP-range file in the corpus; CCBot's is about the highest disallow-given-named rate).

That distinction lives in docs/seo/pseo-cannibalization-exemptions.json, and its own header states the discipline plainly: an exemption is a claim, re-checked every run, not a mute button. It names both keywords — never a section or a pattern — and the two unique_asset keys that make the pages different answers; if a page ever loses that fact, its exemption stops applying automatically. The file's own _notAnExemptionFor field draws the actual line: "the test is not 'do the SERPs overlap for an explainable reason' — it is 'does a reader who lands on one still need the other.'"

I re-ran the gate directly while writing this (npm run gates:cannibalization, 2026-09-02). Both exemptions are still on file, and both currently report as dormant, not stale — the distinction the gate's own code draws between "this claim no longer applies" and "this claim currently has nothing to apply to." The reason is mundane: every row in /crawlers is blocked today, because the section has no generator wired for it yet, so neither exemption's keywords are live. Deleting a dormant exemption to make the report look cleaner would throw away the recorded reasoning and guarantee the same pair re-violates, unexplained, the day the section ships.

05 The blind spot inside the fix

cannibalgate.mjs compares rows within one section — r.section === section is the whole filter. That was the right shape for a gate written to catch /bench's two speed-optimization variants. It is also, by construction, blind to an entity that ranks under two different sections: an /apps alternatives page and the review page for the exact same app.

An engine upgrade three weeks later (commit 541b6f6, 2026-08-17) measured that blind spot directly rather than guessing at its size, by replaying cannibalgate's own overlap logic across all 844 live-and-captured /apps rows: 0 pairs collided within a section at the 60% threshold, and 29 collided across sections. The three tightest, verbatim from that commit: alternatives/boost-ai-search-filter-alternatives vs. reviews/boost-ai-search-filter-review at 67%; alternatives/hulk-product-options-alternatives vs. reviews/hulk-product-options-review at 60%; and a Vify order-printer app's alternatives page against its own /vs comparison page, also at 60%. On this corpus, section-scoping didn't hide most of the collisions. It hid all of them.

The same commit shipped the fix for that gap as a new module, scripts/pseo/intentcluster.mjs, partitioned by locale rather than by section — and along the way found and fixed a second bug in its own inherited design. Capture fidelity (whether a file's results carry a comparable URL, or only a domain) had been decided once per locale; since every live row in this repo is locale en, one decision spanned ten capture files of wildly unlike quality. apps_serp.json is 100% URL-comparable across 16,425 results; connect, build, and flow carry no per-result URL at all. The global decision dragged the fully-precise /apps corpus down into degraded domain-only mode, producing 36 clusters and 44 demoted rows on evidence like two unrelated pages both ranking Reddit, the App Store, and YouTube — exactly the kind of overlap DOMAIN_SCREEN's own documented calibration says can never clear or convict anything. Sub-partitioning by capture fidelity per file, rather than per locale, cut that to 22 clusters and 28 demotions, all URL-measured.

06 Merging is not deleting — until it almost was

The resolver doesn't pick winners by volume, because volume can't arbitrate when most rows sit below the measurement floor — it runs a fixed, documented sequence of rules (R0 through R4) so the same input always produces the same canonical, and every demoted row records which rule fired. A published row always wins over an unpublished one (R0) — a cron re-run must never canonicalize a live URL away. Two or more already-published rows sharing one SERP escalate for a human decision rather than resolving automatically (R0b), because consolidating two live URLs is a content merge plus a redirect, not a queue edit. Below that: measured search volume when it clears a real margin (R1), then a site-measured template priority (R2), then whichever row's SERP best represents the shared intent (R3), then a deterministic tiebreak (R4) — never random.

Losing a query to a merge is designed to cost nothing but a URL. A demoted row's fact is carried onto the canonical as covered_queries and absorbed_assets, and a dedicated assertion, assertCoveredQueriesAnswered, checks that the canonical page actually answers each absorbed phrasing as a heading or FAQ entry — not just that the queue recorded an intention to. The module's own header names why this matters in the driest possible terms: "clustering CONSOLIDATES demand; it does not delete it. A canonical that does not answer the phrasings it absorbed is ten pages deleted and one page unchanged."

07 The afternoon the tie-break rule picked the weaker page

On 2026-08-31 at 10:25, a commit (e4be169) ran the intent-cluster resolver against the live queue for the first time — the same commit, incidentally, that fixed the self-test date bug documented in the sibling piece on this journal. It filled the template priority list from measured 90-day medians (pricing 1,051.5 impressions/page, review 90.5, vs 139, alternatives 12.5 — an order-of-magnitude drop at the bottom) and resolved 26 clusters.

Three of those demotions were wrong in a specific, structural way. pickCanonical checks its rules in sequence, and R0 — a published row always wins — fires before R2, the template-priority tiebreak, is ever consulted. In two clusters, the already-live member happened to be an alternatives page: the exact template the same measurement had just ranked last. So the resolver demoted two pricing rows (the site's strongest template, measured medians an order of magnitude higher) and one review row into two live alternatives pages that had never been told what facts they were now supposed to answer.

docs/seo/yield-review.md, 2026-08-31 entry text
published rows carrying covered_queries: 2
FAIL  alternatives/kaching-bundles-app-upsells-alternatives   · "kaching bundles app & upsells pricing"
FAIL  alternatives/stamped-reviews-loyalty-alternatives       · "stamped reviews & loyalty pricing"
2 checked, 2 failing, 0 with no emitted file.

Nothing in the pipeline would ever have caught that on its own. assertCoveredQueriesAnswered is called from exactly one place — generate.mjs, on the emit path a new page takes. A row that's already published is never re-emitted, so the assertion never runs against it again. And intentcluster.mjs --check, the standing audit that would otherwise flag it, runs in CI with a trailing || true (non-blocking, by design — more on that below). It took someone reading the module, understanding that gap, and running the exact assertion generate.mjs makes by hand against the two live post files to surface the failure at all.

The fix, same day at 16:30 (commit 3a4860f), was a real content edit, not a queue trick: the two live alternatives pages were reopened out of their clusters, then each got a genuine FAQ entry sourced from apps_pricing.json (measured 2026-08-17) answering the exact phrasing it had absorbed — the Kaching page's pricing FAQ states its $14.99/$29.99/$59.99 tiers sit at the 42nd percentile of 33 priced apps in its category; the Stamped page's does the same at the 69th percentile of 32. Both are live today. The resolver was re-run, the same three rows re-clustered, and this time assertCoveredQueriesAnswered passes for both canonicals — verified against the actual emitted files, not assumed from the queue state.

What I won't claim is that the underlying hazard is closed. It isn't, and the repository says so itself: R0 still fires before R2 with no warning when it happens, and nothing re-checks an already-published canonical automatically. docs/seo/yield-review.md records this as a standing risk for "the next person to run --resolve," not a defect this incident retired. That's the honest version of this story — a specific mistake was caught and fixed with real evidence, and the mechanism that let it happen once is still capable of happening again.

08 Why one gate blocks and the other only reports

cannibalgate.mjs is wired into npm run gates and the CI pre-flight — a within-section violation blocks new pages from generating, because it's a property of the queue, knowable before a token is spent. intentcluster.mjs --check runs later in the same workflow, in the post-deploy "discovery sweep" step, and its own comment in .github/workflows/pseo-publish.yml states the reason it's followed by || true: resolving a cross-section collision is a content decision, and a collision between two already-published rows is a page merge plus a redirect — not something an unattended run should ever decide on its own. That's the identical asymmetry the sibling withhold-not-delete piece on this journal documents for a different mechanism: a corpus-wide finding alarms, a page-scoped one blocks, and a live URL never gets touched as a side effect of housekeeping. A gate that halted publishing over pre-existing cross-section debt unrelated to the page in hand is the exact production failure this pipeline has already survived once, from an unrelated cause, and neither gate is allowed to reintroduce that shape.

09 What the queue says right now

As of the 2026-09-02 snapshot of docs/seo/data/queue.json — computed directly, not grep-estimated — 29 rows carry status: "clustered", every one of them in /apps. Twenty-six resolved under the template-priority rule (R2-template); three resolved under the published-row rule (R0-published) — the same three rows from the incident above, now carrying real absorbed facts on their canonicals. Running node scripts/pseo/intentcluster.mjs --check today reports 0 new clusters over the 416 rows still live in the pool, which is the expected steady state: everything already resolved has left the pool the check re-examines.

None of this is a story about a pipeline that got cannibalization right on the first attempt. It's a story about a gate that found five pairs, a second gate that found the first gate's own blind spot on the exact same corpus, and one real afternoon where the second gate's own tie-break order needed a human to catch it before it silently buried a fact. That's a different, narrower claim than "we solved deduplication" — and it's the one the evidence actually supports.

If you're evaluating whether an AI-native pipeline can be trusted to publish volume without duplicating its own work, the question worth asking isn't whether it has a check for that. It's whether the check itself has ever been wrong, and whether the team running it will tell you when it was. That's the kind of system design we do.

Source table

ClaimSource (this repo)
Uniqueness ledger enforces one fact per page, blind to shared queriesscripts/pseo/uniqueasset.mjs; CLAUDE.md ("Different facts do not make two pages")
Five-pair /bench+/crawlers cannibalization, 50-78% overlap, merged same day the gate landedcommits 8306c5c (15:38), b5fb603 (15:43), gate landed in 19554b3 (15:45), all 2026-07-26
Three-rows-two-numbers fact collision (15,256ms lab LCP / 1,953ms field p75)commit b5fb603
11 within-section /apps pairs found and resolved, no ledger so no exemption possiblecommit e5ba2cb (2026-07-26, 18:59)
Two per-bot /crawlers exemptions, re-checked every run, currently dormantdocs/seo/pseo-cannibalization-exemptions.json; verified live via npm run gates:cannibalization, 2026-09-02
Cross-section blind spot: 0 within-section vs. 29 cross-section /apps pairscommit 541b6f6 (2026-08-17); scripts/pseo/intentcluster.mjs header
Fidelity bug (per-locale vs. per-file), 36/44 vs. 22/28 clusters/demotionscommit 541b6f6; scripts/pseo/intentcluster.mjs (fidelityOf)
Canonical-selection rules R0–R4; consolidation via covered_queries/absorbed_assetsscripts/pseo/intentcluster.mjs (pickCanonical, assertCoveredQueriesAnswered)
First real --resolve run, 26 clusters, template priority filled from measured medianscommit e4be169 (2026-08-31, 10:25); docs/seo/pseo-gates.config.json (intentTemplatePriority)
R0-published-before-R2 hazard demoting pricing/review rows into an unready alternatives canonical; caught by handdocs/seo/yield-review.md ("2026-08-31 — the two R0-published clusters reopened")
Durable fix: sourced FAQ added to both live canonicals, re-resolved, assertion passescommit 3a4860f (2026-08-31, 16:30); live pages at apps/alternatives/kaching-bundles-app-upsells-alternatives and apps/alternatives/stamped-reviews-loyalty-alternatives
Hazard recorded as still open, not resolved by the incidentdocs/seo/yield-review.md ("The hazard, which is still standing")
cannibalgate.mjs blocks (npm run gates + CI pre-flight); intentcluster.mjs reports only (|| true)CLAUDE.md ("pSEO gates: what blocks publishing and what only alarms"); .github/workflows/pseo-publish.yml
Current queue: 29 clustered rows (26 R2-template, 3 R0-published), 0 new clusters on re-checkdocs/seo/data/queue.json (computed, 2026-09-02 snapshot); node scripts/pseo/intentcluster.mjs --check, run live 2026-09-02

07 Frequently asked questions

What is the cannibalization gate, and why doesn't the uniqueness ledger already catch this?
The uniqueness ledger (scripts/pseo/uniqueasset.mjs) enforces that no two pages claim the same underlying fact. It says nothing about two pages answering the same search query with two different facts — and different facts do not make two pages; different queries do. scripts/pseo/cannibalgate.mjs closes that gap by comparing each pair of live rows' captured top-10 search results directly: if the smaller capture shares 60% or more of its URLs with the larger one, Google is answering one query for both, and the gate names the pair as a violation regardless of how cleanly each row's fact is ledgered.
Has this pipeline actually merged pages, with real numbers, or is this hypothetical?
On 2026-07-26 it merged five keyword pairs across /bench and /crawlers with measured overlaps of 50-78% (commits 8306c5c and b5fb603), and the same day found and resolved 11 more within /apps (commit e5ba2cb). None of the underlying measurements were discarded — each dropped row keeps its captured data as the record, and only its unique_asset key is cleared so the surviving page can carry the freed fact as supporting material.
Did a real page ever get merged the wrong way?
On 2026-08-31, the resolver's canonical-selection rule ("a published row always wins," checked before the rule that prefers the higher-performing template) demoted two pricing pages and one review page into a live alternatives page that had not yet been given the facts it absorbed. It was caught the same day by manually running the assertion that checks a canonical answers what it absorbed — an assertion that is wired only into new-page generation and had never been run against an already-published row. The three rows were reopened, the live page was edited by hand to add the missing facts, and the merge was re-run and re-verified. The rule-order hazard itself — a published row can still out-rank a stronger template silently — is recorded as open in this repo's own review log, not swept under a "fixed" label.
Why does one gate block publishing and the other only report?
cannibalgate.mjs compares rows within one section and is wired into npm run gates and the CI pre-flight, so a violation blocks new pages before generation spends a token. intentcluster.mjs compares rows across every section — where the real corpus collisions turned out to live — but it runs in CI with a trailing || true, deliberately non-blocking: resolving a collision between two already-published pages is a content-merge-and-redirect decision, not something an unattended run should ever make unilaterally. A corpus-wide gate that halts publishing over pre-existing debt unrelated to the page in hand is the exact failure this pipeline survived once already, documented in the sibling piece on this journal about withholding versus deleting.
What happens to a query when its page gets merged away — is the fact lost?
No, by design. A demoted row's measured fact is preserved as covered_queries and absorbed_assets on the surviving canonical, and a separate assertion (assertCoveredQueriesAnswered) checks that the canonical actually answers each absorbed query as a heading or FAQ entry before it counts as resolved. The 2026-08-31 incident above is precisely what happens when that assertion doesn't get to run — the phrasing wasn't lost forever, but it went unanswered on a live page for a period, invisibly, until someone checked by hand.
AM
Alex Mashkovtsev
Founder · Engineering Lead at INSO

Alex leads engineering at INSO, an AI-native product & commerce studio. He's shipped custom Shopify apps, checkout redesigns, and theme architecture for brands across the US and EU.