OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / 80f7c3c

lib: maxReach — the lattice, deny-by-default, with mutation tests
The one place a defect is a privacy bug rather than a rendering bug (library 6.1). Signature revised: 6.1 signs it maxReach(visibility), from when visibility was a frontmatter field; it is now a PLACE, so the inputs are the room kind and the verb and the result is their meet. The caller supplies the kind because this library has no storage - honest about the 1 boundary. Deny by default everywhere: unknown room type, unknown verb, missing or hostile argument all yield {favela}. Exact match only - 'PUBLIC' is not 'public', because normalising would let a typo WIDEN an audience. A followers room reaches the fediverse but NEVER bsky: AP has addressing, atproto has none, and a projection it cannot restrict is a leak. Verified the tests catch the failures that matter: failing OPEN is caught by 15 assertions, normalising the room type by 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80f7c3c5e13acf45102ea4438793fe08486b40e2
by plankatron <fleeky@gmail.com> · 2026-09-06T00:48:01.000Z

Changed files (4) +194 -2

lib/reach.js +88 -0
……@@ -0,0 +1,88 @@
1// maxReach — the lattice. library.md §6.1, record-vocabulary.md §5.2, interaction.md §5.2
2//
3// ⛔ THIS IS THE ONE PLACE A DEFECT IS A PRIVACY BUG rather than a rendering bug (library §6.1).
4// Written here once, this check would otherwise be written three times — favela-web,
5// hyper-proto-peer, ap-bridge — and the third one would eventually be written wrong.
6//
7// Everything is deny-by-default. Unknown room type, unknown verb, missing argument, hostile input:
8// the answer is {favela}, which is not a restriction but a fact — "every post is local; the
9// canonical record always exists on the P2P side; that is what authoring one means"
10// (record-vocabulary §5.4). Local is the substrate, not a scope you opt into.
11//
12// SIGNATURE CHANGED 2026-09-05. library.md §6.1 signs this `maxReach(visibility)`, from when
13// visibility was a frontmatter field. Visibility is now a PLACE — the room you wrote in
14// (SOCIAL-MODEL §4) — so the inputs are the room's kind and the record's verb. This library has no
15// storage and cannot inspect a room, so the CALLER supplies the kind. That is honest about the
16// library boundary (§1: no storage, no network) rather than pretending to knowledge it lacks.
17
18export const NETWORKS = ['favela', 'bsky', 'fediverse']
19
20// What each kind of room can be expressed as, on each network.
21//
22// public → everywhere. AS2 Public; an atproto repo is world-readable by construction.
23// followers → the fediverse ONLY. AP has addressing, so a followers room maps onto the followers
24// collection. ⛔ atproto has NO addressing at all (record-vocabulary §5.2), so a
25// projection it cannot restrict is a leak, and the only safe answer is to withhold.
26// members → favela only. AS2 *can* address actors individually, but an arbitrary room's
27// dm membership is not expressible as an audience a receiving server will honour, and
28// approximating it is how a private post reaches a stranger. Fails closed.
29const ROOM_REACH = {
30 public: ['favela', 'bsky', 'fediverse'],
31 followers: ['favela', 'fediverse'],
32 members: ['favela'],
33 dm: ['favela']
34}
35
36// What each verb can be expressed as. interaction.md §5.4 (the signed set) and §5.5 (native).
37// ⛔ A verb absent from this table reaches {favela} and nothing else — that IS the extension
38// mechanism (interaction §5.2): a native-only verb cannot fork a signed projection, because it
39// never reaches a shared derivation. Adding a verb here is a spec change, not a plugin point.
40const VERB_REACH = {
41 post: ['favela', 'bsky', 'fediverse'],
42 reply: ['favela', 'bsky', 'fediverse'],
43 like: ['favela', 'bsky', 'fediverse'],
44 repost: ['favela', 'bsky', 'fediverse'],
45 follow: ['favela', 'bsky', 'fediverse'],
46 // AS2 `Block` exists but Mastodon does not federate it, so it projects with a loss entry rather
47 // than being withheld — the receiving server simply does nothing with it.
48 block: ['favela', 'bsky', 'fediverse'],
49 // AS2 `Flag` is a REPORT to the origin's moderators, a different act from a shared signal, so
50 // conflating them would misrepresent the moderator (SOCIAL-MODEL §6.7). atproto has a real
51 // equivalent — a signed com.atproto.label, which we already emit.
52 flag: ['favela', 'bsky'],
53 // Internal: how a node knows two names, or two byte-sets, denote one thing. No projection
54 // anywhere, by design (interaction §5.5, record-vocabulary §3.5).
55 alias: ['favela'],
56 'variant-of': ['favela']
57}
58
59const LOCAL = () => new Set(['favela'])
60
61/**
62 * maxReach({ roomType, verb }) -> Set<network>
63 *
64 * The MEET of what the room can express and what the verb can express. TOTAL: never throws, and
65 * always contains 'favela'.
66 */
67export function maxReach (opts) {
68 const roomType = opts && typeof opts.roomType === 'string' ? opts.roomType : null
69 const verb = opts && typeof opts.verb === 'string' ? opts.verb : null
70
71 // Exact match only. 'PUBLIC' and 'public ' are NOT 'public': normalising input here would mean a
72 // typo could widen an audience, which is precisely the class of bug §6.1 exists to prevent.
73 const room = (roomType && Object.prototype.hasOwnProperty.call(ROOM_REACH, roomType))
74 ? ROOM_REACH[roomType]
75 : null
76 const verbs = (verb && Object.prototype.hasOwnProperty.call(VERB_REACH, verb))
77 ? VERB_REACH[verb]
78 : null
79
80 if (!room || !verbs) return LOCAL()
81
82 const out = new Set()
83 for (const n of room) if (verbs.includes(n)) out.add(n)
84 out.add('favela') // §5.4 — local is the substrate; there is no way to opt out of it
85 return out
86}
87
88export default { maxReach, NETWORKS }
package.json +1 -1
……@@ -16,6 +16,6 @@
1616 "micromark-extension-gfm-table": "^2.1.1"
1717 },
1818 "scripts": {
19 "test": "node test/strip.test.js && node test/record.test.js && node test/strip-fuzz.test.js"
19 "test": "node test/strip.test.js && node test/record.test.js && node test/reach.test.js && node test/strip-fuzz.test.js"
2020 }
2121}
spec/library.md +11 -1
……@@ -163,7 +163,17 @@ record that uses it. It is not a plugin point. Do not build a registry.
163163
164164### 6.1 The lattice
165165
166`maxReach(visibility) -> Set<network>`, total and deny-by-default. `project()` **refuses** to emit
166`maxReach({ roomType, verb }) -> Set<network>`, total and deny-by-default.
167
168⚠ **Signature revised 2026-09-05.** This was `maxReach(visibility)`, from when visibility was a
169frontmatter field. Visibility is now a **place** — the room you wrote in (`SOCIAL-MODEL.md` §4) — so
170the inputs are the room's kind and the record's verb, and the result is their **meet**. This library
171has no storage and cannot inspect a room, so the **caller supplies the kind**: honest about the
172§1 boundary rather than pretending to knowledge it lacks.
173
174⛔ **Exact match only.** `'PUBLIC'` and `'public '` are not `'public'`. Normalising input here would
175mean a typo could *widen* an audience, which is the exact class of bug this section exists to
176prevent. `project()` **refuses** to emit
167177a bsky projection for a record whose `visibility` is not Public, because atproto cannot express
168178addressing at all (`record-vocabulary.md` §5.2) and a projection it cannot restrict is a leak.
169179
test/reach.test.js +94 -0
……@@ -0,0 +1,94 @@
1// maxReach — library.md §6.1, record-vocabulary.md §5.2, interaction.md §5.2
2//
3// ⛔ THIS IS THE ONE PLACE A DEFECT IS A PRIVACY BUG RATHER THAN A RENDERING BUG (library §6.1),
4// so every case below asserts the DENY direction as well as the allow direction, and the default
5// for anything unrecognised is the empty-est answer the model has: {favela}.
6//
7// Signature changed 2026-09-05. §6.1 signs it `maxReach(visibility)`, from when visibility was a
8// frontmatter field. Visibility is now a PLACE — the room you wrote in (SOCIAL-MODEL §4) — so the
9// inputs are the room's kind and the verb. The codec has no storage and cannot inspect a room, so
10// the caller passes the kind; that is honest about the library boundary rather than pretending.
11
12import test from 'brittle'
13import { maxReach, NETWORKS } from '../lib/reach.js'
14
15const set = (r) => [...r].sort().join(',')
16
17test('a PUBLIC room reaches everywhere a verb allows', (t) => {
18 t.is(set(maxReach({ roomType: 'public', verb: 'post' })), 'bsky,favela,fediverse')
19 t.is(set(maxReach({ roomType: 'public', verb: 'like' })), 'bsky,favela,fediverse')
20})
21
22test('⛔ a FOLLOWERS room reaches the fediverse but NEVER bsky (§5.2)', (t) => {
23 // AP has addressing — a followers room maps onto the followers collection. atproto has NONE, so
24 // a projection it cannot restrict is a leak, and the only safe answer is to withhold it.
25 t.is(set(maxReach({ roomType: 'followers', verb: 'post' })), 'favela,fediverse')
26})
27
28test('⛔ a MEMBERS room and a DM reach nothing but favela', (t) => {
29 // AS2 can address actors, but an arbitrary room's membership is not expressible as an audience
30 // the receiving server will honour, so it fails CLOSED rather than approximating.
31 t.is(set(maxReach({ roomType: 'members', verb: 'post' })), 'favela')
32 t.is(set(maxReach({ roomType: 'dm', verb: 'post' })), 'favela')
33})
34
35test('⛔ an UNKNOWN room type reaches only favela — deny by default', (t) => {
36 for (const roomType of ['', null, undefined, 'PUBLIC', 'public ', 'anything', 42, {}]) {
37 t.is(set(maxReach({ roomType, verb: 'post' })), 'favela',
38 'unrecognised room type ' + JSON.stringify(roomType) + ' fails closed')
39 }
40})
41
42test('⛔ an UNKNOWN verb reaches only favela (interaction §5.2)', (t) => {
43 // "An unknown verb's maximum reach is {favela}." That IS the extension mechanism: a native-only
44 // verb cannot fork a signed projection because it never reaches a shared derivation.
45 for (const verb of ['net.example.rsvp', 'bookmark', 'pin', '', null, undefined, 99]) {
46 t.is(set(maxReach({ roomType: 'public', verb })), 'favela',
47 'unknown verb ' + JSON.stringify(verb) + ' is native-only')
48 }
49})
50
51test('internal verbs never leave, even from a public room', (t) => {
52 // alias and variant-of are how a node knows two names/byte-sets denote one thing. They have no
53 // projection anywhere, by design (interaction §5.5, record-vocabulary §3.5).
54 t.is(set(maxReach({ roomType: 'public', verb: 'alias' })), 'favela')
55 t.is(set(maxReach({ roomType: 'public', verb: 'variant-of' })), 'favela')
56})
57
58test('flag projects to bsky as a label, and nowhere on the fediverse', (t) => {
59 // AS2 `Flag` is a REPORT to the origin's moderators — a different act from a shared signal, so
60 // conflating them would misrepresent the moderator (SOCIAL-MODEL §6.7).
61 t.is(set(maxReach({ roomType: 'public', verb: 'flag' })), 'bsky,favela')
62})
63
64test('the meet is per-input: a restrictive room narrows a permissive verb', (t) => {
65 t.is(set(maxReach({ roomType: 'followers', verb: 'flag' })), 'favela',
66 'flag would reach bsky, but a followers room cannot')
67})
68
69test('maxReach is TOTAL — never throws, always returns a Set containing favela', (t) => {
70 const hostile = [undefined, null, {}, { roomType: 'public' }, { verb: 'post' },
71 { roomType: [], verb: {} }, { roomType: 'dm', verb: 'alias' }]
72 for (const arg of hostile) {
73 try {
74 const r = maxReach(arg)
75 t.ok(r instanceof Set && r.has('favela'), 'total on ' + JSON.stringify(arg))
76 } catch (err) {
77 t.fail('threw on ' + JSON.stringify(arg) + ': ' + err.message)
78 }
79 }
80})
81
82test('favela is ALWAYS reachable — local is the substrate, not a scope (§5.4)', (t) => {
83 // "Every post is local. The canonical record always exists on the P2P side; that is what authoring
84 // one means. 'Local' is therefore not a destination you opt into, and there is no way to opt out."
85 for (const roomType of ['public', 'followers', 'members', 'dm', 'nonsense']) {
86 for (const verb of ['post', 'like', 'alias', 'net.example.unknown']) {
87 t.ok(maxReach({ roomType, verb }).has('favela'), roomType + '/' + verb + ' is always local')
88 }
89 }
90})
91
92test('NETWORKS is exported and favela leads it', (t) => {
93 t.ok(NETWORKS.includes('favela') && NETWORKS.includes('bsky') && NETWORKS.includes('fediverse'))
94})