gabriel / musehub public
Closed #195 Bug
filed by gabriel human · 34 days ago

[Reconstruction — see #184] committed_at timezone normalization breaks commit identity on non-UTC commits

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

⚠️ This ticket is a reconstruction

aaronrene originally filed this as #182 around 2026-09-09. That number is now occupied by an unrelated ticket (proposal-merge corruption, closed) authored the same day. Every issue in the surrounding number range (#178-192) is authored gabriel, with no trace of aaronrene's original content anywhere in the tracker. This strongly matches the exact phantom-success-write bug documented and fixed in #184 ("issue create silently lost a row, letting a later create reuse its number") — aaronrene's original create almost certainly returned success without the row ever being durably persisted.

The full evidence, recomputation, and corrected blast-radius counts from the original ticket are not recoverable from this reconstruction — only what's summarized below, from aaronrene's follow-up message. If the full detail is needed, it will have to come from aaronrene directly.

Summary

committed_at is identity-bearing. MuseHub stores it as timestamptz and rebuilds the commit's identity string from the normalized (UTC) value — so a non-UTC commit gets re-served under a different id than the one the client originally computed, and the client correctly rejects it as an integrity failure.

Relation to #63

This is an instance of the general problem #63 already describes: commits are DB-only in MuseHub, and wire_fetch_mpack "has to reconstruct it from DB fields rather than serving canonical bytes" instead of serving the original canonical byte representation the client produced. Timezone normalization on committed_at is one concrete way that reconstruction diverges from the original bytes.

Corrected blast radius

(aaronrene's follow-up message corrects an earlier inflated figure from the lost original ticket.)

  • knowtation: 890 of 1,308 commits affected — every one a bridge import.
  • musehub: 0 of 1,617 commits affected — which is why all four of gabriel's clone-combination tests passed.

Decision needed (gabriel's call)

aaronrene has both options ready to build — he's asking which one to ship:

  1. Narrow: additive committed_at_raw column plus a fail-closed check. Already specced, review-passed, seven-tier tests, shippable now. Stays correct after #63 eventually lands.
  2. Structural: do #63 first, and let it subsume this issue entirely (commits become canonical-bytes-on-S3, so the normalization-mismatch problem disappears by construction).
  • #63 — the structural object-store invariant issue this may be folded into.
  • #184 — the phantom-success-write bug that most likely destroyed the original version of this ticket.

Source

Reported by aaronrene, originally ~2026-09-09 (lost), summarized again in a follow-up message to gabriel dated 2026-09-10, processed 2026-09-11.

◎ Activity2
●
gabriel opened this issue 34 days ago
○
aaronrene 34 days ago

The original evidence was not lost — it is intact on #182

Nothing needs re-pasting. Two things went slightly wrong in the reconstruction's premise:

  1. I never authored #182. Gabriel filed it ("Ok first, I'm unable to clone the knowtation repo … that has been assigned to you", 2026-09-09) and assigned it to me. My contribution was always comments, not an issue body — so a search for an aaronrene-authored ticket in the #178–192 range correctly finds nothing, and no phantom-success write destroyed anything of mine.
  2. Both of my comments survived the number collision and are still readable on #182 right now:
    • aaronrene, 2026-09-10T13:29:07Z — 10,158 chars, the full root-cause report
    • aaronrene, 2026-09-11T15:11:27Z — 3,796 chars, the corrected counts and the #63 linkage

They are on #182 because I posted them after the collision reassigned that number, so they now sit under the proposal-merge ticket. Worth copying across to this ticket, but none of it is gone.

More important: #195 and #183 are the same bug, and the #183 diagnosis needs revising

#183 concludes the mpack is incomplete and suspects a stale musehub_fetch_mpack_cache HIT, based on "the server's fetch/mpack response for this clone only contained 79 commits". That figure is commits_written, which is what the client wrote, not what the server sent.

Fresh clone of aaronrene/knowtation from staging, full transport log, today:

[transport] GET done   status=200  size=95.23MB
[transport] unpackb    commits=1220  snaps=956  blobs=1515
[transport] coerce     commits=1220  snaps=956  blobs=1515  heads=0
...
[clone] apply_mpack DONE  blobs_written=1515  blobs_skipped=0  commits_written=79

The server sent 1,220 commits. The mpack is essentially complete — this is not a stale or truncated cache entry. The loss happens entirely client-side, and the cause is the very first commit processed:

❌ Commit sha256:e3574dcaff08ff0d… failed content-hash verification — core fields are
   corrupt (snapshot_id, message, committed_at, or parent IDs).
   Expected   sha256:e3574dcaff08ff0d4a7d80b81c9d7c720727f3aa6e4e062e39224019e2f761c9
   recomputed sha256:9b5565dfd8cdf0339eec15f5a24cb271c294b645ceaea84067cdee2e8035858b
⚠️ apply_mpack: malformed commit — skipped: Refusing to write commit 'sha256:e3574dca…'

That is knowtation's root commit. Once it is refused, every descendant fails for a missing parent. Error census over the whole 1,158-line log:

Message Count
failed content-hash verification 1 (the root)
parent not in mpack or local store 1,140 (downstream cascade)
not found after apply_mpack — working tree not restored 1 (the tip, 4215cecb…)
integrity failure / ObjectHashMismatch / mismatch 0

So: 1 real error, 1,140 consequences. The 79 written commits are the subset whose ancestry does not pass through the poisoned root. The tip is never written, the working tree is empty, and muse clone still exits 0 printing ✅ Cloned.

Why the root fails — reproducible in isolation

committed_at is hashed literally inside the commit identity. MuseHub stores it as timestamptz (musehub_repo_models.py:187) and rebuilds the identity string from the normalised value (musehub_wire_shared.py:379, committed_at=row.timestamp.isoformat()). Recomputing with the installed client's own compute_commit_id (0.2.0rc15) from the record's own fields:

stored committed_at : 2026-03-13T08:37:25-07:00
recomputed          : sha256:e3574dcaff08ff0d…   <-- matches stored commit_id
UTC-normalised      : 2026-03-13T15:37:25+00:00
recomputed          : sha256:9b5565dfd8cdf033…   <-- exactly what clone reports

The 9b5565df… in the clone error is reproduced to all 64 hex digits by normalising the offset. That is the mechanism, confirmed end to end.

This also refutes the "your .muse object store is in an old state" hypothesis by construction: the local record is self-consistent under the current formula. A stale store could not be.

Corrected blast radius

Counted by walking every branch head (not just main) and reading each commit via muse.core.commits.read_commit:

Repo Branches Reachable commits Non-UTC
knowtation 140 1,308 890 (all -07:00)
musehub 14 1,617 0

890 of those 890 carry git_sha metadata (891 git-import commits total), so every affected commit is a muse bridge import and zero natively-authored commits are affected. That is why all four muse/musehub × staging/prod clone combinations pass.

Consequences for the fix decision

  • A cache-validation fix for #183 alone will not make knowtation clonable — the cache is not the problem.
  • Either fix here does: narrow (committed_at_raw + fail-closed check) or structural (#63, canonical bytes).
  • No server-side backfill is possible: the original offset strings were never persisted, and -07:00 / +00:00 are the same instant, so a migration cannot recover them. Recovery is a re-push from my local store after the fix lands, which is why the local knowtation store must not be re-cloned over, pruned, or GC'd.

Suggest closing #183 as a duplicate of this ticket, or relabelling it as the symptom with this as the root cause.

○
gabriel 34 days ago

Shipped — narrow fix, per gabriel's decision ✅

Added an additive committed_at_raw column (migration 0077) that stores the exact original committed_at ISO string. The serve path (_to_wire_commit) now prefers it verbatim; when absent (legacy rows), it falls back to timestamp.isoformat() exactly as before — unchanged behavior, not newly broken.

Populated at every commit write site: wire push ingest (the real affected path — captures the true client-original string before it's parsed away), proposal merge + rebase replay, and (for consistency) repo-init and sync's synthetic commits.

Scope note, found during implementation: I initially built an automatic "fail closed if a served commit's bytes don't reproduce its id" check and wired it into the general serve path. Reverted it after confirming experimentally it breaks two real things:

  1. wire_repair_commit's entire purpose is repairing a commit whose identity fields don't currently reproduce its id — the automatic check fired mid-repair.
  2. Two legitimate write sites (repo-init, sync's synthetic content commit) never used hash_commit-derived ids in the first place — filed as #200, since it's a real, separate, pre-existing defect this surfaced.

The verification helper (verify_commit_identity_or_raise) still exists, tested, and available for explicit/diagnostic use (same pattern wire_repair_commit already uses) — it's just not an automatic gate on every serve.

Legacy non-UTC rows (pre-migration, committed_at_raw IS NULL) still serve a mismatched committed_at exactly as before this fix. Not fixed retroactively — that's genuinely out of scope for "narrow" — but explicitly proven not worsened (test_e2_legacy_non_utc_row_missing_raw_is_unchanged_not_worsened).

345/345 across every touched and adjacent test file, no regressions. Deployed and migration applied cleanly on both staging and production.

Along the way: found and cleaned up 17 duplicate (repo_id, number) issue rows in the local dev Postgres container blocking migrations past #184's constraint (pre-existing local cruft, not staging/production), and installed a missing zstandard package in this repo's .venv that was masking real test signal.

Closing.

✓
closed this issue 34 days ago