Files
pad/internal/server/access_epoch.go
T
xarmian 10a1683b83 fix: the access epoch tracks the live collection set; deleted collections leave the local index; the open-children guard ignores them (BUG-3428 phase 2) (#1826)
* test(server): the epoch tracks the live collection set; the guard ignores deleted collections' children (BUG-3428 phase 2, red)

* fix: the access epoch fingerprints the live collection set; the index and delta drop deleted collections' rows; the open-children guard ignores them (BUG-3428 phase 2)

* test(server): an item door's epoch is never newer than its rows (BUG-3428 phase 2, codex r1, red)

* fix(server): item doors read the live collection set before their rows, so a response's epoch is never newer than its rows (BUG-3428 phase 2, codex r1)

* test(server): a grant-filtered delta's epoch is never newer than its rows (BUG-3428 phase 2, codex r2, red)

* fix(server): the delta door resolves its live grant set before its rows too (BUG-3428 phase 2, codex r2)
2026-10-06 07:59:20 -04:00

128 lines
5.6 KiB
Go

package server
import (
"crypto/sha256"
"encoding/hex"
"sort"
)
// accessEpochUnrestricted is the epoch for a caller with no collection
// filtering at all (owner, editor with "all" access, cookie-session admin).
//
// It is a SENTINEL rather than a hash of the empty set, because the empty set
// is a real and opposite state: a restricted member with zero visible
// collections. Hashing both to the same value would make the widest and the
// narrowest access indistinguishable — a caller whose access was revoked down
// to nothing would carry the same epoch as an owner, and a promotion from
// nothing to everything would signal no change at all.
const accessEpochUnrestricted = "all"
// computeAccessEpoch fingerprints the caller's EFFECTIVE visible resource set
// so a client can tell that the set changed without being told what it is.
//
// IDEA-2898. The delta stream (/items-changes) can only express changes to
// ROWS. A revocation that writes no item — the ordinary shape of revocation —
// therefore produces no signal at all, and a client's warm local index goes on
// serving rows for a collection the caller can no longer see. This value is
// that missing signal: the client compares it against the one it stored and,
// on a change, runs the authoritative resync it already implements.
//
// Inputs are the two lists the item doors already resolve before writing their
// response, so this costs no additional query:
//
// - visibleCollectionIDs: nil means "no filtering" (see the sentinel above);
// a non-nil slice, possibly empty, is the caller's collection-level set.
// - grantedItemIDs: the caller's item-level grants, empty for most callers.
//
// The hash is over a CANONICAL form — both lists sorted, and the two lists
// separated by a byte that cannot occur in an ID — so a set that has not
// changed cannot produce a different epoch just because the store returned its
// rows in a different order. An epoch that flapped on ordering alone would
// trigger a full resync per poll, which is the one failure mode that would
// make this worse than the defect it closes.
//
// The value is derived only from the caller's OWN access and is opaque: it
// names no collection and no item, and two callers with different access
// cannot learn anything about each other from it.
func computeAccessEpoch(visibleCollectionIDs, grantedItemIDs []string) string {
if visibleCollectionIDs == nil {
// Grants only narrow within a filtered set; an unrestricted caller
// cannot also be item-filtered, and the doors never pass both.
return accessEpochUnrestricted
}
h := sha256.New()
writeSortedIDs(h, visibleCollectionIDs)
// Separator: a newline cannot appear in a UUID, so ["a","b"]+[] and
// ["a"]+["b"] cannot collide.
_, _ = h.Write([]byte("\n--\n"))
writeSortedIDs(h, grantedItemIDs)
return hex.EncodeToString(h.Sum(nil))[:16]
}
// effectiveAccessEpoch is the ONE definition of a caller's epoch, used by
// /items-index, /items-changes and the SSE revalidation tick (BUG-3347). It
// hashes the collection set the item doors actually FILTER BY, which is not
// always the nav-visible set:
//
// - With item grants, the doors swap their collection filter to
// guestResourceFilter's fullCollIDs. That set drops a whole-collection
// grant on a soft-deleted collection (BUG-3333), while the nav set keeps
// it so the grant stays revocable. Hashing the nav set left the epoch
// unchanged when that collection was deleted, so a warm client was never
// told to drop its cached rows.
// - Without item grants, the doors filter by the nav set, and so does this.
//
// grantedItemIDs must be the LIVE grant set (guestResourceFilter, not the
// include-deleted variant): see the review-round-2 note on /items-changes.
//
// liveCollectionIDs is the workspace's live collection set (BUG-3428 phase 2,
// lead ruling: option A). Deleting a collection writes no item rows, so the
// epoch must move on its own: an unrestricted caller's epoch is the "all"
// sentinel plus a hash of the live set, and a restricted caller's collection
// set is intersected with it, because the item doors leave a soft-deleted
// collection's rows out for everyone. A collection create moves it too, which
// costs one resync per client and was ruled acceptable.
func effectiveAccessEpoch(visibleCollectionIDs, fullCollIDs, grantedItemIDs, liveCollectionIDs []string) string {
if visibleCollectionIDs == nil && len(grantedItemIDs) == 0 {
h := sha256.New()
writeSortedIDs(h, liveCollectionIDs)
return accessEpochUnrestricted + ":" + hex.EncodeToString(h.Sum(nil))[:16]
}
coll := visibleCollectionIDs
if len(grantedItemIDs) > 0 {
// Item grants make the caller filtered whatever the collection list
// says; nil must not read as the unrestricted sentinel.
coll = fullCollIDs
}
return computeAccessEpoch(intersectIDs(coll, liveCollectionIDs), grantedItemIDs)
}
// intersectIDs returns the ids in a that are also in b, never nil.
func intersectIDs(a, b []string) []string {
in := make(map[string]bool, len(b))
for _, id := range b {
in[id] = true
}
out := []string{}
for _, id := range a {
if in[id] {
out = append(out, id)
}
}
return out
}
// writeSortedIDs feeds ids to h in sorted order, one per line. The input slice
// is COPIED before sorting: the callers pass slices they go on to use for the
// query itself, and reordering those under them would be an invisible
// side effect of computing a fingerprint.
func writeSortedIDs(h interface{ Write([]byte) (int, error) }, ids []string) {
sorted := make([]string, len(ids))
copy(sorted, ids)
sort.Strings(sorted)
for _, id := range sorted {
_, _ = h.Write([]byte(id))
_, _ = h.Write([]byte("\n"))
}
}