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:
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.
- 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).
- 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.
Summary
persistRegistrySnapshotupserts and de-registers repositories usingcase-sensitive
fullNamematching, whilerepositories.fullNameis acase-sensitive
TEXT primaryKey()and the rest of the system resolves reposcase-insensitively (
getRepositoryhas an explicitlower(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 ("
buildRoleContextdetects maintainerassociation with a case-SENSITIVE
===onrepoFullName"). The registry sync isan unfixed instance.
Evidence
The system otherwise treats casing as insignificant:
normalizeRepo(registry/normalize.ts) passes the upstream repo name throughverbatim (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 (
upsertRepositoryFromGitHubstoresrepo.full_name).Concrete trace
A repo
JSONbored/gittensoryis 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:persistRegistrySnapshotupsert:onConflictDoUpdatetargets thecase-sensitive
fullNamePK."jsonbored/gittensory"≠"JSONbored/gittensory",so no conflict fires → a second row
"jsonbored/gittensory"(
isRegistered: true, registry config) is inserted.getRepository("JSONbored/gittensory")gets the exact-match GitHub row →
isRegistered: false, noregistryConfig→ the repo and its contributions are treated asunregistered (no emission share, no eligibility), even though it is
registered (on the other row).
notInArray(fullName, registeredFullNames)de-registers the now-mismatchedrow 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-insensitiveresolution and the maintainers' own #223/#225 fix establishing that
repoFullNamecasing must be compared case-insensitively.Test status
Not locked in.
registry/synctests use consistent casing throughout, so theduplicate-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
fullNameto a canonical lowercase form before persisting in bothpersistRegistrySnapshotandupsertRepositoryFromGitHub(so a repo has exactlyone row regardless of source casing), and lowercase both sides of the
de-registration
notInArray. Equivalently, switch thefullNameunique key to acase-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
repoFullNamecomparison inbuildRoleContext(maintainer-lane detection); #396 fixed driftaffectedRepoCountaggregation. Neither touches
persistRegistrySnapshot's case-sensitive upserttarget and de-registration, which is the unfixed instance reported here.