Skip to content

[Bug]: Registry sync keys repositories by case-sensitive fullName, splitting a repo into duplicate rows and de-registering the wrong one on a casing mismatch #603

Description

@philluiz2323

Summary

persistRegistrySnapshot upserts and de-registers repositories using
case-sensitive fullName matching, while repositories.fullName is a
case-sensitive TEXT primaryKey() and the rest of the system resolves repos
case-insensitively (getRepository has an explicit lower(fullName)
fallback). When a registry snapshot's repo casing differs from a row already
stored under another casing, the sync inserts a duplicate primary-key row
instead of updating the existing one, and the case-sensitive de-registration
nulls the registration/config on the other casing — splitting a single repo's
identity across two rows (registration on one, GitHub-sourced PR/issue/signal
data on the other).

Casing divergence is not hypothetical: the project already fixed the same
case-sensitivity class in #223 / #225 ("buildRoleContext detects maintainer
association with a case-SENSITIVE === on repoFullName"). The registry sync is
an unfixed instance.

Evidence

// src/db/schema.ts:17  — case-sensitive (BINARY) primary key
export const repositories = sqliteTable("repositories", {
  fullName: text("full_name").primaryKey(),
  ...
});

// src/registry/sync.ts:96  — upsert keyed on the case-sensitive fullName
await db.insert(repositories).values({ fullName: repo.repo, ..., isRegistered: true, ... })
  .onConflictDoUpdate({ target: repositories.fullName, set: { isRegistered: true, ... } });

// src/registry/sync.ts:126  — de-register keyed on case-sensitive NOT IN
const registeredFullNames = snapshot.repositories.map((repo) => repo.repo);
await db.update(repositories)
  .set({ isRegistered: false, registryConfigJson: null, emissionShare: null, ... })
  .where(and(eq(repositories.isRegistered, true), notInArray(repositories.fullName, registeredFullNames)));

The system otherwise treats casing as insignificant:

// src/db/repositories.ts — getRepository: exact match, THEN a case-insensitive fallback
const [row] = await db.select().from(repositories).where(eq(repositories.fullName, fullName)).limit(1);
if (row) return toRepositoryRecord(row);
const [caseInsensitiveRow] = await db.select().from(repositories)
  .where(sql`lower(${repositories.fullName}) = ${fullName.toLowerCase()}`).limit(1);

normalizeRepo (registry/normalize.ts) passes the upstream repo name through
verbatim (no lowercasing), so the registry's casing is whatever the upstream
Gittensor API supplies, independent of GitHub-canonical casing used by webhook/
API-sourced rows (upsertRepositoryFromGitHub stores repo.full_name).

Concrete trace

A repo JSONbored/gittensory is first seen via a GitHub webhook/API path
(upsertRepositoryFromGitHub → row "JSONbored/gittensory", holding its PR/
issue/signal data). The registry snapshot supplies the same repo as
jsonbored/gittensory:

  1. persistRegistrySnapshot upsert: onConflictDoUpdate targets the
    case-sensitive fullName PK. "jsonbored/gittensory""JSONbored/gittensory",
    so no conflict fires → a second row "jsonbored/gittensory"
    (isRegistered: true, registry config) is inserted.
  2. Now scoring/decision code calling getRepository("JSONbored/gittensory")
    gets the exact-match GitHub row → isRegistered: false, no
    registryConfig → the repo and its contributions are treated as
    unregistered (no emission share, no eligibility), even though it is
    registered (on the other row).
  3. If a later snapshot flips casing again, the case-sensitive
    notInArray(fullName, registeredFullNames) de-registers the now-mismatched
    row and nulls its config — flapping registration between duplicate rows.

So a single registered repo can present as unregistered, and its PR/issue data is
divorced from its registry config.

Why it's wrong

Two sources (GitHub-canonical case and the upstream registry) write the same
logical repo to a case-sensitive PK, and every comparison in the sync is
case-sensitive — directly contradicting getRepository's case-insensitive
resolution and the maintainers' own #223/#225 fix establishing that
repoFullName casing must be compared case-insensitively.

Test status

Not locked in. registry/sync tests use consistent casing throughout, so the
duplicate-row / wrong-de-registration path is never exercised; no test asserts
that a casing-variant existing row is updated (not duplicated) or that
de-registration is case-insensitive.

Suggested fix

Normalize fullName to a canonical lowercase form before persisting in both
persistRegistrySnapshot and upsertRepositoryFromGitHub (so a repo has exactly
one row regardless of source casing), and lowercase both sides of the
de-registration notInArray. Equivalently, switch the fullName unique key to a
case-insensitive collation and lowercase the de-register comparison. Add a test
that registers a repo already stored under a different casing and asserts a
single row, isRegistered: true, and no spurious de-registration.

Distinct from prior reports

#223 / #225 fixed the case-sensitive repoFullName comparison in
buildRoleContext (maintainer-lane detection); #396 fixed drift affectedRepoCount
aggregation. Neither touches persistRegistrySnapshot's case-sensitive upsert
target and de-registration, which is the unfixed instance reported here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    slopAI slop and/or attempts to game additional points via manipulation or alt profiles.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions