# 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: ```sql 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 `--domain` flag at repo creation. - `marketplace_domain_id` — an FK-shaped link to a specific `MusehubDomain.domain_id` (a sha256 genesis ID) — the column `list_repos_for_domain` actually 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`: ```python 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` (same `ValueError("marketplace_domain_not_found")` contract `update_repo_settings` already uses), then stored as-is. - Omitted → auto-resolve via `resolve_unambiguous_domain_id_by_category(session, domain_id)` using the same `_domain_id` the row is about to store. Result (a real ID or `None`) is stored — never guessed. - Whenever the repo ends up with a non-`None` `marketplace_domain_id` (explicit or auto-resolved) at creation time, call `record_domain_install(session, owner_user_id, marketplace_domain_id)` — mirrors what `update_repo_settings` already does on a later link, so `install_count` is 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, calling `record_domain_install` for each repo it links (real install-count correctness, not just backfilling the FK). - Idempotent: repos with `marketplace_domain_id IS NOT NULL` are 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 1. `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. 2. 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. 3. `install_count` reflects real installs from creation forward, not only from later explicit re-links. 4. 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. 5. `/domains` on both local and staging shows accurate repo counts per domain after the backfill runs. 6. 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_category` returns the matching `domain_id` when exactly one non-deprecated `MusehubDomain` has that `slug`. - [ ] `MDL_02` — Returns `None` when zero domains match the category. - [ ] `MDL_03` — Returns `None` when 2+ non-deprecated domains share the same category `slug` — the core ambiguity guard this issue exists to add. Red test: seed two live domains both slugged `"code"` (different authors), assert resolution is `None`, 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 with `domain="code"` and no explicit `marketplace_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 leaves `marketplace_domain_id` `NULL` — today's honest behavior, unchanged for the no-match case. - [ ] `MDL_07` — An explicit `marketplace_domain_id` argument to `create_repo` overrides auto-resolution entirely (even if it disagrees with what auto-resolution would have picked). - [ ] `MDL_08` — An explicit `marketplace_domain_id` that doesn't exist raises `ValueError("marketplace_domain_not_found")` — same contract `update_repo_settings` already 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_install` is called exactly once for `owner_user_id` — `install_count` on the target `MusehubDomain` increments immediately, not only after a later explicit patch. - [ ] `MDL_10` — Creating a repo whose category has 2+ live marketplace candidates leaves `marketplace_domain_id` `NULL` — the create-path-level regression guard mirroring `MDL_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-run` reports accurate counts (linked / skipped-ambiguous / skipped-no-match) against a fixture DB, writes nothing. - [ ] `MDL_12` — `--apply` performs exactly the writes the matching dry-run reported, and calls `record_domain_install` once per linked repo. - [ ] `MDL_13` — Repos that already have a non-`NULL` `marketplace_domain_id` are left untouched by both `--dry-run` and `--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 by `repo_id` in 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-run` against **local** musehub's real DB, review the report (expect ~185 linked, given today's one-domain-per-category reality), then run `--apply`. Confirm via `GET /api/domains/@gabriel/code` (and identity/mist) that `install_count`/repo listings now reflect the real numbers, and confirm `https://localhost:1337/domains` in-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_repo` never 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_count` is 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/code` this 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-domain` explicitly at `muse 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.