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). Nomuseclient 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 / depsbefore 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 todev/mainwithout gabriel's explicit permission.- musehub localhost runs uvicorn without
--reload. After editing any musehub Python source you mustdocker restart musehub && sleep 5 && curl -sk https://localhost:1337/healthzbefore the change takes effect. Unit tests againstdb_sessiondo 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:
- 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. - 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 onempack_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
- 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. - 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.
- 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.)
- One reusable invalidation primitive, unit-tested in isolation, used by both triggers.
- Codified as non-skipped regression tests, wired into the existing tier markers and (where the marker applies)
pytest -m wire. - 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 ofwire_repair_object,wire_repair_snapshot,wire_repair_commitimmediately before their existingawait session.commit()(musehub/services/musehub_wire_push.py:150,:224,:315). Optional but recommended (EX_* below): enqueue a freshfetch.mpack.prebuildso 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, whenforce and current_head and current_head != tip, callawait 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 existingawait session.commit()(:1064). The existingenqueue_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 tipT, write a fresh cache row(repo_id, T) → mpack_M. Callwire_repair_objectto change an object reachable fromT. Then callwire_fetch_mpack(want=[T], have=[])and assert it returns a HIT onmpack_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 viawire_repair_snapshot. - [x]
AU_03— Repair-commit stale-HIT repro. Same shape viawire_repair_commit. (Reuse the corrupt-then-repair pattern fromtests/test_repair_commit_endpoint.py:98_capture_corrupt_repair.) - [x]
AU_04— Force-push stale old-tip repro. Seed branchmain → T_oldwith a fresh cache row. Callwire_push_unpack_mpack(branch="main", head_commit_id=T_new, force=True). Assert the(repo_id, T_old)cache row still exists andwire_fetch_mpack(want=[T_old], have=[])still HITs. Documents hazard #1. - [x]
AU_05— Verdict record. Module-docstringAUDIT 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 vsgc_fetch_mpack_cache. Assert a fresh row survivesgc_fetch_mpack_cachebut is removed byinvalidate_fetch_mpack_cache. - [x]
IV_03—test_IV_invalidate_tip_scoped_leaves_siblings: rows for tips[X, Y, Z];invalidate(..., tips=[X])deletes onlyX;Y,Zsurvive. - [x]
IV_04—test_IV_invalidate_best_effort_r2_delete_swallows_failure: patchbackend.deleteto raise; assert rows still deleted and no exception propagates (mirrortests/test_fetch_mpack_cleanup.py:108test_bc2_gc_delete_failure_is_swallowed). - [x]
IV_05—test_IV_invalidate_does_not_commit: assert the function issues noCOMMIT(rows are gone within the open transaction but asession.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— Insertawait invalidate_fetch_mpack_cache(session, repo_id)before the existing commit inwire_repair_object(musehub/services/musehub_wire_push.py:150). - [x]
RP_02— Same inwire_repair_snapshot(before:224). - [x]
RP_03— Same inwire_repair_commit(before:315). - [x]
RP_04— Convert AU_01 to the post-fix contracttest_RP_repair_object_busts_cache: after repair,wire_fetch_mpack(want=[T], have=[])raisesMPackNotReadyError(MISS,force_build=False) — the cache row is gone. Assert row count forrepo_idis 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 guardtest_RP_repair_rollback_keeps_cache: if the repair'ssession.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— Inwire_push_unpack_mpack, after the branch pointer rewrite, beforesession.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-pushT_old → T_new, both(repo_id, T_old)and(repo_id, T_new)cache rows are gone; a clone ofT_oldraisesMPackNotReadyError. - [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, afetch.mpack.prebuildbackground 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_mpackblast 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, afetch.mpack.prebuildjob is enqueued covering all branch tips. Implemented via_enqueue_repair_prebuild(session, repo_id)helper (callsenqueue_jobwithtip_commit_ids= all live branch heads). Idempotent. Wired into all three repair functions afterinvalidate_fetch_mpack_cacheand beforesession.commit(). - [x]
EX_02— Load-bearing E2Etest_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 raisesMPackNotReadyError(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:caplogconfirmsinvalidate_fetch_mpack_cacheemits a log line namingrepo=, 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 --jsonon 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— Confirmtests/test_mwp8_cache_invalidation.pycarries the right markers and is collected (python3 -m pytest tests/test_mwp8_cache_invalidation.py -q --collect-only). Ifwire-marked, confirm it appears underpytest -m wire. ✅ 16 tests collected; EX_02 confirmed underpytest -m wire. - [x]
RG_03— Update 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), thenmuse -C ~/ecosystem/musehub hub issue update 111 --status closed. ✅ - [x]
RG_05— All work ontask/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_05codify the stale-HIT gap for all three repairs + force-push, with an explicit audit verdict. - [x]
invalidate_fetch_mpack_cacheexists, 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_07enforces 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
museclient. - The expired-only
gc_fetch_mpack_cachepath (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; onlympack.index+fetch.mpack.prebuildrun — 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)