Push 422 Root Cause — Have-Anchor Object Gap
Symptom
muse push staging dev returns:
push rejected: 824 snapshot object(s) not in push bundle or storage —
missing: sha256:00145bb1bd38, sha256:002028f94a3b, sha256:0059ccbc0d19…
The same 824 OIDs are missing on every push attempt. The error is deterministic.
Smoking Gun
The push log says:
[stream] walk: … (683 commits, 4789 objects)
[stream] loaded 3965 objects …
4789 − 3965 = 824. Exactly the number of missing objects. The have
computation excluded 824 objects from the wire bundle, and the server reports
exactly those 824 as absent from its DB.
Root Cause
The push command computes have from the target remote's live branch heads
(GET /refs), filtered to commits that exist locally:
candidate_have = list(remote_branch_heads.values())
have = [c for c in candidate_have
if c != local_head and _is_valid_commit_id(c) and commit_exists(root, c)]
If staging has a main branch whose head commit exists in the local repo, it
becomes a have anchor. The object walk stops at main's head, excluding the
824 objects reachable from main but not from the new dev commits.
The integrity check on the final batch requires ALL snapshot-referenced objects
to be in the server DB. Those 824 excluded objects are referenced by dev
snapshot manifests (shared with main), but main was never successfully
pushed to staging — so the server DB has no rows for them.
Invariant
MB-6: A push of branch B that excludes objects via `have=[A_head]`
SUCCEEDS only if the server already has those objects in its DB
(from a prior push of branch A or any other path).
If the server does NOT have those objects, the integrity check
correctly rejects the push with 422.
This invariant is already enforced by the server (see MB-3). The gap is on the client side: the walk assumes the server has branch-A objects, but branch A may never have been pushed to that remote.
Immediate Fix
Push main to staging before pushing dev. This puts the 824 shared
objects into the server DB, so the next dev push finds them.
muse push staging main
muse push staging dev
Long-Term Fix
The have anchor for object exclusion should only include remote branch heads
whose objects are verifiably on the server — i.e., commits that were previously
pushed to THIS specific remote, not just commits that exist locally and whose
ID happens to match a remote branch head.
Options:
- Track per-remote "object-coverage" in local tracking refs (expensive)
- On first push to a remote or force push, widen the bundle to include all
ancestors regardless of
haveanchors (safe default) - Server: expose an object-presence check endpoint so the client can verify before excluding objects from the bundle
Timeline
| Session | What happened |
|---|---|
| Previous session | SSL BAD_RECORD_MAC on MWP POST after 216 R2 PUTs — retry loop not firing (wrong exception type: _httpx.TransportError vs muse TransportError) |
| Previous session | Fixed retry exception type; added 0.5s cooldown after R2 phase |
| This session | Scaled cooldown to min(10s, n_large_objects * 0.05s); bumped retries 3→5 |
| This session | presign_and_register_objects() written + 4 TDD tests (PI-1–PI-4) |
| This session | MB-1–MB-5 written + passing — confirms non-final batch objects land in DB |
| This session | Root cause identified: 4789−3965=824; main never pushed to staging |
Tests
| ID | File | What it proves |
|---|---|---|
| PI-1 | test_wire_presign_integrity.py |
presign_and_register_objects inserts storage_uri=pending row |
| PI-2 | test_wire_presign_integrity.py |
push succeeds when snapshot refs presigned (pre-registered) object |
| PI-3 | test_wire_presign_integrity.py |
presign_and_register_objects is idempotent |
| PI-4 | test_wire_presign_integrity.py |
creates musehub_object_refs row |
| MB-1 | test_wire_multibatch_push.py |
non-final batch objects land in DB after request |
| MB-2 | test_wire_multibatch_push.py |
final batch succeeds when earlier batches stored objects |
| MB-3 | test_wire_multibatch_push.py |
final batch fails when referenced objects never sent (negative baseline) |
| MB-4 | test_wire_multibatch_push.py |
presigned objects pass final batch integrity check |
| MB-5 | test_wire_multibatch_push.py |
rolled-back earlier batch causes final failure (causal proof) |
| MB-6 | test_wire_multibatch_push.py |
final batch fails when have-excluded objects are absent from DB |
| MB-7 | test_wire_multibatch_push.py |
final batch succeeds when have-excluded objects are pre-pushed (push main → dev) |