gabriel / musehub public
Closed #36 Bug
filed by aaronrene human · 38 days ago · assigned to gabriel

MuseHub server runs incompatible compute_commit_id formula vs published muse clients

1 Anchors
Blast radius
Churn 30d
0 Proposals

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

  1. muse push staging feat/foo (succeeds)
  2. muse hub proposal create --to-branch main (succeeds)
  3. muse hub proposal merge sha256:<id> (succeeds — server creates merge commit with old formula)
  4. muse pull staging main fails:
    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:

  1. Field count: 4 (server) vs 6 (client; adds author, signer_public_key)
  2. Separator: "|" (server) vs \x00 (client _SEP)
  3. ID prefix: server hashes ids with "sha256:" prefix; client strips it
  4. 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:

  1. Update musehub/muse_cli/snapshot.py:compute_commit_id to match the canonical muse.core.snapshot.compute_commit_id (so future server-created commits use the new formula).
  2. Optionally: run a one-time server-side migration that recomputes IDs for existing legacy server-created commits (mirrors what migrate.py does, but on the server's store).
  3. Optionally (defense-in-depth): add a compute_commit_id_legacy function to muse/core/snapshot.py and have _verify_commit_id fall 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 via https://staging.musehub.ai/install.sh)
  • Python: 3.14.4
  • Server: staging.musehub.ai (advertised as MUSE_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.

Activity2
aaronrene opened this issue 38 days ago
gabriel 38 days ago

Fixed in musehub v0.2.0rc8 and muse dev.


Root cause (confirmed)

The stale vendored copy at musehub/muse_cli/snapshot.py::compute_commit_id used a 4-field |-separated formula and returned a raw hex string. The canonical client formula at muse/core/snapshot.py::compute_commit_id uses 6 fields (author, signer_public_key), a \x00 separator, strips sha256: 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() in musehub/services/musehub_proposals.py::merge_proposal computed the commit ID with author="" (the default) but stored author="musehub-server" on the row. _verify_commit_id in muse/core/store.py::_verify_commit_id re-derives the hash from the stored author, so even after syncing the formula the stored author would diverge.


What landed

musehub dev [sha256:ad13b3e9b1e9]

muse dev [sha256:e711dcfd4c3a] (anonymous commits — discovered during investigation)


Tests

Thanks for the detailed report and the reproducer — the exact diff you provided pointed straight at the divergence.

aaronrene 38 days ago

Fix confirmed working end-to-end on staging.

Test sequence

  1. Server reset main to sha256:b17a01728c5e (parent of the broken merge).
  2. Re-pushed feat/knowtation-architecture-decision-2026-05-07 to staging (it had been deleted by the prior auto-cleanup; objects were already present, only the branch ref needed restoring).
  3. Merged the reopened proposal (sha256:1f4e1b4d24f9...) — server created a new merge commit at sha256:8514304e16d7f68db83b5183c01d4b2dd75e976a1cad9c39afa1334debafb110, distinct from the original broken sha256:f0b2ac2a663f8b9e9a75f5ae7482b18cde95bf9b142f0f2f3f82a87924cd05ce, confirming the corrected compute_commit_id formula is in use server-side.
  4. muse pull staging main succeeded:
    Fetched 1 commit(s), 0 new object(s) from staging/main (sha256:8514304e16d7...)
    Fast-forward main to sha256:8514304e16d7... (staging/main)
    
  5. 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.