MuseHub server runs incompatible compute_commit_id formula vs published muse clients
TL;DR
musehub/muse_cli/snapshot.py:compute_commit_id is a stale vendored copy. Its formula diverges from muse 0.2.0rc7's muse.core.snapshot.compute_commit_id. Any commit created on the server (notably merge commits from proposal merges) has a hash that no current muse client can verify — muse pull aborts with a content-hash mismatch.
Reproducer
muse push staging feat/foo(succeeds)muse hub proposal create --to-branch main(succeeds)muse hub proposal merge sha256:<id>(succeeds — server creates merge commit with old formula)muse pull staging mainfails:Pull aborted: commit sha256:<hash> is not readable from the local store (corrupt or hash mismatch).
Root cause — exact diff
musehub/muse_cli/snapshot.py:compute_commit_id (server, ~line 31):
def compute_commit_id(parent_ids, snapshot_id, message, committed_at_iso) -> str:
parts = [
"|".join(sorted(parent_ids)),
snapshot_id,
message,
committed_at_iso,
]
payload = "|".join(parts).encode()
return hashlib.sha256(payload).hexdigest()
muse 0.2.0rc7/muse/core/snapshot.py:compute_commit_id (client):
def compute_commit_id(parent_ids, snapshot_id, message, committed_at_iso,
author="", signer_public_key="") -> str:
parts = [
_SEP.join(sorted(split_id(p)[1] for p in parent_ids)),
split_id(snapshot_id)[1],
message,
committed_at_iso,
author,
signer_public_key,
]
payload = _SEP.join(parts).encode()
return blob_id(payload)
Differences:
- Field count: 4 (server) vs 6 (client; adds
author,signer_public_key) - Separator:
"|"(server) vs\x00(client_SEP) - ID prefix: server hashes ids with
"sha256:"prefix; client strips it - Return: raw hex (server) vs prefixed
"sha256:<hex>"(client)
Note on the existing migration framework
muse/core/migrate.py was clearly designed to handle exactly this kind of formula change: it walks all local commits, recomputes their hashes with compute_commit_id, and rewrites the commit graph + refs accordingly. However, the migration is client-side and operates on already-stored commits.
This bug surfaces before migrate can help: the merge commit's bytes are rejected during pull verification and never reach the local store, so there is no commit object for migrate to rewrite.
Suggested resolution path leveraging the existing framework:
- Update
musehub/muse_cli/snapshot.py:compute_commit_idto match the canonicalmuse.core.snapshot.compute_commit_id(so future server-created commits use the new formula). - Optionally: run a one-time server-side migration that recomputes IDs for existing legacy server-created commits (mirrors what
migrate.pydoes, but on the server's store). - Optionally (defense-in-depth): add a
compute_commit_id_legacyfunction tomuse/core/snapshot.pyand have_verify_commit_idfall back to it on mismatch, queueing the commit for next-migrate. This would have caught and self-healed this incident on the client side.
Local-state inconsistency (secondary bug)
After the failed pull, refs/remotes/staging/main was advanced to the unverifiable commit ID, but the commit object was not persisted. Subsequent muse fetch staging reports staging/main is already up to date despite the object being absent. Suggested: do not advance the remote-tracking ref when the corresponding commit object failed verification, and improve the error message to distinguish "object missing" from "object corrupted".
Environment
- Client:
muse 0.2.0rc7(installed viahttps://staging.musehub.ai/install.sh) - Python: 3.14.4
- Server:
staging.musehub.ai(advertised asMUSE_VERSION="0.2.0rc7"by the installer) - Affected repo:
aaronrene/knowtation(sha256:aec36dd88481540a12c2dd82fdc49bba647ff66e229eb5926f731c060e1d73b3) - Affected merge commit:
sha256:f0b2ac2a663f8b9e9a75f5ae7482b18cde95bf9b142f0f2f3f82a87924cd05ce
Available for testing
I'm a contributor and happy to test patches and submit a PR.
Fix confirmed working end-to-end on staging.
Test sequence
- Server reset
maintosha256:b17a01728c5e(parent of the broken merge). - Re-pushed
feat/knowtation-architecture-decision-2026-05-07to staging (it had been deleted by the prior auto-cleanup; objects were already present, only the branch ref needed restoring). - Merged the reopened proposal (
sha256:1f4e1b4d24f9...) — server created a new merge commit atsha256:8514304e16d7f68db83b5183c01d4b2dd75e976a1cad9c39afa1334debafb110, distinct from the original brokensha256:f0b2ac2a663f8b9e9a75f5ae7482b18cde95bf9b142f0f2f3f82a87924cd05ce, confirming the correctedcompute_commit_idformula is in use server-side. muse pull staging mainsucceeded:Fetched 1 commit(s), 0 new object(s) from staging/main (sha256:8514304e16d7...) Fast-forward main to sha256:8514304e16d7... (staging/main)- Verified working tree contains the expected architecture decision documents at the canonical paths.
Closing as resolved
The defense-in-depth suggestion (compute_commit_id_legacy fallback in _verify_commit_id) remains a valid follow-up if the team thinks it's worth the surface area — happy to draft that PR if useful, or leave as a note for future formula bumps.
The local-state-inconsistency secondary bug (advancing refs/remotes/staging/main even when the underlying commit object failed verification) was implicitly resolved here by the --prune fetch we did before re-merging, but the underlying behavior is still worth a separate fix if the team wants stricter atomicity between ref advancement and object persistence.
Thank you @gabriel for the fast turnaround on the server-side fix.
Fixed in
musehubv0.2.0rc8andmusedev.Root cause (confirmed)
The stale vendored copy at
musehub/muse_cli/snapshot.py::compute_commit_idused a 4-field|-separated formula and returned a raw hex string. The canonical client formula atmuse/core/snapshot.py::compute_commit_iduses 6 fields (author,signer_public_key), a\x00separator, stripssha256:prefixes before hashing, and returns a prefixed ID. Every server-created merge commit had a hash the client could never verify.The secondary bug was that
merge_proposal()inmusehub/services/musehub_proposals.py::merge_proposalcomputed the commit ID withauthor=""(the default) but storedauthor="musehub-server"on the row._verify_commit_idinmuse/core/store.py::_verify_commit_idre-derives the hash from the stored author, so even after syncing the formula the stored author would diverge.What landed
musehubdev[sha256:ad13b3e9b1e9]musehub/services/musehub_proposals.py::merge_proposal—merger_handlethreaded from the authenticated token; used as the single source of truth for bothcompute_commit_id(author=merger_handle)andMusehubCommit(author=merger_handle)so hash and stored field always agree.musehub/api/routes/musehub/proposals.py— passesmerger_handle=token.handleat the call site.musehub/muse_cli/snapshot.py::compute_commit_id— now identical to the canonical client formula (6 fields,\x00separator, prefix-stripped IDs, prefixed return value).musedev[sha256:e711dcfd4c3a] (anonymous commits — discovered during investigation)muse/cli/commands/merge.py— readsuser.handleviaget_config_value, passes asauthorto bothcompute_commit_idandCommitRecord(both the strategy-merge and normal-merge paths).muse/cli/commands/revert.py— same pattern.muse/cli/commands/cherry_pick.py— preservestarget.author(original commit's author) so cherry-pick attribution reflects the original writer.muse/cli/commands/rebase.py— squash path usescommits_to_replay[0].author, mirroring git squash semantics.Tests
musehub/tests/test_merge_commit_id_parity.py— P1–P4 (parity unit tests + E2E regression guard). All GREEN.muse/tests/test_anon_commit_bug.py— A1–A4 (merge / revert / cherry-pick / squash-rebase attribution). All GREEN. Tests exercise the real code path (working tree synced to HEAD, no--force).Thanks for the detailed report and the reproducer — the exact diff you provided pointed straight at the divergence.