| … | … | @@ -65,9 +65,16 @@ const isHttp = (uri) => /^https?:\/\//i.test(String(uri || '')) |
| 65 | 65 | // PROJECTION time (identity.md §5). So the facet carries the target exactly as written. |
| 66 | 66 | const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || '')) |
| 67 | 67 | |
| 68 | const linkFeature = (uri) => ({ $type: 'app.bsky.richtext.facet#link', uri }) | |
| 69 | const mentionFeature = (did) => ({ $type: 'app.bsky.richtext.facet#mention', did }) | |
| 70 | const 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. | |
| 75 | const linkFeature = (uri) => ({ type: 'link', value: uri }) | |
| 76 | const mentionFeature = (target) => ({ type: 'mention', value: target }) | |
| 77 | const tagFeature = (tag) => ({ type: 'tag', value: tag }) | |
| 71 | 78 | |
| 72 | 79 | // A block's rendered text plus the facets found inside it, at character offsets relative to |
| 73 | 80 | // 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) { |
| 505 | 512 | const s = startMap[m.start] |
| 506 | 513 | const e = endMap[m.end] |
| 507 | 514 | 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. | |
| 508 | 517 | 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 | |
| 511 | 522 | }) |
| 512 | 523 | } |
| 513 | 524 | |
| 514 | 525 | // §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) | |
| 517 | 527 | 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 || | |
| 520 | 530 | rank(a) - rank(b)) |
| 521 | 531 | |
| 522 | 532 | // §6.2 — no overlaps: the winner of the sort above keeps its range, later overlaps are dropped. |
| 523 | 533 | const out = [] |
| 524 | 534 | let last = -1 |
| 525 | 535 | for (const f of facets) { |
| 526 | if (f.index.byteStart < last) continue | |
| 536 | if (f.byteStart < last) continue | |
| 527 | 537 | out.push(f) |
| 528 | last = f.index.byteEnd | |
| 538 | last = f.byteEnd | |
| 529 | 539 | } |
| 530 | 540 | return { text, facets: out } |
| 531 | 541 | } |
| … | … | @@ -103,6 +103,49 @@ What is given up is the tidiness of one representation for everything. What is k |
| 103 | 103 | actually mattered: **`favela-markdown` still owns every edge's vocabulary, projections and loss |
| 104 | 104 | report**, whether or not the edge has bytes. The codec's surface is unchanged. |
| 105 | 105 | |
| 106 | ### 2.2 ⚠ SUPERSEDED 2026-09-06 — the rule was forced by a premise that is gone | |
| 107 | ||
| 108 | A version of this section generalised §2.1 into *"a record is frontmatter plus an optional body; | |
| 109 | body present ⇒ a file and a pointer, body absent ⇒ the frontmatter is the log entry."* The rule is | |
| 110 | sound, 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 | |
| 112 | volume 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 | |
| 118 | value the pointer set ever holds, whatever motivates it. | |
| 119 | ||
| 120 | #### 2.2.1 ⛔ MEASURED, LIVE — an inline value is invisible to every peer | |
| 121 | ||
| 122 | Retained from the superseded section above, because it is a fact about the substrate rather than | |
| 123 | part of the decision. | |
| 124 | ||
| 125 | `PointerSubstrate._resolve` calls `ensureLocal(collection, rkey, ptr.$oid)` for every non-tombstone | |
| 126 | entry (`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 | ||
| 130 | That 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.** | |
| 137 | Tombstones are safe for exactly one reason: `isTombstone(ptr)` is checked **before** `ensureLocal` | |
| 138 | ever sees them. An inline record needs the same explicit discriminator — a `$` -prefixed marker on | |
| 139 | the value, checked in `set`, `get` and `_resolve` — so it can **never** fall into the `$oid === | |
| 140 | undefined` path. | |
| 141 | ||
| 142 | ⛔ **Ship the discriminator, with its own test, before a single bodiless edge is written.** This is | |
| 143 | the same shape as `library.md` §10.1 (`records()` cannot see a `.md`) and it fails the same way: the | |
| 144 | authoring 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 | |
| 146 | precisely a failure that view exists to make visible, arriving by construction rather than by | |
| 147 | accident. | |
| 148 | ||
| 106 | 149 | ## 3. ⛔ Naming the subject — the one irreversible decision |
| 107 | 150 | |
| 108 | 151 | Once 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): |
| 166 | 209 | linearises. keet absorbs this by rendering optimistically from the tip; we must design for it rather |
| 167 | 210 | than discover it. `SOCIAL-MODEL.md` §3.5.2. |
| 168 | 211 | |
| 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 | |
| 171 | claims to `identityRoot` with device-signed v2 ops — but it is **first-claim-wins and a released | |
| 172 | name is reclaimable**, so a name embedded in immutable bytes could later denote a different person. | |
| 173 | That is a correctness problem, not an aesthetic one. | |
| 212 | #### 3.4.0 ⚠ SUPERSEDED 2026-09-06 — the container question reopened | |
| 213 | ||
| 214 | A version of this section settled the *path* as `<collection>/<position>` with a per-room counter. | |
| 215 | The 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. | |
| 174 | 226 | |
| 175 | **Rejected: `punch://<serverKey>/…`.** It is a *connection* URL, not a reference: the serverKey names | |
| 176 | a **daemon**, so the URI dies with one node even when ten peers hold the bytes, and its petnames are | |
| 177 | node-local config (`git-remote-punch/lib/petnames.js:107-130`) — the same string denoting different | |
| 178 | things on different machines is fine for a git remote and fatal inside replicated bytes. The | |
| 179 | primitive it is built on — a 32-byte identity pubkey as the authority — is the part reused here. | |
| 227 | And #61 §4 removes the premise underneath both: **the room is not a primitive.** A feed and a | |
| 228 | document are; a room is the name given to a dense cluster of matings. The authority segment is | |
| 229 | therefore reopened, and `oplog` — collision-free by construction, known at append time, stable under | |
| 230 | reorg, needing no assign-in-apply — is the candidate for the last segment. | |
| 180 | 231 | |
| 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. | |
| 182 | 233 | |
| 183 | 234 | #### 3.4.1 Minting — a mechanical check, not a discipline |
| 184 | 235 | |
| … | … | @@ -482,13 +533,16 @@ content one shape, and an inbound edge is the same idea applied to relationships |
| 482 | 533 | |
| 483 | 534 | ## 9. Open |
| 484 | 535 | |
| 485 | 1. ~~**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. | |
| 536 | 1. **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. | |
| 488 | 540 | 2. ~~**The native URI scheme**~~ **CLOSED 2026-09-05** — `favela://<roomKey>/<collection>/<position>`, |
| 489 | 541 | 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). | |
| 492 | 546 | 3. ~~**Inbox admission**~~ **LARGELY DISSOLVED 2026-09-05** (§6) — admission is room membership, and |
| 493 | 547 | cold contact is `dm.fromContact` + request/accept. What remains is the open public room (§6.2), and |
| 494 | 548 | the levers there are all existing. |
| … | … | @@ -56,7 +56,7 @@ Read in source on 2026-09-02. Nothing outside this table is verified. |
| 56 | 56 | ```js |
| 57 | 57 | parse(md) -> Record | ParseError // total; fail-closed (§6.2) |
| 58 | 58 | serialize(rec) -> md // canonical; serialize(parse(x)) === x |
| 59 | strip(source) -> { text, facets } // markdown-strip.md; byte-deterministic | |
| 59 | strip(source) -> { text, facets } // markdown-strip.md; byte-deterministic; NATIVE facets | |
| 60 | 60 | project(rec, t) -> { output, loss[] } // t: 'bsky' | 'ap' (§4) |
| 61 | 61 | maxReach(vis) -> Set<network> // fail-closed (§6) |
| 62 | 62 | fromAS2(obj) -> ImportedView // NEVER a Record (§8) |
| … | … | @@ -67,6 +67,14 @@ STRIP_VERSION -> string // consensus-critical (§10.3) |
| 67 | 67 | Must run under Bare and Node alike (the daemon is Bare; the tests are Node), so no Node-only |
| 68 | 68 | builtins. |
| 69 | 69 | |
| 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')` | |
| 72 | is 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 | |
| 74 | capabilities, and for the same reason: whatever this surface emits is what every consumer gets | |
| 75 | written against. It is enforced by a test that greps `strip()`'s serialized output for atproto names | |
| 76 | across all 36 vectors, not by review. | |
| 77 | ||
| 70 | 78 | ⚠ **Amended 2026-09-05.** This previously said *"no dependencies beyond a CommonMark parser"*, which |
| 71 | 79 | the pinned dialect makes impossible: §2.1 of `markdown-strip.md` requires CommonMark **plus six |
| 72 | 80 | extensions**, 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 |
| 113 | 121 | { rule: 'strip§4.6', kind: 'table-flattened', span: [120, 260] } |
| 114 | 122 | ``` |
| 115 | 123 | |
| 124 | A 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}]}`, | |
| 126 | where a `mention`'s `value` — a `favela://` identity root — is **resolved to a DID** (§3.1) and the | |
| 127 | facet is dropped, text kept, when there is none. That resolution is exactly why the shape belongs | |
| 128 | here and never in the codec: `strip()` has no registry access, so a bsky facet built inside it could | |
| 129 | only ever have carried a lie in the `did` field. | |
| 130 | ||
| 116 | 131 | | kind | source rule | |
| 117 | 132 | |---|---| |
| 118 | 133 | | `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 |
| 270 | 285 | store, which is exactly the double-write of `record-vocabulary.md` §7. Ship this change, with its |
| 271 | 286 | own test, **before** a single `.md` record exists. |
| 272 | 287 | |
| 288 | ### 10.1.1 ⛔ HAZARD — an inline pointer-set value resolves only for its author | |
| 289 | ||
| 290 | Not a decision, a measured property of the substrate. It applies to **any** value the pointer set | |
| 291 | holds that carries no `$oid`, whatever put it there. | |
| 292 | ||
| 293 | `_resolve` passes `ptr.$oid` to `ensureLocal` for every non-tombstone entry, and `ensureLocal` with | |
| 294 | no oid returns the local file or **null** (`overlay-repo-store.js:149,176`). So such a value resolves | |
| 295 | on 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 | ||
| 301 | Tombstones 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 | |
| 306 | everything working. | |
| 307 | ||
| 273 | 308 | ### 10.2 The double-write is what keeps atproto peers in sync |
| 274 | 309 | |
| 275 | 310 | Restated here because §10.1 makes it structural: a public post is two files sharing one rkey |
| … | … | @@ -26,8 +26,19 @@ strip(source: string) -> { text: string, facets: Facet[] } |
| 26 | 26 | ``` |
| 27 | 27 | |
| 28 | 28 | - **`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. | |
| 31 | 42 | - **Deterministic**: same input bytes → same output bytes, on every implementation, forever. |
| 32 | 43 | - **Total**: never throws. Malformed markdown degrades to its literal text. |
| 33 | 44 | |
| … | … | @@ -117,9 +128,11 @@ alice rotates, every mention of her everywhere would point at a dead identifier. |
| 117 | 128 | - ⛔ **A bare `@alice` with no target is plain text with no facet** — never a guess. Unchanged |
| 118 | 129 | (favela-web 0.3.11: *a mention resolves or is omitted*). |
| 119 | 130 | |
| 120 | ⚠ **Observation, not fixed here.** §2 says facets are `app.bsky.richtext.facet` objects, so this | |
| 121 | codec's output shape is atproto's. That is the inherited-shape problem one level down, and it is why | |
| 122 | a 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 | |
| 132 | facets are `app.bsky.richtext.facet` objects, so this codec's output shape is atproto's. That is the | |
| 133 | inherited-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 | |
| 135 | in `value`, and the field named `did` appears only in the bsky projection, holding an actual DID. | |
| 123 | 136 | |
| 124 | 137 | ### 3.5 Images |
| 125 | 138 | `` 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. |
| 222 | 235 | **mention > link > tag**. |
| 223 | 236 | 3. A facet whose range is empty after stripping is **dropped**. |
| 224 | 237 | 4. Offsets are recomputed against the **final** `text` from §5 — never against an intermediate. |
| 225 | 5. `features` arrays contain exactly one feature. We never emit a multi-feature facet. | |
| 238 | 5. **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. | |
| 226 | 242 | |
| 227 | 243 | --- |
| 228 | 244 | |
| … | … | @@ -236,8 +252,9 @@ applies to the **projection**, not to `text`. `text` is the full canonical body. |
| 236 | 252 | ## 8. Test vectors |
| 237 | 253 | |
| 238 | 254 | `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 | |
| 257 | directly, and the translation it used to perform now lives in the bsky projection where it belongs. | |
| 241 | 258 | |
| 242 | 259 | An implementation is conformant when it reproduces **every** vector byte-for-byte. The multi-byte |
| 243 | 260 | cases are not optional extras: they are the ones that catch an implementation using character |
| … | … | @@ -80,7 +80,7 @@ test('fuzz: facet byte ranges decode to exactly the label they cover', (t) => { |
| 80 | 80 | t.fail('expected exactly one facet for ' + JSON.stringify(src) + ' — got ' + facets.length) |
| 81 | 81 | continue |
| 82 | 82 | } |
| 83 | const { byteStart, byteEnd } = facets[0].index | |
| 83 | const { byteStart, byteEnd } = facets[0] | |
| 84 | 84 | |
| 85 | 85 | // ★ THE ORACLE. Slice by BYTES and decode: it must be the label, exactly. |
| 86 | 86 | const covered = buf.subarray(byteStart, byteEnd).toString('utf8') |
| … | … | @@ -104,10 +104,10 @@ test('fuzz: hashtag facets cover "#tag" including the hash (§3.7)', (t) => { |
| 104 | 104 | |
| 105 | 105 | const { text, facets } = strip(src) |
| 106 | 106 | 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') | |
| 108 | 108 | if (tagFacets.length !== 1) { t.fail('expected one tag facet for ' + JSON.stringify(src)); continue } |
| 109 | 109 | |
| 110 | const { byteStart, byteEnd } = tagFacets[0].index | |
| 110 | const { byteStart, byteEnd } = tagFacets[0] | |
| 111 | 111 | t.is(buf.subarray(byteStart, byteEnd).toString('utf8'), '#' + tag, |
| 112 | 112 | 'the facet spans #tag; the VALUE drops the hash') |
| 113 | 113 | } |
| … | … | @@ -135,12 +135,12 @@ test('fuzz: invariants hold on arbitrary markdown-ish input (§6, §2)', (t) => |
| 135 | 135 | let prevEnd = -1 |
| 136 | 136 | let ok = true |
| 137 | 137 | for (const f of out.facets) { |
| 138 | const { byteStart, byteEnd } = f.index | |
| 138 | const { byteStart, byteEnd } = f | |
| 139 | 139 | if (!(byteStart >= 0 && byteEnd <= buf.length)) { ok = false; t.fail('out of bounds: ' + JSON.stringify(src)); break } |
| 140 | 140 | if (!(byteEnd > byteStart)) { ok = false; t.fail('empty facet not dropped (§6.3): ' + JSON.stringify(src)); break } |
| 141 | 141 | if (byteStart < prevEnd) { ok = false; t.fail('overlapping facets (§6.2): ' + JSON.stringify(src)); break } |
| 142 | 142 | 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 } | |
| 144 | 144 | prevEnd = byteEnd |
| 145 | 145 | } |
| 146 | 146 | t.ok(ok, 'invariants hold') |
| … | … | @@ -6,9 +6,11 @@ |
| 6 | 6 | // exists to catch (an implementation using character offsets instead of UTF-8 byte offsets, |
| 7 | 7 | // which is invisible until a non-ASCII author posts). |
| 8 | 8 | // |
| 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. | |
| 12 | 14 | |
| 13 | 15 | import test from 'brittle' |
| 14 | 16 | import { readFileSync } from 'node:fs' |
| … | … | @@ -18,18 +20,6 @@ const VECTORS = JSON.parse( |
| 18 | 20 | readFileSync(new URL('../spec/vectors/markdown-strip.json', import.meta.url), 'utf8') |
| 19 | 21 | ).vectors |
| 20 | 22 | |
| 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. | |
| 23 | function 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 | ||
| 33 | 23 | const utf8 = (s) => Buffer.from(s, 'utf8') |
| 34 | 24 | |
| 35 | 25 | test('strip: every vector reproduces byte-for-byte', (t) => { |
| … | … | @@ -41,7 +31,7 @@ test('strip: every vector reproduces byte-for-byte', (t) => { |
| 41 | 31 | // Bytes, not strings: two different strings can compare equal after normalisation, and |
| 42 | 32 | // strip §5.5 forbids Unicode normalisation precisely because it would change the CID. |
| 43 | 33 | 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) | |
| 45 | 35 | } |
| 46 | 36 | }) |
| 47 | 37 | |
| … | … | @@ -67,12 +57,25 @@ test('strip: facet invariants hold on every vector (strip §6)', (t) => { |
| 67 | 57 | const bytes = utf8(text).length |
| 68 | 58 | let prev = -1 |
| 69 | 59 | for (const f of facets) { |
| 70 | const { byteStart, byteEnd } = f.index | |
| 60 | const { byteStart, byteEnd } = f | |
| 71 | 61 | t.ok(byteStart >= 0 && byteEnd <= bytes, v.name + ': facet within text bounds') |
| 72 | 62 | t.ok(byteEnd > byteStart, v.name + ': facet is non-empty (§6.3 drops empty ranges)') |
| 73 | 63 | 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)') | |
| 75 | 66 | prev = byteEnd |
| 76 | 67 | } |
| 77 | 68 | } |
| 78 | 69 | }) |
| 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. | |
| 75 | test('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 | }) |