Repos are never linked to their marketplace domain — fix + backfill
Background
The proximate trigger
While verifying muse#74 (auto-derived supported_commands) against local
musehub, https://localhost:1337/domains showed @gabriel/code with
exactly 1 linked repo, despite https://localhost:1337/explore listing
roughly 180 code repos. Not a display bug — confirmed directly against
Postgres:
SELECT domain_id, marketplace_domain_id, count(*)
FROM musehub_repos GROUP BY domain_id, marketplace_domain_id;
domain_id | marketplace_domain_id | count
-----------+------------------------+-------
code | (null) | 179
identity | (null) | 4
mist | (null) | 2
code | sha256:b5d4... | 1 ← only a throwaway verification repo
Root cause — verified against source, not assumed
MusehubRepo has two distinct domain columns (see musehub#117 Phase 3 /
the list_repos_for_domain fix that already shipped for the read side):
domain_id— a plain VCS-plugin category string ("code","mist","identity"), set from the CLI's--domainflag at repo creation.marketplace_domain_id— an FK-shaped link to a specificMusehubDomain.domain_id(a sha256 genesis ID) — the columnlist_repos_for_domainactually queries.
create_repo() (musehub/services/musehub_repository.py:146) sets
domain_id from the caller's domain argument but never touches
marketplace_domain_id. The only code path that ever sets it is
update_repo_settings()'s explicit --capabilities-style patch
(musehub_repository.py:2342) — a repo only gets linked if someone
later calls PATCH /api/repos/{id}/settings with an explicit
marketplace_domain_id. Nothing in the create flow, the CLI, or any
wizard does that today.
This is not just a one-time historical gap: every repo created from
now on will keep landing with marketplace_domain_id = NULL unless
this is fixed at the source. A backfill alone would only paper over the
existing 185 repos and the bug would silently reopen on the next commit.
The ambiguity this issue must resolve — the reason it isn't a one-line fix
domain at repo-creation time is a bare category string ("code"), not
a scoped domain ID ("@gabriel/code") — there is no author context to
resolve against. Today exactly one non-deprecated MusehubDomain row
exists per category (all owned by gabriel), so category → domain is
unambiguous. That will not always be true: nothing stops a second author
from publishing their own @alice/code tomorrow. Auto-linking must
never guess when more than one candidate exists — silently picking one
author's domain over another's would be a data-integrity bug, not a fix
for one.
Resolution rule (this issue's core design decision): auto-link only
when exactly one non-deprecated MusehubDomain matches the category
by slug. Zero matches or 2+ matches → leave marketplace_domain_id
NULL, unchanged from today's (broken but honest) behavior. An explicit,
caller-supplied marketplace_domain_id always overrides auto-resolution
and is validated to exist, exactly like update_repo_settings already
does — this is the future-proof way for a caller to disambiguate once
multiple domains of the same category exist (e.g. eventually a CLI flag
--marketplace-domain @alice/code).
Design — the shape to build toward
A single resolution helper, consulted by both the create path (new) and the backfill script (one-time), with the same never-guess contract both places.
musehub/services/musehub_domains.py:
async def resolve_unambiguous_domain_id_by_category(
session: AsyncSession, category: str
) -> str | None:
"""Return the sole non-deprecated MusehubDomain.domain_id whose slug
matches *category*, or None if zero or multiple candidates exist.
Never guesses: ambiguity (2+ live domains sharing a category slug)
and absence (0 domains) are both honest None, not a fallback.
"""
create_repo() gains an optional marketplace_domain_id: str | None = None parameter:
- Explicit value provided → validated via
get_domain_by_id(sameValueError("marketplace_domain_not_found")contractupdate_repo_settingsalready uses), then stored as-is. - Omitted → auto-resolve via
resolve_unambiguous_domain_id_by_category(session, domain_id)using the same_domain_idthe row is about to store. Result (a real ID orNone) is stored — never guessed. - Whenever the repo ends up with a non-
Nonemarketplace_domain_id(explicit or auto-resolved) at creation time, callrecord_domain_install(session, owner_user_id, marketplace_domain_id)— mirrors whatupdate_repo_settingsalready does on a later link, soinstall_countis correct from the moment of creation instead of only reflecting repos that were explicitly re-linked after the fact.
scripts/backfill_marketplace_domain_links.py (new, one-off, following
the existing scripts/ convention — see stale_branches.py for style):
--dry-run(default): reports counts — linked, skipped-ambiguous, skipped-no-match — without writing anything.--apply: performs the writes reported by dry-run, callingrecord_domain_installfor each repo it links (real install-count correctness, not just backfilling the FK).- Idempotent: repos with
marketplace_domain_id IS NOT NULLare always skipped, dry-run or apply, re-run or first-run. - Every skipped-ambiguous and skipped-no-match repo is printed by
repo_id— no silent under-reporting of what wasn't fixed.
Goal — definition of done
create_repo()never leaves an unambiguously-resolvable repo unlinked — every new repo in a single-domain-per-category world (today's reality) is linked at creation time.- Ambiguous or unmatched categories are left
NULL, not guessed — verified by a test that deliberately creates two competing domains for one category and confirms no link is made. install_countreflects real installs from creation forward, not only from later explicit re-links.- All 185 currently-orphaned local repos (179 code + 4 identity + 2 mist) get backfilled correctly, with a full dry-run report reviewed before any write.
/domainson both local and staging shows accurate repo counts per domain after the backfill runs.- Every deliverable is TDD'd: red test first, then green.
Phases
Ordered by load-bearing dependency — the resolution helper must exist and be correct before either consumer (create path, backfill script) can use it.
Phase 1 — The resolution helper, in isolation
- [ ]
MDL_01—resolve_unambiguous_domain_id_by_categoryreturns the matchingdomain_idwhen exactly one non-deprecatedMusehubDomainhas thatslug. - [ ]
MDL_02— ReturnsNonewhen zero domains match the category. - [ ]
MDL_03— ReturnsNonewhen 2+ non-deprecated domains share the same categoryslug— the core ambiguity guard this issue exists to add. Red test: seed two live domains both slugged"code"(different authors), assert resolution isNone, not either one. - [ ]
MDL_04— A deprecated domain sharing a slug with a live one does not count as a second candidate — resolves unambiguously to the live domain. Proves deprecation status is part of the candidate filter, not just a display-layer concern.
Exit gate: all four resolution-contract cases pass in isolation,
with no dependency yet on create_repo or the backfill script.
Phase 2 — Wire into create_repo (stops the bug from recurring)
- [ ]
MDL_05— Creating a repo withdomain="code"and no explicitmarketplace_domain_id, in a fixture with exactly one live"code"domain, auto-links to it. - [ ]
MDL_06— Creating a repo with a category that matches zero marketplace domains leavesmarketplace_domain_idNULL— today's honest behavior, unchanged for the no-match case. - [ ]
MDL_07— An explicitmarketplace_domain_idargument tocreate_repooverrides auto-resolution entirely (even if it disagrees with what auto-resolution would have picked). - [ ]
MDL_08— An explicitmarketplace_domain_idthat doesn't exist raisesValueError("marketplace_domain_not_found")— same contractupdate_repo_settingsalready has, pinned here too so the two call sites can't silently diverge. - [ ]
MDL_09— When a repo ends up linked at creation (explicit or auto-resolved),record_domain_installis called exactly once forowner_user_id—install_counton the targetMusehubDomainincrements immediately, not only after a later explicit patch. - [ ]
MDL_10— Creating a repo whose category has 2+ live marketplace candidates leavesmarketplace_domain_idNULL— the create-path-level regression guard mirroringMDL_03.
Exit gate: every new repo created after this phase lands correctly
linked whenever its category is unambiguous; ambiguous/unmatched cases
stay honestly NULL; install bookkeeping is correct from creation.
Phase 3 — Backfill existing repos
- [ ]
MDL_11—scripts/backfill_marketplace_domain_links.py --dry-runreports accurate counts (linked / skipped-ambiguous / skipped-no-match) against a fixture DB, writes nothing. - [ ]
MDL_12—--applyperforms exactly the writes the matching dry-run reported, and callsrecord_domain_installonce per linked repo. - [ ]
MDL_13— Repos that already have a non-NULLmarketplace_domain_idare left untouched by both--dry-runand--apply— idempotent, safe to re-run after Phase 2 ships (new repos created in between are correctly skipped, not re-linked). - [ ]
MDL_14— Every skipped-ambiguous and skipped-no-match repo is printed byrepo_idin both modes — confirms the script never silently under-reports what it left unfixed.
Exit gate: dry-run report reviewed and matches expectations against
a full copy of local musehub's real data before --apply is ever run
for real (Phase 4).
Phase 4 — Live re-verification
- [ ]
MDL_15— Run--dry-runagainst local musehub's real DB, review the report (expect ~185 linked, given today's one-domain-per-category reality), then run--apply. Confirm viaGET /api/domains/@gabriel/code(and identity/mist) thatinstall_count/repo listings now reflect the real numbers, and confirmhttps://localhost:1337/domainsin-browser. - [ ]
MDL_16— Repeat against staging once local is confirmed correct.
Exit gate: Live, in-browser/curl confirmation against both local and
staging — not just the test suite. /domains shows real repo counts;
no repo is silently mis-linked to the wrong author's domain.
Acceptance criteria (whole-issue gate)
create_reponever leaves an unambiguous-category repo unlinked.- Ambiguous or unmatched categories are always
NULL, never guessed — proven by tests that construct real ambiguity, not just the happy path. install_countis correct for repos linked at creation time, not only for repos explicitly re-linked later.- All existing orphaned repos backfilled where unambiguously resolvable; every unresolved repo explicitly reported, not silently skipped.
- Full TDD coverage: every phase's deliverables have a red-then-green test.
- Verified live on both local and staging, not just unit-tested.
Out of scope (explicit, for future issues)
- Any UI or CLI affordance for a human to manually resolve an
ambiguous or no-match repo (e.g. "pick which
@author/codethis repo belongs to"). This issue makes today's single-domain-per-category reality correct and safe; multi-domain-per-category UX is real future work once a second author actually publishes a competing domain. - Changing
update_repo_settings's existing explicit-link behavior — already correct, already tested; this issue only adds an equivalent auto-resolve path at creation time. - A CLI flag to pass
--marketplace-domainexplicitly atmuse push/ repo-creation time — the service-layer parameter is added in this issue so a future CLI change can wire into it, but exposing it through the CLI itself is a separate, smaller follow-up once the service contract is proven.