<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One Record, Two Projections</title>
<meta name="description" content="How the canonical markdown record replicates, how it projects to atproto and ActivityPub, and where the three sync hazards sit.">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@600;700&display=swap">
<style>
*, *::before, *::after { box-sizing: border-box; }
html { color-scheme: light dark; }
body { margin: 0; }
img { max-width: 100%; }
</style>
</head>
<body>
<style>
:root {
--ground: #e9edf1;
--surface: #ffffff;
--plane: #dde4ea;
--plane-2: #d3dce4;
--ink: #16202b;
--muted: #5c6b7a;
--rule: #b9c5d0;
--bsky: #0a6fd8;
--fedi: #5254d6;
--hazard: #b4400c;
--hazard-t: #ffffff;
--serif: "IBM Plex Serif", Georgia, "Times New Roman", serif;
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #0f1720;
--surface: #18222d;
--plane: #1d2833;
--plane-2: #22303d;
--ink: #e2e9f0;
--muted: #93a3b3;
--rule: #35485a;
--bsky: #57a9ff;
--fedi: #9799ff;
--hazard: #f0803c;
--hazard-t: #14202b;
}
}
:root[data-theme="dark"] {
--ground: #0f1720;
--surface: #18222d;
--plane: #1d2833;
--plane-2: #22303d;
--ink: #e2e9f0;
--muted: #93a3b3;
--rule: #35485a;
--bsky: #57a9ff;
--fedi: #9799ff;
--hazard: #f0803c;
--hazard-t: #14202b;
}
body {
background: var(--ground);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.6;
margin: 0;
padding: 0 20px 88px;
}
.wrap { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 40px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
header { padding-top: 56px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); margin: 0;
}
h1 {
font-family: var(--serif); font-weight: 700; font-size: clamp(34px, 5.5vw, 52px);
line-height: 1.08; letter-spacing: -.015em; margin: 0; text-wrap: balance;
}
.standfirst { font-size: 19px; line-height: 1.5; color: var(--muted); margin: 0; max-width: 62ch; }
.meta {
display: flex; flex-wrap: wrap; gap: 0 28px;
font-family: var(--mono); font-size: 12.5px; color: var(--muted);
border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
padding: 12px 0; margin-top: 6px;
}
.meta b { font-weight: 500; color: var(--ink); }
.meta a { color: var(--ink); text-decoration-color: var(--rule); text-underline-offset: 3px; }
.meta a:focus-visible { outline: 2px solid var(--bsky); outline-offset: 3px; border-radius: 2px; }
h2 {
font-family: var(--serif); font-weight: 600; font-size: 27px; line-height: 1.2;
letter-spacing: -.01em; margin: 0; text-wrap: balance;
}
p { margin: 0; }
.prose code, .meta code { font-family: var(--mono); font-size: .88em; }
figure { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.figbox {
background: var(--surface); border: 1px solid var(--rule); border-radius: 4px;
padding: 20px 18px; overflow-x: auto;
}
.figbox svg { display: block; width: 100%; min-width: 660px; height: auto; color: var(--ink); }
figcaption { font-size: 14.5px; line-height: 1.55; color: var(--muted); max-width: 76ch; }
figcaption b { color: var(--ink); font-weight: 600; }
figcaption code { font-family: var(--mono); font-size: .89em; }
.keys { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.keys li { display: flex; gap: 10px; align-items: baseline; }
.keys .n {
flex: none; width: 19px; height: 19px; border-radius: 50%;
background: var(--hazard); color: var(--hazard-t);
font-family: var(--mono); font-size: 11.5px; font-weight: 500;
display: inline-flex; align-items: center; justify-content: center;
transform: translateY(2px);
}
table { border-collapse: collapse; width: 100%; font-size: 15px; }
caption {
text-align: left; font-family: var(--mono); font-size: 12px; letter-spacing: .09em;
text-transform: uppercase; color: var(--muted); padding-bottom: 10px;
}
th, td { text-align: left; vertical-align: top; padding: 11px 16px 11px 0; border-bottom: 1px solid var(--rule); }
th { font-family: var(--mono); font-weight: 500; font-size: 12.5px; color: var(--muted); letter-spacing: .04em; }
td:first-child { font-weight: 600; white-space: nowrap; padding-right: 26px; }
td code { font-family: var(--mono); font-size: .87em; }
.tablewrap { overflow-x: auto; }
/* SVG primitives */
.plane { fill: var(--plane); stroke: none; }
.plane-b { fill: var(--plane-2); stroke: none; }
.bx { fill: var(--surface); stroke: currentColor; stroke-width: 1.4; }
.bx-canon { stroke-width: 3; }
.bx-bsky { stroke: var(--bsky); }
.bx-fedi { stroke: var(--fedi); }
.bx-haz { stroke: var(--hazard); stroke-width: 2; }
.ln { stroke: currentColor; stroke-width: 1.4; fill: none; }
.ln-bsky { stroke: var(--bsky); }
.ln-fedi { stroke: var(--fedi); }
.ln-haz { stroke: var(--hazard); stroke-width: 1.8; }
.ln-dash { stroke-dasharray: 5 4; }
.t { font-family: var(--mono); font-size: 12.5px; fill: var(--ink); }
.t-sm { font-family: var(--mono); font-size: 10.5px; fill: var(--muted); }
.t-lab { font-family: var(--mono); font-size: 11.5px; letter-spacing: .07em; fill: var(--muted); }
.t-haz { fill: var(--hazard); }
.t-bsky { fill: var(--bsky); }
.t-fedi { fill: var(--fedi); }
.mid { text-anchor: middle; }
.ah { fill: currentColor; }
.ah-bsky { fill: var(--bsky); }
.ah-fedi { fill: var(--fedi); }
.ah-haz { fill: var(--hazard); }
.badge { fill: var(--hazard); }
.badge-t { font-family: var(--mono); font-size: 11.5px; font-weight: 500; fill: var(--hazard-t); text-anchor: middle; }
</style>
<div class="wrap">
<header>
<p class="eyebrow">favela-markdown · spec/library.md §10</p>
<h1>One Record, Two Projections</h1>
<p class="standfirst">A post is a markdown file. atproto and ActivityPub are derived from it on read. The canonical record replicates the same way it always did — but moving the encoding puts the strip inside the signature, and that is where three hazards live.</p>
<div class="meta">
<span><b>repo</b> <a href="https://7rnx.net/git/repos/favela-markdown">7rnx.net/git/repos/favela-markdown</a></span>
<span><b>master</b> <code>333c046</code></span>
<span><b>tracking</b> hyper-social #57</span>
<span><b>status</b> design — nothing built</span>
</div>
</header>
<figure>
<div class="figbox">
<svg viewBox="0 0 960 560" role="img" aria-label="The canonical markdown record converges across peers as a hash pointer in a shared autobase, and is separately projected on read into a signed atproto commit and an ActivityPub object. Three hazards sit on the projection path.">
<defs>
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah" points="0,1 10,5 0,9"></polygon>
</marker>
<marker id="ab" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah ah-bsky" points="0,1 10,5 0,9"></polygon>
</marker>
<marker id="af" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah ah-fedi" points="0,1 10,5 0,9"></polygon>
</marker>
</defs>
<!-- PLANE 1 -->
<text class="t-lab" x="16" y="26">P2P PLANE — CONVERGES, ENCODING-BLIND</text>
<rect class="plane" x="8" y="38" width="944" height="180" rx="5"></rect>
<rect class="bx bx-canon" x="40" y="74" width="270" height="68" rx="4"></rect>
<text class="t mid" x="175" y="101">records/net.favela.post/</text>
<text class="t mid" x="175" y="121">3msdnle65lk22<tspan font-weight="500">.md</tspan></text>
<rect class="bx" x="390" y="74" width="250" height="68" rx="4"></rect>
<text class="t mid" x="515" y="101">{ $oid, size } pointer</text>
<text class="t-sm mid" x="515" y="121">shared StateBase autobase</text>
<rect class="bx" x="720" y="74" width="200" height="68" rx="4"></rect>
<text class="t mid" x="820" y="101">every attested</text>
<text class="t mid" x="820" y="121">peer</text>
<line class="ln" x1="310" y1="108" x2="384" y2="108" marker-end="url(#a)"></line>
<text class="t-sm mid" x="347" y="94">put()</text>
<text class="t-sm mid" x="347" y="128">blake2b</text>
<line class="ln" x1="640" y1="108" x2="714" y2="108" marker-end="url(#a)"></line>
<text class="t-sm mid" x="677" y="94">linearizes</text>
<text class="t-sm mid" x="677" y="128">converges</text>
<line class="ln ln-dash" x1="180" y1="192" x2="815" y2="192" marker-end="url(#a)" marker-start="url(#a)"></line>
<text class="t-sm mid" x="497" y="181">record bytes travel by oid — hyper-overlay, fetched from any peer that holds them</text>
<!-- SPINE -->
<path class="ln" d="M175 142 L175 474"></path>
<text class="t-sm" x="188" y="239">projected on read — the canonical file is never re-authored</text>
<!-- PLANE 2 -->
<text class="t-lab" x="16" y="264">PROJECTION PLANE — SIGNED, ENCODING-CRITICAL</text>
<rect class="plane-b" x="8" y="276" width="944" height="264" rx="5"></rect>
<line class="ln" x1="175" y1="330" x2="224" y2="330" marker-end="url(#a)"></line>
<rect class="bx" x="230" y="292" width="250" height="76" rx="4"></rect>
<text class="t mid" x="355" y="318">elected signer</text>
<text class="t-sm mid" x="355" y="338">favela-markdown strip()</text>
<text class="t-sm mid" x="355" y="354">then DAG-CBOR encode</text>
<rect class="bx" x="540" y="292" width="170" height="76" rx="4"></rect>
<text class="t mid" x="625" y="318">MST +</text>
<text class="t mid" x="625" y="338">signCommit</text>
<text class="t-sm mid" x="625" y="354">RFC6979</text>
<rect class="bx bx-bsky" x="790" y="292" width="130" height="76" rx="4"></rect>
<text class="t t-bsky mid" x="855" y="330">bsky</text>
<text class="t-sm mid" x="855" y="350">atproto relay</text>
<line class="ln" x1="480" y1="330" x2="534" y2="330" marker-end="url(#a)"></line>
<line class="ln ln-bsky" x1="710" y1="330" x2="784" y2="330" marker-end="url(#ab)"></line>
<text class="t-sm mid" x="747" y="318">firehose</text>
<text class="t-sm mid" x="747" y="352">#commit</text>
<line class="ln" x1="175" y1="474" x2="224" y2="474" marker-end="url(#a)"></line>
<rect class="bx bx-fedi" x="230" y="440" width="250" height="68" rx="4"></rect>
<text class="t mid" x="355" y="467">ap-bridge</text>
<text class="t-sm mid" x="355" y="487">record → AS2 + HTML</text>
<rect class="bx bx-fedi" x="790" y="440" width="130" height="68" rx="4"></rect>
<text class="t t-fedi mid" x="855" y="480">fediverse</text>
<line class="ln ln-fedi" x1="480" y1="474" x2="784" y2="474" marker-end="url(#af)"></line>
<text class="t-sm mid" x="632" y="463">HTTP Signatures — an Update MUST carry `updated`</text>
<!-- HAZARD BADGES -->
<circle class="badge" cx="200" cy="303" r="11"></circle>
<text class="badge-t" x="200" y="307">1</text>
<circle class="badge" cx="507" cy="303" r="11"></circle>
<text class="badge-t" x="507" y="307">2</text>
<circle class="badge" cx="507" cy="357" r="11"></circle>
<text class="badge-t" x="507" y="361">3</text>
</svg>
</div>
<figcaption>
<b>The canonical record is encoding-blind above the line and encoding-critical below it.</b>
A pointer is a hash of a file, so nothing in the top plane cares that the file became markdown —
convergence, tombstones and byte transport are untouched. The bottom plane is where
<code>strip()</code> now runs <em>inside</em> the signing path, and all three hazards land there.
<ul class="keys">
<li><span class="n">1</span><span><code>records()</code> filters <code>endsWith('.dagcbor')</code> and is “the sole input to the MST build” — a <code>.md</code> record is invisible to the signer.</span></li>
<li><span class="n">2</span><span>Signing is byte-identical across lease-holders only if every peer strips identically. Version skew forks the chain.</span></li>
<li><span class="n">3</span><span>The 300-grapheme truncation happens here, and grapheme segmentation is still unpinned.</span></li>
</ul>
</figcaption>
</figure>
<section class="prose">
<h2>Hazard 1 is the one that can destroy data</h2>
<p>It is not a divergence, it is a deletion. <code>records()</code> returns the record set that <code>sign()</code> materialises. If it cannot see the new files, the repo signs as empty — and an empty repo is not a no-op, it is a commit that deletes every record from the relay.</p>
</section>
<figure>
<div class="figbox">
<svg viewBox="0 0 940 250" role="img" aria-label="A markdown record file is dropped by the dagcbor extension filter in records(), so the record set reaching sign() is empty and the resulting commit deletes every record.">
<defs>
<marker id="a2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah" points="0,1 10,5 0,9"></polygon>
</marker>
<marker id="a2h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah ah-haz" points="0,1 10,5 0,9"></polygon>
</marker>
</defs>
<text class="t-lab" x="16" y="26">ON DISK</text>
<rect class="bx bx-canon" x="16" y="46" width="310" height="48" rx="4"></rect>
<text class="t" x="32" y="76">…/net.favela.post/3msdnle65lk22.md</text>
<rect class="bx" x="16" y="122" width="310" height="48" rx="4"></rect>
<text class="t" x="32" y="152">…/app.bsky.feed.post/….dagcbor</text>
<line class="ln ln-dash" x1="420" y1="34" x2="420" y2="212"></line>
<text class="t-sm mid" x="420" y="228">records() — n.endsWith('.dagcbor')</text>
<line class="ln ln-haz" x1="326" y1="70" x2="400" y2="70"></line>
<path class="ln ln-haz" d="M412 62 L428 78 M428 62 L412 78"></path>
<text class="t-sm t-haz" x="326" y="52">dropped — never reaches the signer</text>
<line class="ln" x1="326" y1="146" x2="486" y2="146" marker-end="url(#a2)"></line>
<rect class="bx" x="492" y="118" width="196" height="56" rx="4"></rect>
<text class="t mid" x="590" y="142">the record set that</text>
<text class="t mid" x="590" y="160">reaches sign()</text>
<line class="ln ln-haz" x1="688" y1="146" x2="742" y2="146" marker-end="url(#a2h)"></line>
<rect class="bx bx-haz" x="748" y="106" width="176" height="80" rx="4"></rect>
<text class="t t-haz mid" x="836" y="132">if every record</text>
<text class="t t-haz mid" x="836" y="150">is .md → empty</text>
<text class="t-sm t-haz mid" x="836" y="170">delete-every-record</text>
</svg>
</div>
<figcaption>
<b>The extension filter is the whole bug.</b> <code>EXT</code> is a module-level constant in
<code>overlay-repo-store.js</code> and <code>records()</code> also calls <code>dagCbor.decode</code>
unconditionally, so the fix is to make both per-collection before a single <code>.md</code> record exists.
The existing anti-wipe guard does not catch this: it aborts only when record <em>files</em> exist, and by
its own count none do.
</figcaption>
</figure>
<section class="prose">
<h2>Hazard 2 turns a deploy into a fork</h2>
<p>The signer is an elected, movable role. It is safe to move because ECDSA here is RFC6979-deterministic, so any lease-holder signing the same view produces byte-identical output. Once the strip runs inside that path, the guarantee rests on the strip too — and a rolling deploy is exactly how two peers come to hold different ones.</p>
</section>
<figure>
<div class="figbox">
<svg viewBox="0 0 960 300" role="img" aria-label="Without a version guard, two peers running different strip versions sign the same canonical record into two different commit CIDs. With the version stamped in the autobase, the under-versioned peer refuses the signer role and one chain survives.">
<defs>
<marker id="a3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah" points="0,1 10,5 0,9"></polygon>
</marker>
<marker id="a3h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<polygon class="ah ah-haz" points="0,1 10,5 0,9"></polygon>
</marker>
</defs>
<!-- LEFT -->
<rect class="plane" x="4" y="4" width="462" height="292" rx="5"></rect>
<text class="t-lab t-haz mid" x="235" y="30">NO GUARD — SILENT FORK</text>
<rect class="bx bx-canon" x="130" y="46" width="210" height="42" rx="4"></rect>
<text class="t mid" x="235" y="73">the same canonical .md</text>
<path class="ln" d="M200 88 L120 128" marker-end="url(#a3)"></path>
<path class="ln" d="M270 88 L350 128" marker-end="url(#a3)"></path>
<rect class="bx" x="34" y="134" width="172" height="46" rx="4"></rect>
<text class="t mid" x="120" y="163">peer A · strip v1</text>
<rect class="bx" x="264" y="134" width="172" height="46" rx="4"></rect>
<text class="t mid" x="350" y="163">peer B · strip v2</text>
<line class="ln ln-haz" x1="120" y1="180" x2="120" y2="218" marker-end="url(#a3h)"></line>
<line class="ln ln-haz" x1="350" y1="180" x2="350" y2="218" marker-end="url(#a3h)"></line>
<rect class="bx bx-haz" x="34" y="224" width="172" height="46" rx="4"></rect>
<text class="t t-haz mid" x="120" y="252">commit CID x</text>
<rect class="bx bx-haz" x="264" y="224" width="172" height="46" rx="4"></rect>
<text class="t t-haz mid" x="350" y="252">commit CID y</text>
<text class="t t-haz mid" x="235" y="253" font-size="19">≠</text>
<!-- RIGHT -->
<rect class="plane" x="494" y="4" width="462" height="292" rx="5"></rect>
<text class="t-lab mid" x="725" y="30">STRIP_VERSION IN BAND — LOUD HALT</text>
<rect class="bx bx-canon" x="620" y="46" width="210" height="42" rx="4"></rect>
<text class="t mid" x="725" y="73">the same canonical .md</text>
<path class="ln" d="M690 88 L610 128" marker-end="url(#a3)"></path>
<path class="ln ln-dash" d="M760 88 L840 128" marker-end="url(#a3)"></path>
<rect class="bx" x="524" y="134" width="172" height="46" rx="4"></rect>
<text class="t mid" x="610" y="163">peer A · strip v1</text>
<rect class="bx" x="754" y="134" width="172" height="46" rx="4"></rect>
<text class="t mid" x="840" y="163">peer B · strip v2</text>
<line class="ln" x1="610" y1="180" x2="610" y2="218" marker-end="url(#a3)"></line>
<line class="ln ln-dash" x1="840" y1="180" x2="840" y2="216"></line>
<path class="ln ln-haz" d="M832 208 L848 224 M848 208 L832 224"></path>
<rect class="bx" x="524" y="224" width="172" height="46" rx="4"></rect>
<text class="t mid" x="610" y="252">one commit chain</text>
<text class="t-sm mid" x="840" y="248">refuses the</text>
<text class="t-sm mid" x="840" y="264">signer role</text>
</svg>
</div>
<figcaption>
<b>The guard does not prevent the skew — it makes the skew loud.</b> An under-versioned peer
declines to sign rather than signing with an older strip, which converts an invisible divergence into a
visible stall. This is Keet’s interrupt pattern, already used twice in this stack: pear-git-v3’s
in-band schema guard in the replicated OrgEventLog, and the autobee migration guard.
</figcaption>
</figure>
<section class="prose">
<h2>What the library guarantees once</h2>
<p>Three packages project from this contract — favela-web, the atproto signer, and ap-bridge. Every feature below exists so a property is enforced in the codec instead of being a discipline all three must separately remember.</p>
</section>
<div class="tablewrap">
<table>
<caption>spec/library.md §4–§9</caption>
<thead>
<tr><th scope="col">feature</th><th scope="col">the guarantee it makes once</th></tr>
</thead>
<tbody>
<tr><td>Loss report</td><td><code>project()</code> returns <code>{ output, loss[] }</code>. The honesty requirement is stated three times in the specs as non-negotiable and has no mechanism today; every loss is already enumerable from the strip rules.</td></tr>
<tr><td>Pinned directives</td><td><code>:::spoiler</code> projects as its inner text, so a spoilered post arrives <em>un-spoilered</em> on bsky — while the same directive is lossless to the fediverse as <code>summary</code> + <code>sensitive</code>.</td></tr>
<tr><td>Fail-closed reach</td><td><code>project()</code> refuses a bsky projection for a non-public record, and unparseable frontmatter degrades to local-only — never to Public. The one place a defect is a privacy bug.</td></tr>
<tr><td>Conformance</td><td>The 36 vectors plus a runner and property laws, so a second implementation self-certifies. Fuzz UTF-8: byte-versus-character offsets is the defect the strip spec names as most likely.</td></tr>
<tr><td>Import</td><td><code>fromAS2</code> / <code>fromBsky</code> return an <code>ImportedView</code> that can never be written back as canonical — re-encoding a record you did not author already cost two of wj62’s posts, unrecoverably.</td></tr>
<tr><td>CLI</td><td><code>strip</code>, <code>project</code>, <code>explain</code>, <code>lint</code>. Every projection defect so far was diagnosed by reading daemon logs; this makes it a one-liner against a file, offline.</td></tr>
</tbody>
</table>
</div>
</div>
</body>
</html>