// conformance.js — does the network actually hold what we think we published?
//
// WHY THIS EXISTS. Both real interop defects found on 2026-07-31 were SILENT SUCCESSES: every local
// surface reported health while the network saw nothing.
// - 4 of 6 likes carried a strongRef with no `cid`. Valid here, rejected by relay lexicon validation,
// federated as nothing. Invisible for weeks.
// - two records were re-encoded by a second writer, stopped hashing to their pointers, and silently
// dropped out of the signed repo.
// Nothing compared WHAT WE HOLD against WHAT BSKY INDEXES, so nothing could see either. This does.
//
// TWO LAYERS, cheapest first:
// 1. STRUCTURAL (offline, deterministic) — is the record even valid atproto? This is where the
// strongRef class lives, and it needs no network, so it can run on every deploy.
// 2. NETWORK (a real diff) — for each record we hold, does the network agree it exists? This is the
// only layer that catches "we signed it and nobody indexed it".
//
// ⛔ ABSENCE FROM BSKY IS NOT PROOF OF A DEFECT. bsky's AppView is eventually consistent, its relay is
// non-archival, and a freshly-signed record legitimately takes time to appear. So a network miss is
// reported as `pending` when the record is recent and `missing` only past a grace window — and even then
// it is a FINDING, not a verdict. Reporting a propagation delay as corruption would be its own silent
// failure, in the opposite direction.
//
// PURE + INJECTED. `doFetch` and `now` are parameters; every check is testable with no network.
const APPVIEW = 'https://public.api.bsky.app'
// How long after createdAt a record may be absent from bsky before we call it missing rather than pending.
const GRACE_MS = 10 * 60 * 1000
// ── layer 1: structural ──────────────────────────────────────────────────────────────────────────
// Fields that MUST be a com.atproto.repo.strongRef ({uri, cid} — both required).
const STRONGREF_PATHS = {
'app.bsky.feed.like': [['subject']],
'app.bsky.feed.repost': [['subject']],
'app.bsky.feed.post': [['reply', 'parent'], ['reply', 'root']]
}
const REQUIRED = {
'app.bsky.feed.post': ['text', 'createdAt'],
'app.bsky.feed.like': ['subject', 'createdAt'],
'app.bsky.feed.repost': ['subject', 'createdAt'],
'app.bsky.graph.follow': ['subject', 'createdAt']
}
const at = (o, p) => p.reduce((x, k) => (x == null ? x : x[k]), o)
// -> [{ code, field, detail }]. Empty means structurally valid as far as we can tell offline.
export function validateRecord (collection, record) {
const out = []
if (!record || typeof record !== 'object') return [{ code: 'not-an-object', field: null }]
for (const f of REQUIRED[collection] || []) {
if (record[f] === undefined || record[f] === null) out.push({ code: 'missing-required', field: f })
}
// The defect that federated 4 likes as nothing: uri present, cid absent.
for (const path of STRONGREF_PATHS[collection] || []) {
const ref = at(record, path)
if (!ref || typeof ref !== 'object') continue
const label = path.join('.')
if (typeof ref.uri !== 'string') out.push({ code: 'strongref-no-uri', field: label })
else if (!ref.cid) out.push({ code: 'strongref-no-cid', field: label, detail: ref.uri })
}
// A reply must carry BOTH sides; one alone is not a valid thread edge.
if (collection === 'app.bsky.feed.post' && record.reply) {
if (!record.reply.parent) out.push({ code: 'reply-no-parent', field: 'reply.parent' })
if (!record.reply.root) out.push({ code: 'reply-no-root', field: 'reply.root' })
}
// langs is not required by lexicon, but a post without it is dropped by some custom feeds — a real
// observed loss (bsky feed drops langs-less posts), so it is reported as a WARNING, not an error.
if (collection === 'app.bsky.feed.post' && !Array.isArray(record.langs)) {
out.push({ code: 'no-langs', field: 'langs', warning: true })
}
return out
}
// ── layer 2: the network diff ────────────────────────────────────────────────────────────────────
async function getJson (doFetch, url) {
try {
const res = await doFetch(url, { method: 'GET', headers: { accept: 'application/json' }, timeout: 10000 })
if (!res || res.ok === false) return null
return await res.json()
} catch { return null }
}
// ⚠ GRACE IS KEYED ON createdAt, WHICH IS WRONG FOR A REPUBLISHED RECORD. A record REPAIRED today
// (repair-strongrefs rewrites it under its original rkey and createdAt) is freshly re-signed but reads
// as old, so it is classified `missing` while it is genuinely still propagating. Observed immediately:
// the 4 likes repaired on 2026-07-31 reported `missing` seconds after being rewritten. Pass
// `republishedAfter` (a timestamp) to treat anything re-signed since then as pending regardless of
// createdAt. Without it, read `missing` on a recently-repaired record as "check again later", not as a
// defect — the whole point of this file is to not report propagation as corruption.
const recent = (record, now, graceMs, republishedAfter = null) => {
if (republishedAfter && now - republishedAfter < graceMs) return true
const t = Date.parse((record && record.createdAt) || '')
return Number.isFinite(t) && (now - t) < graceMs
}
// Does bsky's AppView hold this post? Absence is meaningful only outside the grace window.
export async function probePost (doFetch, did, rkey, record, { appview = APPVIEW, now = Date.now(), graceMs = GRACE_MS, republishedAfter = null } = {}) {
const uri = `at://${did}/app.bsky.feed.post/${rkey}`
const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0`)
const found = !!(body && body.thread && body.thread.post)
if (found) return { state: 'ok' }
return { state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', uri }
}
// Our follows, as bsky sees them. ONE call for the whole collection rather than one per record.
export async function networkFollows (doFetch, did, { appview = APPVIEW, limit = 100 } = {}) {
const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(did)}&limit=${limit}`)
if (!body || !Array.isArray(body.follows)) return null
return new Set(body.follows.map((f) => f && f.did).filter(Boolean))
}
// Does bsky record US as having liked this subject? One call per SUBJECT, so likes are grouped first.
export async function likedBy (doFetch, subjectUri, did, { appview = APPVIEW, limit = 100 } = {}) {
const body = await getJson(doFetch, `${appview}/xrpc/app.bsky.feed.getLikes?uri=${encodeURIComponent(subjectUri)}&limit=${limit}`)
if (!body || !Array.isArray(body.likes)) return null
return body.likes.some((l) => l && l.actor && l.actor.did === did)
}
// ── the check ────────────────────────────────────────────────────────────────────────────────────
//
// listRecords(collection) -> [{ rkey, record }]
// Returns { structural, network, summary } — findings, never a pass/fail verdict.
export async function checkConformance ({
did,
listRecords,
doFetch = null, // omit to run the offline structural layer only
collections = ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.feed.repost', 'app.bsky.graph.follow'],
appview = APPVIEW,
now = Date.now(),
graceMs = GRACE_MS,
maxProbes = 40, // bound the network layer; a full repo is not worth probing every run
republishedAfter = null, // ms timestamp: records re-signed since then count as pending (see `recent`)
onEvent = null
} = {}) {
if (!did) throw new Error('checkConformance requires did')
if (typeof listRecords !== 'function') throw new Error('checkConformance requires listRecords')
const emit = (e, d) => { if (onEvent) onEvent(e, d) }
const structural = []
const network = []
const byCollection = {}
let checked = 0
for (const collection of collections) {
let recs = []
try { recs = (await listRecords(collection)) || [] } catch (err) { emit('list-failed', { collection, error: err.message }); continue }
byCollection[collection] = recs.length
for (const { rkey, record } of recs) {
checked++
for (const p of validateRecord(collection, record)) {
structural.push({ collection, rkey, ...p })
}
}
}
if (doFetch) {
// posts — bounded, newest-last so a big repo still checks the interesting tail
const posts = (await listRecords('app.bsky.feed.post').catch(() => [])) || []
for (const { rkey, record } of posts.slice(-maxProbes)) {
const r = await probePost(doFetch, did, rkey, record, { appview, now, graceMs, republishedAfter })
if (r.state !== 'ok') network.push({ collection: 'app.bsky.feed.post', rkey, ...r })
}
// follows — one call, set difference
const follows = (await listRecords('app.bsky.graph.follow').catch(() => [])) || []
if (follows.length) {
const net = await networkFollows(doFetch, did, { appview })
if (net === null) emit('probe-unavailable', { collection: 'app.bsky.graph.follow' })
else {
for (const { rkey, record } of follows) {
const subject = typeof record.subject === 'string' ? record.subject : (record.subject && record.subject.did)
if (!subject || net.has(subject)) continue
network.push({ collection: 'app.bsky.graph.follow', rkey, state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', subject })
}
}
}
// likes — grouped by subject so N likes on one post cost ONE call
const likes = (await listRecords('app.bsky.feed.like').catch(() => [])) || []
const bySubject = new Map()
for (const { rkey, record } of likes) {
const uri = record && record.subject && record.subject.uri
if (!uri) continue // structural layer already reported it
if (!bySubject.has(uri)) bySubject.set(uri, [])
bySubject.get(uri).push({ rkey, record })
}
for (const [uri, group] of [...bySubject].slice(0, maxProbes)) {
const seen = await likedBy(doFetch, uri, did, { appview })
if (seen === null || seen) continue
for (const { rkey, record } of group) {
network.push({ collection: 'app.bsky.feed.like', rkey, state: recent(record, now, graceMs, republishedAfter) ? 'pending' : 'missing', subject: uri })
}
}
}
const errors = structural.filter((s) => !s.warning)
const summary = {
did,
checked,
byCollection,
structuralErrors: errors.length,
structuralWarnings: structural.length - errors.length,
networkMissing: network.filter((n) => n.state === 'missing').length,
networkPending: network.filter((n) => n.state === 'pending').length,
networkChecked: !!doFetch
}
return { structural, network, summary }
}