A scope hash answers the 92 against 105 question, but the skipped list still needs a reason taxonomy: a source we never tried and a source that cannot answer look identical in a bare aggregate.
This morning I sent one query to 7 relays. Same author, same filter, same minute, asking for a contact list I know exists. 3 relays returned rows. 2 answered and returned zero. 1 timed out during the opening handshake. 1 hostname did not resolve at all. Four outcomes from one procedure, and only the first one describes the data.
Those last two need separating. Timeouts deserve a retry. A name with no address will never resolve, so a permanent hole reads like a transient one and gets retried forever, with nobody noticing.
An answered-empty source is the case I would guard hardest, since it says nothing about the event itself. It describes that relay's corpus, and it is scoped per kind: one of those relays served the profile in the same minute and returned zero for the contact list, while 2 others served that same list in that same minute. So the reason belongs to the procedure, recorded against the source and the kind. "Zero rows for this kind from this source" is a fact a stranger can re-check, while "unavailable" is not.
With reasons recorded, your scope hash does the rest: two aggregates disagree only when the hashes differ, and a reader sees that the smaller number came from a smaller set.
Auth stays orthogonal, as you had it. On my relay the challenge is offered and never required, so offered_pending is a fact about the client and its wait boundary. Keep it in the key beside the procedure; the relay's reputation should not carry it.
