# MWP-8 [musehub] — Cache-invalidation audit: bust `musehub_fetch_mpack_cache` on repair and force-push > **Master tracker:** [muse#58](https://staging.musehub.ai/gabriel/muse/issues/58) — Muse Wire Protocol MVP hardening. This is the last open sub-ticket (MWP-1..MWP-7 all ✅). > **Repo target:** `musehub` (the clone/fetch *server*). No `muse` client changes. > **Severity:** data-correctness (a clone can silently serve pre-repair bytes). Defensive — the trigger is rare (operator repair / force-push) but the failure is invisible and long-lived (7-day cache TTL). > **IMPORTANT — this project uses Muse (not git) for version control.** > - Never run `git`, `gh`, or any git subcommand. Repo paths: `~/ecosystem/muse`, `~/ecosystem/musehub`, `~/ecosystem/agentception`. > - Use `muse code grep / symbols / cat / impact / deps` before reading files directly. Every command accepts `--json` / `-j`. > - Use `muse -C ~/ecosystem/musehub ` when CWD differs from the target repo. > - Commit with full provenance: `muse commit -m "..." --agent-id claude-code --model-id claude-sonnet-4-6 --sign`. > - Branch first; never commit to `dev`/`main`. **Never merge to `dev`/`main` without gabriel's explicit permission.** > - **musehub localhost runs uvicorn without `--reload`.** After editing any musehub Python source you must `docker restart musehub && sleep 5 && curl -sk https://localhost:1337/healthz` before the change takes effect. Unit tests against `db_session` do NOT need a restart; only live-server E2E does. Read the **Code Intelligence Appendix** at the bottom first — it lists every symbol you will touch and the exact `muse code` command to read it. --- ## Background ### What the cache is MuseHub does not serve a fresh clone (`have=[]`) by walking the commit DAG synchronously — on a large repo that blocks long enough to trip a Cloudflare 524. Instead, after every push a background job (`fetch.mpack.prebuild`) builds **one combined mpack** covering all current branch tips, uploads it to R2, and writes one cache row per tip into [`musehub_fetch_mpack_cache`](musehub/db/musehub_repo_models.py) (`MusehubFetchMPackCache`, `musehub/db/musehub_repo_models.py:741`). A subsequent clone is a sub-second cache HIT: the fetch path presigns the cached `mpack_id` and returns immediately. The cache row is: ```python class MusehubFetchMPackCache(Base): __tablename__ = "musehub_fetch_mpack_cache" cache_id: str # = blob_id((repo_id + tip_commit_id).encode()).replace("sha256:", "") — DETERMINISTIC repo_id: str tip_commit_id: str # UNIQUE per repo: uq_fetch_mpack_cache_repo_tip mpack_id: str # content-addressed R2 key of the prebuilt mpack created_at: datetime expires_at: datetime # server_default now() + 7 days ``` **Key fact for this ticket:** the row is keyed by **`(repo_id, tip_commit_id)`** and the `cache_id` is a pure function of `(repo_id, tip)` — `blob_id((repo_id + tip).encode())` (see `process_fetch_mpack_prebuild_job`, `musehub/services/musehub_wire_fetch.py:1148`, and the MISS-write at `:864`). The `mpack_id` it stores is the content-address of the bytes that were *current at prebuild time*. Nothing re-derives that mpack when the underlying object/snapshot/commit rows change but the tip id does not. ### The cache HIT path (where stale bytes escape) `wire_fetch_mpack` (`musehub/services/musehub_wire_fetch.py:414`) — the clone/fetch entry — short-circuits on `have=[]`: ```python # musehub/services/musehub_wire_fetch.py:447-470 if not have: _cached_rows = (await session.execute( select(MusehubFetchMPackCache) .where(MusehubFetchMPackCache.repo_id == repo_id) .where(MusehubFetchMPackCache.tip_commit_id.in_(want)) .where(MusehubFetchMPackCache.expires_at > _utc_now()) )).scalars().all() _cached_tips = {r.tip_commit_id: r.mpack_id for r in _cached_rows} _mpack_ids = set(_cached_tips.values()) if len(_cached_tips) == len(want) and len(_mpack_ids) == 1: _hit_mpack_id = next(iter(_mpack_ids)) _cached_url = await backend.presign_mpack_get(_hit_mpack_id, ttl_seconds) return { "mpack_url": _cached_url, "mpack_id": _hit_mpack_id, ... } # ← serves WHATEVER bytes mpack_id addresses ``` A HIT presigns `mpack_id` and returns. The server never re-reads the (now-repaired) object/snapshot/commit rows. **The clone receives pre-repair bytes and exits 0.** The HIT condition only checks *freshness* (`expires_at > now`) and *tip coverage* — never whether the underlying content changed. ### Trigger 1 — repair endpoints do not invalidate Three operator-only repair endpoints replace the bytes/manifest/identity of a stored object, snapshot, or commit *in place* — same content-address id, corrected bytes: | Endpoint (route `musehub/api/routes/wire.py`) | Service fn (`musehub/services/musehub_wire_push.py`) | Mutates | |---|---|---| | `POST /{owner}/{slug}/repair-object` (`:368`) | `wire_repair_object` (`:108`) | `MusehubObject` bytes + R2 | | `POST /{owner}/{slug}/repair-snapshot` (`:416`) | `wire_repair_snapshot` (`:174`) | `MusehubSnapshot.manifest_blob` | | `POST /{owner}/{slug}/repair-commit` (`:470`) | `wire_repair_commit` (`:232`) | `MusehubCommit` identity fields | **Confirmed gap (read all three):** every one ends with `await session.commit()` → `return {"repaired": True}`. **None touches `musehub_fetch_mpack_cache`.** A prebuilt mpack covering a tip that *reaches* the repaired object/snapshot/commit keeps being served verbatim until the 7-day TTL expires or an unrelated push happens to rebuild it. This is the exact documented gotcha: *"a `repair-*` fixes the DB rows but does NOT invalidate that cache — so a clone can keep serving a stale mpack built from the pre-repair rows, and the repair appears to do nothing."* The historical incident: the rc10 migration stamped a spurious `signer_public_key` onto two `gabriel/muse` main-line merge commits; `repair-commit` fixed the rows but the staging clone stayed broken until the cache was manually expired with `UPDATE musehub_fetch_mpack_cache SET expires_at = NOW() - INTERVAL '1 second' WHERE repo_id = ...`. ### Trigger 2 — force-push does not invalidate `wire_push_unpack_mpack` (`musehub/services/musehub_wire_push.py:508`) takes `force: bool` (`:517`). On force it advances the branch pointer past the fast-forward / ancestry checks (`:1037-1056`) and enqueues the usual async jobs via `enqueue_push_intel` (`:1071`). **It never deletes cache rows.** Two distinct hazards: 1. **Stale old-tip rows.** Force-push that rewrites history leaves the *old* tip's cache row in place (fresh, non-expired). It is unreachable as a branch head but still a valid `(repo_id, tip)` row. A client that still has the old tip and requests it (`want=[old_tip], have=[]`) gets a HIT on content that is no longer on any branch. Lower-severity (the bytes are internally consistent for that tip) but it masks the rollback and wastes R2. 2. **Combined-mpack skew (correctness).** The prebuild's "skip if already cached" guard (`musehub/services/musehub_wire_fetch.py:1106-1114`) skips the rebuild only when *all* tips are cached **and** share one `mpack_id`. After a force-push, the rewritten branch's new tip is uncached, so a rebuild *is* triggered for that case — but if force-push moves a branch to a commit that *already* has a fresh cache row from a prior life (e.g. rollback to an earlier tip that was once a head), the guard can mark all-tips-cached and **skip**, leaving the combined-mpack invariant satisfied by stale per-tip rows. The clone HIT check (`len(_mpack_ids) == 1`) can then fail (forcing a MISS→503) or, worse, succeed against a combination that no longer reflects the live tip set. ### Why the prebuild does not self-heal a repair Even though `enqueue_push_intel` would, on a *push*, enqueue a `fetch.mpack.prebuild`, repair endpoints enqueue **nothing**. And even if a prebuild *did* run after a repair, `process_fetch_mpack_prebuild_job` (`musehub/services/musehub_wire_fetch.py:1034`) **skips the build** when the tip is already cached with a consistent `mpack_id` (`:1106-1114`) — the tip id is unchanged by a repair, so the guard short-circuits and the stale row survives. **Repair correctness therefore requires explicit invalidation; it cannot be delegated to the existing prebuild path.** ### What exists today for cache deletion Only `gc_fetch_mpack_cache` (`musehub/services/musehub_gc.py:284`) deletes rows — and only **expired** ones (`expires_at <= now`), with best-effort R2 delete. There is **no** repo-wide / tip-scoped *active* invalidation helper. This ticket adds one and wires it into the two triggers. --- ## Goal — definition of done 1. **Repair invalidates.** After any successful `repair-object` / `repair-snapshot` / `repair-commit`, every cache row for that repo is gone (and its R2 mpack best-effort deleted), so the *next* clone is a MISS that rebuilds from the repaired rows — never a HIT on pre-repair bytes. 2. **Force-push invalidates.** After a force-push, cache rows for the affected branch's old and new tips are gone; the post-push prebuild repopulates a correct combined mpack. No stale old-tip row can be served. 3. **Normal (fast-forward) push is unchanged.** A non-force push must NOT invalidate sibling tips — their content is unchanged and their cache rows are still correct. Only the pushed tip gets a fresh row via the existing prebuild. (Guards against a perf regression where every push nukes the whole repo cache.) 4. **One reusable invalidation primitive**, unit-tested in isolation, used by both triggers. 5. **Codified as non-skipped regression tests**, wired into the existing tier markers and (where the marker applies) `pytest -m wire`. 6. **No regression** in the prebuild / fetch / gc corpus. --- ## Design ### The primitive — `invalidate_fetch_mpack_cache` Add to `musehub/services/musehub_gc.py` (co-located with `gc_fetch_mpack_cache`, reusing its `get_backend()` + `delete()` + best-effort-R2 pattern): ```python async def invalidate_fetch_mpack_cache( session: AsyncSession, repo_id: str, tips: list[str] | None = None, ) -> int: """Actively delete fetch-mpack cache rows (and best-effort their R2 mpacks). Unlike gc_fetch_mpack_cache (expired-only), this deletes rows REGARDLESS of expires_at — the content behind the cached mpack is no longer trustworthy. tips=None → delete ALL rows for repo_id (repair: a repaired object/snapshot/ commit may be reachable from any tip; repo-wide is the only safe scope). tips=[...] → delete only those (repo_id, tip) rows (force-push: scope to the branch's old + new tip so sibling caches survive). Does NOT commit — the caller owns the transaction so invalidation is atomic with the repair/branch-advance it accompanies. Returns the number of cache rows deleted. """ ``` Rationale for the scope split (Design Decision **D1**): a repair mutates a *content-addressed object* that may be reachable from arbitrarily many tips — there is no cheap reverse index from object→tips, so **repo-wide** deletion is the only correct scope and repairs are rare enough that the cost is irrelevant. A force-push mutates exactly one branch, so **tip-scoped** deletion preserves untouched sibling-branch caches (no perf regression on the common multi-branch repo). Both share one primitive. Rationale for **not committing inside** the primitive (Design Decision **D2**): the repair functions and `wire_push_unpack_mpack` already own an explicit `await session.commit()`. Invalidation must be in the *same* transaction as the row repair / branch advance, or a crash between them re-opens the gap. The caller commits. ### Wiring - **Repair** — call `await invalidate_fetch_mpack_cache(session, repo_id)` (repo-wide, `tips=None`) in each of `wire_repair_object`, `wire_repair_snapshot`, `wire_repair_commit` **immediately before** their existing `await session.commit()` (`musehub/services/musehub_wire_push.py:150`, `:224`, `:315`). Optional but recommended (**EX_*** below): enqueue a fresh `fetch.mpack.prebuild` so the next clone is a fast HIT instead of a slow synchronous MISS-build — but correctness depends only on the deletion. - **Force-push** — in `wire_push_unpack_mpack`, when `force and current_head and current_head != tip`, call `await invalidate_fetch_mpack_cache(session, repo_id, tips=[current_head, tip])` right after the branch pointer is rewritten (`musehub/services/musehub_wire_push.py:1056`) and before the existing `await session.commit()` (`:1064`). The existing `enqueue_push_intel` (`:1071`) → prebuild then repopulates the new tip. ### The clone-after-repair contract (the load-bearing E2E) The regression that proves the whole ticket: **push → clone (HIT) → repair → clone again → second clone reflects the repair.** Today the second clone is a stale HIT. After the fix it is a MISS that rebuilds. This mirrors the `gabriel/muse` incident and the local repro `repro_clone_repair_localhost.sh`. --- ## Phases — TDD-first, load-bearing, each green before the next > **Execution rule:** write the test first (red), then implement to green. Test IDs appear in **both** this plan and the test file. Run one file at a time — never the whole suite. ### Phase 0 — Audit & red-repro (the gate) Prove the gap exists before changing any production code. New test file `tests/test_mwp8_cache_invalidation.py` (markers: `@pytest.mark.asyncio @pytest.mark.tier2`; add `@pytest.mark.wire` if the wire marker is in `pyproject.toml` — verify with `muse -C ~/ecosystem/musehub content-grep "wire" pyproject.toml`). Reuse fixtures from `tests/test_fetch_mpack_prebuild.py` (`db_session`, `_insert_job`) and the cache-row builder in `tests/test_fetch_mpack_cleanup.py:48` (`_insert_cache_row`). - [x] `AU_01` — **Repair-object stale-HIT repro.** Seed a repo with one tip `T`, write a fresh cache row `(repo_id, T) → mpack_M`. Call `wire_repair_object` to change an object reachable from `T`. Then call `wire_fetch_mpack(want=[T], have=[])` and assert it returns a HIT on `mpack_M` (`mpack_id == mpack_M`). **RED-as-bug:** this assertion documents the *current wrong* behavior — the cache row survived the repair. Comment the expected post-fix behavior (MISS / `MPackNotReadyError`). - [x] `AU_02` — **Repair-snapshot stale-HIT repro.** Same shape via `wire_repair_snapshot`. - [x] `AU_03` — **Repair-commit stale-HIT repro.** Same shape via `wire_repair_commit`. (Reuse the corrupt-then-repair pattern from `tests/test_repair_commit_endpoint.py:98` `_capture_corrupt_repair`.) - [x] `AU_04` — **Force-push stale old-tip repro.** Seed branch `main → T_old` with a fresh cache row. Call `wire_push_unpack_mpack(branch="main", head_commit_id=T_new, force=True)`. Assert the `(repo_id, T_old)` cache row still exists and `wire_fetch_mpack(want=[T_old], have=[])` still HITs. Documents hazard #1. - [x] `AU_05` — **Verdict record.** Module-docstring `AUDIT VERDICT:` section stating, from AU_01..AU_04, exactly which rows survive which trigger and what each *should* become. This is the spec the fix is measured against. **Exit gate:** AU_01..AU_05 committed; verdict in the test-module docstring and an issue comment (comment separately — never edit this body). ✅ COMPLETE — commit `sha256:0e5174da` ### Phase 1 — The invalidation primitive - [x] `IV_01` — `test_IV_invalidate_all_deletes_every_row_for_repo`: seed 3 rows for repo A + 2 for repo B (mix fresh + expired). `invalidate_fetch_mpack_cache(session, A)` returns 3; A has 0 rows; **B untouched** (cross-repo isolation). - [x] `IV_02` — `test_IV_invalidate_deletes_regardless_of_expiry`: a *fresh* (non-expired) row is deleted — the distinguishing behavior vs `gc_fetch_mpack_cache`. Assert a fresh row survives `gc_fetch_mpack_cache` but is removed by `invalidate_fetch_mpack_cache`. - [x] `IV_03` — `test_IV_invalidate_tip_scoped_leaves_siblings`: rows for tips `[X, Y, Z]`; `invalidate(..., tips=[X])` deletes only `X`; `Y`,`Z` survive. - [x] `IV_04` — `test_IV_invalidate_best_effort_r2_delete_swallows_failure`: patch `backend.delete` to raise; assert rows still deleted and no exception propagates (mirror `tests/test_fetch_mpack_cleanup.py:108` `test_bc2_gc_delete_failure_is_swallowed`). - [x] `IV_05` — `test_IV_invalidate_does_not_commit`: assert the function issues no `COMMIT` (rows are gone within the open transaction but a `session.rollback()` restores them) — proves D2. - [x] `IV_06` — `test_IV_invalidate_empty_repo_is_noop_returns_zero`. **Exit gate:** IV_01..IV_06 green; `muse code impact "musehub/services/musehub_gc.py::invalidate_fetch_mpack_cache" --json` reviewed. ✅ COMPLETE — commit `sha256:521444d9` ### Phase 2 — Wire repair → invalidation (make AU_01..AU_03 go green) - [x] `RP_01` — Insert `await invalidate_fetch_mpack_cache(session, repo_id)` before the existing commit in `wire_repair_object` (`musehub/services/musehub_wire_push.py:150`). - [x] `RP_02` — Same in `wire_repair_snapshot` (before `:224`). - [x] `RP_03` — Same in `wire_repair_commit` (before `:315`). - [x] `RP_04` — Convert AU_01 to the **post-fix contract** `test_RP_repair_object_busts_cache`: after repair, `wire_fetch_mpack(want=[T], have=[])` raises `MPackNotReadyError` (MISS, `force_build=False`) — the cache row is gone. Assert row count for `repo_id` is 0. - [x] `RP_05` / `RP_06` — Same conversion for snapshot (AU_02→`test_RP_repair_snapshot_busts_cache`) and commit (AU_03→`test_RP_repair_commit_busts_cache`). - [x] `RP_07` — **Atomicity guard** `test_RP_repair_rollback_keeps_cache`: if the repair's `session.commit()` is made to fail (patch to raise) the invalidation must roll back with it — the cache row must still exist after a failed repair (no half-state where the repair is rejected but the cache was nuked). Proves D2's same-transaction requirement. **Exit gate:** RP_01..RP_07 green; AU_01..AU_03 now satisfied by their RP successors. ✅ COMPLETE ### Phase 3 — Wire force-push → invalidation (make AU_04 go green); protect the fast-forward path - [x] `FP_01` — In `wire_push_unpack_mpack`, after the branch pointer rewrite, before `session.commit()`, add: `if force and current_head and current_head != tip: await invalidate_fetch_mpack_cache(session, repo_id, tips=[current_head, tip])`. - [x] `FP_02` — `test_FP_02_force_push_busts_old_and_new_tip` (AU_04 → post-fix): after force-push `T_old → T_new`, both `(repo_id, T_old)` and `(repo_id, T_new)` cache rows are gone; a clone of `T_old` raises `MPackNotReadyError`. - [x] `FP_03` — `test_FP_03_normal_push_does_not_invalidate`: a non-force push (new branch creation, `current_head=None`) must leave a sibling branch's cache row intact. Asserts Goal #3 / D3 — the common path pays zero invalidation cost. - [x] `FP_04` — `test_FP_04_force_push_enqueues_prebuild_for_new_tip`: after force-push, a `fetch.mpack.prebuild` background job is enqueued for the repo (proves enqueue→prebuild path still fires, closing the loop — D4). - [x] `FP_05` — blast radius confirmed: `wire_push_unpack_mpack` blast radius = API route + adjacent push/prebuild tests; all 38 adjacent tests green. **Exit gate:** FP_01..FP_05 green. ✅ COMPLETE ### Phase 4 — Optional prebuild-on-repair + end-to-end clone-after-repair - [x] `EX_01` — `test_EX_01_repair_enqueues_prebuild`: after repair, a `fetch.mpack.prebuild` job is enqueued covering all branch tips. Implemented via `_enqueue_repair_prebuild(session, repo_id)` helper (calls `enqueue_job` with `tip_commit_ids` = all live branch heads). Idempotent. Wired into all three repair functions after `invalidate_fetch_mpack_cache` and before `session.commit()`. - [x] `EX_02` — **Load-bearing E2E** `test_EX_02_clone_after_repair_reflects_repair` (`@pytest.mark.wire`): seeds a fresh cache row (simulates post-push HIT state), first clone HITs mpack_stale, repair-commit runs (invalidates cache + enqueues prebuild), second clone raises `MPackNotReadyError` (MISS — stale row gone), corrected commit row in DB confirmed, prebuild job enqueued confirmed. Direct regression test for the gabriel/muse rc10 incident. - [x] `EX_03` — `test_EX_03_invalidation_is_logged`: `caplog` confirms `invalidate_fetch_mpack_cache` emits a log line naming `repo=`, deletion count, and scope after a repair trigger. **Exit gate:** EX_02 green (EX_01/EX_03 green or explicitly waived with reason). ✅ COMPLETE ### Phase 5 — Suite wiring, regression sweep, close-out - [x] `RG_01` — `muse -C ~/ecosystem/musehub code test --json` on the changed files; then run the adjacent corpus one file at a time and assert green: `tests/test_fetch_mpack_prebuild.py`, `tests/test_fetch_mpack_cleanup.py`, `tests/test_repair_commit_endpoint.py`, `tests/test_mwp3_job_idempotency.py`, `tests/test_mwp4_prebuild_ordering.py`. Zero regressions. ✅ 54 adjacent tests GREEN. - [x] `RG_02` — Confirm `tests/test_mwp8_cache_invalidation.py` carries the right markers and is collected (`python3 -m pytest tests/test_mwp8_cache_invalidation.py -q --collect-only`). If `wire`-marked, confirm it appears under `pytest -m wire`. ✅ 16 tests collected; EX_02 confirmed under `pytest -m wire`. - [x] `RG_03` — **Update master tracker [muse#58](https://staging.musehub.ai/gabriel/muse/issues/58):** added RC-8 root-cause paragraph, ticked MWP-8 acceptance line, marked MWP-8 ✅ COMPLETE. ✅ - [x] `RG_04` — Close this issue: posted audit verdict comment (row counts per trigger, EX_02 MISS proof), then `muse -C ~/ecosystem/musehub hub issue update 111 --status closed`. ✅ - [x] `RG_05` — All work on `task/mwp-8-cache-invalidation`; handed back to gabriel for dev/main merge. ✅ **Exit gate:** all MWP-8 tests green and wired; muse#58 updated; this issue closed. --- ## Branching & commits ```bash muse -C ~/ecosystem/musehub checkout dev muse -C ~/ecosystem/musehub checkout -b task/mwp-8-cache-invalidation \ --intent "bust fetch-mpack cache on repair + force-push (RC-8)" --resumable # one commit per phase minimum; test-first within each muse -C ~/ecosystem/musehub commit -m "test(mwp8/phase0): audit — repair+force-push leave stale cache (AU_01..AU_05)" \ --agent-id claude-code --model-id claude-sonnet-4-6 --sign ``` **Do not merge to `dev`/`main`** — that is gabriel's call. --- ## Acceptance criteria (whole-ticket gate) - [x] `AU_01..AU_05` codify the stale-HIT gap for all three repairs + force-push, with an explicit audit verdict. - [x] `invalidate_fetch_mpack_cache` exists, is unit-tested (`IV_01..IV_06`), deletes regardless of expiry, is cross-repo isolated, tip-scopable, best-effort on R2, and does not commit. - [x] All three repair endpoints bust the repo cache in the same transaction as the repair (`RP_01..RP_07`); a failed repair rolls the invalidation back. - [x] Force-push busts the old+new tip rows (`FP_01..FP_02`); a fast-forward push does **not** invalidate siblings (`FP_03`); the post-push prebuild repopulates (`FP_04`). - [x] End-to-end: a clone issued after a repair reflects the repaired bytes, never a stale HIT (`EX_02`). - [x] Each invalidation is logged with repo + trigger + rows-deleted (`EX_03`). - [x] Zero regressions across the prebuild / fetch / gc / repair corpus (`RG_01`). ✅ 54 adjacent tests GREEN. - [x] muse#58's MWP-8 line ticked; MWP-8 marked ✅ COMPLETE. ✅ --- ## Design decisions - **D1 — Repair invalidates repo-wide; force-push invalidates tip-scoped.** A content-addressed object/snapshot/commit may be reachable from any number of tips and there is no reverse object→tip index, so repo-wide is the only *correct* scope for repair; repairs are rare so cost is irrelevant. A force-push mutates exactly one branch, so tip-scoped deletion preserves untouched sibling caches and avoids a per-push perf regression. - **D2 — Invalidation never commits; the caller owns the transaction.** The delete must be atomic with the repair / branch-advance it accompanies. If it committed independently, a crash between the two re-opens the gap, or a rolled-back repair could still nuke the cache. `RP_07` enforces this. - **D3 — Normal fast-forward push is left exactly as-is.** It already gets a correct fresh row via the existing `enqueue_push_intel` → `process_fetch_mpack_prebuild_job`; sibling tips are unchanged and must keep their valid cache rows. Invalidation fires only on the two *correctness-threatening* triggers (repair, force). - **D4 — Correctness via deletion, speed via prebuild.** Deletion alone is sufficient for correctness (next clone MISS-rebuilds from repaired rows). Enqueuing a prebuild (EX_01) is a latency optimization, explicitly optional, never a correctness dependency. ## Out of scope - Any change to the mpack binary format, the presign model, or the `muse` client. - The expired-only `gc_fetch_mpack_cache` path (unchanged; complementary). - A reverse object→tip reachability index (rejected — repo-wide repair invalidation makes it unnecessary; see D1). - Reviving the disabled intel jobs (`intel.*` are deliberately no-oped in the worker; only `mpack.index` + `fetch.mpack.prebuild` run — do not touch them). - Cross-repo / fork cache sharing semantics. --- ## Code Intelligence Appendix — symbol anchors Read these **before** editing. Every anchor is a `muse code` target (`muse -C ~/ecosystem/musehub code cat "" --json`). ### Primary edit sites | Symbol / anchor | Role | |---|---| | `musehub/services/musehub_gc.py::gc_fetch_mpack_cache` (`:284`) | Pattern to clone for the new primitive (delete + best-effort R2). **Add `invalidate_fetch_mpack_cache` beside it.** | | `musehub/services/musehub_wire_push.py::wire_repair_object` (`:108`) | Insert repo-wide invalidation before `session.commit()` (`:150`). | | `musehub/services/musehub_wire_push.py::wire_repair_snapshot` (`:174`) | Insert before `:224`. | | `musehub/services/musehub_wire_push.py::wire_repair_commit` (`:232`) | Insert before `:315`. | | `musehub/services/musehub_wire_push.py::wire_push_unpack_mpack` (`:508`) | Force-push branch (`force` param `:517`; pointer rewrite `:1037-1056`; commit `:1064`; enqueue `:1071`). Insert tip-scoped invalidation on force. | ### Reference / dependency reads | Symbol / anchor | Why | |---|---| | `musehub/db/musehub_repo_models.py::MusehubFetchMPackCache` (`:741`) | The cache row shape, unique constraint, `cache_id` derivation. | | `musehub/services/musehub_wire_fetch.py::wire_fetch_mpack` (`:414`) | The HIT path that serves stale bytes (`:447-470`); MISS-write (`:864`). | | `musehub/services/musehub_wire_fetch.py::process_fetch_mpack_prebuild_job` (`:1034`) | The "skip if cached" guard (`:1106-1114`) that defeats self-healing; cache-row upsert + `cache_id` formula (`:1148`). | | `musehub/services/musehub_jobs.py::enqueue_push_intel` (`:116`) | Prebuild-payload assembly; reuse for the optional EX_01 enqueue. | | `musehub/api/routes/wire.py` `repair_object/snapshot/commit` (`:373/:421/:475`) | Route → service wiring; `caller_id = claims.handle`; owner/collaborator auth. | ### Blast-radius checks (run before/after editing) ```bash muse -C ~/ecosystem/musehub code impact "musehub/services/musehub_wire_push.py::wire_push_unpack_mpack" --json muse -C ~/ecosystem/musehub code impact "musehub/services/musehub_wire_fetch.py::wire_fetch_mpack" --json muse -C ~/ecosystem/musehub code impact "musehub/services/musehub_gc.py::gc_fetch_mpack_cache" --json muse -C ~/ecosystem/musehub code deps "musehub/services/musehub_wire_push.py" --json ``` ### Existing tests to read (don't reinvent fixtures) | File | Reuse | |---|---| | `tests/test_fetch_mpack_prebuild.py` | `db_session`, `_insert_job`, FMC_07/08/13/14/20 cache-row + prebuild patterns. | | `tests/test_fetch_mpack_cleanup.py` | `_insert_cache_row` (`:48`), `_remaining_mpack_ids` (`:71`), `test_bc2` swallow-failure pattern (`:108`). | | `tests/test_repair_commit_endpoint.py` | `_capture_corrupt_repair` (`:98`), owner-auth repair call (`caller_id="gabriel"`). | | `tests/test_mwp3_job_idempotency.py`, `tests/test_mwp4_prebuild_ordering.py` | Job-enqueue + prebuild-ordering invariants to keep green. | ### Test run discipline ```bash muse -C ~/ecosystem/musehub code test --json # only tests for changed files python3 -m pytest tests/test_mwp8_cache_invalidation.py -q --tb=short python3 -m pytest tests/test_fetch_mpack_prebuild.py -q --tb=short # NEVER: python3 -m pytest tests/ (whole-suite run is forbidden) ```