OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / a9cb469

spec+lib: native facets shipped; the room decisions reopened by measurement
strip() emits { byteStart, byteEnd, type, value } — flat, native. The bsky shape moves to project(rec,'bsky'), the only place that can resolve a mention to a DID, so the field named `did` finally holds one. Vectors were already in this form, so the harness expander is deleted and they compare directly. 41 tests / 2,324 asserts. interaction.md 3.4.0 and 2.2 are superseded: roomKey derives from the DID (device-node.js:127), and position is a view coordinate — keet addresses by oplog (chat.js:30,31,62). 2.2.1 stays as a measured live defect and library.md 10.1.1 is demoted to a hazard note. See hyper-social #61. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a9cb4691a3f987ee2046fc58cc4f6d6c32bc079e
by plankatron <fleeky@gmail.com> · 2026-09-06T15:16:08.000Z

Changed files (6) +178 -59

lib/strip.js +21 -11
……@@ -65,9 +65,16 @@ const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
6565// PROJECTION time (identity.md §5). So the facet carries the target exactly as written.
6666const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || ''))
6767
68const linkFeature = (uri) => ({ $type: 'app.bsky.richtext.facet#link', uri })
69const mentionFeature = (did) => ({ $type: 'app.bsky.richtext.facet#mention', did })
70const tagFeature = (tag) => ({ $type: 'app.bsky.richtext.facet#tag', tag })
68// §6 — the NATIVE facet feature: { type, value }, the same compact form the vectors are written in.
69//
70// This used to be app.bsky.richtext.facet shape ($type / features[] / index{}), which put atproto's
71// data format in the core library's OUTPUT — the inherited-shape problem one level below the one
72// design.md §1 caught, and flagged as such by markdown-strip.md §3.4. It is gone (MVP.md §4
73// decision 3): a mention's target is a favela:// URI, and it travelled in a field named `did`.
74// project(rec, 'bsky') is the one place that knows atproto's names; nothing else may.
75const linkFeature = (uri) => ({ type: 'link', value: uri })
76const mentionFeature = (target) => ({ type: 'mention', value: target })
77const tagFeature = (tag) => ({ type: 'tag', value: tag })
7178
7279// A block's rendered text plus the facets found inside it, at character offsets relative to
7380// this block. Kept per-block so §4.1 can drop an empty block and its separator together.
……@@ -505,27 +512,30 @@ export function strip (source) {
505512 const s = startMap[m.start]
506513 const e = endMap[m.end]
507514 if (s === undefined || e === undefined || s < 0 || e < 0 || e <= s) continue // §6.3
515 // §6.5 — exactly one feature per facet. Flat, so a multi-feature facet is not expressible
516 // rather than being forbidden by a rule someone has to remember.
508517 facets.push({
509 index: { byteStart: byteLen(text.slice(0, s)), byteEnd: byteLen(text.slice(0, e)) },
510 features: [m.feature] // §6.5 — exactly one feature per facet
518 byteStart: byteLen(text.slice(0, s)),
519 byteEnd: byteLen(text.slice(0, e)),
520 type: m.feature.type,
521 value: m.feature.value
511522 })
512523 }
513524
514525 // §6.1 sorted by byteStart; §6.2 on a tie the longer wins, then mention > link > tag.
515 const rank = (f) => (f.features[0].$type.endsWith('#mention') ? 0
516 : f.features[0].$type.endsWith('#link') ? 1 : 2)
526 const rank = (f) => (f.type === 'mention' ? 0 : f.type === 'link' ? 1 : 2)
517527 facets.sort((a, b) =>
518 a.index.byteStart - b.index.byteStart ||
519 b.index.byteEnd - a.index.byteEnd ||
528 a.byteStart - b.byteStart ||
529 b.byteEnd - a.byteEnd ||
520530 rank(a) - rank(b))
521531
522532 // §6.2 — no overlaps: the winner of the sort above keeps its range, later overlaps are dropped.
523533 const out = []
524534 let last = -1
525535 for (const f of facets) {
526 if (f.index.byteStart < last) continue
536 if (f.byteStart < last) continue
527537 out.push(f)
528 last = f.index.byteEnd
538 last = f.byteEnd
529539 }
530540 return { text, facets: out }
531541}
spec/interaction.md +70 -16
……@@ -103,6 +103,49 @@ What is given up is the tidiness of one representation for everything. What is k
103103actually mattered: **`favela-markdown` still owns every edge's vocabulary, projections and loss
104104report**, whether or not the edge has bytes. The codec's surface is unchanged.
105105
106### 2.2 ⚠ SUPERSEDED 2026-09-06 — the rule was forced by a premise that is gone
107
108A version of this section generalised §2.1 into *"a record is frontmatter plus an optional body;
109body present ⇒ a file and a pointer, body absent ⇒ the frontmatter is the log entry."* The rule is
110sound, but the thing that **forced** it was edge volume in a popular room — and **hyper-social #61**
111§4 removes the room as a primitive, so an interaction is written in the **author's own feed** and its
112volume is bounded by that author's activity.
113
114⇒ The bodiless/inline optimisation becomes a choice, not a requirement. Prefer one representation
115(every record is a file) until a measurement says otherwise.
116
117⛔ **§2.2.1 below is NOT superseded.** It is a measured, live defect and it applies to any inline
118value the pointer set ever holds, whatever motivates it.
119
120#### 2.2.1 ⛔ MEASURED, LIVE — an inline value is invisible to every peer
121
122Retained from the superseded section above, because it is a fact about the substrate rather than
123part of the decision.
124
125`PointerSubstrate._resolve` calls `ensureLocal(collection, rkey, ptr.$oid)` for every non-tombstone
126entry (`pointer-substrate.js:94-102`). With no `$oid` that call falls through to
127`if (!this._overlay || !oid) return null` after a local-file lookup (`overlay-repo-store.js:149,176`)
128— so an inline value **resolves on the node that wrote it and is null on every other node.**
129
130That is not a hypothesis. It is a live defect with a two-pass migration written for it:
131
132> *"Such a repo reads perfectly on the node that holds it and is UNFETCHABLE BY EVERY OTHER NODE…
133> A co-owner would converge N keys it can never turn into bytes. Live on loser-club: three records,
134> all inline, so that DID cannot be co-owned at all."* — `pointer-substrate.js:120-133`
135
136**So storing edges inline is either correct or it is that bug, and the difference is one predicate.**
137Tombstones are safe for exactly one reason: `isTombstone(ptr)` is checked **before** `ensureLocal`
138ever sees them. An inline record needs the same explicit discriminator — a `$` -prefixed marker on
139the value, checked in `set`, `get` and `_resolve` — so it can **never** fall into the `$oid ===
140undefined` path.
141
142⛔ **Ship the discriminator, with its own test, before a single bodiless edge is written.** This is
143the same shape as `library.md` §10.1 (`records()` cannot see a `.md`) and it fails the same way: the
144authoring node sees a working edge, every peer sees nothing, and no surface reports a difference.
145`EDGE-GRAPH.md` §4 names the state it would look like — ② CONVERGED but ③ never RESOLVED — which is
146precisely a failure that view exists to make visible, arriving by construction rather than by
147accident.
148
106149## 3. ⛔ Naming the subject — the one irreversible decision
107150
108151Once records exist, their subject names cannot be rewritten: `record-vocabulary.md` §8 forbids it
……@@ -166,19 +209,27 @@ along with its shape. Audited against keet (`SOCIAL-MODEL.md` §3.5):
166209linearises. keet absorbs this by rendering optimistically from the tip; we must design for it rather
167210than discover it. `SOCIAL-MODEL.md` §3.5.2.
168211
169**A key in the bytes; a name only on screen.** pgh's `NameRegistry`
170(`pgh/lib/name-registry.js:74`) is the resolver for display and discovery — it already anchors
171claims to `identityRoot` with device-signed v2 ops — but it is **first-claim-wins and a released
172name is reclaimable**, so a name embedded in immutable bytes could later denote a different person.
173That is a correctness problem, not an aesthetic one.
212#### 3.4.0 ⚠ SUPERSEDED 2026-09-06 — the container question reopened
213
214A version of this section settled the *path* as `<collection>/<position>` with a per-room counter.
215The path reasoning may survive; its premises did not, and both were measured the same day
216(**hyper-social #61** §2):
217
218- **`roomKey` derives from the DID**, not from the identity root — `device-node.js:127`
219 `crypto.hash([NS('room-key'), b4a.from(did)])`. §3.4's bridging claim is false in code, and a DID
220 rotation would change every address in the room: the failure this scheme exists to prevent.
221- **`position` is a view coordinate, not an address.** keet resolves every cross-reference through
222 `@keet/chat-by-message-id` keyed by **`oplog`** (`chat.js:30,31,62`); `index` at `:49` is where a
223 message sits in a thread. A counter read inside `apply()` is a function of the linearization, and
224 apply is replayed on every reorg — so a position is not final until its block is indexed, which
225 under N=2 quorum may be never.
174226
175**Rejected: `punch://<serverKey>/…`.** It is a *connection* URL, not a reference: the serverKey names
176a **daemon**, so the URI dies with one node even when ten peers hold the bytes, and its petnames are
177node-local config (`git-remote-punch/lib/petnames.js:107-130`) — the same string denoting different
178things on different machines is fine for a git remote and fatal inside replicated bytes. The
179primitive it is built on — a 32-byte identity pubkey as the authority — is the part reused here.
227And #61 §4 removes the premise underneath both: **the room is not a primitive.** A feed and a
228document are; a room is the name given to a dense cluster of matings. The authority segment is
229therefore reopened, and `oplog` — collision-free by construction, known at append time, stable under
230reorg, needing no assign-in-apply — is the candidate for the last segment.
180231
181`at://` remains correct for **foreign** subjects (§3.2); that is genuinely where they live.
232⛔ Nothing here is settled. Do not implement against this section.
182233
183234#### 3.4.1 Minting — a mechanical check, not a discipline
184235
……@@ -482,13 +533,16 @@ content one shape, and an inbound edge is the same idea applied to relationships
482533
483534## 9. Open
484535
4851. ~~**Edge as empty-body record vs. pointer-valued log entry**~~ **DECIDED 2026-09-05** — bodiless
486 edges are log entries, `reply` is a record (§2.1). No measurement needed: the room model makes edge
487 volume unbounded in a popular room, which settles it.
5361. **Edge as empty-body record vs. pointer-valued log entry** — **REOPENED 2026-09-06** (§2.2).
537 Decided 2026-09-05 on the grounds that a popular room's edge volume is unbounded; hyper-social #61
538 §4 writes interactions into the author's own feed instead, so that ground is gone and one
539 representation may be the simpler answer. §2.2.1's hazard stands regardless.
4885402. ~~**The native URI scheme**~~ **CLOSED 2026-09-05** — `favela://<roomKey>/<collection>/<position>`,
489541 with the identity root as the personal-room case (§3.4) and `position` assigned in `apply()`.
490 Remaining: the fail-closed minting rule (§3.4.1), and whether `collection` belongs in the URI at
491 all now that bodiless edges are log entries rather than files (§2.1).
542 ⚠ **REOPENED 2026-09-06** (§3.4.0). Both the authority (`roomKey` derives from the DID) and
543 the last segment (`position` is a view coordinate, not an address) were measured false the same
544 day; hyper-social #61 §2. The whole URI is open again, `oplog` being the candidate for the last
545 segment. Also remaining: the fail-closed minting rule (§3.4.1).
4925463. ~~**Inbox admission**~~ **LARGELY DISSOLVED 2026-09-05** (§6) — admission is room membership, and
493547 cold contact is `dm.fromContact` + request/accept. What remains is the open public room (§6.2), and
494548 the levers there are all existing.
spec/library.md +36 -1
……@@ -56,7 +56,7 @@ Read in source on 2026-09-02. Nothing outside this table is verified.
5656```js
5757parse(md) -> Record | ParseError // total; fail-closed (§6.2)
5858serialize(rec) -> md // canonical; serialize(parse(x)) === x
59strip(source) -> { text, facets } // markdown-strip.md; byte-deterministic
59strip(source) -> { text, facets } // markdown-strip.md; byte-deterministic; NATIVE facets
6060project(rec, t) -> { output, loss[] } // t: 'bsky' | 'ap' (§4)
6161maxReach(vis) -> Set<network> // fail-closed (§6)
6262fromAS2(obj) -> ImportedView // NEVER a Record (§8)
……@@ -67,6 +67,14 @@ STRIP_VERSION -> string // consensus-critical (§10.3)
6767Must run under Bare and Node alike (the daemon is Bare; the tests are Node), so no Node-only
6868builtins.
6969
70⛔ **No atproto vocabulary crosses this surface (MVP.md §4 decision 3, settled 2026-09-06).**
71`strip()` returns native facets — `{ byteStart, byteEnd, type, value }` — and `project(rec, 'bsky')`
72is the **only** function permitted to emit `$type`, `app.bsky.*`, `features[]` or a field named
73`did`. This is the same rule as §1's "no storage, no network", applied to *names* rather than to
74capabilities, and for the same reason: whatever this surface emits is what every consumer gets
75written against. It is enforced by a test that greps `strip()`'s serialized output for atproto names
76across all 36 vectors, not by review.
77
7078⚠ **Amended 2026-09-05.** This previously said *"no dependencies beyond a CommonMark parser"*, which
7179the pinned dialect makes impossible: §2.1 of `markdown-strip.md` requires CommonMark **plus six
7280extensions**, and four of them — GFM strikethrough, GFM tables, footnotes, directives — change block
……@@ -113,6 +121,13 @@ Every loss is already enumerable from the strip rules. Make it structured output
113121{ rule: 'strip§4.6', kind: 'table-flattened', span: [120, 260] }
114122```
115123
124A bsky facet is assembled **here**, at projection time: `{byteStart, byteEnd, type, value}` becomes
125`{index: {byteStart, byteEnd}, features: [{$type: 'app.bsky.richtext.facet#<type>', <field>: value}]}`,
126where a `mention`'s `value` — a `favela://` identity root — is **resolved to a DID** (§3.1) and the
127facet is dropped, text kept, when there is none. That resolution is exactly why the shape belongs
128here and never in the codec: `strip()` has no registry access, so a bsky facet built inside it could
129only ever have carried a lie in the `did` field.
130
116131| kind | source rule |
117132|---|---|
118133| `footnote-dropped` | strip §3.9 — reference removed, definition dropped |
……@@ -270,6 +285,26 @@ parse for `net.favela.*`, `.dagcbor` with `dagCbor.decode` for `app.bsky.*`. Bot
270285store, which is exactly the double-write of `record-vocabulary.md` §7. Ship this change, with its
271286own test, **before** a single `.md` record exists.
272287
288### 10.1.1 ⛔ HAZARD — an inline pointer-set value resolves only for its author
289
290Not a decision, a measured property of the substrate. It applies to **any** value the pointer set
291holds that carries no `$oid`, whatever put it there.
292
293`_resolve` passes `ptr.$oid` to `ensureLocal` for every non-tombstone entry, and `ensureLocal` with
294no oid returns the local file or **null** (`overlay-repo-store.js:149,176`). So such a value resolves
295on the node that wrote it and nowhere else — with a two-pass migration already written to undo it:
296
297> *"UNFETCHABLE BY EVERY OTHER NODE… A co-owner would converge N keys it can never turn into bytes.
298> Live on loser-club: three records, all inline, so that DID cannot be co-owned at all."*
299> — `pointer-substrate.js:120-133`
300
301Tombstones are the one safe case, and only because `isTombstone(ptr)` is checked **before**
302`ensureLocal` is reached. Anything else inline needs the same explicit discriminator, checked in
303`set`/`get`/`_resolve`, with its own test — before the first such value is written.
304
305⚠ It fails the same way §10.1 does: silently, and only for other peers. The authoring node sees
306everything working.
307
273308### 10.2 The double-write is what keeps atproto peers in sync
274309
275310Restated here because §10.1 makes it structural: a public post is two files sharing one rkey
spec/markdown-strip.md +25 -8
……@@ -26,8 +26,19 @@ strip(source: string) -> { text: string, facets: Facet[] }
2626```
2727
2828- **`text`** is UTF-8 plain text. No markup survives.
29- **`facets`** are `app.bsky.richtext.facet` objects with `index: { byteStart, byteEnd }` —
30 **byte offsets into `text`**, UTF-8, half-open `[byteStart, byteEnd)`.
29- **`facets`** are **native** objects — `{ byteStart, byteEnd, type, value }`, flat — where the
30 offsets are **byte offsets into `text`**, UTF-8, half-open `[byteStart, byteEnd)`, and
31 `type` is one of `link` · `mention` · `tag`.
32
33 ⛔ **Revised 2026-09-06 (MVP.md §4 decision 3).** This was `app.bsky.richtext.facet` shape —
34 `{ index: { byteStart, byteEnd }, features: [{ $type: 'app.bsky.richtext.facet#…', … }] }`. That put
35 atproto's *data format* in the core codec's output, one level below the inherited shape
36 `design.md` §1 caught, and §3.4 below already flagged it: a mention's target is a `favela://` URI
37 and it travelled in a field named **`did`**. The bsky shape is now built by `project(rec, 'bsky')`,
38 which is the only place permitted to know atproto's names.
39
40 The native form is the one the vectors were always written in (§8), so the harness no longer
41 translates anything and the vectors are compared to `strip()`'s output directly.
3142- **Deterministic**: same input bytes → same output bytes, on every implementation, forever.
3243- **Total**: never throws. Malformed markdown degrades to its literal text.
3344
……@@ -117,9 +128,11 @@ alice rotates, every mention of her everywhere would point at a dead identifier.
117128- ⛔ **A bare `@alice` with no target is plain text with no facet** — never a guess. Unchanged
118129 (favela-web 0.3.11: *a mention resolves or is omitted*).
119130
120⚠ **Observation, not fixed here.** §2 says facets are `app.bsky.richtext.facet` objects, so this
121codec's output shape is atproto's. That is the inherited-shape problem one level down, and it is why
122a mention target has to travel in a field named `did`. Worth revisiting; it is not blocking.
131✅ **RESOLVED 2026-09-06 — the output shape is native.** This paragraph used to read: *"§2 says
132facets are `app.bsky.richtext.facet` objects, so this codec's output shape is atproto's. That is the
133inherited-shape problem one level down, and it is why a mention target has to travel in a field named
134`did`."* It was right, and it was the whole of MVP.md §4 decision 3. A mention's target now travels
135in `value`, and the field named `did` appears only in the bsky projection, holding an actual DID.
123136
124137### 3.5 Images
125138`![alt](url)` produces **no text and no facet**. Images are `embed`, not body text.
……@@ -222,7 +235,10 @@ Applied to `text` **after** assembly, **before** facet offsets are computed.
222235 **mention > link > tag**.
2232363. A facet whose range is empty after stripping is **dropped**.
2242374. Offsets are recomputed against the **final** `text` from §5 — never against an intermediate.
2255. `features` arrays contain exactly one feature. We never emit a multi-feature facet.
2385. **A facet carries exactly one feature**, and since 2026-09-06 the shape is flat
239 (`{byteStart, byteEnd, type, value}`) so a multi-feature facet is **not expressible** rather than
240 forbidden by a rule an implementer has to remember. Where a projection needs an array — bsky's
241 `features: [...]` — the bridge builds it.
226242
227243---
228244
……@@ -236,8 +252,9 @@ applies to the **projection**, not to `text`. `text` is the full canonical body.
236252## 8. Test vectors
237253
238254`spec/vectors/markdown-strip.json`. Each entry is `{ name, why, source, text, facets }`, with
239`facets` as `{ byteStart, byteEnd, type, value }` — a compact form to be expanded into
240`app.bsky.richtext.facet` shape by the test harness.
255`facets` as `{ byteStart, byteEnd, type, value }` — which, since 2026-09-06, is **exactly what
256`strip()` emits**. There is no expansion step: the harness compares the vectors to the output
257directly, and the translation it used to perform now lives in the bsky projection where it belongs.
241258
242259An implementation is conformant when it reproduces **every** vector byte-for-byte. The multi-byte
243260cases are not optional extras: they are the ones that catch an implementation using character
test/strip-fuzz.test.js +5 -5
……@@ -80,7 +80,7 @@ test('fuzz: facet byte ranges decode to exactly the label they cover', (t) => {
8080 t.fail('expected exactly one facet for ' + JSON.stringify(src) + ' — got ' + facets.length)
8181 continue
8282 }
83 const { byteStart, byteEnd } = facets[0].index
83 const { byteStart, byteEnd } = facets[0]
8484
8585 // ★ THE ORACLE. Slice by BYTES and decode: it must be the label, exactly.
8686 const covered = buf.subarray(byteStart, byteEnd).toString('utf8')
……@@ -104,10 +104,10 @@ test('fuzz: hashtag facets cover "#tag" including the hash (§3.7)', (t) => {
104104
105105 const { text, facets } = strip(src)
106106 const buf = Buffer.from(text, 'utf8')
107 const tagFacets = facets.filter((f) => f.features[0].$type.endsWith('#tag'))
107 const tagFacets = facets.filter((f) => f.type === 'tag')
108108 if (tagFacets.length !== 1) { t.fail('expected one tag facet for ' + JSON.stringify(src)); continue }
109109
110 const { byteStart, byteEnd } = tagFacets[0].index
110 const { byteStart, byteEnd } = tagFacets[0]
111111 t.is(buf.subarray(byteStart, byteEnd).toString('utf8'), '#' + tag,
112112 'the facet spans #tag; the VALUE drops the hash')
113113 }
……@@ -135,12 +135,12 @@ test('fuzz: invariants hold on arbitrary markdown-ish input (§6, §2)', (t) =>
135135 let prevEnd = -1
136136 let ok = true
137137 for (const f of out.facets) {
138 const { byteStart, byteEnd } = f.index
138 const { byteStart, byteEnd } = f
139139 if (!(byteStart >= 0 && byteEnd <= buf.length)) { ok = false; t.fail('out of bounds: ' + JSON.stringify(src)); break }
140140 if (!(byteEnd > byteStart)) { ok = false; t.fail('empty facet not dropped (§6.3): ' + JSON.stringify(src)); break }
141141 if (byteStart < prevEnd) { ok = false; t.fail('overlapping facets (§6.2): ' + JSON.stringify(src)); break }
142142 if (!onBoundary(buf, byteStart) || !onBoundary(buf, byteEnd)) { ok = false; t.fail('offset mid-character: ' + JSON.stringify(src)); break }
143 if (f.features.length !== 1) { ok = false; t.fail('multi-feature facet (§6.5): ' + JSON.stringify(src)); break }
143 if (typeof f.type !== 'string' || !('value' in f)) { ok = false; t.fail('not a native facet (§6.5): ' + JSON.stringify(src)); break }
144144 prevEnd = byteEnd
145145 }
146146 t.ok(ok, 'invariants hold')
test/strip.test.js +21 -18
……@@ -6,9 +6,11 @@
66// exists to catch (an implementation using character offsets instead of UTF-8 byte offsets,
77// which is invisible until a non-ASCII author posts).
88//
9// Facets in the vector file are the compact form { byteStart, byteEnd, type, value }; strip()
10// returns app.bsky.richtext.facet shape. expandFacet() below is the ONLY translation, and it is
11// deliberately in the test rather than in lib/ so that lib/ owns one representation.
9// Facets in the vector file are the NATIVE form { byteStart, byteEnd, type, value } and strip()
10// emits exactly that, so there is no translation step here at all. There used to be one:
11// expandFacet() rebuilt each vector into app.bsky.richtext.facet shape because that is what the
12// codec emitted. That shape left the core on 2026-09-06 (MVP.md §4 decision 3) — a bsky facet is
13// now built by project(rec,'bsky'), the one place allowed to know atproto's names.
1214
1315import test from 'brittle'
1416import { readFileSync } from 'node:fs'
……@@ -18,18 +20,6 @@ const VECTORS = JSON.parse(
1820 readFileSync(new URL('../spec/vectors/markdown-strip.json', import.meta.url), 'utf8')
1921).vectors
2022
21// Compact vector facet -> the app.bsky.richtext.facet shape strip() emits.
22// strip §6.5: features arrays contain EXACTLY ONE feature; we never emit a multi-feature facet.
23function expandFacet (f) {
24 const feature =
25 f.type === 'link' ? { $type: 'app.bsky.richtext.facet#link', uri: f.value }
26 : f.type === 'mention' ? { $type: 'app.bsky.richtext.facet#mention', did: f.value }
27 : f.type === 'tag' ? { $type: 'app.bsky.richtext.facet#tag', tag: f.value }
28 : null
29 if (!feature) throw new Error('vector uses an unknown facet type: ' + f.type)
30 return { index: { byteStart: f.byteStart, byteEnd: f.byteEnd }, features: [feature] }
31}
32
3323const utf8 = (s) => Buffer.from(s, 'utf8')
3424
3525test('strip: every vector reproduces byte-for-byte', (t) => {
……@@ -41,7 +31,7 @@ test('strip: every vector reproduces byte-for-byte', (t) => {
4131 // Bytes, not strings: two different strings can compare equal after normalisation, and
4232 // strip §5.5 forbids Unicode normalisation precisely because it would change the CID.
4333 t.alike(utf8(got.text), utf8(v.text), v.name + ': text — ' + v.why)
44 t.alike(got.facets, v.facets.map(expandFacet), v.name + ': facets — ' + v.why)
34 t.alike(got.facets, v.facets, v.name + ': facets — ' + v.why)
4535 }
4636})
4737
……@@ -67,12 +57,25 @@ test('strip: facet invariants hold on every vector (strip §6)', (t) => {
6757 const bytes = utf8(text).length
6858 let prev = -1
6959 for (const f of facets) {
70 const { byteStart, byteEnd } = f.index
60 const { byteStart, byteEnd } = f
7161 t.ok(byteStart >= 0 && byteEnd <= bytes, v.name + ': facet within text bounds')
7262 t.ok(byteEnd > byteStart, v.name + ': facet is non-empty (§6.3 drops empty ranges)')
7363 t.ok(byteStart >= prev, v.name + ': facets sorted by byteStart (§6.1)')
74 t.is(f.features.length, 1, v.name + ': exactly one feature per facet (§6.5)')
64 t.alike(Object.keys(f).sort(), ['byteEnd', 'byteStart', 'type', 'value'],
65 v.name + ': the native facet has exactly four keys (§6.5 — one feature, so no array)')
7566 prev = byteEnd
7667 }
7768 }
7869})
70
71// The gate for MVP.md §4 decision 3. It is not a style check: a facet shape is what every future
72// consumer gets written against, so a single leaked $type here is the leak that "leaks forever".
73// Asserted over the SERIALIZED output rather than over field names, because that is the only form
74// that catches a bsky string smuggled in as a value.
75test('strip: no atproto vocabulary reaches the core output (MVP §4 decision 3)', (t) => {
76 for (const v of VECTORS) {
77 const blob = JSON.stringify(strip(v.source))
78 t.absent(/\$type|app\.bsky|richtext|\bfeatures\b|"did"/.test(blob),
79 v.name + ': strip() output carries no atproto names')
80 }
81})