gabriel / musehub public
mwp-8-cache-invalidation.md markdown
304 lines 28.0 KB
Raw
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago

MWP-8 [musehub] — Cache-invalidation audit: bust musehub_fetch_mpack_cache on repair and force-push

Master tracker: muse#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 <command> 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 (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:

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=[]:

# 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):

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_01Repair-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_02Repair-snapshot stale-HIT repro. Same shape via wire_repair_snapshot.
  • [x] AU_03Repair-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_04Force-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_05Verdict 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_01test_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_02test_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_03test_IV_invalidate_tip_scoped_leaves_siblings: rows for tips [X, Y, Z]; invalidate(..., tips=[X]) deletes only X; Y,Z survive.
  • [x] IV_04test_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_05test_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_06test_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_07Atomicity 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_02test_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_03test_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_04test_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_01test_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_02Load-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_03test_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_01muse -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_03Update master tracker muse#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

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_intelprocess_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 "<anchor>" --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)

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

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)
File History 4 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b Merge branch 'fix/wire-push-external-parent-manifest' into dev Human 8 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 8 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 11 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 14 days ago