gabriel / musehub public
mwp-8-cache-invalidation.md markdown
304 lines 28.7 KB
Raw
sha256:0e5174da777684df43b2db71f282079030ef28bacffb93e19c29ee712b2a8bc4 test(mwp8/phase0): audit — repair+force-push leave stale ca… Sonnet 4.6 21 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).

  • [ ] 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).
  • [ ] AU_02Repair-snapshot stale-HIT repro. Same shape via wire_repair_snapshot.
  • [ ] 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.)
  • [ ] 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.
  • [ ] 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).

Phase 1 — The invalidation primitive

  • [ ] 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).
  • [ ] 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.
  • [ ] IV_03test_IV_invalidate_tip_scoped_leaves_siblings: rows for tips [X, Y, Z]; invalidate(..., tips=[X]) deletes only X; Y,Z survive.
  • [ ] 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).
  • [ ] 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.
  • [ ] 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.

Phase 2 — Wire repair → invalidation (make AU_01..AU_03 go green)

  • [ ] 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).
  • [ ] RP_02 — Same in wire_repair_snapshot (before :224).
  • [ ] RP_03 — Same in wire_repair_commit (before :315).
  • [ ] 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.
  • [ ] 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).
  • [ ] 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.

Phase 3 — Wire force-push → invalidation (make AU_04 go green); protect the fast-forward path

  • [ ] FP_01 — In wire_push_unpack_mpack, after the branch pointer rewrite (musehub/services/musehub_wire_push.py:1056) and before session.commit() (:1064), add: if force and current_head and current_head != tip: await invalidate_fetch_mpack_cache(session, repo_id, tips=[current_head, tip]).
  • [ ] FP_02test_FP_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 is now a MISS.
  • [ ] FP_03No-op on fast-forward test_FP_normal_push_does_not_invalidate: a non-force push that fast-forwards main must leave a sibling branch's (dev) cache row intact and must NOT delete main's pre-push row beyond what the normal flow does. Asserts Goal #3 — the common path pays zero invalidation cost. (Construct via force=False; sibling dev tip cached; push main ff; assert dev row survives.)
  • [ ] FP_04Prebuild repopulates test_FP_force_push_then_prebuild_rebuilds_new_tip: after FP force-push + running process_fetch_mpack_prebuild_job, wire_fetch_mpack(want=[T_new], have=[]) HITs a freshly built mpack (proves the enqueue→prebuild path closes the loop, not just deletes).
  • [ ] FP_05muse code impact "musehub/services/musehub_wire_push.py::wire_push_unpack_mpack" --json — confirm blast radius; run any impacted push tests surfaced.

Exit gate: FP_01..FP_05 green.

Phase 4 — Optional prebuild-on-repair + end-to-end clone-after-repair

  • [ ] EX_01 (optional, recommended) — After invalidation in each repair fn, enqueue a fresh prebuild so the next clone is a fast HIT not a slow MISS-build. Reuse enqueue_push_intel(session, repo_id, head=<each live branch tip>, ...) or a narrower fetch.mpack.prebuild-only enqueue. Test test_EX_repair_enqueues_prebuild: a fetch.mpack.prebuild job exists for repo_id after repair. If you skip this, document why (correctness holds without it; the next clone just pays one synchronous build).
  • [ ] EX_02Load-bearing E2E test_EX_clone_after_repair_reflects_repair (mark @pytest.mark.wire): push a repo (prebuild → cache HIT on first clone), repair a commit so its served identity changes, clone again, assert the second clone's served mpack reflects the repaired row (different mpack_id than the first clone, or a MISS that rebuilds to the corrected bytes). This is the regression that would have caught the gabriel/muse incident. Subprocess/live-server variant only if the in-process db_session path cannot exercise backend.presign_mpack_get; otherwise keep it in-process.
  • [ ] EX_03Audit log line test_EX_invalidation_is_logged: each trigger emits a logger.info/warning naming repo, trigger (repair/force-push), and rows deleted — so an operator can see the bust happened (the historical failure was invisible). Assert via caplog.

Exit gate: EX_02 green (EX_01/EX_03 green or explicitly waived with reason).

Phase 5 — Suite wiring, regression sweep, close-out

  • [ ] 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.
  • [ ] 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.
  • [ ] RG_03Update master tracker muse#58: add the RC-8 root-cause paragraph if absent, tick the MWP-8 acceptance line, and mark MWP-8 ✅ COMPLETE in the sub-tickets list. (Read the local source ~/ecosystem/muse/docs/issues/mwp-mvp-master.md first, edit locally, push --body-file — never cowboy-push.)
  • [ ] RG_04 — Close this issue: post a comment with the audit verdict → fix evidence (row-count before/after per trigger, the EX_02 mpack_id divergence), then muse -C ~/ecosystem/musehub hub issue update <this#> --status closed --hub https://staging.musehub.ai.
  • [ ] RG_05 — Merge prep: ensure all work is on the feature branch; stop there and hand back to gabriel for the dev/main merge (do not self-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)

  • [ ] AU_01..AU_05 codify the stale-HIT gap for all three repairs + force-push, with an explicit audit verdict.
  • [ ] 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.
  • [ ] 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.
  • [ ] 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).
  • [ ] End-to-end: a clone issued after a repair reflects the repaired bytes, never a stale HIT (EX_02).
  • [ ] Each invalidation is logged with repo + trigger + rows-deleted (EX_03).
  • [ ] Zero regressions across the prebuild / fetch / gc / repair corpus (RG_01).
  • [ ] 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 1 commit
sha256:0e5174da777684df43b2db71f282079030ef28bacffb93e19c29ee712b2a8bc4 test(mwp8/phase0): audit — repair+force-push leave stale ca… Sonnet 4.6 21 days ago