OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown commits / a432582

spec+lib: a mention names the USER, not a DID (C7)
Closes the last live contradiction in the record spec. [@alice](did:plc:xyz) put a TARGET's DID in canonical bytes - the same defect identity.md 5 removed for the AUTHOR's, for the same reason: rotation would be a rewrite of every record, which 8 forbids. When alice rotates, every mention of her points at a dead identifier. Canonical is favela://<identity-root>; did: stays accepted as legacy because 8 is forward-only. strip() resolves NEITHER - it is a codec with no registry access, so resolution to a bsky DID happens at projection time and fails closed to text-without-facet, reported as mention-unresolvable in loss[]. Two vectors (41 total). Notes but does not fix the deeper smell: strip's output shape is atproto's, which is why a target travels in a field named did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a4325821811656f731035a8146c82b98e631fdc2
by plankatron <fleeky@gmail.com> · 2026-09-05T23:18:43.000Z

Changed files (6) +72 -19

lib/strip.js +10 -5
……@@ -55,10 +55,15 @@ const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\][]*))?\]\]/g
5555// letter. A '#' inside a word, or one followed only by digits, is literal text.
5656const HASHTAG_RE = /(^|\s)(#([A-Za-z0-9_À-￿]{1,64}))/g
5757
58// §3.2 — only http/https may carry a link facet. did: is handled separately, and ONLY for
59// mentions (§3.4); anything else emits its label as plain text with no facet.
58// §3.2 — only http/https may carry a link facet. Mention schemes are handled separately (§3.4);
59// anything else emits its label as plain text with no facet.
6060const isHttp = (uri) => /^https?:\/\//i.test(String(uri || ''))
61const isDid = (uri) => /^did:/i.test(String(uri || ''))
61
62// §3.4 — a mention names a USER. `favela://<identity-root>` is the canonical form; `did:` is the
63// LEGACY form kept because §8 is forward-only and nothing is rewritten. strip() does NOT resolve
64// either: it is a codec with no registry access, and resolution to a bsky DID belongs at
65// PROJECTION time (identity.md §5). So the facet carries the target exactly as written.
66const isMention = (uri) => /^(favela:\/\/|did:)/i.test(String(uri || ''))
6267
6368const linkFeature = (uri) => ({ $type: 'app.bsky.richtext.facet#link', uri })
6469const mentionFeature = (did) => ({ $type: 'app.bsky.richtext.facet#mention', did })
……@@ -153,8 +158,8 @@ function inline (nodes, buf) {
153158 const inner = new Buf()
154159 inline(node.children, inner)
155160
156 if (isDid(uri)) {
157 // §3.4 — a mention carries a resolved DID in the target. The label is kept as-is.
161 if (isMention(uri)) {
162 // §3.4 — a mention target names the user, not a network identifier. The label is kept.
158163 buf.append(inner)
159164 buf.mark(start, buf.length, mentionFeature(uri))
160165 } else if (isHttp(uri)) {
spec/design.md +2 -2
……@@ -109,7 +109,7 @@ artifact is the property worth keeping.
109109}
110110---
111111
112hello [@alice](did:plc:xyz) — see [[the design note]]
112hello [@alice](favela://96b89f87…) — see [[the design note]]
113113
114114:::spoiler
115115the ending
……@@ -153,7 +153,7 @@ CommonMark 0.31.2 + exactly the six extensions pinned in `markdown-strip.md` §2
153153GFM tables, footnotes, `[[wiki-links]]`, block directives, hashtags. Unknown extensions need no
154154rule — markdown degrades to literal text by nature.
155155
156Markdown links are mentions: `[@alice](did:plc:xyz)` is display text plus resolved target, so
156Markdown links are mentions: `[@alice](favela://<identity-root>)` is display text plus resolved target, so
157157facets are **derived from the parse** at projection time, never stored or hand-maintained.
158158
159159---
spec/library.md +1 -0
……@@ -124,6 +124,7 @@ Every loss is already enumerable from the strip rules. Make it structured output
124124| `spoiler-revealed` | strip §4.8 — **see §5** |
125125| `truncated` | record-vocabulary §4.3 — 300 graphemes + permalink |
126126| `visibility-unenforceable` | record-vocabulary §5.2 — atproto has no addressing |
127| `mention-unresolvable` | strip §3.4 — the mentioned user has no designated atproto credential, so the facet is dropped and the text kept |
127128
128129A composer can then render *"on bsky this becomes…"* from data rather than from a hand-written
129130warning list that drifts. **No protocol offers this, because no protocol knows what the other one
spec/markdown-strip.md +23 -5
……@@ -97,11 +97,29 @@ so trailing punctuation stays outside the facet ("see https://x.com." keeps the
9797This regex is normative; changing it changes records.
9898
9999### 3.4 Mentions
100`[@alice](did:plc:xyz)` → `@alice`, plus a `#mention` facet with `did = did:plc:xyz`.
101
102⛔ **A mention MUST carry a resolved DID in the link target.** A bare `@alice` with no target is
103**plain text with no facet** — never a guess. This preserves the existing rule (favela-web 0.3.11):
104*a mention resolves to a DID or is omitted.*
100`[@alice](favela://<identity-root>)` → `@alice`, plus a `#mention` facet carrying the target
101**exactly as written**.
102
103⛔ **Revised 2026-09-05 — a mention names a USER, not a DID.** This previously required
104`[@alice](did:plc:xyz)`, putting a *target's* DID into canonical bytes. That is the same defect
105`identity.md` §5 removed for the *author's* DID, for the same reason: a DID in the bytes makes
106rotating one a rewrite of every record, which `record-vocabulary.md` §8 forbids absolutely. When
107alice rotates, every mention of her everywhere would point at a dead identifier.
108
109- **Canonical form:** `favela://<identity-root>` — the same key that anchors her personal room
110 (`interaction.md` §3.4), and the one thing that does not rotate.
111- **Legacy form:** `did:` targets are still accepted and still emit a mention. §8 is forward-only;
112 nothing is rewritten.
113- ⛔ **`strip()` does NOT resolve either.** It is a codec with no registry access. Resolving a
114 mention to a bsky DID happens at **projection** time (`identity.md` §5), and if the mentioned user
115 has no designated atproto credential the facet is **dropped, the text kept** — the same fail-closed
116 degradation §3.4 already specifies for a bare `@alice`, reported in `loss[]`.
117- ⛔ **A bare `@alice` with no target is plain text with no facet** — never a guess. Unchanged
118 (favela-web 0.3.11: *a mention resolves or is omitted*).
119
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.
105123
106124### 3.5 Images
107125`![alt](url)` produces **no text and no facet**. Images are `embed`, not body text.
spec/record-vocabulary.md +8 -7
……@@ -185,16 +185,17 @@ spelling is still open (`design.md` §8 #2).
185185
186186### 3.4 Markdown links *are* facets
187187
188`[@alice](did:plc:xyz)` carries display text plus resolved target — exactly what a facet is. A
188`[@alice](favela://<identity-root>)` carries display text plus resolved target — exactly what a facet is. A
189189CommonMark parser yields exact source ranges, so facet offsets are **derived from the parse**, never
190190hand-maintained. This preserves the existing rule (favela-web 0.3.11) that a mention resolves to a
191191target or is omitted, never guessed.
192192
193⛔ **OPEN — a mention embeds a DID in canonical bytes.** `[@alice](did:plc:xyz)` writes a *target's*
194DID into the record, which is the same defect §8 and `identity.md` §5 forbid for the *author's* DID:
195when alice rotates, every mention of her everywhere points at a dead identifier, and §8 forbids
196rewriting them. The mention target should be a `favela://` name resolved to a DID at projection time
197— the treatment the author already gets. Not settled here.
193✅ **RESOLVED 2026-09-05 — a mention names the user, not the DID.** `[@alice](favela://<identity-root>)`
194keeps a *target's* DID out of canonical bytes exactly as `identity.md` §5 keeps the *author's* out,
195and for the same reason: rotation would otherwise be a rewrite of every record, which §8 forbids.
196The DID is resolved at **projection** time; with no designated atproto credential the facet is
197dropped and the text kept, fail-closed. `did:` targets remain accepted as legacy (§8 is
198forward-only). See `markdown-strip.md` §3.4.
198199
199200---
200201
……@@ -233,7 +234,7 @@ why the extension set can grow without coordination.
233234six extensions** — strikethrough, GFM tables, footnotes, `[[wiki-links]]`, block directives
234235(`:::name`), and hashtags. *"An implementation MUST NOT enable further extensions. A new one is a
235236spec change, because it changes the bytes of every record that uses it."* Fenced code is CommonMark
236core, not an extension. Markdown links are mentions — `[@alice](did:plc:xyz)` carries display text
237core, not an extension. Markdown links are mentions — `[@alice](favela://<identity-root>)` carries display text
237238plus resolved target, so facets are *derived from the parse* when projecting (§3.4), never
238239hand-maintained.
239240
spec/vectors/markdown-strip.json +28 -0
……@@ -350,6 +350,34 @@
350350 "source": ":::unknownName\nbody\n:::",
351351 "text": "body",
352352 "facets": []
353 },
354 {
355 "name": "mention-favela-target",
356 "why": "§3.4 a mention names the USER by identity root; the DID is resolved at PROJECTION time, not stored (identity.md §5)",
357 "source": "hi [@alice](favela://96b89f87)",
358 "text": "hi @alice",
359 "facets": [
360 {
361 "byteStart": 3,
362 "byteEnd": 9,
363 "type": "mention",
364 "value": "favela://96b89f87"
365 }
366 ]
367 },
368 {
369 "name": "mention-did-target-legacy",
370 "why": "§8 forward-only — a legacy record carrying a did: target still resolves; nothing is rewritten",
371 "source": "hi [@alice](did:plc:xyz)",
372 "text": "hi @alice",
373 "facets": [
374 {
375 "byteStart": 3,
376 "byteEnd": 9,
377 "type": "mention",
378 "value": "did:plc:xyz"
379 }
380 ]
353381 }
354382 ]
355383}