---
title: Separate forked league branches on mirror lookup
type: task
status: completed
description: >-
  A deep fork shares a chain root with its sibling branch, so both the backward walk and the new
  root lookup can attach a descendant of one branch to the other branch's mirror, cross-writing
  rosters and transactions.
base_uri: user:task/league/separate-forked-league-branches-on-mirror-lookup.md
created_at: '2026-09-21T15:46:48.722Z'
entity_id: 0e1f43e5-3ca7-4ccd-b616-ba261e447b49
finished_at: '2026-09-21T17:06:17.405Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Low
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-21T22:43:15.085Z'
visibility_analyzed_at: '2026-11-05T23:50:23.143Z'
---

## Problem

Two leagues that SPLIT from a common ancestor share a chain root forever, so a mirror lookup keyed on the root can attach a descendant of one branch to the other branch's mirror. One league's rosters and transactions then resolve `tid` through the other league's team mapping, and the `teams` rows are never overwritten — so the corruption sits entirely in the content hung off the mapping and reads as healthy.

## This is NOT a regression from the chain-root key

Stated plainly because the root key is the obvious suspect and it is the wrong one. `bcb291536` added root matching, but the BACKWARD WALK has the identical blind spot and reached the same wrong mirror before it existed:

- The walk (`find-or-create-league-mirror.mjs`) stops at the first stored connection it reaches, without asking which BRANCH that connection sits on.
- So importing D (a child of C, where B and C both forked from A) walks D → C → A, finds A's connection on B's mirror, and attaches D to B's mirror.

Root matching reaches the same wrong answer by a different route. Closing this fixes both paths, and fixing only the root path would leave the walk defective.

## Why the existing fork guard does not catch it

The `forked` discriminator asks whether the candidate mirror already carries a connection that succeeded the SAME predecessor under a different external id. That is exact for the SHALLOW case — two siblings both naming A — and it is what [[██████████████████████-████████-██████-██████-█████████████]] closed.

It is structurally blind one hop up. When D's predecessor is C and the candidate mirror holds B (whose predecessor is A), the two never share a predecessor value, so the guard finds nothing and the attach proceeds.

## The shape of a fix

The root says WHICH TREE a league belongs to. It cannot say which BRANCH, and that is the missing fact. Options worth weighing before writing anything:

- **Record the branch**, as the id of the connection's own fork point, resolved when a fork is first detected. Decidable at the same seam the `forked` guard already runs in, but it needs a rule for leagues imported before the fork was known.
- **Compare full chains rather than roots.** Two connections belong to one league when one chain is a PREFIX of the other, not merely when they end at the same id. Needs the chain persisted rather than just its root, which is a larger column and a larger backfill.
- **Refuse rather than guess.** When a root matches but the chains cannot be shown to be linearly related, mint a separate mirror. Fails toward the duplicate this lane spent its effort removing, but a duplicate is recoverable by hand and a cross-write is not.

The third is the cheapest correct answer and is probably the right first move; the first is the right end state.

## Measure before designing

No fork is known to exist in production. `external_league_connections` holds 3 rows across 2 mirrors, and neither is forked. So this is a latent defect, and the first step is to find out whether the corpus has any real forks at all — `external_leagues` holds ~71,000 Sleeper rows with ~49,000 carrying a prior link, which is the population that can answer it. A fork rate of zero argues for the refuse-rather-than-guess option and against building branch tracking.

## Measured 2026-09-21, and it RULES the design choice

Run before any design work, per the section above. Every figure is one query against `external_leagues`, the Sleeper corpus.

| Figure | Count |
| --- | --- |
| Leagues in the corpus | 77,924 |
| Carrying a prior link | 52,811 |
| Parents named by more than one league (forks) | 59 |
| Leagues sitting on a forked parent | 119 |
| Widest fork | 3 branches |
| **Fork branches that themselves have children** | **1** |

Containment holds: 119 sits inside 52,811 sits inside 77,924.

**Forks are real, but the DEEP case this task exists for has ONE instance in the whole corpus.** The other 118 branches are leaves, and a leaf fork is exactly the shallow case the existing `forked` guard already handles correctly — two siblings naming one predecessor, caught without a vendor call.

So the third option above — **refuse rather than guess** — is the answer, and branch tracking is over-engineering against n=1. When a root matches but the chains cannot be shown to be linearly related, mint a separate mirror. It fails toward a duplicate, which is recoverable by hand; the cross-write it prevents is not.

Two caveats on spending these numbers. They describe the CRAWLED corpus rather than imported mirrors — `external_league_connections` holds 3 rows and none is forked, so nothing is broken in production today and this stays latent. And `'0'` is excluded explicitly: Sleeper spells "no predecessor" as that string, and grouping without the exclusion reports one enormous false fork containing every chain terminus.

## Done when

- A case reproduces the deep fork and is shown to go red against today's tree.
- Both the walk path and the root path give the two branches separate mirrors.
- The module doc's "WHAT REMAINS NARROWER THAN THIS" paragraph is retired or narrowed to what is actually left.
