What's wrong
The lock snapshot is keyed by (upstream, repositoryPath, ref) (LockSnapshotKey), and LockRouteHandler.ListAsync fills Ref from the client's ?refspec=. The design doc (docs/superpowers/specs/2026-08-19-locks-subsystem-design.md, "The snapshot") gives the reason:
ref is part of the key because the specification states the locking API's ref property is for authentication only, which means two callers presenting different refs may legitimately receive different answers and must not share an entry.
The requests that actually fill that entry never send the ref, though. UpstreamRequests.BuildLockListRequest(upstreamBase, repositoryPath, cursor, limit, authorization) has no refspec parameter, and neither caller passes key.Ref:
LockListRefresher.RefreshAsync, the page walk that builds the snapshot
LockListRefresher.ProbeAsync, the one-page credential check used to admit a caller to an existing snapshot
So the entry stored under refspec=refs/heads/feature holds upstream's answer to an unscoped GET .../locks, and the credential check behind it is also unscoped. Keying by ref adds nothing here: every ref gets the same content, fetched separately.
Batched lock/unlock is handled correctly. LockFanOut does forward request.Ref in the body it sends upstream.
Why it matters
- Answers depend on cache state. With
Locks:Enabled=false, or whenever the handler falls back to relaying, GET .../locks?refspec=X goes upstream verbatim with the refspec (UpstreamRelay forwards QueryString). With the cache on, the same request gets the unscoped answer. A forge that scopes listing by ref returns different lock sets for the same client request depending on whether a snapshot happened to be warm.
- Authorization isn't checked against the ref the client presented. The design doc treats
ref as an authentication input, and ProbeAsync exists so that upstream stays the authority on whether this client may read these locks. Because the probe omits the ref, a forge that would refuse a credential for refs/heads/feature but accept it unscoped will let that caller through, and it will be served the cached listing.
Against a forge that ignores refspec (probably GitHub), the only effect is duplicate snapshots per ref. Against one that honours it, the cache gives wrong answers.
Suggested fix
- Add a
string? refspec parameter to BuildLockListRequest and append refspec=<escaped> when it's non-null.
- Pass
key.Ref from both RefreshAsync and ProbeAsync.
- Check whether
CredentialAdmission's key should include the ref too, so a credential admitted for one ref isn't treated as admitted for another.
Acceptance: an integration test with StubUpstream that records the query string shows that a cached GET /locks?refspec=refs/heads/main produces upstream list and probe requests that carry refspec=refs%2Fheads%2Fmain, and that a request with no refspec produces upstream requests without one.
Related but separate: #46 covers invalidation after create/unlock using the wrong key. This issue is about what the snapshot is filled with.
What's wrong
The lock snapshot is keyed by
(upstream, repositoryPath, ref)(LockSnapshotKey), andLockRouteHandler.ListAsyncfillsReffrom the client's?refspec=. The design doc (docs/superpowers/specs/2026-08-19-locks-subsystem-design.md, "The snapshot") gives the reason:The requests that actually fill that entry never send the ref, though.
UpstreamRequests.BuildLockListRequest(upstreamBase, repositoryPath, cursor, limit, authorization)has no refspec parameter, and neither caller passeskey.Ref:LockListRefresher.RefreshAsync, the page walk that builds the snapshotLockListRefresher.ProbeAsync, the one-page credential check used to admit a caller to an existing snapshotSo the entry stored under
refspec=refs/heads/featureholds upstream's answer to an unscopedGET .../locks, and the credential check behind it is also unscoped. Keying by ref adds nothing here: every ref gets the same content, fetched separately.Batched lock/unlock is handled correctly.
LockFanOutdoes forwardrequest.Refin the body it sends upstream.Why it matters
Locks:Enabled=false, or whenever the handler falls back to relaying,GET .../locks?refspec=Xgoes upstream verbatim with the refspec (UpstreamRelayforwardsQueryString). With the cache on, the same request gets the unscoped answer. A forge that scopes listing by ref returns different lock sets for the same client request depending on whether a snapshot happened to be warm.refas an authentication input, andProbeAsyncexists so that upstream stays the authority on whether this client may read these locks. Because the probe omits the ref, a forge that would refuse a credential forrefs/heads/featurebut accept it unscoped will let that caller through, and it will be served the cached listing.Against a forge that ignores
refspec(probably GitHub), the only effect is duplicate snapshots per ref. Against one that honours it, the cache gives wrong answers.Suggested fix
string? refspecparameter toBuildLockListRequestand appendrefspec=<escaped>when it's non-null.key.Reffrom bothRefreshAsyncandProbeAsync.CredentialAdmission's key should include the ref too, so a credential admitted for one ref isn't treated as admitted for another.Acceptance: an integration test with
StubUpstreamthat records the query string shows that a cachedGET /locks?refspec=refs/heads/mainproduces upstream list and probe requests that carryrefspec=refs%2Fheads%2Fmain, and that a request with no refspec produces upstream requests without one.Related but separate: #46 covers invalidation after create/unlock using the wrong key. This issue is about what the snapshot is filled with.