// reach — where a post actually went.
//
// THE GAP THIS FILLS. Every public post already reaches bsky AND the fediverse, from the same record
// bytes, automatically. Verified 2026-08-05: "need to package" (2026-08-05T13:38:23.294Z) is in
// app.bsky.feed.getAuthorFeed AND in ap.7rnx.net's outbox, same timestamp. But nothing in the client
// ever said so, so the one architecturally interesting fact about this project — one record, several
// networks, no copies — was invisible to the person using it.
//
// ★ OBSERVED vs ELIGIBLE — the distinction is the whole point, and collapsing it would be a lie.
//
// fediverse OBSERVED. ap-bridge's outbox is the actual list of what it projected. If an rkey is
// there, it was published. One cheap fetch covers a whole page of posts.
// bsky ELIGIBLE, not observed. We know bsky's AppView indexes `app.bsky.*` lexicons and
// ignores everything else, so the collection tells us whether the post was OFFERED.
// Whether bsky actually indexed it needs a per-post API call — too expensive for a
// timeline, and its absence would usually mean lag rather than rejection.
// favela always. We hold the record; that is what "we have it" means.
//
// So the UI must not render these three identically. "went to bsky" and "bsky has it" are different
// claims, and this codebase has repeatedly paid for surfaces that reported success they had not
// checked (#24, #26: "everything we control reports success").
import { scopeForCollection } from './scopes.js'
export const TIER = Object.freeze({
FAVELA: 'favela',
BSKY: 'bsky',
FEDIVERSE: 'fediverse'
})
// -> [{ tier, state }] where state is 'observed' | 'eligible'.
//
// apRkeys is a Set of rkeys ap-bridge reports having projected, or null when the AP side is not
// configured/reachable. NULL IS NOT "NO" — it means unknown, and an unknown must not render as an
// absence. A post that really did federate showing "not on the fediverse" because one HTTP call
// failed is worse than showing nothing at all.
export function reachFor ({ collection, rkey, apRkeys = null } = {}) {
const out = [{ tier: TIER.FAVELA, state: 'observed' }]
if (!collection) return out
// bsky indexes app.bsky.* and silently ignores unknown lexicons — that IS the local-only mechanism
// (scopes.js), so the collection is exactly the signal for whether the post was offered to bsky.
const isPublic = scopeForCollection(collection) === 'public'
if (isPublic) out.push({ tier: TIER.BSKY, state: 'eligible' })
// Only claim the fediverse when the outbox names it AND the post is in a collection ap-bridge
// actually projects. Unknown stays silent.
//
// ⚠ THE COLLECTION GATE IS LOAD-BEARING, not belt-and-braces. ap-bridge's object ids are keyed by
// RKEY ALONE (`objectIdFor({host, rkey})`), and an rkey is unique only WITHIN a collection. So a
// local-only post sharing an rkey with a public one would match the outbox set and render as
// "published to the fediverse" — announcing that we federated a post whose whole purpose was NOT
// being federated. Caught by test/reach.test.mjs; it is the same cross-collection collision
// documented in spec/record-vocabulary.md §3.1.
if (isPublic && apKnown(apRkeys) && rkey && apRkeys.has(rkey)) {
out.push({ tier: TIER.FEDIVERSE, state: 'observed' })
}
return out
}
// True when the AP side told us anything at all. The caller uses this to distinguish "no fediverse
// row because it wasn't projected" from "no fediverse row because we couldn't ask".
export function apKnown (apRkeys) { return apRkeys instanceof Set }