tinyrelaycommit

Commit 71d8d53c8edf

browse files at this commit | history

1diff --git a/architecture.html b/architecture.html
2deleted file mode 100644
3index d7ae415..0000000
4--- a/architecture.html
5+++ /dev/null
6@@ -1,433 +0,0 @@
7-<!doctype html>
8-<html lang="en">
9-<head>
10-<meta charset="utf-8">
11-<meta name="viewport" content="width=device-width, initial-scale=1">
12-<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%23245d43'/%3E%3Cpath d='M9 9h6v6H9zm8 0h6v6h-6zM9 17h6v6H9z' fill='%23dfebda'/%3E%3Cpath d='M17 17h6v6h-6z' fill='%23e89966'/%3E%3C/svg%3E">
13-<meta name="description" content="An architectural proposal for tinyrelay: templated, on-demand, self-hosted Nostr relays in Go.">
14-<title>tinyrelay — Architecture for a relay you own</title>
15-<style>
16-:root{--bg:#f5f4ef;--paper:#fffef9;--ink:#182820;--muted:#56635b;--line:#ccd3c8;--green:#245d43;--mint:#dfebda;--orange:#a34724;--warm:#f6e6d5;--code:#192b23;--white:#f8faf5;--sidebar:222px}
17-*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:28px}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--green);text-underline-offset:3px}a:hover{text-decoration-thickness:2px}button,select{font:inherit}button{cursor:pointer}button:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--orange);outline-offset:4px}button{color:inherit}::selection{background:var(--mint)}.skip{position:absolute;left:12px;top:-100px;background:var(--paper);padding:10px;z-index:10}.skip:focus{top:12px}
18-.rail{position:fixed;inset:0 auto 0 0;width:var(--sidebar);border-right:1px solid var(--line);padding:31px 25px;display:flex;flex-direction:column;background:var(--bg);overflow:auto}.brand{font-weight:760;font-size:23px;letter-spacing:-1.2px;color:var(--ink);text-decoration:none;display:flex;align-items:center;gap:10px}.mark{width:24px;height:24px;display:grid;grid-template-columns:repeat(2,8px);gap:3px;transform:rotate(-8deg)}.mark i{background:var(--green);border-radius:2px}.mark i:last-child{background:var(--orange)}.rail .sub{font-size:10px;letter-spacing:1.6px;text-transform:uppercase;margin:12px 0 38px;color:var(--muted)}nav{display:grid;gap:3px}nav a{display:flex;gap:13px;align-items:center;font-size:13px;line-height:1.35;text-decoration:none;padding:9px 0;color:var(--muted)}nav a span{font:10px ui-monospace,SFMono-Regular,monospace;color:var(--muted)}nav a.active,nav a:hover{color:var(--green);font-weight:700}.rail-foot{margin-top:auto;padding-top:35px;font-size:11px;color:var(--muted);line-height:1.6}.rail-foot b{display:block;color:var(--ink);font-size:12px}.rail-foot .dot{display:inline-block;width:6px;height:6px;background:var(--green);border-radius:50%;margin-right:5px}
19-main{margin-left:var(--sidebar);max-width:1510px;padding:0 64px 50px}.topline{display:flex;justify-content:space-between;align-items:center;padding:27px 0;border-bottom:1px solid var(--line);font-size:11px;letter-spacing:1.3px;text-transform:uppercase;gap:15px}.topline button{border:0;background:transparent;padding:4px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:1px}.hero{padding:67px 0 42px}.eyebrow{font:11px/1.4 ui-monospace,SFMono-Regular,monospace;letter-spacing:1.8px;text-transform:uppercase;color:var(--green);display:flex;align-items:center;gap:10px}.eyebrow:before{content:"";height:7px;width:7px;background:var(--orange);display:inline-block}.hero h1{font-size:clamp(42px,5.4vw,78px);line-height:1.06;letter-spacing:-3.3px;margin:23px 0 25px;max-width:900px;font-weight:680}.hero h1 em{font-family:Georgia,serif;font-weight:400;color:var(--green)}.lead{max-width:790px;color:var(--muted);font-size:20px;line-height:1.6}.hero-foot{display:flex;flex-wrap:wrap;gap:8px;margin:26px 0}.pill{border:1px solid var(--line);padding:5px 10px;font-size:11px;border-radius:3px;letter-spacing:.3px}.pill.strong{background:var(--green);border-color:var(--green);color:var(--white)}.principles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.principle{padding:26px 22px 28px 0}.principle+.principle{border-left:1px solid var(--line);padding-left:24px}.principle .big{font-size:27px;letter-spacing:-1px;line-height:1.2;margin:7px 0 11px}.principle p{font-size:13px;margin:0;color:var(--muted)}.micro{font:10px ui-monospace,SFMono-Regular,monospace;text-transform:uppercase;letter-spacing:1.5px;color:var(--muted)}
20-section{padding:61px 0 7px;scroll-margin-top:14px}section+section{margin-top:35px;border-top:1px solid var(--line)}.section-head{display:flex;gap:22px;align-items:flex-start;margin-bottom:24px}.section-num{font:12px ui-monospace,SFMono-Regular,monospace;color:var(--orange);padding-top:12px}.section-head h2{font-size:34px;line-height:1.2;letter-spacing:-1.1px;margin:0 0 11px;font-weight:650}.section-head p{color:var(--muted);margin:0;max-width:800px;font-size:16px}h3{font-size:19px;line-height:1.35;letter-spacing:-.4px;margin:0 0 12px}h4{font-size:14px;margin:0 0 8px}p{margin:0 0 15px}.fine{font-size:12px;color:var(--muted)}.source{font-size:11px;line-height:1.65;color:var(--muted);margin-top:13px}.source a{overflow-wrap:anywhere}.note{padding:18px 22px;border-left:3px solid var(--green);background:var(--mint);font-size:14px;margin:23px 0}.note.warm{background:var(--warm);border-color:var(--orange)}.note p:last-child{margin:0}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:32px}.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:25px}.block{background:var(--paper);border:1px solid var(--line);padding:23px}.block p,.block li{font-size:14px}.block p:last-child{margin-bottom:0}ul,ol{padding-left:20px;margin:10px 0 17px}li{padding-left:3px;margin:7px 0}code{font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;background:#e8ece3;padding:2px 4px;border-radius:2px}pre{background:var(--code);color:#e1efde;padding:23px;overflow:auto;line-height:1.65;font-size:12px;margin:0;border-radius:3px;tab-size:2}pre code{background:none;color:inherit;padding:0;font-size:inherit;white-space:pre;overflow-wrap:normal}
21-.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:13px;line-height:1.55}th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1.4px;color:var(--muted);font-weight:600;padding:13px 12px;border-bottom:2px solid var(--ink)}td{padding:17px 12px;border-bottom:1px solid var(--line);vertical-align:top}td:first-child{font-weight:620;min-width:142px}thead th:first-child,tbody td:first-child{padding-left:0}tbody tr:last-child td{border-bottom:0}.phenotype td:nth-child(2){color:var(--muted);width:30%}.phenotype td:nth-child(3){width:45%}.tag{font:9px/1.3 ui-monospace,SFMono-Regular,monospace;text-transform:uppercase;letter-spacing:.7px;display:inline-block;padding:4px 6px;background:var(--mint);color:var(--green);margin-bottom:5px}.tag.drop{color:var(--orange);background:var(--warm)}
22-.map{background:var(--paper);border:1px solid var(--line);padding:28px}.map-head{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap;margin-bottom:25px}.map-head p{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:1.2px}.path-buttons{display:flex;flex-wrap:wrap;gap:5px}.path-buttons button{background:transparent;border:1px solid var(--line);border-radius:3px;padding:7px 12px;font-size:12px}.path-buttons button[aria-pressed=true]{background:var(--green);color:var(--white);border-color:var(--green)}.actors{display:grid;grid-template-columns:1fr 32px 1fr 32px 1fr;align-items:stretch;gap:0}.actor{border:1px solid var(--line);padding:17px;min-width:0;background:var(--bg);position:relative;transition:background .15s,border-color .15s}.actor strong{display:block;font-size:15px;margin:5px 0}.actor small{font-size:12px;line-height:1.55;display:block;color:var(--muted)}.actor.hot{background:var(--mint);border-color:var(--green)}.actor.hot .micro{color:var(--green)}.arrow{display:flex;align-items:center;justify-content:center;font-size:21px;color:var(--muted)}.tier{margin-top:18px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.tier-label{margin-top:23px;margin-bottom:9px}.telemetry-strip{margin-top:18px;padding:13px 15px;background:var(--code);color:var(--white);font:11px/1.6 ui-monospace,SFMono-Regular,monospace}.map-reading{border-top:1px solid var(--line);margin-top:23px;padding-top:21px;display:grid;grid-template-columns:160px 1fr;gap:20px;min-height:113px}.map-reading h3{font-size:16px}.map-reading p{font-size:13px;color:var(--muted);margin:0}.map-reading strong{color:var(--ink)}.flow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:20px 0}.flow span{padding:8px 12px;border:1px solid var(--line);font:12px ui-monospace,SFMono-Regular,monospace}.flow b{font-size:17px;font-weight:400;color:var(--green)}.template-head{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px}.template-head label{font-size:12px;font-weight:600}.template-head select{background:var(--paper);border:1px solid var(--line);padding:8px;max-width:100%;color:var(--ink);font-size:13px}.template-info{padding:23px;background:var(--mint);border:1px solid var(--line)}.template-info dl{margin:18px 0 0;display:grid;grid-template-columns:85px 1fr;gap:11px 16px;font-size:13px}.template-info dt{color:var(--muted)}.template-info dd{margin:0}.pipeline{counter-reset:step;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:25px 0}.pipeline div{padding:21px 18px;border-top:2px solid var(--green);border-right:1px solid var(--line);position:relative}.pipeline div:last-child{border-right:0}.pipeline div:before{counter-increment:step;content:"0" counter(step);display:block;font:10px ui-monospace,SFMono-Regular,monospace;color:var(--green);margin-bottom:12px}.pipeline strong{display:block;font-size:14px;margin-bottom:8px}.pipeline p{font-size:12px;margin:0;color:var(--muted)}details{border-top:1px solid var(--line);padding:17px 0}details:last-child{border-bottom:1px solid var(--line)}summary{cursor:pointer;font-size:15px;font-weight:630;list-style-position:outside;margin-left:17px}details .detail-body{padding:18px 0 0 17px;font-size:14px}.metric{font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;font-weight:400!important;overflow-wrap:anywhere}.decision-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 35px}.decision{padding:24px 0;border-top:1px solid var(--line)}.decision .micro{color:var(--orange);margin-bottom:10px}.decision p{font-size:14px}.roadmap{display:grid;grid-template-columns:92px 1fr;gap:0 25px}.phase{padding:23px 0;border-top:1px solid var(--line)}.phase-code{color:var(--green);font:12px ui-monospace,SFMono-Regular,monospace;padding-top:27px;border-top:1px solid var(--line)}.phase p{font-size:14px}.gate{font-size:12px;border-left:2px solid var(--green);padding-left:12px;color:var(--muted)}.reference-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 35px}.reference-grid p{font-size:12px;margin:0;border-bottom:1px solid var(--line);padding:0 0 13px}.reference-grid a{font-size:13px;font-weight:600}.foot{border-top:1px solid var(--line);margin-top:60px;padding-top:25px;display:flex;justify-content:space-between;gap:20px;font-size:11px;color:var(--muted)}.no-js{padding:12px;background:var(--warm);font-size:13px}
23-@media(min-width:1600px){main{margin-right:auto}}@media(max-width:1150px){:root{--sidebar:185px}.rail{padding:28px 18px}main{padding:0 34px 40px}.hero h1{font-size:58px}.three{grid-template-columns:1fr}.pipeline{grid-template-columns:repeat(2,minmax(0,1fr))}.pipeline div:nth-child(2){border-right:0}.pipeline div:nth-child(n+3){border-top:1px solid var(--line)}}@media(max-width:850px){:root{--sidebar:0px}.rail{position:static;width:auto;border-right:0;border-bottom:1px solid var(--line);padding:18px 24px;display:block}.rail .sub,.rail-foot{display:none}.rail nav{display:flex;overflow-x:auto;margin-top:14px;gap:19px}.rail nav a{white-space:nowrap;font-size:12px;padding:5px 0}.rail nav a span{display:none}main{margin:0;padding:0 24px 30px}.topline{padding:20px 0}.hero{padding:45px 0 30px}.hero h1{font-size:55px}.two{gap:23px}.section-head h2{font-size:29px}.map{padding:22px}.map-reading{grid-template-columns:1fr;gap:5px}.reference-grid{gap:14px 22px}}
24-@media(max-width:580px){body{font-size:15px}main{padding:0 19px 28px}.rail{padding:17px 19px}.topline{font-size:9px;letter-spacing:.8px}.topline button{font-size:10px}.hero h1{font-size:43px;letter-spacing:-2px}.hero .lead{font-size:17px}.principles{grid-template-columns:1fr}.principle,.principle+.principle{padding:20px 0;border-left:0}.principle+.principle{border-top:1px solid var(--line)}.principle .big{font-size:23px}.section-head{gap:14px}.section-head h2{font-size:28px}.section-head p{font-size:14px}section{padding-top:38px}section+section{margin-top:28px}.two,.decision-grid,.reference-grid{grid-template-columns:1fr}.actors{grid-template-columns:1fr}.arrow{height:30px;transform:rotate(90deg)}.tier{grid-template-columns:1fr;gap:12px}.map{padding:17px}.map-head{gap:13px}.path-buttons{gap:6px}.path-buttons button{padding:9px 10px}.pipeline{grid-template-columns:1fr}.pipeline div{border-right:0!important;border-top:1px solid var(--line)}.template-head{align-items:flex-start;flex-direction:column}.template-head select{width:100%}.block,.template-info{padding:18px}pre{font-size:11px;padding:18px}.roadmap{grid-template-columns:51px 1fr;gap:0 13px}.phase-code{font-size:10px}.foot{flex-direction:column;gap:5px}th,td{padding:12px 10px}.phenotype{min-width:610px}table.stack{min-width:610px}details .detail-body{padding-left:0}.note{padding:16px;font-size:13px}.pill{font-size:10px}.topline span{max-width:230px}}
25-@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
26-.two>*{min-width:0}pre{max-width:100%}
27-@media print{body{font-size:10pt;background:white}.rail,.topline button,.path-buttons,.template-head select{display:none}main{margin:0;padding:0;max-width:none}.hero{padding:25px 0}.hero h1{font-size:38pt}.lead{font-size:13pt}section{padding-top:25px;break-inside:auto}section+section{margin-top:20px}.section-head h2{font-size:23pt}.block,.actor,.template-info,.note,pre{break-inside:avoid}pre{white-space:pre-wrap;background:#f3f3ef;color:#111}pre code{white-space:pre-wrap}.table-wrap{overflow:visible}td,th{font-size:9pt;padding:9px}a{color:inherit}details{break-inside:avoid}.hero-foot{margin:15px 0}.topline{padding:10px 0}.foot{margin-top:25px}}
28-</style>
29-</head>
30-<body>
31-<a href="#overview" class="skip">Skip to architecture</a>
32-<aside class="rail">
33- <a class="brand" href="#overview"><span class="mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>tinyrelay</a>
34- <div class="sub">Architecture proposal / 01</div>
35- <nav aria-label="Plan contents">
36- <a href="#overview"><span>00</span>Direction</a>
37- <a href="#phenotype"><span>01</span>Project phenotype</a>
38- <a href="#architecture"><span>02</span>System architecture</a>
39- <a href="#tenancy"><span>03</span>Names &amp; templates</a>
40- <a href="#delivery"><span>04</span>Inbox &amp; outbox</a>
41- <a href="#storage"><span>05</span>Storage &amp; correctness</a>
42- <a href="#o11y"><span>06</span>Observability</a>
43- <a href="#performance"><span>07</span>Performance loop</a>
44- <a href="#reuse"><span>08</span>SDKs &amp; prior art</a>
45- <a href="#operations"><span>09</span>Self-host operations</a>
46- <a href="#delivery-plan"><span>10</span>Build &amp; migration</a>
47- <a href="#decisions"><span>11</span>Decision record</a>
48- <a href="#sources"><span>12</span>Evidence &amp; sources</a>
49- </nav>
50- <div class="rail-foot"><b><span class="dot"></span> Native implementation in progress</b>Based on bindws @ 5d31267<br>Research checked 07 Sep 2026<br>Designed for machines you own.</div>
51-</aside>
52-<main>
53-<div class="topline"><span>Design study | Bindws → native Go</span><button type="button" id="print">Print / save PDF ↗</button></div>
54-<header class="hero" id="overview">
55- <div class="eyebrow">Self-hosted Nostr infrastructure</div>
56- <h1>Many relays.<br>One process.<br><em>Your machine.</em></h1>
57- <p class="lead">Bring bindws’s full capabilities and user workflows to a native, self-hosted Go service. Preserve the experience of creating and running templated relays while replacing the Worker runtime with local services.</p>
58- <div class="hero-foot"><span class="pill strong">Full functional &amp; UX parity</span><span class="pill strong">No fuel or leases</span><span class="pill">No product quotas</span><span class="pill">Automatic inbox / outbox</span><span class="pill">Observability from day one</span></div>
59- <p class="fine">Recommendation: a modular monolith with an embedded control plane, a SQLite database per named relay, and shared workers. A personal template provisions a family of relays. Public and management pages are plain HTML and basic forms, rebuilt without bindws branding or styling.</p>
60- <div class="note"><strong>Measured Git results | 7 September 2026.</strong> Six repositories from the local Developer folder passed push, clone, fetch, and concurrent clone tests on Slate. Streaming reduced peak Go RSS from 6,094 MiB to 40.6 MiB; Atlas cloned in 9.15 seconds against an 8.92-second native Git baseline. <a href="artifacts/git-performance/git-performance.html">Open the report, failures, methodology and raw data.</a></div>
61- <div class="note"><strong>Completion contract: work until parity.</strong> The finished Go project must cover every existing bindws feature, protocol behavior, management action and user journey, except the explicitly agreed changes below. Intermediate phases are implementation checkpoints; a working core relay is not completion. Features that owners can switch off still have to be implemented and tested.</div>
62-</header>
63-<div class="principles">
64- <div class="principle"><div class="micro">01 / Product</div><div class="big">Preserve the workflows.</div><p>All relay, community, hosting and management capabilities carry forward. Templates remain the API; visual simplicity does not reduce functionality.</p></div>
65- <div class="principle"><div class="micro">02 / Runtime</div><div class="big">Local by default.</div><p>One daemon, local disk, long-lived sockets and restart-safe jobs. Capacity is determined by the machine and its workload.</p></div>
66- <div class="principle"><div class="micro">03 / Engineering</div><div class="big">Measure before tuning.</div><p>Instrument acceptance, query, fan-out and delivery before optimization. Use traces and profiles to explain benchmark results.</p></div>
67-</div>
68-<noscript><p class="no-js">The full plan is readable without JavaScript. Interactive path and template previews require JavaScript; their default views remain visible.</p></noscript>
69-
70-<section id="phenotype">
71- <div class="section-head"><span class="section-num">01</span><div><h2>The runtime changes. The workflows carry forward.</h2><p>Target full bindws parity on a local host. Rework provider-shaped implementation details while preserving what users can do, how they authorize it, and the result they receive.</p></div></div>
72- <div class="table-wrap"><table class="phenotype">
73- <thead><tr><th>Trait</th><th>Bindws today</th><th>Proposed tinyrelay</th></tr></thead>
74- <tbody>
75- <tr><td>Identity of the product</td><td>On-demand hosted relays with a console, fuel and many hosting features.</td><td><span class="tag">Full parity</span><br>A self-hosted relay factory with the complete relay, community, hosting and management surface. Personal and community relays remain template instances.</td></tr>
76- <tr><td>Provisioning</td><td>Unclaimed → leased or claimed. A lease lasts 14 days and can be claimed in place.</td><td><span class="tag drop">Remove</span><br>Creation requires an owner and template. No lease endpoint, trial state, claim conversion, trial retention or expiry teardown.</td></tr>
77- <tr><td>Economics &amp; limits</td><td>Fuel credits, free allowances, paid overages, fixed job/queue ceilings and per-member caps.</td><td><span class="tag drop">Remove</span><br>No billing ledger, fuel gate, built-in tenant quotas or paid capacity tiers. Preserve explicit content policy and host-level backpressure.</td></tr>
78- <tr><td>Unit of execution</td><td>One Durable Object per name; hibernating sockets and alarm rounds.</td><td><span class="tag">Replace</span><br>A lazily loaded relay runtime with owned goroutines; shared scheduling continues when no users are connected.</td></tr>
79- <tr><td>Template model</td><td>JSONC presets compile into the Worker; omitted configuration sections stay unchanged.</td><td><span class="tag">Evolve</span><br>Embedded catalog + local files, immutable revisions, plan/apply/export and first-class inbox/outbox rules. No tenant-supplied executable code.</td></tr>
80- <tr><td>Automatic delivery</td><td>Opt-in durable NIP-65 queue using locally stored lists. Pending queue capped at 512; four attempts; full queues can omit delivery.</td><td><span class="tag">Evolve</span><br>Template-driven routing; durable intent in the event transaction; list discovery, connection reuse, retry state and visible terminal outcomes. No fixed tenant delivery ceiling.</td></tr>
81- <tr><td>Public inbox</td><td>Kind allowlist with 90-day retention. Template comments acknowledge that the owner-tag predicate is missing.</td><td><span class="tag">Complete</span><br>Require intended-recipient targeting. Continuously gather matching public mentions from configured sources; retention is an explicit content choice.</td></tr>
82- <tr><td>Persistence</td><td>Durable Object SQLite + R2; SQL and storage shaped by provider restrictions.</td><td><span class="tag">Replace</span><br>Catalog database + tenant SQLite files and content-addressed blobs on local disk. Preserve events, files, sites, repositories, history and backups; S3 is an optional storage adapter.</td></tr>
83- <tr><td>Names &amp; domains</td><td>Wildcard host routing, HOSTS KV and Cloudflare for SaaS; celld has a manual hostname map.</td><td><span class="tag">Replace</span><br>Explicit catalog aliases, host or path routing, TLS at the operator’s proxy. Unknown hosts never allocate a relay.</td></tr>
84- <tr><td>Owner &amp; community</td><td>Nostr ownership, members, bans, moderators, invitations, group records and relay signing identity.</td><td><span class="tag">Preserve</span><br>Keep membership, invite trees, guest rules, moderation, groups, transfer and succession. Separate host administrator from relay owner; retain Nostr authorization.</td></tr>
85- <tr><td>Site &amp; management UI</td><td>Branded public pages and a generated console bundle with many feature tabs.</td><td><span class="tag">Rebuild presentation</span><br>Every existing action and role-specific workflow remains available through plain HTML/forms. Preserve sign-in, previews, progress, recovery, QR and app handoff. No inherited visual branding.</td></tr>
86- <tr><td>Hosting &amp; computed services</td><td>Blossom/NIP-96, sites, GRASP Git, Marmot, views, succession and NIP-9a callbacks.</td><td><span class="tag">Required for parity</span><br>Implement all of them on local storage and workers. Preserve feature toggles and their existing access rules. Stage the work, but do not finish with these features missing.</td></tr>
87- <tr><td>Observability</td><td>Usage meters, JSON logs, Cloudflare traces and optional Analytics Engine.</td><td><span class="tag">Replace</span><br>Prometheus metrics, structured logs, optional OTel traces and protected pprof/runtime diagnostics. No telemetry feeds a fuel calculation.</td></tr>
88- </tbody>
89- </table></div>
90- <p class="source">Local evidence: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/10-architecture.md">architecture</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/16-hosting-without-cloudflare.md">celld hosting</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/delivery.ts">delivery.ts:7–113</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/03-inbox.jsonc">inbox template:1–42</a>. Inspected in the adjacent checkout; links identify the corresponding revision.</p>
91- <div class="note"><strong>“No resource limits” is a product commitment.</strong> There is no maximum relay count, monthly event allowance, fuel balance or tenant storage tier in the domain model. The host still has finite RAM, disk and file descriptors. Flow control uses finite buffers, fair scheduling and connection deadlines; overload returns a clear retryable failure before acceptance. Retention, bans and authorized access remain owner-selected relay behavior.</div>
92- <div class="note"><strong>Product UI layout.</strong> The rebuilt public and management pages use bare HTML, tables and basic forms. The fixed layout target is 960px on desktop and 360px on mobile, with no inherited bindws branding or visual system. When useful, a locally vendored Fixi project bundle (fixi, moxi, paxi, ssexi and rexi) may provide progressive form swaps, inline actions, morphing, streamed job progress and ergonomic requests. It remains an enhancement layer: signed NIP-98 mutations, accessible forms and server responses remain usable without it.</div>
93- <details open><summary>Explicit changes to the parity baseline</summary><div class="detail-body"><ul><li><strong>Remove fuel and product capacity entitlements:</strong> no balance, top-up, billing ledger, paid tier or inherited fixed tenant quota. NIP-57 zap event support and non-fuel notifications remain in scope.</li><li><strong>Remove relay leases and trials:</strong> assign ownership at creation, including fork destinations. Preserve membership invitation codes and their claim/join workflows; those are unrelated to leasing a relay.</li><li><strong>Replace cloud infrastructure:</strong> Workers, Durable Objects, R2/KV bindings, alarms and provider domain provisioning become local Go services. Preserve their user-facing capabilities through local implementations.</li><li><strong>Rebuild presentation:</strong> plain HTML and forms, no bindws style or branding. Keep task flows, permissions, signers, feedback, sharing and accessible alternatives. The command is <code>tiny</code>.</li><li><strong>Improve inbox/outbox and observability:</strong> add targeted inbox admission, persistent automation and measurement-driven performance work. Document behavioral improvements explicitly; do not silently reinterpret imported policies.</li></ul><p>These changes do not authorize dropping unrelated features. Keep a precise source-to-target mapping for every exception and verify that its replacement workflow is complete.</p></div></details>
94-</section>
95-
96-<section id="architecture">
97- <div class="section-head"><span class="section-num">02</span><div><h2>A small process with clear ownership.</h2><p>The tenant boundary is a named relay. Each runtime owns its policy, subscriptions and store. Cross-relay work goes through explicit services.</p></div></div>
98- <div class="map">
99- <div class="map-head"><p>One host / one daemon</p><div class="path-buttons" role="group" aria-label="Highlight an architectural path"><button type="button" data-path="publish" aria-pressed="true">Publish</button><button type="button" data-path="subscribe" aria-pressed="false">Subscribe</button><button type="button" data-path="delivery" aria-pressed="false">Deliver</button><button type="button" data-path="create" aria-pressed="false">Create</button></div></div>
100- <div class="actors" aria-label="Primary request path">
101- <div class="actor hot" data-node="edge"><div class="micro">Network boundary</div><strong>TLS proxy → HTTP / WS</strong><small>Clients, console and CLI<br>Canonical host or relay path</small></div>
102- <div class="arrow" aria-hidden="true">→</div>
103- <div class="actor hot" data-node="registry"><div class="micro">Process boundary</div><strong>Tenant registry</strong><small>Resolve name → stable ID<br>Load once, retain while active</small></div>
104- <div class="arrow" aria-hidden="true">→</div>
105- <div class="actor hot" data-node="runtime"><div class="micro">Tenant boundary</div><strong>Relay runtime × N</strong><small>Protocol | auth | policy<br>Queries | subscriptions | fan-out</small></div>
106- </div>
107- <div class="micro tier-label">Services inside the same Go process</div>
108- <div class="tier">
109- <div class="actor" data-node="control"><div class="micro">Control plane</div><strong>Provision &amp; configure</strong><small>Catalog DB, template revisions,<br>owners, aliases and audit trail</small></div>
110- <div class="actor hot" data-node="store"><div class="micro">Durable boundary</div><strong>SQLite per relay</strong><small>Events + policy + delivery intent<br>One local transaction</small></div>
111- <div class="actor" data-node="workers"><div class="micro">Background work</div><strong>Shared scheduler &amp; dialer</strong><small>Inbox pulls ↔ remote relays<br>Outbox delivery → remote relays</small></div>
112- </div>
113- <div class="telemetry-strip">All boundaries → metrics + structured logs + optional OTel spans → local diagnostics / optional collector</div>
114- <div class="map-reading" aria-live="polite"><h3 id="path-title">Publish an event</h3><p id="path-description"><strong>Resolve → validate → authorize → commit → acknowledge → fan-out.</strong> Persist the event and routing intent together. An OK confirms local durability; remote delivery proceeds independently. The event never crosses another tenant’s runtime implicitly.</p></div>
115- </div>
116- <div class="two" style="margin-top:26px">
117- <div><h3>Own the domain, reuse the wire protocol.</h3><p>Use a Khatru adapter for Nostr transport and hooks. Tinyrelay owns admission, transaction boundaries, lifecycle, routing and telemetry. The web surface uses Go HTML templates and ordinary form handlers. Start with one Khatru instance per active tenant; do not mutate framework hooks during concurrent requests.</p><p>Every task belongs to a context, has a stop path, and is joined on shutdown. The registry never holds a global lock during SQL or network I/O. Live subscriptions remain in memory and are rebuilt by reconnecting clients after restart.</p></div>
118- <div><h3>Concurrency without a second platform.</h3><p>Readers run concurrently; each tenant serializes short writes. A shared scheduler fairly dispatches due jobs, backed by persistent tenant state. Each socket has a single writer and bounded pending output; a slow consumer is disconnected with a reason.</p><p>Runtime eviction requires zero connections, zero in-flight requests and no running task. Due jobs reacquire the tenant. Closing an idle database releases handles; it never deletes relay state.</p></div>
119- </div>
120- <details><summary>Proposed Go package boundaries</summary><div class="detail-body"><pre><code>cmd/tiny startup, serve, management CLI
121-internal/host listeners, routing, registry, lifecycle
122-internal/control creation, owners, aliases, template plan/apply
123-internal/relay ingest/query services and policy snapshots
124-internal/protocol Khatru adapter, AUTH, HTTP and NIP adapters
125-internal/storage catalog + tenant SQLite; atomic ingest
126-internal/delivery discovery, routing, durable attempts, dialer
127-internal/jobs fair scheduler, pulls, expiry, backup tasks
128-internal/media local Blossom/NIP-96 blobs, mirror, reports
129-internal/sites manifests, isolated origins, verified serving
130-internal/git GRASP policy, Git objects/refs, recovery journal
131-internal/community members, invites, moderation, signed group state
132-internal/records views, discovery, notifications, succession
133-internal/telemetry metric vocabulary, spans, slog, diagnostics
134-internal/migrate bindws reader, import report, reconciliation
135-templates/ embedded versioned JSONC documents
136-web/ plain HTML templates; optional signer script</code></pre><p class="source">This is a proposed layout. Use small interfaces at storage, clock, dialer and signer boundaries; avoid a general plugin engine or an interface for every struct. The public site and management pages have no frontend framework or inherited bindws assets.</p></div></details>
137-</section>
138-
139-<section id="tenancy">
140- <div class="section-head"><span class="section-num">03</span><div><h2>Create owned relays on demand.</h2><p>Provisioning is a durable control operation. Activation is a transient runtime concern. Neither introduces a lease.</p></div></div>
141- <div class="two">
142- <div class="block"><h3>Durable identity</h3><p>An authorized operator creates a relay with a stable internal ID, unique name, owner pubkey and template revision. A configured owner allowlist may self-provision through signed management requests. There is no open anonymous creation door by default.</p><div class="flow"><span>creating</span><b>→</b><span>ready</span><b>↔</b><span>disabled</span></div><p><strong>Deletion is explicit.</strong> Disabling closes access and pauses delivery while preserving data. No idle timeout, owner inactivity or calendar date deletes a relay. Content expiry under NIP-40 is separate.</p></div>
143- <div class="block"><h3>Runtime lifecycle</h3><p>Resolve a registered host or path, acquire its runtime, and lazily open the tenant database. Single-flight activation prevents two runtimes from owning the same tenant. Unknown names return an error and allocate nothing.</p><div class="flow"><span>unloaded</span><b>↔</b><span>active</span><b>→</b><span>draining</span></div><p>A database handle cache is a host implementation detail, not a relay-count limit. Background jobs can activate a relay even with no connected clients.</p></div>
144- </div>
145- <p class="fine" style="margin-top:18px">Creation spans catalog and tenant files: write an idempotent “creating” catalog record, initialize the tenant database and owner, then mark ready. Only ready records are routable. Startup completes or reports interrupted creation. Unique names, idempotency keys and a data-directory process lock prevent duplicate ownership.</p>
146- <h3 style="margin-top:30px">Keep configuration portable; make behavior explicit.</h3>
147- <p>A personal bundle creates three ordinary named relays: <code>alice-outbox</code>, <code>alice-inbox</code> and <code>alice-dm</code>. Each has its own database and policy. A bundle is an additional provisioning convenience. Preserve the full existing template catalog: default, outbox, inbox, private, chat, media, search, articles, dm, quiet, site, marmot, grasp, marmot-members and home, together with their connection templates and source-dependent jobs.</p>
148- <div class="template-head"><label for="template-select">Explore the proposed built-in roles</label><select id="template-select"><option value="outbox">Personal / public outbox</option><option value="inbox">Personal / public inbox</option><option value="dm">Personal / private DM inbox</option><option value="community">Community / members publish</option></select></div>
149- <div class="two">
150- <div class="template-info" aria-live="polite"><div class="micro">Template behavior</div><h3 id="template-title" style="margin-top:10px">Public outbox</h3><p id="template-about">Your public events live here and automatically travel to the relays that should receive them.</p><dl><dt>Writes</dt><dd id="template-writes">Owner; signed events only</dd><dt>Reads</dt><dd id="template-reads">Public</dd><dt>Automation</dt><dd id="template-auto">Owner write relays + tagged recipients’ read relays + explicit mirrors</dd><dt>Retention</dt><dd id="template-retention">No automatic age-based purge</dd></dl></div>
151- <pre aria-label="Illustrative template configuration"><code id="template-code">{
152- "format": "tinyrelay/template/1",
153- "id": "personal-outbox",
154- "revision": 1,
155- "policy": {
156- "writes": "owner",
157- "reads": "public",
158- "blockPrivateKinds": true
159- },
160- "automation": {
161- "delivery": "nip65-public",
162- "origin": "local-client"
163- },
164- "retention": []
165-}</code></pre>
166- </div>
167- <p class="fine" style="margin-top:14px">The selector illustrates four roles; it does not define the scope of the template catalog. This is a proposed schema, not an implemented API. Owner and name belong to the creation request. Both hostname endpoints and <code>wss://relay.example/r/alice-outbox</code> paths resolve to the same stable tenant ID.</p>
168- <div class="three" style="margin-top:28px">
169- <div><h4>Compile once</h4><p>Validate JSONC against a strict schema. Resolve an immutable revision into a typed policy snapshot. No JavaScript, WASM, shell interpolation or arbitrary code execution inside templates.</p></div>
170- <div><h4>Change deliberately</h4><p>Plan/apply/export remain first-class. On update, omitted sections stay unchanged; an empty list explicitly clears a list. Reject unknown fields and use a revision precondition to prevent lost updates.</p></div>
171- <div><h4>Publish capabilities truthfully</h4><p>Template files do not hot-change existing tenants. Applying a revision records an audit entry and swaps the policy atomically. Generate NIP-11 from a build capability registry intersected with tenant-enabled features; only conformance-tested capabilities enter that registry.</p></div>
172- </div>
173- <p class="source">Preserved semantics: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/config.ts">bindws config.ts</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/README.md">relay template contract</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/connection-templates/README.md">connection shortcuts</a>. App shortcuts stay presentation metadata; visibility never substitutes for authorization.</p>
174-</section>
175-
176-<section id="delivery">
177- <div class="section-head"><span class="section-num">04</span><div><h2>Automatic inbox and outbox, precisely.</h2><p>Separate public event distribution, mention collection and encrypted-message receipt. The same worker infrastructure serves them; their authorization rules differ.</p></div></div>
178- <div class="table-wrap"><table class="stack"><thead><tr><th>Role</th><th>Acceptance &amp; visibility</th><th>Automatic behavior</th></tr></thead><tbody>
179- <tr><td>Public outbox</td><td>Owner writes by default; public reads. Block private kinds, exclude ephemeral traffic from durable routing, and never forward NIP-70 protected events.</td><td>Forward newly accepted local public events to the author’s NIP-65 write relays, tagged recipients’ read relays and configured mirrors. Imported/pulled events do not trigger redistribution.</td></tr>
180- <tr><td>Public inbox</td><td>Accept configured public kinds only when a valid <code>p</code> tag names the owner or an allowed recipient. Sender profiles may be fetched as related metadata; they do not get an unrestricted kind-0 admission bypass.</td><td>Maintain subscriptions to configured sources and the owner’s other public read relays for matching mentions. Recover gaps with overlapping windows and ID deduplication; use NIP-77 reconciliation where available.</td></tr>
181- <tr><td>Private DM inbox</td><td>Verify the outer kind-1059 event ID/signature and its recipient <code>p</code> tag against local recipients. This does not verify the encrypted inner message. Require NIP-42 AUTH matching the outer recipient for queries, counts and live delivery; membership alone grants no access.</td><td>Advertise this endpoint through an owner-signed kind-10050 list. Clients deliver their recipient-specific wraps directly. The relay stores opaque ciphertext and does not discover, decrypt or blast private messages.</td></tr>
182- </tbody></table></div>
183- <p class="source"><a href="https://github.com/nostr-protocol/nips/blob/master/65.md">NIP-65</a> defines public relay-list discovery, not a server delivery guarantee. <a href="https://github.com/nostr-protocol/nips/blob/master/17.md">NIP-17</a> uses kind 10050 for DM relays and recipient-restricted gift-wrap reads. These are distinct from a public mentions inbox.</p>
184- <div class="note"><strong>Automatic begins after explicit setup.</strong> Creating a personal bundle enables its local policies and workers. The console prepares merged kind-10002 and kind-10050 updates for the owner’s NIP-07/NIP-46 signer. The daemon never possesses the owner’s secret key or pretends its relay key is that owner. Display “discovery not published” until the signed lists are published.</div>
185- <div class="pipeline">
186- <div><strong>Record intent</strong><p>In the event transaction, add durable routing intent with origin and policy revision. A cold discovery cache never blocks a local publish on network I/O.</p></div>
187- <div><strong>Resolve destinations</strong><p>Read the newest verified signed lists; use configured indexers on cache misses. Persist each normalized destination independently. Missing lists remain visible as unresolved work.</p></div>
188- <div><strong>Attempt delivery</strong><p>Reuse connections by endpoint and authentication context. Correlate each OK with its event ID. Record attempts, next-attempt time and structured outcome per target.</p></div>
189- <div><strong>Recover &amp; report</strong><p>Retry transient failures with jittered backoff. Duplicate remote acceptance is success. Permanent rejections remain inspectable and can be retried deliberately.</p></div>
190- </div>
191- <div class="two">
192- <div><h3>The delivery contract</h3><ul><li><strong>At-least-once attempts</strong>, with idempotency by tenant + event ID + destination. A crash after remote acceptance can cause a duplicate retry.</li><li><strong>Local OK ≠ remote delivery.</strong> Accepted means committed locally with routing intent when enabled. The console reports remote status separately.</li><li><strong>No silent queue eviction.</strong> Pending intent stays on disk through temporary outages. Optional expiry is an explicit operator retention policy, never a hidden tenant ceiling; expiry, deletion or cancellation records a terminal status.</li><li><strong>Policy is checked again before sending.</strong> A deletion, expiration, disabled relay, revoked author or newly private policy cancels now-ineligible work.</li></ul></div>
193- <div><h3>Discovery and network boundaries</h3><ul><li>Verify IDs and signatures on list events; resolve replaceable-event ordering deterministically. A list refresh changes future routes and revalidates pending destinations. Send the author’s verified kind-10002 list alongside public distribution as NIP-65 recommends.</li><li>Track inbox progress per source and filter; NIP-01 time-window pulls cannot prove a complete history. Surface partial backfills; late/backdated events require reconciliation.</li><li>Block loopback, link-local, metadata and private-network egress by default, including resolved IPs on every dial. Operators may explicitly allow LAN relays. Normalize aliases to prevent self-delivery loops.</li><li>If an upstream requires owner AUTH, report an authentication requirement and use a scoped external signer only when explicitly configured. A relay’s service key is a different identity and cannot impersonate its owner.</li></ul></div>
194- </div>
195- <details><summary>Job ownership and inbox checkpoint rules</summary><div class="detail-body"><p>Unique delivery keys prevent duplicate queue rows. Workers claim due rows using an atomic conditional update that records a reservation token and expiry; long tasks renew ownership. Completion updates must match the token, fencing stale workers from overwriting newer state. Cancel expired work before reclaiming it locally. Remote relays do not support this fencing token, so remote duplicate sends remain possible and are part of the at-least-once contract.</p><p>One job owns each tenant/source/filter checkpoint. Commit ingested event batches and their replay checkpoint together where the source exposes a usable cursor. For time-window Nostr queries, advance the completed window only after all returned events are committed and EOSE is received; otherwise replay that window with overlap and ID deduplication. Never derive a completed checkpoint from the greatest timestamp seen so far.</p></div></details>
196- <p class="fine" style="margin-top:18px">Remote availability and client discovery cannot be guaranteed. Local workers can use already verified lists or configured targets immediately after setup; new endpoints become discoverable to other clients only after the owner signs and publishes updated lists. Public inbox collection is limited to selected sources. Following an owner’s contact list to build a feed is a separate optional mirror job.</p>
197- <p class="source">Prior behavior: <a href="https://github.com/barrydeen/haven">HAVEN</a> combines role-specific relays, inbox gathering and outbox broadcasting. Bindws already implements <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/04-data-and-names.md">NIP-65 delivery and per-target push cursors</a>; its <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/push.ts">NIP-9a callbacks</a> are a distinct delivery service required for parity, with their own registration, authorization, retry and revocation behavior.</p>
198-</section>
199-
200-<section id="storage">
201- <div class="section-head"><span class="section-num">05</span><div><h2>Durability is part of the fast path.</h2><p>Start with SQLite because events, policy changes and delivery intent can share one transaction. Choose a different engine only when measured costs justify a new consistency model.</p></div></div>
202- <div class="two">
203- <div><pre><code>/var/lib/tinyrelay/
204- catalog.sqlite
205- tenants/
206- &lt;stable-id&gt;/
207- relay.sqlite
208- relay.sqlite-wal
209- blobs/ # Blossom, NIP-96 and verified site assets
210- repositories/ # GRASP Git data; layout behind adapter
211- backups/
212- secrets/ # relay keys; restrictive permissions
213-
214-Tenant tables
215- events, tags, replacement_keys, tombstones
216- policy_revisions, members, bans, audit
217- routing_intents, deliveries, sync_cursors, jobs</code></pre><p class="source">Use stable generated IDs for directories; relay names are catalog aliases. Event IDs deduplicate within a tenant, never globally across private tenants.</p></div>
218- <div><h3>One transaction, one truthful acknowledgement.</h3><p>Validate the event’s canonical ID and signature, apply authorization and content rules, then begin a short write transaction. Recheck the policy revision. Atomically apply replacement/deletion semantics, indexes and required delivery intent; commit before sending a successful stored-event OK.</p><p>Use WAL, one write connection per tenant, short read transactions and a small read pool. Set durability pragmas explicitly on every applicable connection. Start with <code>synchronous=FULL</code>; make a weaker durability mode an explicit later choice, never a silent throughput tweak.</p><p>SQLite WAL supports concurrent readers with one writer per database. Long readers can delay checkpoints. A local filesystem is required for this layout; a shared NFS volume is not a multi-node scaling mechanism.</p><p class="source"><a href="https://www.sqlite.org/wal.html">SQLite WAL</a> | <a href="https://www.sqlite.org/pragma.html#pragma_synchronous">synchronous durability</a></p></div>
219- </div>
220- <details open><summary>Correctness invariants to test before optimization</summary><div class="detail-body"><div class="two"><ul><li>Every ingest source uses the same validation/storage service; internal imports carry an explicit authority and origin, not a blanket policy bypass.</li><li>Replaceable and addressable events obey timestamp and ID tie-breaking. Authorized deletion, deletion-before-arrival tombstones, expiry and index cleanup are transactional.</li><li>Ephemeral events are never persisted and have a distinct acceptance contract. Duplicates do not create extra routing intent.</li><li>Reads, COUNT, search, sync and live fan-out enforce the same visibility policy; private data cannot leak through counts or auxiliary paths.</li></ul><ul><li>Register a subscription against a commit sequence barrier; read its bounded snapshot, then drain buffered later commits. Deduplicate overlaps and send EOSE after the historical snapshot.</li><li>Policy tightening invalidates affected subscriptions. Slow readers cannot pin a SQL cursor while waiting on a network socket; page or materialize bounded batches.</li><li>Crash before commit yields no successful OK; crash after commit preserves the event and intent. Local live delivery is best effort; reconnect queries recover durable events.</li><li>Disk-full or I/O failure rejects the write before OK. Stop accepting affected writes and report storage health; never report durable success from an in-memory queue.</li></ul></div></div></details>
221- <details><summary>Query strategy and the storage-adapter decision</summary><div class="detail-body"><p>Begin with indexes for ID, author/time, kind/time, author/kind/time and tag name/value/time. Order by creation time and ID, limit each filter as the protocol requires, deduplicate unions, and use keyset iteration for jobs. Record query plans and rows visited in developer benchmarks. Add FTS only with the search milestone.</p><p>The proposed SQLite store must own atomic event + intent writes. Khatru’s storage hooks can delegate to that service; do not enqueue from an after-save callback. Eventstore is useful as an adapter/reference, but its event-only interface does not establish a transaction spanning a separate job database. A split event engine would require a durable change journal and recovery protocol.</p><p>The first implementation spike must prove atomic replacement/deletion, truthful query errors, the historical/live handoff, per-event private visibility and runtime shutdown through Khatru. If hooks cannot preserve these invariants, keep the SDK and implement the small missing protocol/lifecycle adapter directly.</p></div></details>
222- <details><summary>Backup, restore and tenant isolation</summary><div class="detail-body"><p>Use SQLite’s online backup API or a coordinated quiescent snapshot, never copy a live main database file alone. A manifest records schema, tenant IDs, config revisions, event counts, checksums and key references. Encrypt backups containing signing keys. Optional blob storage needs a manifest-consistent snapshot with garbage collection paused for the backup.</p><p>Restore into a new directory, validate hashes/schema, run integrity and protocol checks, and default restored outbound workers to paused so a rehearsal cannot rebroadcast. Catalog/tenant backups reconcile by stable ID. Logical isolation is enforced in code and files; one process is not a security sandbox against malicious extensions or host compromise.</p><p class="source"><a href="https://www.sqlite.org/backup.html">SQLite backup API</a>. Proposed reliability target: preserve every successfully acknowledged stored event across process restart and, with FULL sync and a correctly functioning storage stack, power loss; a single host is still a hardware failure domain.</p></div></details>
223-</section>
224-
225-<section id="o11y">
226- <div class="section-head"><span class="section-num">06</span><div><h2>Observability is the first subsystem.</h2><p>Define the measurement vocabulary alongside the domain model. Instrument the first vertical slice and every subsequent service as it is added. Instrumentation is required; external telemetry export is optional.</p></div></div>
227- <div class="three">
228- <div class="block"><div class="micro">Always available</div><h3 style="margin-top:10px">Metrics &amp; logs</h3><p>Prometheus text exposition on the private diagnostics listener; structured <code>slog</code> records to stderr. Use one metric registry. The relay works normally without a scraper.</p></div>
229- <div class="block"><div class="micro">Optional export</div><h3 style="margin-top:10px">OpenTelemetry traces</h3><p>Instrument with the OTel API and enable the SDK/OTLP exporter when configured. Short spans per operation, plus links across durable jobs; do not keep one span open for a socket’s lifetime.</p></div>
230- <div class="block"><div class="micro">On demand</div><h3 style="margin-top:10px">Go diagnostics</h3><p>CPU, heap, goroutine, block and mutex profiles; short runtime traces for scheduling and blocking. Bind pprof to loopback or an authenticated administrative listener.</p></div>
231- </div>
232- <p class="source"><a href="https://opentelemetry.io/docs/languages/go/instrumentation/">OTel Go instrumentation</a> | <a href="https://opentelemetry.io/docs/languages/go/exporters/">exporters</a> | <a href="https://go.dev/doc/diagnostics">Go diagnostics</a> | <a href="https://pkg.go.dev/net/http/pprof">pprof</a></p>
233- <div class="flow"><span>ingress</span><b>→</b><span>validate / policy</span><b>→</b><span>commit</span><b>→</b><span>fan-out</span><b>→</b><span>delivery attempt</span></div>
234- <div class="table-wrap"><table class="stack"><thead><tr><th>Boundary</th><th>Signals to ship first</th><th>Question answered</th></tr></thead><tbody>
235- <tr><td>Acceptance</td><td class="metric">relay_ingest_total{outcome}<br>relay_ingest_duration_seconds<br>relay_validation_duration_seconds</td><td>Is time spent validating, waiting for a writer or committing? Are rejections policy decisions or failures?</td></tr>
236- <tr><td>Connections &amp; queries</td><td class="metric">relay_connections_active<br>relay_subscriptions_active<br>relay_query_duration_seconds<br>relay_eose_duration_seconds</td><td>How much state does an idle connection cost? Which query shapes delay historical replay?</td></tr>
237- <tr><td>Fan-out</td><td class="metric">relay_fanout_duration_seconds<br>relay_socket_queue_bytes<br>relay_slow_consumer_disconnects_total</td><td>Is subscriber matching expensive, or are downstream sockets slow?</td></tr>
238- <tr><td>Delivery &amp; gathering</td><td class="metric">relay_delivery_pending<br>relay_delivery_oldest_age_seconds<br>relay_delivery_attempts_total{outcome}<br>relay_sync_lag_seconds</td><td>Is an upstream unavailable, discovery missing, or the local scheduler saturated?</td></tr>
239- <tr><td>Storage</td><td class="metric">relay_write_queue_wait_seconds<br>relay_commit_duration_seconds<br>relay_storage_busy_total<br>relay_wal_bytes / relay_checkpoint_duration_seconds</td><td>Do write serialization, fsync or long readers explain the tail?</td></tr>
240- <tr><td>Lifecycle &amp; runtime</td><td class="metric">relay_tenants_active / relay_tenants_registered<br>relay_activation_duration_seconds<br>Go/process collectors; disk free bytes; job recovery failures</td><td>Are idle tenants retaining handles or goroutines? Can the process drain, recover and keep headroom?</td></tr>
241- <tr><td>Files, sites &amp; Git</td><td class="metric">relay_blob_operations_total{operation,outcome}<br>relay_blob_io_bytes_total{direction}<br>relay_site_verify_duration_seconds<br>relay_git_operation_duration_seconds{operation,outcome}</td><td>Is hashing, pack processing, file I/O or publication delaying an upload, site request or Git operation? Do large transfers affect relay traffic?</td></tr>
242- <tr><td>Derived records &amp; management</td><td class="metric">relay_job_duration_seconds{job_class,outcome}<br>relay_job_oldest_due_age_seconds{job_class}<br>relay_management_duration_seconds{action_class,outcome}</td><td>Are views, callbacks, notifications, succession and owner actions making progress? Keep job/action classes finite and exclude payloads and identities.</td></tr>
243- </tbody></table></div>
244- <p class="fine">Names are proposed. All durations are histograms in seconds. Define p50/p95/p99 panels and buckets around agreed workloads. Export aggregate depths and maximum ages; do not create a time series per socket or tenant.</p>
245- <div class="two" style="margin-top:25px">
246- <div><h3>Useful telemetry with bounded cardinality.</h3><p>Labels come from a fixed vocabulary: operation, transport, event class and outcome. No event IDs, pubkeys, tenant names, subscription IDs, arbitrary kinds, URLs or full filter text in metric labels. Hashing those values does not reduce cardinality.</p><p>Provide authenticated, on-demand tenant diagnostics for detailed state. Exclude event bodies, ciphertext, keys, tokens, full URLs and identifiers from default traces/logs. Management audit records have a separate access and retention policy.</p><p class="source"><a href="https://prometheus.io/docs/practices/naming/">Prometheus naming and label guidance</a></p></div>
247- <div><h3>Sampling and overhead are design choices.</h3><p>Start with low-rate head sampling for traces. Record errors as counters/logs regardless of trace sampling; head sampling cannot promise to retain every slow/error trace. An optional collector with tail sampling can retain those traces when configured to receive them.</p><p>Exporter failure never blocks acceptance. Bound export queues, count dropped telemetry, and compare instrumentation on/off in the benchmark harness. Use low-rate diagnostic sampling and opt-in mutex/block profiling.</p><p class="source"><a href="https://opentelemetry.io/docs/concepts/sampling/">OTel sampling</a> | <a href="https://prometheus.io/docs/practices/histograms/">histogram guidance</a></p></div>
248- </div>
249- <div class="note"><strong>First operational view:</strong> traffic and outcomes; accept/query latency; fan-out pressure; delivery age and retries; SQLite/WAL/checkpoints; CPU, RSS and goroutines. Alert on sustained inability to make progress, disk headroom and recovery failure. Keep tenant debugging in the admin view.</div>
250-</section>
251-
252-<section id="performance">
253- <div class="section-head"><span class="section-num">07</span><div><h2>Optimize the bottleneck you can show.</h2><p>Go and local disk enable a different runtime. Capacity claims require a defined workload and host. The linked Git report records measured repository transfers and relay responsiveness; broader capacity targets remain to be measured.</p></div></div>
254- <div class="pipeline"><div><strong>Establish correctness</strong><p>Protocol conformance, tenant isolation, crash recovery, duplicate delivery and historical/live handoff.</p></div><div><strong>Record a baseline</strong><p>Fixed hardware, seed, dataset, versions and durability mode. Capture latency, throughput, memory and errors.</p></div><div><strong>Explain the cost</strong><p>Metrics locate the boundary; a sampled trace isolates the operation; a profile explains CPU, allocation or blocking.</p></div><div><strong>Change one thing</strong><p>Repeat the same workload. Keep gains only when correctness and tail latency remain acceptable.</p></div></div>
255- <div class="table-wrap"><table class="stack"><thead><tr><th>Workload</th><th>Measure</th><th>Only then consider</th></tr></thead><tbody>
256- <tr><td>Public publish / duplicates</td><td>ACK p99, signature CPU, commits/sec, allocs/event, fsync cost.</td><td>Decode once, reuse immutable encoded bytes, prepared statements, batch commits with a bounded delay.</td></tr>
257- <tr><td>History + new subscriptions</td><td>REQ→EOSE p99, rows visited/returned, reader age and WAL growth.</td><td>Composite indexes, query-shape planning and bounded batch reads.</td></tr>
258- <tr><td>One event → many subscribers</td><td>Match time, write-queue age, slow consumers, per-connection memory.</td><td>Index subscriptions by kind/author/tag; share encoded event bytes. Preserve per-subscription IDs and filters.</td></tr>
259- <tr><td>Many mostly idle tenants</td><td>RSS per active tenant, open file descriptors, activation latency, idle goroutines.</td><td>Handle eviction, fewer background loops, cache sizing and lazy index warm-up.</td></tr>
260- <tr><td>Outages and backfill</td><td>Backlog age, retry rate, sockets, fairness, live traffic latency during imports.</td><td>Connection reuse, destination backoff and separate priority for live ingest versus backfill.</td></tr>
261- <tr><td>Crash / disk pressure / churn</td><td>Recovery duration, acknowledged-event loss, queue replay, memory after reconnect storms.</td><td>Shorter recovery scans and persisted due indexes; never weaker acknowledgement semantics.</td></tr>
262- <tr><td>Files / sites / Git under relay load</td><td>Transfer throughput, peak RSS, hash/pack CPU, request tails, object/ref publication and backup duration.</td><td>Streaming, chunked I/O, checkpoint coordination and fair scheduling. Re-run upload, site verification and Git recovery cases after each change.</td></tr>
263- <tr><td>Views / callbacks / management</td><td>Schedule lateness, signing cost, callback retries, form/API response time and concurrent job interference.</td><td>Incremental computation, debouncing and worker-class fairness while preserving triggers, visibility and task feedback.</td></tr>
264- </tbody></table></div>
265- <div class="two" style="margin-top:26px">
266- <div><h3>Reproducible load, not a marketing score.</h3><p>Report Go/module/SQLite versions, CPU count, OS, disk, sync settings, dataset size, event-size distribution, filter shapes, tenant skew and cache state. Sweep load and record the latency/error curve; avoid a load generator that hides queuing by slowing down with the server.</p><p>Archive seeds, raw results, profiles and configuration with each comparison. Use Go benchmarks and benchstat for hot-path comparisons, and a WebSocket load harness for whole-system behavior.</p><p class="source"><a href="https://pkg.go.dev/testing">Go testing benchmarks</a> | <a href="https://pkg.go.dev/golang.org/x/perf/cmd/benchstat">benchstat</a> | <a href="https://pkg.go.dev/runtime/trace">runtime tracing</a></p></div>
267- <div><h3>Acceptance gates before capacity targets.</h3><p>Release gates: no cross-tenant/private visibility leak; no loss of acknowledged stored events in restart tests; no orphaned routing intent; duplicate retries converge; backlog drains when a healthy target recovers; RSS/goroutines stabilize after churn.</p><p>Choose numeric service objectives after the first representative baseline on the intended host. Gate a performance change on repeatable improvement in its target metric and no material regression in p99, memory, errors or durability. Run race tests separately from timing benchmarks.</p></div>
268- </div>
269-</section>
270-
271-<section id="reuse">
272- <div class="section-head"><span class="section-num">08</span><div><h2>Borrow proven pieces. Keep the seam small.</h2><p>Current package paths and licenses matter. The current Nostr toolkit is not simply the old go-nostr + khatru repository layout.</p></div></div>
273- <div class="table-wrap"><table class="stack"><thead><tr><th>Candidate</th><th>What it contributes</th><th>Recommendation</th></tr></thead><tbody>
274- <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr">fiatjaf.com/nostr</a><br><span class="tag">Unlicense</span></td><td>Current event types, crypto, filters, relay/client utilities and NIP packages.</td><td>Preferred protocol toolkit. Pin an exact tested module revision; do not mix old and new event types across the application.</td></tr>
275- <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr/khatru">…/khatru</a><br><span class="tag">Unlicense</span></td><td>Custom relay hooks for auth, storage, query, broadcast and management.</td><td>Preferred transport candidate, subject to the storage/lifecycle spike. Audit inherited max-query/message defaults against the no-product-quota model.</td></tr>
276- <tr><td><a href="https://pkg.go.dev/fiatjaf.com/nostr/eventstore">…/eventstore</a><br><span class="tag">Unlicense</span></td><td>Iterator-based store abstraction and embedded backends including LMDB, BoltDB and MMM.</td><td>Reference or adapter; not the transaction contract for event + delivery intent. Build a focused SQLite ingest store initially.</td></tr>
277- <tr><td><a href="https://pkg.go.dev/github.com/nbd-wtf/go-nostr">nbd-wtf/go-nostr</a><br><span class="tag">MIT</span></td><td>Established Go SDK and ecosystem examples.</td><td>Compatibility reference. Older examples and indexed documentation may target this API; prefer a single current module family for new code.</td></tr>
278- <tr><td><a href="https://pkg.go.dev/modernc.org/sqlite">modernc.org/sqlite</a><br><span class="tag">BSD-3-Clause</span></td><td>SQLite without a cgo requirement; compatible with database/sql.</td><td>Baseline choice for simpler builds. Verify embedded SQLite version, FTS/backup needs and WAL fixes in the pinned build.</td></tr>
279- <tr><td><a href="https://github.com/mattn/go-sqlite3">mattn/go-sqlite3</a><br><span class="tag">MIT</span></td><td>Cgo SQLite driver; requires a C compiler at build time.</td><td>Benchmark alternative if storage profiles justify it. Cross-compilation and packaging are part of the tradeoff; no presumed speed ranking.</td></tr>
280- <tr><td><a href="https://github.com/barrydeen/haven">HAVEN</a><br><span class="tag">MIT</span></td><td>Closest behavioral prior art: role-based personal relays, gathering and broadcasting. README describes maintenance as bug-fix-only.</td><td>Borrow UX, policy ideas and test scenarios. Generalize role instances into templates for arbitrary owners; avoid a dependency on the entire daemon.</td></tr>
281- <tr><td><a href="https://github.com/hoytech/strfry">strfry</a><br><span class="tag drop">GPLv3</span></td><td>C++/LMDB relay; durable ACK behavior, specialized indexing, streaming and sync.</td><td>Operational/performance comparison and architectural prior art. Do not copy implementation into an intended permissive Go codebase.</td></tr>
282- <tr><td><a href="https://github.com/scsibug/nostr-rs-relay">nostr-rs-relay</a><br><span class="tag">MIT</span></td><td>Rust relay using SQLite; PostgreSQL is described as experimental.</td><td>Study SQL and operational/conformance behavior. Reuse ideas or clearly attributed compatible portions only where they help.</td></tr>
283- </tbody></table></div>
284- <div class="note"><strong>Current upstream, 07 Sep 2026.</strong> Khatru has moved to the ngit/Nostr-hosted <a href="https://basspistol.org/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib.git">nostrlib repository</a>, with source browsing through <a href="https://viewsource.win/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/nostrlib/_/master/~/khatru">viewsource</a> and the module path <code>fiatjaf.com/nostr/khatru</code>. The archived GitHub repository is the legacy location. Use the current upstream and pin revisions for reproducible builds, as with other dependencies. Verify each dependency’s license and retain the required notices.</div>
285-</section>
286-
287-<section id="operations">
288- <div class="section-head"><span class="section-num">09</span><div><h2>A service an operator can understand.</h2><p>The executable is <code>tiny</code>: run the daemon with <code>tiny serve</code> and use the same command for management. Ship plain HTML management pages, a systemd example and an optional container image. The default deployment is one host and one data directory.</p></div></div>
289- <div class="note"><strong>Preserve UX through simpler presentation.</strong> Every retained bindws task must be achievable from the web interface with equivalent authorization, inputs, preview, progress, outcome and recovery. Rebuild with basic HTML and forms; no bindws branding, CSS or generated console bundle carries over. Simplifying markup does not permit removing a workflow or forcing an existing web task into the CLI. This document’s presentation is not the product design.</div>
290- <div class="two" style="margin-bottom:27px">
291- <div><h3>All workflows, ordinary pages</h3><p>Keep the full People, Moderation, Rules, Identity, Connect, Data, Sync, Views, Health and Owner workflow families. Include files, sites, Git, invitations, list recovery, ownership/succession and callback management. Navigation may become links and forms rather than tabs; role-specific actions remain discoverable.</p><p>Preserve public event/article pages, feeds, cards, QR handoff, connection shortcuts and sign-in. Use GET for reads and POST for mutations, followed by clear results. Render plan/preview and validation on the server. Long-running work keeps progress and retry/cancel actions; small scripts or polling can preserve time-sensitive feedback where plain refresh would worsen the task.</p></div>
292- <div><h3>Keep extension and remote signing</h3><p>Rebuild the NIP-07/NIP-46 helper so existing extension, phone pairing and remote-signer flows remain supported, including their relay transport and discovery behavior. Never ask users to paste a private key. Any browser session bridge uses an origin-bound, expiring one-time challenge, Secure/HttpOnly/SameSite cookies, CSRF protection and per-action authorization.</p><p>Preserve the signed NIP-98/NIP-86 contracts for scripts and clients, with documented exceptions for removed fuel/lease operations. Keep signer scripts isolated from page rendering. The CLI is an additional complete interface; it is not a substitute for missing web functionality.</p></div>
293- </div>
294- <div class="two">
295- <div class="block"><h3>Serving and access</h3><ul><li>TLS proxy fronts a loopback-bound HTTP/WS listener. Use explicit hosts or wildcard DNS/certificates, or a single domain with relay paths.</li><li>Trust forwarding headers only from configured proxies. Match canonical host/path against the registry; validate custom-domain ownership before alias activation.</li><li>Host admin uses a local socket or separately authenticated control endpoint. Relay owners manage only their tenant via signed NIP-98/NIP-86 operations.</li><li>Diagnostics are private. Tenant signing keys are distinct from owner keys and stored outside exported template files.</li></ul></div>
296- <div class="block"><h3>Restarts, backups and capacity</h3><ul><li>On shutdown, stop admission, drain accepted writes, cancel/join workers, close sockets and release databases. Clients reconnect and reissue subscriptions.</li><li>On startup, check schema versions, reconcile incomplete creation and recover expired task reservations. Persist due times; the scheduler’s heap is only a cache.</li><li>Expose liveness separately from readiness and per-tenant storage health. A remote relay outage does not make local ingest unready.</li><li>A single host does not provide automatic failover. Scale vertically first; multi-host placement is a separate topology choice after application parity. Never run two owners over the same tenant files.</li></ul></div>
297- </div>
298- <div class="note"><strong>Task reservations are internal crash-recovery state.</strong> They are short worker ownership records, unrelated to relay lifetime. User relays remain owned and persistent; there is no leased-relay feature anywhere in the runtime model.</div>
299- <h3 style="margin-top:28px">Move every service to a local implementation.</h3>
300- <p>Blossom/NIP-96 use local content-addressed files, streaming uploads and atomic publication. Sites retain manifest verification, mirroring, authenticated access and isolated origins. GRASP retains its signed authorization and event/object publication boundary while its storage adapter may use native Git. Marmot, views, succession, owner notifications and callbacks run through durable local jobs. All are required implementation work, even where a tenant can disable them.</p>
301- <div class="table-wrap"><table class="stack"><thead><tr><th>Worker mechanism</th><th>Local replacement</th><th>UX contract to preserve</th></tr></thead><tbody>
302- <tr><td>Named Durable Object + RPC</td><td>Catalog, single-flight tenant runtime, typed in-process services and tenant transactions.</td><td>A named relay behaves independently; create, configure and fork remain direct actions. Fork assigns a permanent owner without a lease.</td></tr>
303- <tr><td>Hibernating sockets / attachments</td><td>Owned connection state, cancellation and reconnect recovery in Go.</td><td>AUTH, private visibility, subscriptions, signer transport and sync remain interoperable; process restarts recover stored work.</td></tr>
304- <tr><td>Alarm + waitUntil</td><td>Durable scheduler, due records, reservation tokens and joined workers.</td><td>Imports, mirrors, backups, delivery, expiry, views and succession continue without a connected browser; progress and failures remain visible.</td></tr>
305- <tr><td>R2 blobs / multipart objects</td><td>Streaming local file writes, verified hashes, atomic rename and durable metadata; optional S3 adapter.</td><td>The same upload, mirror, download, report, delete and backup tasks; no provider-shaped file-size ceiling.</td></tr>
306- <tr><td>KV + Cloudflare domain provisioning</td><td>Transactional alias map, local resolver, domain verification and TLS-proxy integration with status.</td><td>Owner can Add, Check and Remove relay/site domains from the web interface and signed API. Show the DNS action and readiness; do not replace this with a manual operator-only map.</td></tr>
307- <tr><td>SQLite Git chunks / admission token</td><td>Repository service with per-repository ownership, staged objects, ref transactions and a recovery journal spanning Git and SQL.</td><td>Smart HTTP, signed state admission, inventory and backup behavior persist. Never expose an accepted event before required objects are durable, or lose ref/event consistency on restart.</td></tr>
308- <tr><td>Generated console + edge pages</td><td>Go HTML templates, standard forms, server-side previews and narrowly scoped signer/progress helpers.</td><td>Every role’s tasks, sharing and app handoff remain available; only visual design and the agreed removed concepts change.</td></tr>
309- <tr><td>Provider meters / traces</td><td>Local operational counters, audit history, Prometheus, OTel and Go diagnostics.</td><td>Keep health, usage visibility and job diagnostics while removing pricing/fuel. Expand evidence for local performance work.</td></tr>
310- </tbody></table></div>
311- <p class="fine">The durable data directory and its backup policy are the operator’s responsibility. “Unlimited” describes removal of platform entitlements, not infinite hardware. Fairness and backpressure protect useful service without inventing another fuel system. Local deployment can remain a single daemon while delivering the complete application; matching Cloudflare’s global infrastructure is distinct from application and UX parity.</p>
312-</section>
313-
314-<section id="delivery-plan">
315- <div class="section-head"><span class="section-num">10</span><div><h2>Continue until every parity requirement is met.</h2><p>Full functional and UX parity is the finish line. The phases order the engineering work; they are not alternative stopping points or permission to leave features for an unspecified future.</p></div></div>
316- <div class="note"><strong>A core-only build is an intermediate checkpoint.</strong> Completion requires all retained protocol/API behavior, all existing template and user workflows, complete data migration and local recovery. A feature being disabled by default does not remove it from scope. Keep working through the matrix until every required row is verified.</div>
317- <h3>Source-to-target parity matrix</h3>
318- <p class="fine">Seed the implementation ledger from bindws revision 5d31267, then enumerate individual cases from the source registries and tests. Each ledger entry records source route/method/UI action, input and role, observable behavior, local implementation, test evidence and status. Allowed statuses are planned, in progress, verified or an explicitly documented intentional change. Unmapped or unverified retained behavior blocks completion.</p>
319- <div class="table-wrap"><table class="stack"><thead><tr><th>Required family</th><th>Behavior &amp; UX to preserve</th><th>Evidence of completion</th></tr></thead><tbody>
320- <tr><td>Relay protocol &amp; queries</td><td>EVENT/REQ/CLOSE/AUTH, HTTP event/query/count bridge, filters, EOSE, replaceables/addressables, deletion, vanish, expiry, protected events, search modes, COUNT/HLL and negentropy. Preserve policy changes on existing subscriptions.</td><td>Port all corresponding black-box cases; compare normal, error and privacy behavior on both backends. Run reconnect, concurrent query/write and restart tests.</td></tr>
321- <tr><td>Ownership, auth &amp; discovery</td><td>NIP-42/98, extension and remote signer flows, relay identity, NIP-05 names, NIP-11 capability data, NIP-66 records and web-address discovery. Transfer and succession retain their signed-record effects.</td><td>Existing client sign-in and discovery journeys succeed against local URLs. Identity preservation or an explicitly accepted rotation is verified through restore and transfer.</td></tr>
322- <tr><td>People, membership &amp; groups</td><td>Members, names/notes, guest kinds and one-hop replies, member/follow write rules, join terms, invite trees and membership claim codes, join/leave, directory privacy, NIP-29/43 records and role hierarchy.</td><td>Owner, moderator, member and guest journey matrix; tests for tree removal, invite revocation, signed roster/group updates and immediate read revocation. Relay trials remain removed.</td></tr>
323- <tr><td>Moderation</td><td>Reports, hide-after-report thresholds, dismiss/delete/ban, ban-and-erase including files, blocked words/regex/tag matching, IP blocks, pins, recent-event search and paginated audit history.</td><td>Hidden data is absent from queries, counts, sync, pages and delivery. Owner recovery from a self-IP-block stays available. Do not silently replace supported regex semantics with a narrower engine.</td></tr>
324- <tr><td>Templates &amp; configuration</td><td>All 15 relay templates, every connection template, source/replica behavior, owner-list exceptions, plan/apply/export, partial-section semantics, feature defaults and visibility placeholders.</td><td>Each template has an end-to-end case; config round-trips and diffs preserve behavior. Reapplying a replica template replaces its job rather than duplicating it.</td></tr>
325- <tr><td>Lists, sharing &amp; app handoff</td><td>List history and restore/re-sign, Check/Add/Remove relay-list actions, kinds 10002/10050/10007/10063, app shortcut editing/order/visibility, copyable URLs/commands, QR codes and cards.</td><td>Owner can recover a list and complete phone/app handoff from plain HTML pages. Private shortcuts and public visibility follow the existing authorization model.</td></tr>
326- <tr><td>Data, jobs &amp; forks</td><td>Data-by-kind and retention, purge exceptions, JSONL/JSON import, progress and skip reasons, pull/mirror/history/rebroadcast, run-now/remove, per-target cursors, fork and copy.</td><td>Work resumes after restart; failed targets never advance successful-target state incorrectly. Fork creates a permanent owner-assigned relay and preserves the intended data/membership copy workflow.</td></tr>
327- <tr><td>Automatic delivery &amp; callbacks</td><td>NIP-65 public distribution, NIP-9a registration/revocation and callback privacy, per-target status, retries, deduplication, owner notifications and notification test/preferences.</td><td>Network-failure and policy-change tests; public/private routing separation; no lost intent after local OK. Automatic inbox/outbox improvements also pass their additional requirements.</td></tr>
328- <tr><td>Files</td><td>Blossom BUDs, NIP-96/NIP-94 descriptors, upload/preflight/list/read/delete/mirror, signed tokens, reports and banned hashes, file access under relay policy.</td><td>Existing client and web upload/download/report flows work with local disk; interrupted upload/mirror leaves no falsely published descriptor; backup/restore retains blobs and authorization.</td></tr>
329- <tr><td>Pages &amp; static sites</td><td>Notes/articles, feed and link metadata; NIP-5A root/named/snapshot manifests, path rules, verified cache fill, mirrors, sign-in, custom domains and origin isolation.</td><td>Browse all page/site modes; verify hash/path failure behavior, private site access and deletion/expiry cleanup. Site origins cannot access relay management or event doors.</td></tr>
330- <tr><td>Git / GRASP</td><td>NIP-34 authorization, Smart HTTP, signed repository state, maintainers and refs, currently implemented GRASP profiles and preview paths, inventory, synchronization and backup/recovery.</td><td>Push/clone/fetch/state-update workflows, admission failures and crash tests across object/ref/event publication. Preserve each profile’s documented maturity and dependency gates.</td></tr>
331- <tr><td>Marmot</td><td>KeyPackages, opaque MLS envelopes, public/member templates, authenticated account admission, gift-wrap recipient checks, feature and retention behavior.</td><td>Both Marmot templates pass client transport and unauthorized-access cases across restart; the server never needs client encryption keys.</td></tr>
332- <tr><td>Views, presence &amp; succession</td><td>All registered views, audience-aware storage/on-demand signing, dirty/debounced/hourly/daily triggers, fingerprints, run history, presence, heir configuration, warnings, cancellation and transfer.</td><td>Clock-controlled tests preserve schedules and signed outputs. Member-only derived data stays private. Owner activity cancels succession as before; the relay itself never expires.</td></tr>
333- <tr><td>Domains &amp; management surface</td><td>All retained ROUTES and METHODS, CORS/content negotiation, signed API schemas and error behavior. Full ten-family console workflow coverage, including domain Add/Check/Remove, site mapping and destructive-action previews.</td><td>Every retained registry entry has a local handler and tests. Browser journeys exercise all roles, validation/error states, pagination, progress and recovery without requiring the CLI.</td></tr>
334- <tr><td>Backup, migration &amp; operations</td><td>Dumps, portable backup list/create/download/delete, preview/restore, complete metadata and keys, files/sites/Git, status/health, audit and local startup/shutdown recovery.</td><td>A whole-feature relay restores into an empty local data directory and passes the same journeys. Final cutover reconciles all stores and queues; an unsupported data family blocks cutover.</td></tr>
335- </tbody></table></div>
336- <p class="source">Primary inventories: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/routes.ts">ROUTES</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/manage.ts">METHODS</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/01-relay-configuration.md">console workflows</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/14-http-reference.md">HTTP reference</a>, <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/views.ts">view registry</a>, and both template catalogs. This family matrix organizes the work; individual source entries must still be enumerated so no method is lost inside a broad category.</p>
337- <details><summary>Protocol baseline and treatment of previews</summary><div class="detail-body"><p>Inventory the source’s implemented NIP-01, 05, 09, 11, 13, 17, 29, 40, 42, 43, 45, 46 transport, 50, 56, 57, 59, 62, 66, 67, 70, 77, 86, 94, 96 and 98 behavior, together with Blossom, Marmot, NIP-5A, NIP-AD, NIP-9a and GRASP. Include helper behavior such as NIP-65 routing even when it is not represented by a supported-NIP flag.</p><p>Preserve currently implemented preview features and dependency rules at their existing level of support; do not promote them to standards-complete claims or exclude them because they are previews. Derive advertised capabilities from verified enabled implementations. Tests for fuel/lease endpoints become explicit removal tests; all other exclusions need a documented agreed reason. Reconcile source changes before declaring parity rather than silently using a stale checklist.</p></div></details>
338- <h3 style="margin-top:30px">Implementation sequence — all phases are required</h3>
339- <div class="roadmap">
340- <div class="phase-code">P0 / BASELINE</div><div class="phase"><h3>Enumerated parity contract and instrumented slice</h3><p>Inventory every source route, management method, feature flag, view, template, persisted entity and UI journey. Pin SDK/Khatru/SQLite revisions. Build the first ingest/query slice with metrics, spans and pprof; validate the transaction and lifecycle integration.</p><div class="gate">Checkpoint: every source item has a parity ledger entry; crash/OK and historical/live tests pass; a reproducible benchmark exists. The project continues through every remaining phase.</div></div>
341- <div class="phase-code">P1 / CORE</div><div class="phase"><h3>Owned, templated multitenancy</h3><p>Add catalog, owner-at-creation, idempotent provisioning, stable IDs, host/path aliases, policy revisions and lazy runtime lifecycle. Implement plan/apply/export and plain HTML pages with basic forms; reuse no bindws UI assets. Add persistent jobs and backup/restore.</p><div class="gate">Exit: identical event IDs and concurrent requests remain isolated across tenants; restart and handle-eviction tests pass; no lease/trial/billing concepts in the runtime schema; form actions enforce authorization and CSRF protection.</div></div>
342- <div class="phase-code">P2 / PERSONAL</div><div class="phase"><h3>Automatic public delivery and private receipt</h3><p>Ship outbox, targeted inbox and DM templates, plus the personal provisioning bundle. Add signed-list discovery, durable routing intent, independent target outcomes, inbox reconnect/backfill and external-signing setup.</p><div class="gate">Exit: failed targets do not stall healthy targets; restart cannot lose intent; policy changes stop forbidden sends; gift wraps are recipient-readable only; duplicate retries converge.</div></div>
343- <div class="phase-code">P3 / COMMUNITY</div><div class="phase"><h3>Complete relay and community behavior</h3><p>Finish the protocol/query surface, search/COUNT/negentropy, guest and follow policies, invitation trees, groups, moderation, audit, list history/recovery and all associated web/API workflows. Port the source conformance cases as each behavior lands.</p><div class="gate">Checkpoint: relay/community entries are verified, including private visibility and role-specific browser journeys. Begin migration rehearsals early; this checkpoint does not authorize full cutover.</div></div>
344- <div class="phase-code">P4 / HOSTING</div><div class="phase"><h3>Complete files, sites, Git and Marmot</h3><p>Implement all file protocols, verified site hosting/mirroring, custom-domain management, GRASP repository behavior and Marmot transport on local backends. Complete media/site/grasp/marmot/home templates and corresponding connection handoffs.</p><div class="gate">Checkpoint: every hosting family passes its API, browser/client and restart/recovery cases. Objects, refs, signed events and metadata remain consistent; no hosting feature is left as a stub.</div></div>
345- <div class="phase-code">P5 / WORKFLOWS</div><div class="phase"><h3>Complete background services and UX coverage</h3><p>Finish callbacks, owner notifications, all views/presence, succession, transfer, forks, backup/restore and any remaining management paths. Check all ten console workflow families, all relay/connection templates, sharing/signers and long-running job feedback.</p><div class="gate">Checkpoint: no retained route, method, feature, template or user journey is unmapped or unimplemented. A plain-HTML interface completes the same tasks without CLI-only substitutions.</div></div>
346- <div class="phase-code">P6 / PARITY</div><div class="phase"><h3>Verify full parity and complete local cutover</h3><p>Run the complete retained conformance and workflow matrix; reconcile intentional changes; rehearse migration and rollback with every data family enabled. Profile representative whole-product workloads and fix measured regressions while retaining correctness.</p><div class="gate">Completion: every required ledger entry is verified, full-feature restore/cutover passes, no unexplained data or UX gap remains, and the local deployment is documented. Until then, continue implementation.</div></div>
347- </div>
348- <details open><summary>Migration contract: preserve what exists, report every change</summary><div class="detail-body"><ol>
349- <li><strong>Inventory and dry-run.</strong> Read bindws config exports, portable backup manifests, events, lists/history, memberships, bans, aliases, invites, audit, jobs/callbacks, blobs, site manifests and Git state. Produce mappings of every retained field and data family. Extend the exporter/reader wherever an event-only JSONL or existing portable backup cannot preserve required state.</li>
350- <li><strong>Map ownership explicitly.</strong> Claimed relays map to the existing owner. Existing leased/unclaimed data requires an explicit owner assignment or stays in the migration report for manual handling. Do not create ownerless running tenants, trial timers or automatic purge rules.</li>
351- <li><strong>Remove economics with a report.</strong> Fuel balances, payment configuration, trial state and hosted quota fields have no runtime destination. Retention remains content policy; show inherited 14/90-day rules and require deliberate selection of the intended final rule.</li>
352- <li><strong>Import into a fresh directory.</strong> Verify signatures, retain original event IDs, preserve deletion/vanish state and latest replaceables, rebuild indexes, then compare counts and checksums. Translate policy vocabulary and template revisions. Preserve relay signing identity only through an explicit secure export. If unavailable, record the old/new key mapping, update relay metadata, and regenerate supported NIP-43/NIP-29 records under the new key; disclose the continuity break rather than re-signing historical events.</li>
353- <li><strong>Quiesce and cut over after parity.</strong> Rehearse the complete feature set first. Missing support for an enabled feature or stored data family blocks final cutover and remains implementation work. Freeze source management and writes, export final state, reconcile every store, and switch proxy/DNS while preserving public URLs where possible. If URLs change, prepare owner-signed relay-list updates. Start destination workers after queue/origin reconciliation.</li>
354- <li><strong>Retain a rollback artifact.</strong> Keep the source read-only and its complete backup. A switch back after new Go writes requires reconciling those writes first; DNS rollback alone is not data rollback. A source backup is recovery material, not a substitute for migrating files, sites, Git or any other retained feature.</li>
355- </ol><p class="source">Existing test investment: <a href="https://github.com/FelineStateMachine/bindws/tree/5d31267/test/conformance">bindws black-box conformance suite</a>. Existing backup behavior: <a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/backups.ts">backups.ts</a>. Full parity requires the retained protocol tests and complete end-to-end workflow/data matrix; protocol advertising alone is insufficient.</p></div></details>
356-</section>
357-
358-<section id="decisions">
359- <div class="section-head"><span class="section-num">11</span><div><h2>Decisions this plan makes.</h2><p>These defaults are concrete enough to implement. A few choices remain experiments, with evidence required to change direction.</p></div></div>
360- <div class="decision-grid">
361- <div class="decision"><div class="micro">D01 / Adopt</div><h3>Single daemon, logical tenant isolation.</h3><p>Reduces deployment and coordination cost. Tradeoff: a process or host failure affects every resident relay. Strong process isolation or multiple hosts is a later deployment choice.</p></div>
362- <div class="decision"><div class="micro">D02 / Adopt</div><h3>No fuel, trials or relay leases.</h3><p>Owner is assigned at creation; lifetime is indefinite. Remove economic and lease concepts entirely. Keep operator flow control separate from tenant content rules.</p></div>
363- <div class="decision"><div class="micro">D03 / Start here</div><h3>SQLite per tenant, FULL sync.</h3><p>Enables event + intent atomicity and simple tenant backup/movement. Tradeoff: one writer per tenant, file-handle management and fsync cost. Compare modernc/cgo only under the same durability contract.</p></div>
364- <div class="decision"><div class="micro">D04 / Prove in P0</div><h3>Khatru adapter + current Nostr toolkit.</h3><p>Reuse protocol work while preserving ownership of the domain. Keep the adapter contingent on lifecycle, transaction and visibility tests; do not inherit framework limits without inspection.</p></div>
365- <div class="decision"><div class="micro">D05 / Adopt</div><h3>Declarative templates with immutable revisions.</h3><p>A template changes policy and services; it never runs tenant code. A personal bundle is a family of standard relay instances, each independently manageable.</p></div>
366- <div class="decision"><div class="micro">D06 / Adopt</div><h3>Local acceptance; asynchronous distribution.</h3><p>Durable routing intent is committed with the event. Delivery is at least once and independently observable. Private DM routing remains separate from public fan-out.</p></div>
367- <div class="decision"><div class="micro">D07 / Adopt</div><h3>Metrics now; optimization when measured.</h3><p>Ship a stable low-cardinality metric vocabulary, logs and optional tracing with the first slice. Numeric capacity targets follow baseline measurements on the intended host.</p></div>
368- <div class="decision"><div class="micro">D08 / Required</div><h3>Full functional and UX parity.</h3><p>All retained bindws features, APIs, templates, data and user journeys are required. Blobs, sites, Git, Marmot, views, callbacks and succession are part of completion. Phases sequence this work; a subset does not satisfy the project.</p></div>
369- <div class="decision"><div class="micro">D09 / Adopt</div><h3>Plain HTML with complete workflows.</h3><p>Carry over no bindws branding or styling. Preserve tasks, permissions, input/output behavior, signing, previews, progress, sharing and recovery through simple pages/forms and focused helpers. Keep all existing web tasks available on the web.</p></div>
370- <div class="decision"><div class="micro">D10 / Separate concern</div><h3>Local application parity; optional clustering.</h3><p>Replace Worker mechanisms with local services and finish the complete application on one host. Multi-host placement or automatic failover can be separate deployment work; neither is a reason to omit application capabilities.</p></div>
371- </div>
372-</section>
373-
374-<section id="sources">
375- <div class="section-head"><span class="section-num">12</span><div><h2>Evidence behind the proposal.</h2><p>Repository observations are separated from proposed behavior. External references are primary project or specification sources checked on 07 Sep 2026.</p></div></div>
376- <div class="reference-grid">
377- <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/10-architecture.md">Bindws / current architecture</a><br>Inspected local revision 5d31267. Routing, DO lifecycle, SQLite/R2, alarms, jobs, auth and optional hosting.</p>
378- <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/src/delivery.ts">Bindws / durable NIP-65 delivery</a><br>Queue schema, target selection, fixed queue/attempt bounds, policy revalidation and delivery status.</p>
379- <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/relay-templates/README.md">Bindws / relay and connection templates</a><br>First-class configuration documents and omitted-section semantics. Inbox predicate gap is documented in 03-inbox.jsonc.</p>
380- <p><a href="https://github.com/FelineStateMachine/bindws/blob/5d31267/docs/11-hosting-bindws.md">Bindws / existing telemetry</a><br>Usage logs, optional metrics and provider traces. The proposal replaces their billing/runtime coupling.</p>
381- <p><a href="https://github.com/nostr-protocol/nips">Nostr / NIP specifications</a><br>Implement selected NIPs against their exact checked revision; test storage, AUTH, deletion, expiration and relay-list semantics.</p>
382- <p><a href="https://pkg.go.dev/fiatjaf.com/nostr/khatru">Current Khatru API</a><br>Current module path, relay hooks and Unlicense classification. SDK/store APIs and legacy paths are linked in the comparison above.</p>
383- <p><a href="https://github.com/barrydeen/haven">HAVEN / personal relay roles</a><br>Closest product prior art for public/private role separation and automated gather/broadcast behavior.</p>
384- <p><a href="https://github.com/hoytech/strfry">strfry / operational reference</a><br>Durable acknowledgements, storage specialization and relay synchronization; GPLv3 implementation.</p>
385- <p><a href="https://www.sqlite.org/wal.html">SQLite / WAL</a><br>Concurrency, local-filesystem requirement and checkpoint behavior. Inspect embedded-version fixes when pinning a driver.</p>
386- <p><a href="https://opentelemetry.io/docs/languages/go/">OpenTelemetry / Go</a><br>Instrumentation and optional export. Tail sampling is an additional collector choice, not a property of head sampling.</p>
387- <p><a href="https://prometheus.io/docs/practices/naming/">Prometheus / metric design</a><br>Units, metric names and label cardinality. Detailed identifiers belong in protected diagnostics.</p>
388- <p><a href="https://go.dev/doc/diagnostics">Go / profiling and diagnostics</a><br>Use metrics, profiles and execution traces to explain costs before changing implementation.</p>
389- </div>
390- <p class="fine" style="margin-top:23px">Research scope: adjacent source checkout, current package documentation, relay repositories and protocol/observability documentation. This document records the original architectural decisions. Native implementation and retained conformance tests now run in this workspace. See the <a href="artifacts/git-performance/git-performance.html">real-repository Git performance report</a> and <a href="docs/parity.md">parity ledger</a> for current evidence; the original proposals below are not a claim of completed acceptance.</p>
391-</section>
392-<footer class="foot"><span>tinyrelay / Architectural proposal 01</span><span>Full functional &amp; UX parity. Native Go. No fuel. No leases.</span></footer>
393-</main>
394-<script>
395-(() => {
396- const paths = {
397- publish: {title:"Publish an event",nodes:["edge","registry","runtime","store"],text:"<strong>Resolve → validate → authorize → commit → acknowledge → fan-out.</strong> Persist the event and routing intent together. An OK confirms local durability; remote delivery proceeds independently. The event never crosses another tenant’s runtime implicitly."},
398- subscribe: {title:"Open a subscription",nodes:["edge","registry","runtime","store"],text:"<strong>Resolve → authenticate → authorize → snapshot → EOSE → live events.</strong> A commit-sequence barrier bridges the historical query and live stream. Per-event visibility applies in both paths. Slow sockets are disconnected without holding a database reader open."},
399- delivery: {title:"Deliver in the background",nodes:["registry","runtime","store","workers"],text:"<strong>Due intent → acquire tenant → resolve lists → recheck policy → send → persist outcome.</strong> Shared workers continue with no connected clients. Each target has independent progress. Inbox pulls enter the same ingest service with an origin that prevents rebroadcast loops."},
400- create: {title:"Create an owned relay",nodes:["edge","registry","control","store"],text:"<strong>Authenticate creator → reserve name → initialize owner + template → mark ready.</strong> The catalog publishes the route only after the tenant database is initialized. An idempotent operation recovers interrupted creation. No lease, trial or claim conversion exists."}
401- };
402- document.querySelectorAll("[data-path]").forEach(button => button.addEventListener("click", () => {
403- const selected = paths[button.dataset.path];
404- document.querySelectorAll("[data-path]").forEach(item => item.setAttribute("aria-pressed",String(item === button)));
405- document.querySelectorAll("[data-node]").forEach(node => node.classList.toggle("hot",selected.nodes.includes(node.dataset.node)));
406- document.getElementById("path-title").textContent = selected.title;
407- document.getElementById("path-description").innerHTML = selected.text;
408- }));
409- const roles = {
410- outbox:{title:"Public outbox",about:"Your public events live here and automatically travel to the relays that should receive them.",writes:"Owner; signed events only",reads:"Public",auto:"Owner write relays + tagged recipients’ read relays + explicit mirrors",retention:"No automatic age-based purge",config:{format:"tinyrelay/template/1",id:"personal-outbox",revision:1,policy:{writes:"owner",reads:"public",blockPrivateKinds:true},automation:{delivery:"nip65-public",origin:"local-client"},retention:[]}},
411- inbox:{title:"Public mentions inbox",about:"A targeted home for mentions, replies, reactions and other configured public events addressed to you.",writes:"Valid public events with p tag naming an allowed local recipient",reads:"Public",auto:"Gather matching mentions from configured sources and owner read relays",retention:"Explicit content policy; no implicit trial expiry",config:{format:"tinyrelay/template/1",id:"personal-inbox",revision:1,policy:{writes:"recipient-tagged",reads:"public",recipients:"owner",blockPrivateKinds:true},automation:{gather:"public-mentions",sources:"owner-read-relays"},retention:[]}},
412- dm:{title:"Private DM inbox",about:"Store opaque gift wraps for local recipients. Public discovery and private readability are different concerns.",writes:"Valid kind 1059 addressed to a local recipient",reads:"Authenticated recipient, checked per event",auto:"Owner-signed kind 10050 discovery; clients deliver wraps directly",retention:"No automatic age-based purge",config:{format:"tinyrelay/template/1",id:"personal-dm",revision:1,policy:{writes:"recipient-tagged",reads:"authenticated-recipient",allowKinds:[1059],recipients:"owner"},automation:{discovery:"nip17",delivery:"disabled"},retention:[]}},
413- community:{title:"Community relay",about:"A named place for members to publish, with a portable policy and clear moderator ownership.",writes:"Admitted members; optional moderator rules",reads:"Public by this template; private is an explicit policy change",auto:"None by default; owner can configure public delivery or mirrors",retention:"Owner-selected content policy",config:{format:"tinyrelay/template/1",id:"community",revision:1,policy:{writes:"members",reads:"public",blockPrivateKinds:true},automation:{delivery:"disabled"},retention:[]}}
414- };
415- document.getElementById("template-select").addEventListener("change",event => {
416- const role = roles[event.target.value];
417- ["title","about","writes","reads","auto","retention"].forEach(key => document.getElementById("template-"+key).textContent = role[key]);
418- document.getElementById("template-code").textContent = JSON.stringify(role.config,null,2);
419- });
420- const navLinks = [...document.querySelectorAll("nav a")];
421- if ("IntersectionObserver" in window) {
422- const observer = new IntersectionObserver(entries => {
423- entries.forEach(entry => {
424- if(entry.isIntersecting) navLinks.forEach(link => link.classList.toggle("active",link.getAttribute("href")==="#"+entry.target.id));
425- });
426- },{rootMargin:"-5% 0px -70% 0px",threshold:0});
427- document.querySelectorAll("main section, #overview").forEach(section => observer.observe(section));
428- }
429- let printDetails = [];
430- window.addEventListener("beforeprint",() => {
431- printDetails = [...document.querySelectorAll("details")].map(element => ({element,open:element.open}));
432- printDetails.forEach(item => item.element.open = true);
433- });
434- window.addEventListener("afterprint",() => printDetails.forEach(item => item.element.open = item.open));
435- document.getElementById("print").addEventListener("click",() => window.print());
436-})();
437-</script>
438-</body>
439-</html>
440diff --git a/docs/git-performance.md b/docs/git-performance.md
441deleted file mode 100644
442index df78a88..0000000
443--- a/docs/git-performance.md
444+++ /dev/null
445@@ -1,146 +0,0 @@
446-# Git performance
447-
448-Phase wall times come from the recorded harness. Missing or failed phases remain visible. This report does not compare against the bindws Worker runtime.
449-
450-Method: Linux Slate, 16 CPUs, 10 GiB test-container ceiling, no CPU cap, no swap; client/server loopback; Git 2.50.1 client and 2.39.5 server; fixed local heads and tags with no cold page-cache reset. Phase CPU deltas are total container CPU inclusive of Git children and background probes, and values below sampler resolution are shown as missing.
451-
452-Methods: native Git HTTP baseline, buffered Tiny, and streaming Tiny where result artifacts exist.
453-
454-## Findings
455-
456-| finding | before | after |
457-| --- | --- | --- |
458-| Peak Go memory (MiB) | 6,094 | 40.6 |
459-| Tag-heavy strudel incremental push (ms) | 1,510 | 94.1 |
460-
461-| repo | before clone | before concurrent4 | before state ACK | before push | after clone | after concurrent4 | after state ACK | after push |
462-| --- | --- | --- | --- | --- | --- | --- | --- | --- |
463-| atlas.git | 12,971 | — | FAILED: Nostr request timed out after 30 seconds | — | 9,148 | 14,383 | 26.8 | 67 |
464-| bindws.git | — | — | — | — | 178.0 | 267.5 | 21.3 | 59.4 |
465-| diagramzip.git | 1,844 | 2,391 | 27.7 | 58.8 | 1,559 | 2,118 | 21.9 | 58.3 |
466-| doorbearer.git | 5,669 | 7,782 | 18.6 | 60.4 | 4,444 | 6,795 | 24.5 | 59.6 |
467-| nzip.git | 100.1 | 114.8 | 33.4 | 99.4 | 73.4 | 79.3 | 19.4 | 59.2 |
468-| strudel.git | 3,052 | 4,933 | 615.6 | 1,510 | 2,035 | 3,747 | 24.3 | 94.1 |
469-
470-## Remaining relay latency
471-
472-Every probe succeeded, but durable event acknowledgements still stalled occasionally during large Git transfers, including native Git baseline phases on the same host. These results do not identify the cause. Capture disk latency and scheduling before selecting another optimization.
473-
474-| workload | ACK P95 ms | ACK max ms | query P95 ms | query max ms |
475-| --- | --- | --- | --- | --- |
476-| Public Atlas | 68.3 | 719.3 | 1.71 | 331.1 |
477-| Native Git Atlas phases | 53.7 | 656.4 | 1.69 | 4.51 |
478-| Private atlas.git | 86.7 | 1,384 | 2.98 | 33.5 |
479-
480-## Phase results
481-
482-| repo | method | clone n | initial push ms | clone median ms | fetch ms | incremental state ACK ms | incremental push ms | concurrent ×2 wall ms | concurrent ×4 wall ms |
483-| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
484-| nzip.git | streaming Tiny | 3 | 111.0 | 73.4 | 26.9 | 19.4 | 59.2 | 75.3 | 79.3 |
485-| nzip.git | native Git HTTP (after) | 3 | 84 | 67.6 | 23.9 | — | 33.6 | 73.1 | 77.6 |
486-| bindws.git | streaming Tiny | 3 | 249.2 | 178.0 | 27.9 | 21.3 | 59.4 | 203.8 | 267.5 |
487-| bindws.git | native Git HTTP (after) | 3 | 171.7 | 172.1 | 24.6 | — | 33.4 | 205.5 | 256.1 |
488-| diagramzip.git | streaming Tiny | 3 | 1,557 | 1,559 | 26.3 | 21.9 | 58.3 | 1,698 | 2,118 |
489-| diagramzip.git | native Git HTTP (after) | 3 | 1,470 | 1,538 | 24.5 | — | 35.8 | 1,720 | 2,112 |
490-| strudel.git | streaming Tiny | 3 | 2,595 | 2,035 | 36.1 | 24.3 | 94.1 | 2,625 | 3,747 |
491-| strudel.git | native Git HTTP (after) | 3 | 2,499 | 2,054 | 31.4 | — | 55.6 | 2,458 | 3,645 |
492-| doorbearer.git | streaming Tiny | 3 | 4,009 | 4,444 | 28.8 | 24.5 | 59.6 | 4,847 | 6,795 |
493-| doorbearer.git | native Git HTTP (after) | 3 | 4,039 | 4,430 | 24.3 | — | 35.3 | 5,095 | 9,770 |
494-| atlas.git | streaming Tiny | 3 | 10,233 | 9,148 | 27.7 | 26.8 | 67 | 10,290 | 14,383 |
495-| atlas.git | native Git HTTP (after) | 3 | 9,600 | 8,918 | 26.4 | — | 36.8 | 14,813 | 12,208 |
496-| nzip.git | buffered Tiny | 3 | 186.7 | 100.1 | 56.7 | 33.4 | 99.4 | 113.8 | 114.8 |
497-| nzip.git | native Git HTTP (before) | 3 | 79.9 | 68.7 | 24.5 | — | 31.1 | 76.5 | 78.3 |
498-| bindws.git | buffered Tiny | 0 | FAILED: Command failed: git -C /tmp/tiny-git-benchmark-pCF5Bp push http://127.0.0.1:17447/npub1t23e93dv97z5k320gm3tjp65z6ga249tt76c2pjwlx0rxr7wwvss9tuxhn/bindws-1.git r | — | — | — | — | — | — |
499-| diagramzip.git | buffered Tiny | 3 | 1,578 | 1,844 | 33.8 | 27.7 | 58.8 | 1,888 | 2,391 |
500-| diagramzip.git | native Git HTTP (before) | 3 | 1,401 | 1,658 | 24.6 | — | 35.9 | 1,717 | 1,958 |
501-| strudel.git | buffered Tiny | 3 | 4,639 | 3,052 | 783.7 | 615.6 | 1,510 | 3,499 | 4,933 |
502-| strudel.git | native Git HTTP (before) | 3 | 2,470 | 1,935 | 32.3 | — | 56.2 | 2,616 | 3,889 |
503-| doorbearer.git | buffered Tiny | 3 | 4,024 | 5,669 | 33.3 | 18.6 | 60.4 | 5,876 | 7,782 |
504-| doorbearer.git | native Git HTTP (before) | 3 | 3,951 | 4,111 | 25.3 | — | 34.6 | 5,543 | 5,448 |
505-| atlas.git | buffered Tiny | 3 | 9,906 | 12,971 | 47.1 | FAILED: Nostr request timed out after 30 seconds | — | — | — |
506-| atlas.git | native Git HTTP (before) | 3 | 9,275 | 9,229 | 28.6 | — | — | — | — |
507-| atlas.git | buffered Tiny | 0 | FAILED: Command failed: git -C /tmp/tiny-git-benchmark-szM6s6 push http://127.0.0.1:17447/npub15a63zxpyhpz85v554d6f24gctr840ytc3xsaq7pk3xd076nrhacqxc79mu/atlas-0.git re | — | — | — | — | — | — |
508-| atlas.git | private Tiny (NIP-98) | 3 | 13,537 | 9,005 | 39.6 | 23.2 | 79 | 10,601 | 18,337 |
509-
510-## Resource results
511-
512-| repo | method | Go RSS high-water MiB | cgroup anon peak MiB | cgroup file peak MiB | cgroup total peak MiB | CPU initial µs | CPU incremental µs | CPU concurrent2 µs | CPU concurrent4 µs | container |
513-| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
514-| nzip.git | streaming Tiny | 34.4 | 13.3 | 5.46 | 30.9 | 204,937 | — | — | — | tiny-git-perf-final |
515-| nzip.git | native Git HTTP (after) | 8.03 | 2.69 | 0.516 | 10.1 | — | — | — | — | tiny-git-perf-final-baseline |
516-| bindws.git | streaming Tiny | 35.3 | 23.4 | 14.8 | 57.1 | 395,910 | — | 531,919 | — | tiny-git-perf-final |
517-| bindws.git | native Git HTTP (after) | 10.3 | 12.6 | 9.66 | 42.7 | — | — | 469,261 | — | tiny-git-perf-final-baseline |
518-| diagramzip.git | streaming Tiny | 37.1 | 122.3 | 82.1 | 209.6 | 1,531,645 | — | 837,444 | 2,024,123 | tiny-git-perf-final |
519-| diagramzip.git | native Git HTTP (after) | 11.7 | 49.5 | 76.5 | 190.4 | 1,745,696 | — | 860,621 | 1,412,613 | tiny-git-perf-final-baseline |
520-| strudel.git | streaming Tiny | 39.3 | 179.2 | 174.1 | 372.7 | 6,636,176 | — | 1,604,850 | 3,353,508 | tiny-git-perf-final |
521-| strudel.git | native Git HTTP (after) | 12.2 | 165.0 | 167.7 | 357.4 | 6,434,735 | — | 1,323,538 | 2,209,831 | tiny-git-perf-final-baseline |
522-| doorbearer.git | streaming Tiny | 40.3 | 40.7 | 548.1 | 621.3 | 4,892,027 | — | 5,021,984 | 10,789,638 | tiny-git-perf-final |
523-| doorbearer.git | native Git HTTP (after) | 12.6 | 29.6 | 540.8 | 601.6 | 4,548,602 | — | 4,224,315 | 10,196,891 | tiny-git-perf-final-baseline |
524-| atlas.git | streaming Tiny | 40.6 | 179.8 | 1,477 | 1,765 | 11,991,370 | — | 11,659,980 | 28,932,729 | tiny-git-perf-final |
525-| atlas.git | native Git HTTP (after) | 12.8 | 178.2 | 1,469 | 1,756 | 11,375,064 | — | 11,745,735 | 27,511,063 | tiny-git-perf-final-baseline |
526-| nzip.git | buffered Tiny | 39.7 | 18.7 | 10.1 | 36.6 | 319,330 | — | — | 511,003 | tiny-git-perf-before |
527-| nzip.git | native Git HTTP (before) | 9.43 | 4.03 | 0.52 | 15.9 | — | — | — | — | tiny-git-perf-baseline |
528-| bindws.git | buffered Tiny | 39.9 | 18.9 | 10.2 | 36.6 | 417,278 | — | — | — | tiny-git-perf-before |
529-| diagramzip.git | buffered Tiny | 813.4 | 792.4 | 77.6 | 889.9 | 1,910,533 | — | 1,905,437 | 1,340,161 | tiny-git-perf-before |
530-| diagramzip.git | native Git HTTP (before) | 11.1 | 63.6 | 67.4 | 180.7 | 1,471,525 | — | 1,238,327 | 1,567,270 | tiny-git-perf-baseline |
531-| strudel.git | buffered Tiny | 1,566 | 1,566 | 170.1 | 1,766 | 8,893,943 | 1,629,815 | 4,022,310 | 9,036,055 | tiny-git-perf-before |
532-| strudel.git | native Git HTTP (before) | 11.7 | 108.0 | 158.6 | 340.7 | 6,261,466 | — | 1,354,373 | 3,098,503 | tiny-git-perf-baseline |
533-| doorbearer.git | buffered Tiny | 3,732 | 3,729 | 544.2 | 4,300 | 4,823,998 | — | 4,355,754 | 10,081,976 | tiny-git-perf-before |
534-| doorbearer.git | native Git HTTP (before) | 11.6 | 29.4 | 532.0 | 580.1 | 4,500,482 | — | 4,283,006 | 10,758,873 | tiny-git-perf-baseline |
535-| atlas.git | buffered Tiny | 6,094 | 6,097 | 1,365 | 7,256 | 11,809,027 | — | — | — | tiny-git-perf-before |
536-| atlas.git | native Git HTTP (before) | 10.8 | 143.4 | 1,414 | 1,663 | 11,397,964 | — | — | — | tiny-git-perf-baseline |
537-| atlas.git | buffered Tiny | 6,094 | 226.7 | 2,125 | 7,256 | 11,603,962 | — | — | — | tiny-git-perf-before |
538-| atlas.git | private Tiny (NIP-98) | 38.9 | 180.1 | 1,860 | 2,158 | 12,983,504 | — | 11,985,201 | 29,095,672 | tiny-git-perf-private |
539-
540-## Probe results
541-
542-| repo | method | idle publish P50 ms | idle publish P95 ms | idle publish max ms | idle query P50 ms | idle query P95 ms | idle query max ms | busy publish P50 ms | busy publish P95 ms | busy publish max ms | busy query P50 ms | busy query P95 ms | busy query max ms | idle errors | busy errors |
543-| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
544-| nzip.git | streaming Tiny | 6.4 | 11.7 | 19.5 | 1.73 | 4.12 | 20.8 | 6.04 | 15.3 | 18.6 | 1.33 | 2.28 | 2.69 | 0 | 0 |
545-| nzip.git | native Git HTTP (after) | — | — | — | — | — | — | 7.44 | 10.7 | 10.8 | 1.16 | 1.55 | 1.65 | 0 | 0 |
546-| bindws.git | streaming Tiny | 6.11 | 7.38 | 10.3 | 1.63 | 2.05 | 5.05 | 5.88 | 12.8 | 23 | 1.43 | 1.92 | 5.43 | 0 | 0 |
547-| bindws.git | native Git HTTP (after) | — | — | — | — | — | — | 5.96 | 17.8 | 20 | 1.32 | 1.72 | 2.33 | 0 | 0 |
548-| diagramzip.git | streaming Tiny | 6.16 | 10.3 | 12 | 1.56 | 2.05 | 4.81 | 5.7 | 17.3 | 102.2 | 1.33 | 1.73 | 3.13 | 0 | 0 |
549-| diagramzip.git | native Git HTTP (after) | — | — | — | — | — | — | 5.81 | 17.6 | 84.7 | 1.33 | 1.7 | 2.24 | 0 | 0 |
550-| strudel.git | streaming Tiny | 6.1 | 10.2 | 15.2 | 1.54 | 1.98 | 4.3 | 6.08 | 12.7 | 330.9 | 1.36 | 2.47 | 5.88 | 0 | 0 |
551-| strudel.git | native Git HTTP (after) | — | — | — | — | — | — | 5.61 | 9.49 | 100.3 | 1.35 | 2.03 | 5.91 | 0 | 0 |
552-| doorbearer.git | streaming Tiny | 4.16 | 6.79 | 9.06 | 1.52 | 1.88 | 3.21 | 5.64 | 47.1 | 299.4 | 1.25 | 1.68 | 3.49 | 0 | 0 |
553-| doorbearer.git | native Git HTTP (after) | — | — | — | — | — | — | 5.83 | 47.5 | 1,117 | 1.23 | 1.62 | 3.7 | 0 | 0 |
554-| atlas.git | streaming Tiny | 5.86 | 10.5 | 26.2 | 1.47 | 1.9 | 5.18 | 5.64 | 68.3 | 719.3 | 1.26 | 1.71 | 331.1 | 0 | 0 |
555-| atlas.git | native Git HTTP (after) | — | — | — | — | — | — | 5.71 | 53.7 | 656.4 | 1.28 | 1.69 | 4.51 | 0 | 0 |
556-| nzip.git | buffered Tiny | 6.29 | 9.96 | 11 | 1.64 | 2.26 | 5.71 | 6.11 | 14.2 | 35.6 | 1.38 | 1.63 | 5.34 | 0 | 0 |
557-| nzip.git | native Git HTTP (before) | — | — | — | — | — | — | 6.26 | 7.43 | 7.57 | 1.28 | 2.82 | 3.84 | 0 | 0 |
558-| bindws.git | buffered Tiny | 6.27 | 11.1 | 15.7 | 1.63 | 2.25 | 21.7 | 5.79 | 6.13 | 6.18 | 1.45 | 4.77 | 5.36 | 0 | 0 |
559-| diagramzip.git | buffered Tiny | 6.39 | 10.7 | 12.6 | 1.71 | 2.33 | 2.52 | 6.2 | 18.6 | 201.0 | 1.34 | 1.66 | 2.13 | 0 | 0 |
560-| diagramzip.git | native Git HTTP (before) | — | — | — | — | — | — | 6.56 | 23.4 | 209.8 | 1.35 | 1.71 | 3.8 | 0 | 0 |
561-| strudel.git | buffered Tiny | 4.37 | 6.34 | 8.87 | 1.58 | 2.09 | 3.7 | 3.76 | 9.38 | 567.1 | 1.26 | 1.91 | 24.2 | 0 | 0 |
562-| strudel.git | native Git HTTP (before) | — | — | — | — | — | — | 4.04 | 9.99 | 179.1 | 1.3 | 2.62 | 8.03 | 0 | 0 |
563-| doorbearer.git | buffered Tiny | 5.9 | 9.14 | 12.2 | 1.45 | 1.8 | 2.11 | 5.72 | 46.3 | 426.0 | 1.2 | 1.59 | 2.77 | 0 | 0 |
564-| doorbearer.git | native Git HTTP (before) | — | — | — | — | — | — | 5.58 | 23.1 | 288.1 | 1.25 | 1.63 | 5.11 | 0 | 0 |
565-| atlas.git | buffered Tiny | 5.86 | 9.99 | 13.9 | 1.51 | 1.78 | 3.48 | 5.28 | 11.5 | 1,323 | 1.41 | 2.06 | 3.72 | 0 | 0 |
566-| atlas.git | native Git HTTP (before) | — | — | — | — | — | — | 5.53 | 19.2 | 242.5 | 1.26 | 1.72 | 4.38 | 0 | 0 |
567-| atlas.git | buffered Tiny | 4.58 | 6.84 | 9.25 | 1.94 | 2.41 | 6.25 | 3.43 | 6 | 91.4 | 1.1 | 1.58 | 2.14 | 0 | 0 |
568-| atlas.git | private Tiny (NIP-98) | 6.65 | 9.31 | 12.8 | 1.83 | 2.28 | 5.93 | 5.95 | 86.7 | 1,384 | 1.35 | 2.98 | 33.5 | 0 | 0 |
569-
570-## Status
571-
572-| repo | method | status | failed phases |
573-| --- | --- | --- | --- |
574-| nzip.git | streaming Tiny | complete | — |
575-| bindws.git | streaming Tiny | complete | — |
576-| diagramzip.git | streaming Tiny | complete | — |
577-| strudel.git | streaming Tiny | complete | — |
578-| doorbearer.git | streaming Tiny | complete | — |
579-| atlas.git | streaming Tiny | complete | — |
580-| nzip.git | buffered Tiny | complete | — |
581-| bindws.git | buffered Tiny | failed | initial_push |
582-| diagramzip.git | buffered Tiny | complete | — |
583-| strudel.git | buffered Tiny | complete | — |
584-| doorbearer.git | buffered Tiny | complete | — |
585-| atlas.git | buffered Tiny | failed | incremental_state_ack |
586-| atlas.git | buffered Tiny | failed | initial_push |
587-| atlas.git | private Tiny (NIP-98) | complete | — |
588-
589-Profile finding from the captured before run: CPU samples identify memory clearing/copying as the visible hot path; the independent Go RSS reading is 6093.9 MiB. Keep the raw profile and process/cgroup captures beside any interpretation.
590-
591-Raw JSON artifacts are linked from the HTML report.
592diff --git a/docs/khatru-integration.md b/docs/khatru-integration.md
593deleted file mode 100644
594index 66f44fb..0000000
595--- a/docs/khatru-integration.md
596+++ /dev/null
597@@ -1,81 +0,0 @@
598-# Khatru integration spike
599-
600-Date: 2026-09-07
601-Module inspected: `fiatjaf.com/nostr@v0.0.0-20260902034142-316ef6591fa2/khatru`
602-Reference transport: `../bindws/src/relay.ts`
603-
604-## Decision
605-
606-Khatru is useful protocol prior art and can carry a small first milestone, but it
607-is not a drop-in transport for tinyrelay's full contract. Use its event types,
608-NIP-42/NIP-45/NIP-77 codecs and policy vocabulary where useful. Keep the
609-production websocket transport under tinyrelay's ownership once the requirements
610-below matter. The decisive reasons are the historical/live subscription race,
611-the lack of a connection shutdown API, and the absence of an error-bearing query
612-iterator. These are correctness and lifecycle boundaries, not benchmark concerns.
613-
614-## What the pinned module provides
615-
616-`Relay` exposes hooks for event admission, storage, replacement, deletion,
617-request and count authorization, historical query, authentication, listener
618-add/remove, live broadcast filtering, and NIP-11/NIP-86. `RequestAuth(ctx)` can
619-send a challenge from `OnConnect`, `OnEvent`, `OnRequest`, or another hook. NIP-45
620-COUNT and NIP-77 negentropy are wired through `Count`/`CountHLL` and
621-`QueryStored`.
622-
623-The wire path verifies event IDs and signatures before hooks. Normal events are
624-stored, then `OnEventSaved` runs, then listeners are notified. Deletion events
625-are stored before Khatru's NIP-09 target deletion pass. `QueryStored` is
626-`iter.Seq[nostr.Event]`; iteration has no error return. The default websocket
627-handler starts a goroutine for every incoming message and two connection loops.
628-Writes are serialized per socket, but there is no relay-level worker registry or
629-wait group.
630-
631-## Compatibility matrix
632-
633-| Requirement | Native hook fit | Adapter / blocker |
634-|---|---|---|
635-| Proactive AUTH challenge | Yes | Call `RequestAuth(ctx)` from `OnConnect`; configure the relay URL carefully. This is opt-in and not sent by `NewRelay`. |
636-| kind 24133 signer exception | Partial | `OnRequest`, `QueryStored`, and `PreventBroadcast` can implement the encrypted NIP-46 visibility rule. No built-in exception exists; make it an explicit policy branch and test historical, live, COUNT, and negentropy paths separately. |
637-| Per-event privacy for REQ/live | Partial | Historical privacy belongs inside `QueryStored`; live privacy belongs in `PreventBroadcast`. `OnRequest` alone is too coarse. |
638-| Per-event privacy for COUNT | Yes, with own store | Implement `Count`/`CountHLL` against the same authorization predicate. The hook only gates the request; it does not filter rows for you. |
639-| Per-event privacy for negentropy | Yes, with own store | Negentropy calls `QueryStored` with a negentropy context, so the same filtered iterator can work. Verify that the vector contains exactly the events visible to that authenticated client. |
640-| No historical/live gap | No | Khatru queries history first and adds listeners afterward. An event committed between those operations can be missed. There is no public snapshot/fence hook. Own the transport or add a store-level sequence barrier and modify the dispatch path. |
641-| NIP-09 deletion-before-arrival tombstones | No | Khatru only deletes rows it finds. Persist tombstones in an admission transaction and have future `StoreEvent` reject/ignore matching events. This requires an own storage/admission layer. |
642-| Gift-recipient deletion | Partial | `AllowDeleting(target, deletion)` can authorize the p-tagged recipient for kind 1059. The default path has no recipient tombstone model, so recipient deletes still need the own store. |
643-| Private policy change closes sessions | No | The public API returns snapshots and listener callbacks, but does not expose a socket close/remove method. `WebSocket.Context` is observable, not cancellable by callers. Own transport or maintain a fork. |
644-| Event + delivery intent atomic acknowledgement | Partial | Put both writes in a custom `StoreEvent`/`ReplaceEvent` transaction. Do not use `UseEventstore` unchanged: its `SaveEvent` cannot atomically add tinyrelay delivery rows. Intent calculation must be deterministic and side-effect free inside the transaction. |
645-| Query/storage errors | No | `iter.Seq` cannot report a database error. A failing iterator can only stop early, which Khatru will treat as a complete EOSE. Use a query API with an error result in the owned transport, or expose a conservative health/error side channel and fail closed. |
646-| Clean shutdown of all goroutines | No | The comments mention `Server.Shutdown`, but this pinned package has no exported relay shutdown/wait method. `http.Server.Shutdown` does not provide a join for Khatru's websocket goroutines. Track and close sockets in an owned transport. |
647-| Arbitrary operator host limits | Partial | `MaxMessageSize`, `MaxAuthenticatedClients`, websocket deadlines, and query limits are configurable. Defaults include 512000-byte messages, eight authenticated keys, and a caller-supplied query limit; set every limit from explicit host configuration so no default becomes a product cap. |
648-
649-## Required adapter rules if Khatru is used temporarily
650-
651-* Set `ChallengePrefix`, `ServiceURL`, `MaxMessageSize`,
652- `MaxAuthenticatedClients`, write/read/ping deadlines, and query limits from the
653- operator configuration. Do not inherit `NewRelay` defaults silently.
654-* Implement one authorization predicate and reuse it from `QueryStored`,
655- `Count`/`CountHLL`, `PreventBroadcast`, and negentropy. It must understand
656- authenticated pubkeys, event author, p-tags, private kinds, and the 24133
657- signer exception.
658-* Wrap `StoreEvent`, `ReplaceEvent`, and deletion admission in the tenant
659- transaction. Store a deletion tombstone before acknowledging a deletion and
660- check it before accepting a late event. Add recipient tombstones for gift
661- wraps according to the local privacy policy.
662-* Persist delivery intent before returning a successful local `OK`. Remote
663- sends remain at-least-once and must be idempotent by tenant, event, and target.
664-* Treat a query iterator ending without an explicit store-success marker as
665- incomplete. Until the owned transport exists, surface a degraded health state
666- rather than claiming a complete history.
667-* On policy revision, reject new requests immediately and close every affected
668- socket through the owned connection registry. Khatru hooks alone cannot do the
669- close.
670-
671-## Recommendation
672-
673-Prototype event policy and storage transactions behind Khatru only if it shortens
674-the first vertical slice. Before enabling private relay templates or promising
675-durable subscriptions, replace `HandleWebsocket` with a tinyrelay transport that
676-owns a per-connection reader, single writer, cancellation, admission fence, and
677-wait-group shutdown. Keep Khatru's codecs and NIP implementations, but do not
678-make Khatru's default `ServeHTTP` the long-term lifecycle boundary.
679diff --git a/docs/observability.md b/docs/observability.md
680index b27701b..6a058c0 100644
681--- a/docs/observability.md
682+++ b/docs/observability.md
683@@ -74,6 +74,6 @@ NIP-66 monitoring is opt-in and accepts only the peer URLs supplied in `PeerMoni
684
685 ## Git workload captures
686
687-The [real repository experiment](git-performance.md) follows this workflow. The original Git HTTP wrapper buffered entire pack responses; process RSS and the CPU profile exposed allocation, clearing and copying costs. Streaming the response and batching signed-ref/object checks removed the measured bottlenecks. The harness checks EVENT acknowledgement and exact-ID query latency while push, clone and fetch operations run, so a fast Git transfer cannot conceal a stalled relay.
688+The Git performance harness follows this workflow. The original Git HTTP wrapper buffered entire pack responses; process RSS and the CPU profile exposed allocation, clearing and copying costs. Streaming the response and batching signed-ref/object checks removed the measured bottlenecks. The harness checks EVENT acknowledgement and exact-ID query latency while push, clone and fetch operations run, so a fast Git transfer cannot conceal a stalled relay.
689
690 Use `scripts/observe-git-benchmark.py` for repeatable captures. Keep Go process RSS separate from cgroup memory: native Git children and filesystem cache are charged to the container, and do not appear in a Go heap profile. Likewise, Go CPU profiles exclude Git child CPU; use cgroup CPU deltas to account for the whole server. Record concurrency, repository object/ref counts, Git versions, page-cache conditions and the native Git baseline with each result. Instructions and comparison limits are in [testing.md](testing.md#real-git-repositories).
691diff --git a/docs/parity-gap-audit.md b/docs/parity-gap-audit.md
692deleted file mode 100644
693index f232269..0000000
694--- a/docs/parity-gap-audit.md
695+++ /dev/null
696@@ -1,87 +0,0 @@
697-# Parity gap audit
698-
699-Compared with bindws revision `5d31267`. This is a behavior ledger, not a
700-claim that package compilation proves full UX parity. The native contract
701-removes hosted leases/trials, ownership provisioning claims, fuel/billing, and
702-product entitlement caps. Membership invite claims remain required.
703-
704-## Evidence recorded
705-
706-- The retained 90-method source registry is exposed through the tenant
707- dispatcher. `internal/daemon/management_golden_test.go` invokes every method
708- on a fresh tenant and fails on `unsupported`; representative read methods
709- must succeed.
710-- `internal/daemon/ux_contract_test.go` makes signed HTTP calls for invite
711- creation/listing, member mutation, moderation, IP rules, policy and identity
712- changes, config export, fork, and deletion. It also checks guided Sync
713- argument shapes.
714-- Domain/NIP-AD validation, custom host catalog mappings, retention sweeps,
715- records scheduling/privacy, callback registration validation, backup sealing,
716- Git behavior, and replication queues have focused package tests.
717-- The current conformance artifact `20260907T170116Z` runs all 14 configured
718- files (the source NIP-11 file is replaced by the quota-free compatibility
719- test) and records 17 passing tests with no skips. Earlier artifacts remain
720- historical evidence only.
721-- The follow-up Git artifact `20260907T154227Z` passes its Git case. A
722- separate browser run also exercised the remote-signer backup flow. These
723- close those observed cases only, not the entire GRASP or browser families.
724-- The latest retained protocol artifact `20260907T170116Z` again passes all
725- 17 tests with no skips. `go test -race ./...` and `go vet ./...` also pass.
726-- `routes_acceptance_test.go` covers mounted NIP-11, NIP-86, bridge auth,
727- NIP-05, NIP-AD auth behavior, public pages, CORS, and data-route denial.
728- `durable_acceptance_test.go` covers restart recovery for a dump job, view
729- checkpoint, and notification intent. `git_push_test.go` covers immediate
730- 30-ref HTTP push after router publication.
731-- Browser evidence covers a distinct NIP-46 signer reconnect and signed
732- mutation (`output/playwright/nip46-distinct-resume-evidence.json`) and a
733- valid backup restore plus malformed-archive retry
734- (`artifacts/browser-restore-qa.json`). The restore run used a page-local NIP-07 fixture, so its subsequent search route
735- lost that injected signer and required authentication again. The retained NIP-46
736- browser evidence now closes navigation persistence for the supported remote
737- signer path with distinct remote and user keys; NIP-07 extension persistence
738- remains browser-managed and is not emulated by the page.
739-- Git performance evidence covers six real repositories through all harness
740- phases in `artifacts/git-performance/results-after`.
741-- `hosting_routes_acceptance_test.go` verifies per-tenant site isolation and a
742- custom host mapping. `callback_restart_acceptance_test.go` verifies durable
743- callback recovery and revocation. `git_private_restart_test.go` verifies
744- pending private state recovery, unsigned denial, signed HTTP discovery and
745- receive-pack negotiation after restart, and tampered-payload rejection.
746-- Public transfers pass all six fixtures; private nzip and Atlas transfers
747- also pass. Authenticated upload spooling keeps Go RSS independent of pack
748- size. Tail acknowledgement stalls remain visible in the performance report.
749-- The complete Linux race suite passes at
750- `artifacts/slate/tiny-private-git-final-20260907/20260907T165524Z`.
751- The subsequent Git policy changes pass the daemon race suite on Linux at
752- `artifacts/slate/tiny-git-policy-final-20260907/20260907T165928Z`.
753-
754-## Remaining acceptance work
755-
756-1. HTTP portable import now has signed mounted-route acceptance for final
757- imported counts and persisted content, alongside durable import-job restart
758- coverage. The combined Git-containing HTTP backup/restore journey also has
759- focused acceptance coverage. A streamed progress UI remains outside the
760- current native route contract. Positive signed NIP-AD and NIP-96
761- upload/get/delete, site isolation/custom mapping, Smart HTTP push, and
762- backup preview/restore are already covered by focused tests.
763-2. Complete browser tree traversal and moderation recovery acceptance.
764- Invite claim, Sync progress/retry, view publication and fork navigation
765- have recorded checks in `output/playwright/browser-checks.md`; deletion
766- recovery also has focused daemon tests. The
767- NIP-46 reconnect and signed mutation path is covered; the NIP-07 restore
768- fixture intentionally loses its page-local signer after navigation and is
769- not evidence of a native signer persistence failure.
770-3. Restart coverage now includes inbox/outbox delivery retry, callback
771- revocation, dump and backup jobs, view checkpoints, notification intents,
772- and terminal replication-job failure status with retry attempts. Portable
773- import progress remains an unverified restart case.
774-4. Complete a private object transfer and full inventory assertion after
775- restart. Signed private HTTP discovery/negotiation and pending inventory
776- survive restart; the separate real private transfer run passes too. Native state projection,
777- stale-worker protection, dropped-ref cleanup, journal recovery, immediate
778- multi-ref push, and private-state hiding already have focused tests.
779-
780-The complete source inventory and explicit exclusions remain machine-readable
781-in `docs/parity.json`. Items stay `pending` when their implementation has
782-focused tests but lacks the route, browser, restart, or source conformance
783-evidence named above.
784diff --git a/docs/parity.json b/docs/parity.json
785deleted file mode 100644
786index 9ddc368..0000000
787--- a/docs/parity.json
788+++ /dev/null
789@@ -1,410 +0,0 @@
790-{
791- "schema": "tiny/parity/1",
792- "source": {
793- "repository": "../bindws",
794- "revision": "5d31267",
795- "routes": "src/routes.ts",
796- "management": "src/manage.ts",
797- "relayTemplates": "relay-templates",
798- "connectionTemplates": "connection-templates",
799- "conformance": "test/conformance"
800- },
801- "statusMeaning": {
802- "pending": "retained behavior not yet verified in tiny",
803- "verified": "tiny implementation and acceptance check pass",
804- "intentional-change": "explicitly removed by the project contract"
805- },
806- "evidence": {
807- "conformanceArtifact": "20260907T170116Z: 14 configured files, 17 tests passed, no skips (NIP-11 uses the quota-free compatibility test)",
808- "runtimeChecks": "Full local race suite and go vet pass; full Linux race suite passes at artifacts/slate/tiny-private-git-final-20260907/20260907T165524Z. The final daemon policy changes also pass Linux race tests at artifacts/slate/tiny-git-policy-final-20260907/20260907T165928Z. Focused daemon tests cover mounted NIP-AD signed discovery and isolation, private Git authorization/restart, bounded authenticated upload spooling, tampered payload rejection, and callback recovery/revocation. Public Git performance covers six repositories at artifacts/git-performance/results-after; private nzip and Atlas pass separately at results-private-smoke and results-private-atlas.",
809- "managementGolden": "internal/daemon/management_golden_test.go",
810- "signedUXJourneys": "internal/daemon/ux_contract_test.go",
811- "scope": "focused package tests do not close route, browser, restart, or unlisted conformance matrices"
812- },
813- "exclusions": [
814- {
815- "id": "change.route.lease",
816- "source": "src/edge.ts:/lease",
817- "category": "provisioning",
818- "status": "intentional-change",
819- "check": "tiny creates an owned tenant atomically"
820- },
821- {
822- "id": "change.management.claim",
823- "source": "src/manage.ts:METHODS.claim",
824- "category": "provisioning",
825- "status": "intentional-change",
826- "check": "relay ownership is assigned by tiny tenant create; membership invitation claims remain"
827- },
828- {
829- "id": "change.runtime.lease-lifecycle",
830- "source": "src/settings.ts:Policy.lease;src/relay.ts:lease/alarm",
831- "category": "provisioning",
832- "status": "intentional-change",
833- "check": "no lease expiry, claim conversion, trial retention or teardown"
834- },
835- {
836- "id": "change.route.fuel",
837- "source": "src/routes.ts:/fuel,/fuel/invoice;src/fuel.ts",
838- "category": "economics",
839- "status": "intentional-change",
840- "check": "fuel balances, invoices and fuel admission gates are absent; NIP-57 events remain supported"
841- },
842- {
843- "id": "change.runtime.product-capacity",
844- "source": "src/fuel.ts;src/settings.ts:Policy limits",
845- "category": "economics",
846- "status": "intentional-change",
847- "check": "no billing ledger, paid tier, monthly allowance or built-in tenant capacity entitlement"
848- }
849- ],
850- "items": [
851- {
852- "id": "route.git-smart-http",
853- "source": "src/routes.ts:isGitPath",
854- "category": "http",
855- "status": "pending",
856- "check": "GRASP Smart HTTP paths work when enabled"
857- },
858- {
859- "id": "route.web-address-discovery",
860- "source": "src/routes.ts:isWebAddressRequest",
861- "category": "http",
862- "status": "verified",
863- "check": "routes_acceptance_test.go rejects unsigned discovery; hosting_routes_acceptance_test.go verifies positive signed NIP-AD through mounted tenant routing and isolation; domains tests cover path validation and read denial"
864- },
865- {
866- "id": "route.nip11",
867- "source": "src/routes.ts:Accept application/nostr+json",
868- "category": "http",
869- "status": "verified",
870- "check": "routes_acceptance_test.go verifies mounted NIP-11 content negotiation and absence of removed fuel capability; full capability matrix remains in protocol items"
871- },
872- {
873- "id": "route.nip86",
874- "source": "src/routes.ts:isManagementRequest",
875- "category": "http",
876- "status": "verified",
877- "check": "routes_acceptance_test.go verifies signed and unsigned mounted NIP-86 requests"
878- },
879- {
880- "id": "route.bridge",
881- "source": "src/routes.ts:/events,/query,/count",
882- "category": "http",
883- "status": "verified",
884- "check": "routes_acceptance_test.go verifies signed query, unsigned denial, malformed request, and CORS preflight"
885- },
886- {
887- "id": "route.nip05",
888- "source": "src/routes.ts:/.well-known/nostr.json",
889- "category": "http",
890- "status": "verified",
891- "check": "routes_acceptance_test.go verifies mounted NIP-05 unknown-name response; named-user and directory visibility remain in hosting acceptance"
892- },
893- {
894- "id": "route.people",
895- "source": "src/routes.ts:/people",
896- "category": "http",
897- "status": "pending",
898- "check": "directory listing and self key match"
899- },
900- {
901- "id": "route.invites",
902- "source": "src/routes.ts:/terms,/invite/*,/api/join-policy,/api/invites/claim",
903- "category": "http",
904- "status": "pending",
905- "check": "membership invitation claim/join flow works; this is not relay leasing"
906- },
907- {
908- "id": "route.data",
909- "source": "src/routes.ts:/dumps/*,/backups/*,/backups/restore,/backups/preview,/view/*,/import",
910- "category": "http",
911- "status": "pending",
912- "check": "download, restore, preview, views and import preserve auth"
913- },
914- {
915- "id": "route.files",
916- "source": "src/routes.ts:/upload,/mirror,/report,/list/*,blob paths,/.well-known/nostr/nip96.json,/nip96/*",
917- "category": "http",
918- "status": "pending",
919- "check": "Blossom and NIP-96 behavior works on local object storage"
920- },
921- {
922- "id": "route.public-pages",
923- "source": "src/routes.ts:/card.json,/card.nostr,/card.svg,/qr.svg,/connect.json,/* pages,/signer.js",
924- "category": "http",
925- "status": "pending",
926- "check": "plain HTML site keeps all public tasks and handoffs"
927- },
928- {
929- "id": "route.cors",
930- "source": "src/routes.ts:OPTIONS",
931- "category": "http",
932- "status": "pending",
933- "check": "CORS preflight and content types match"
934- },
935- {
936- "id": "management.methods",
937- "source": "src/manage.ts:METHODS",
938- "category": "management",
939- "status": "verified",
940- "check": "management_golden_test invokes all 90 retained methods on fresh tenants without unsupported dispatch; ux_contract_test covers signed invite/member/moderation/config/identity/fork/delete and guided Sync journeys; browser and source-conformance coverage remain separate items"
941- },
942- {
943- "id": "relay.websocket",
944- "source": "src/relay.ts",
945- "category": "protocol",
946- "status": "pending",
947- "check": "NIP-01, NIP-42, filtering, EOSE, replaceables, deletes, expiry, protected events and reconnect behavior"
948- },
949- {
950- "id": "relay.query-search-sync",
951- "source": "src/store.ts;src/filter.ts;src/negentropy.ts",
952- "category": "protocol",
953- "status": "pending",
954- "check": "COUNT/HLL, NIP-50 modes, NIP-77 and visibility parity"
955- },
956- {
957- "id": "identity-groups",
958- "source": "src/identity.ts;src/groups.ts;src/roles.ts;src/succession.ts",
959- "category": "community",
960- "status": "pending",
961- "check": "NIP-29/43 identity, roles, invites, joins, leaves, pins, transfer and succession"
962- },
963- {
964- "id": "moderation",
965- "source": "src/audit.ts;src/manage.ts;src/settings.ts",
966- "category": "community",
967- "status": "pending",
968- "check": "reports, hide thresholds, bans/erase, blocked words, IP blocks, audit and pin flows"
969- },
970- {
971- "id": "delivery",
972- "source": "src/delivery.ts;src/push.ts;src/notify.ts",
973- "category": "automation",
974- "status": "pending",
975- "check": "NIP-65, NIP-9a, NIP-17/NIP-59 notifications, retries, deduplication and terminal status"
976- },
977- {
978- "id": "jobs",
979- "source": "src/jobs.ts;src/pull.ts",
980- "category": "automation",
981- "status": "pending",
982- "check": "durable_acceptance_test.go proves dump/view/notification recovery and replication_acceptance_test.go proves delivery retry across restart; backup jobs, inbox/outbox restart, and terminal failure metrics remain"
983- },
984- {
985- "id": "storage-files",
986- "source": "src/blossom.ts;src/nip96.ts;src/site-mirror.ts",
987- "category": "hosting",
988- "status": "verified",
989- "check": "nip96_test.go covers mounted upload/get/delete, sites_test.go covers verified mirror and manifest deletion, and the 17-test conformance artifact covers Blossom behavior"
990- },
991- {
992- "id": "hosting-sites",
993- "source": "src/sites.ts;src/domains.ts;src/pages.ts;src/nipad.ts",
994- "category": "hosting",
995- "status": "pending",
996- "check": "Mounted site/custom-host isolation and positive signed NIP-AD discovery pass; domains tests cover validation/auth and conformance covers NIP-5A. Combined pages/feed UX acceptance remains separate."
997- },
998- {
999- "id": "hosting-git",
1000- "source": "src/grasp.ts;src/grasp-state.ts;src/git-storage.ts;src/git-sqlite.ts;src/git-objects.ts",
1001- "category": "hosting",
1002- "status": "pending",
1003- "check": "Immediate 30-ref HTTP push, live hosting/read policy changes, native ref projection/deletion/recovery, private HTTP discovery and negotiation after restart, payload binding, six public repositories and private Atlas transfers pass. Full inventory/repaired private transfer after restart remains an acceptance scenario."
1004- },
1005- {
1006- "id": "hosting-marmot",
1007- "source": "src/marmot.ts",
1008- "category": "hosting",
1009- "status": "pending",
1010- "check": "KeyPackages, opaque MLS envelopes, recipient checks and templates"
1011- },
1012- {
1013- "id": "views",
1014- "source": "src/views.ts",
1015- "category": "derived-records",
1016- "status": "pending",
1017- "check": "all views, presence, schedules, fingerprints, audience privacy and run history"
1018- },
1019- {
1020- "id": "config",
1021- "source": "src/config.ts;src/presets.ts;src/connections.ts",
1022- "category": "configuration",
1023- "status": "pending",
1024- "check": "plan/apply/export, partial sections, presets, placeholders and visibility"
1025- },
1026- {
1027- "id": "backups-imports",
1028- "source": "src/backups.ts;src/dumps.ts;src/imports.ts;src/git-backup.ts",
1029- "category": "operations",
1030- "status": "pending",
1031- "check": "data_http_test.go and browser-restore-qa.json cover signed preview/restore, malformed retry, freshness, private state, files, and Git backup recovery; portable import progress and full cross-version format parity remain"
1032- },
1033- {
1034- "id": "ux.console",
1035- "source": "src/console/console.html;src/console/console.js",
1036- "category": "ux",
1037- "status": "pending",
1038- "check": "plain HTML/forms retain all ten tabs, roles, signers, validation, progress, recovery, QR and app handoff"
1039- }
1040- ],
1041- "registryInventories": {
1042- "managementMethods": {
1043- "source": "src/manage.ts:METHODS",
1044- "status": "pending",
1045- "items": [
1046- "supportedmethods",
1047- "listaudit",
1048- "stats",
1049- "getpolicy",
1050- "setpolicy",
1051- "listviews",
1052- "banpubkey",
1053- "setblockedwords",
1054- "allowpubkey",
1055- "setmember",
1056- "unrulepubkey",
1057- "removemember",
1058- "listbannedpubkeys",
1059- "listallowedpubkeys",
1060- "listmembers",
1061- "listpeople",
1062- "createinvite",
1063- "listinvites",
1064- "revokeinvite",
1065- "listclaims",
1066- "listlisthistory",
1067- "restorelist",
1068- "createclaim",
1069- "deleteclaim",
1070- "removesubtree",
1071- "banevent",
1072- "allowevent",
1073- "listeventsneedingmoderation",
1074- "blockip",
1075- "unblockip",
1076- "listblockedips",
1077- "listreports",
1078- "resolvereport",
1079- "exportconfig",
1080- "importconfig",
1081- "deleterelay",
1082- "listblobs",
1083- "listsites",
1084- "deleteblob",
1085- "listbannedevents",
1086- "deleteevent",
1087- "listrecentevents",
1088- "searchevents",
1089- "pinevent",
1090- "unpinevent",
1091- "listpins",
1092- "allowkind",
1093- "disallowkind",
1094- "unrulekind",
1095- "storagestats",
1096- "gitstorage",
1097- "setretention",
1098- "listretention",
1099- "purgekind",
1100- "listallowedkinds",
1101- "listblockedkinds",
1102- "notifytest",
1103- "resetrules",
1104- "listpresets",
1105- "listconnectiontemplates",
1106- "listconnections",
1107- "setconnections",
1108- "applypreset",
1109- "forkrelay",
1110- "pullfrom",
1111- "pullstatus",
1112- "listjobs",
1113- "deliverystatus",
1114- "addjob",
1115- "removejob",
1116- "runjob",
1117- "backfill",
1118- "transferowner",
1119- "listdumps",
1120- "deletedump",
1121- "dumpnow",
1122- "backupnow",
1123- "listbackups",
1124- "deletebackup",
1125- "setsuccession",
1126- "clearsuccession",
1127- "successionstatus",
1128- "changerelayname",
1129- "changerelaydescription",
1130- "changerelayicon",
1131- "adddomain",
1132- "setdomainsite",
1133- "checkdomain",
1134- "removedomain",
1135- "listdomains"
1136- ]
1137- },
1138- "relayTemplates": {
1139- "source": "relay-templates/*.jsonc",
1140- "status": "pending",
1141- "items": [
1142- "01-default",
1143- "02-outbox",
1144- "03-inbox",
1145- "04-private",
1146- "05-chat",
1147- "06-media",
1148- "07-search",
1149- "08-articles",
1150- "09-dm",
1151- "10-quiet",
1152- "11-site",
1153- "12-marmot",
1154- "13-grasp",
1155- "14-marmot-members",
1156- "15-home"
1157- ]
1158- },
1159- "connectionTemplates": {
1160- "source": "connection-templates/*.jsonc",
1161- "status": "pending",
1162- "items": [
1163- "01-notes",
1164- "02-find-me",
1165- "03-group",
1166- "04-blog",
1167- "05-repos",
1168- "06-sites",
1169- "07-bookmarks",
1170- "08-photos",
1171- "09-files",
1172- "10-dm",
1173- "11-marmot",
1174- "12-relay-page"
1175- ]
1176- },
1177- "conformanceTests": {
1178- "source": "test/conformance/*.test.ts",
1179- "status": "verified",
1180- "check": "artifacts/conformance/20260907T170116Z passes 17 tests across every configured source conformance family; nip11.test.ts is replaced by scripts/conformance/nip11.compat.test.ts for the quota-free self-hosted contract",
1181- "items": [
1182- "nip01.test.ts",
1183- "nip09.test.ts",
1184- "nip11.test.ts",
1185- "nip17.test.ts",
1186- "nip40.test.ts",
1187- "nip42.test.ts",
1188- "nip45.test.ts",
1189- "nip50.test.ts",
1190- "nip62.test.ts",
1191- "nip70.test.ts",
1192- "nip77.test.ts",
1193- "nip5a.test.ts",
1194- "blossom.test.ts",
1195- "grasp.test.ts"
1196- ]
1197- }
1198- }
1199-}
1200diff --git a/docs/parity.md b/docs/parity.md
1201deleted file mode 100644
1202index 6b70d12..0000000
1203--- a/docs/parity.md
1204+++ /dev/null
1205@@ -1,45 +0,0 @@
1206-# Tiny parity ledger
1207-
1208-`docs/parity.json` is the machine-readable completion ledger for the Go
1209-implementation. Every retained item starts as `pending`; implementation work
1210-may change it to `verified` only with a test or recorded acceptance check.
1211-
1212-The source baseline is bindws revision `5d31267`. The registry inventories are
1213-deliberately explicit: all retained management methods, relay templates,
1214-connection templates and black-box conformance files are listed. Broad family
1215-items point to the source modules that must be decomposed into individual
1216-ledger entries as work lands.
1217-
1218-The only intentional changes are the `/lease` and lease lifecycle, the relay
1219-ownership `claim` method, fuel and billing endpoints, and hosted product
1220-capacity entitlements. Membership
1221-invitation claims (`createclaim`, `listclaims`, `deleteclaim` and invite join
1222-flows) remain required. NIP-57 zap events remain ordinary supported Nostr
1223-events; they do not create fuel credit.
1224-
1225-## Acceptance
1226-
1227-Run the black-box suite against a running local `tiny` instance with
1228-`RELAY_URL=ws://host:port`. The conformance setup provisions a permanent,
1229-owned tenant through `tiny tenant create` when configured. It never skips a
1230-test because the relay has no `claim` method. A legacy bindws run is available
1231-only with `LEGACY_CLAIM=1` for comparison.
1232-
1233-Full completion requires every retained ledger entry to be `verified`, every
1234-conformance file to pass, and an end-to-end browser matrix covering the plain
1235-HTML replacement for all ten console families: People, Moderation, Rules,
1236-Identity, Connect, Data, Sync, Views, Health and Owner. The matrix includes
1237-NIP-07 and NIP-46 signing, role authorization, validation and error states,
1238-progress/recovery, backup/restore, QR and app handoff. Disabled features still
1239-need an implementation and an enabled-path test.
1240-
1241-Current evidence includes the 20260907T170116Z artifact (14 configured files,
1242-17 tests, no skips), passing `go test -race ./...` and `go vet ./...`, mounted
1243-route acceptance tests, durable restart tests, distinct NIP-46 browser resume
1244-evidence, browser backup restore/retry evidence, and six-repository Git
1245-performance results. The ledger still separates those checks from the
1246-remaining full route, browser navigation, restart coverage, and native Git
1247-state semantics.
1248-
1249-No `skipped` status is permitted. Removed fuel/lease tests are explicit
1250-negative tests for absence; all other bindws behavior remains required.
1251diff --git a/docs/protocol-expansion.md b/docs/protocol-expansion.md
1252deleted file mode 100644
1253index c65d5ba..0000000
1254--- a/docs/protocol-expansion.md
1255+++ /dev/null
1256@@ -1,49 +0,0 @@
1257-# Approved protocol and client expansion
1258-
1259-Approved 2026-09-07. Full retained bindws behavior and UX parity remains required.
1260-BUD-05 media optimization and BUD-07 payments are excluded. Tenants remain
1261-permanent, without trials, fuel, billing, or product resource entitlements.
1262-
1263-## Acceptance ledger
1264-
1265-An implementation is complete only after relevant mounted routes, permissions,
1266-restart behavior, interoperability, and user journeys have evidence. Compilation
1267-or a feature flag alone does not establish protocol support.
1268-
1269-| Work | Required outcome | Status |
1270-| --- | --- | --- |
1271-| GRASP-01 | Native streaming Git, signed refs, recovery and HTTP interoperability | In progress |
1272-| GRASP-02/03 | Durable independent repository synchronization and complete collaboration filtering | In progress |
1273-| GRASP-05/06 | Archive and alternative PR behavior with recoverable inventory | In progress |
1274-| GRASP-08 | Private service boundary, authenticated metadata and Git, private peer replication | In progress |
1275-| BUD-01/02/04/06/08/09/11/12 | Correct file retrieval, ownership, authorization, pagination, mirrors and reports | In progress |
1276-| BUD-03/10 and NIP-B7/94/92 | Portable server lists, references, metadata and client fallback | In progress |
1277-| NIP-34/22 | Repository activity, threads, statuses and GRASP server lists | In progress |
1278-| NIP-65/77/67 | Inbox/outbox recovery and accurate scalable reconciliation | In progress |
1279-| NIP-42/98/17/59/70 | Consistent authentication and private data access | In progress |
1280-| NIP-43/56 | Membership requests and integrated moderation | In progress |
1281-| NIP-11/66/86 | Truthful capabilities, optional peer monitoring, operational visibility | In progress |
1282-| NIP-96 | Preserve upload/get/delete compatibility through shared file storage | In progress |
1283-
1284-## Contained client experience
1285-
1286-The same embedded site must provide repository discovery, branch/tag selection,
1287-source trees, escaped source previews, downloads, history, commit diffs and
1288-repository activity. File browsing includes pagination, metadata, previews,
1289-downloads and existing signed management actions. Connect publishes owner-signed
1290-service lists through the existing signer. Operational pages expose synchronization,
1291-jobs, failures, storage, backups and capabilities with links to recovery actions.
1292-
1293-All routes share the tenant's current access policy; private data must not become
1294-public through a browsing or download route. The site retains its plain HTML
1295-foundation and fixed desktop/mobile variants. The user explicitly approved richer
1296-Git/source components: repository tabs, branch selectors, breadcrumbs, file trees,
1297-syntax highlighting, compact line numbers, colored diffs and clone controls.
1298-Basic forms, locally served scripts and optional Fixi enhancement remain the
1299-foundation. Native Git supplies object reads and diffs. Large
1300-files are streamed for download and previews disclose truncation.
1301-
1302-Observability accompanies implementation: queue age, retries, last success,
1303-subprocess time, memory and relay acknowledgement latency under mixed workloads.
1304-Real repository performance fixtures on Slate remain regression evidence; tail
1305-latency is still an investigation target.
1306diff --git a/docs/testing.md b/docs/testing.md
1307index 3f81c4b..0e29914 100644
1308--- a/docs/testing.md
1309+++ b/docs/testing.md
1310@@ -47,7 +47,7 @@ The conformance harness runs the copied bindws-compatible cases against a runnin
1311
1312 ## Real Git repositories
1313
1314-The [Git performance report](git-performance.md) records the six-repository Slate experiment. Its HTML version and raw profiles live in `artifacts/git-performance/`; regenerate both reports with `python3 scripts/report-git-performance.py`.
1315+The Git performance harness below records raw profiles and results under `artifacts/git-performance/`.
1316
1317 Prepare a new corpus from committed local branches and tags. This leaves the source repositories unchanged and refuses an existing output directory:
1318
1319diff --git a/package.json b/package.json
1320index 88cde9e..9773144 100644
1321--- a/package.json
1322+++ b/package.json
1323@@ -6,8 +6,7 @@
1324 "build:signer": "node scripts/build-signer.mjs",
1325 "test:conformance": "node scripts/conformance/run.mjs",
1326 "test:webmcp": "node --test scripts/webmcp.test.mjs",
1327- "test:signer": "node --test scripts/signer.test.mjs",
1328- "check:parity": "node scripts/conformance/check-parity.mjs"
1329+ "test:signer": "node --test scripts/signer.test.mjs"
1330 },
1331 "devDependencies": {
1332 "@noble/hashes": "2.4.0",
1333diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md
1334index 27d7249..26c2668 100644
1335--- a/scripts/conformance/README.md
1336+++ b/scripts/conformance/README.md
1337@@ -16,10 +16,4 @@ For comparison only, `LEGACY_CLAIM=1` invokes bindws's old NIP-86 `claim`
1338 setup. This compatibility path is not a tiny requirement and does not skip or
1339 alter test assertions.
1340
1341-Check that the machine-readable registry still matches the bindws source with:
1342-
1343-```sh
1344-node scripts/conformance/check-parity.mjs
1345-```
1346-
1347-Each run preserves `environment.txt`, `stdout.log`, `stderr.log`, and `status` under `artifacts/conformance/<UTC-stamp>/`. Set `CONFORMANCE_ARTIFACTS` to choose another destination. The source conformance files remain unchanged. The hosted NIP-11 file is replaced in this harness by `nip11.compat.test.ts`, which preserves every assertion except the numeric `limitation.max_limit` requirement because self-hosted relays have no hosted quota. Other self-hosting differences belong in separately named compatibility tests or an explicit parity ledger entry.
1348+Each run preserves `environment.txt`, `stdout.log`, `stderr.log`, and `status` under `artifacts/conformance/<UTC-stamp>/`. Set `CONFORMANCE_ARTIFACTS` to choose another destination. The source conformance files remain unchanged. The hosted NIP-11 file is replaced in this harness by `nip11.compat.test.ts`, which preserves every assertion except the numeric `limitation.max_limit` requirement because self-hosted relays have no hosted quota. Other self-hosting differences belong in separately named compatibility tests.
1349diff --git a/scripts/conformance/check-parity.mjs b/scripts/conformance/check-parity.mjs
1350deleted file mode 100644
1351index e515cbb..0000000
1352--- a/scripts/conformance/check-parity.mjs
1353+++ /dev/null
1354@@ -1,31 +0,0 @@
1355-#!/usr/bin/env node
1356-import { readFileSync, readdirSync } from "node:fs";
1357-import { resolve } from "node:path";
1358-
1359-const root = resolve(import.meta.dirname, "../..");
1360-const sourceRoot = resolve(process.env.BINDWS_ROOT ?? resolve(root, "../bindws"));
1361-const ledger = JSON.parse(readFileSync(resolve(root, "docs/parity.json"), "utf8"));
1362-const fail = (message) => { throw new Error(`parity ledger: ${message}`); };
1363-
1364-if (!/^[0-9a-f]{7,40}$/.test(ledger.source.revision)) fail("source revision is missing");
1365-for (const item of [...ledger.items, ...ledger.exclusions]) {
1366- if (!item.id || !item.source || !item.status || !item.check) fail(`incomplete item ${item.id ?? "<unnamed>"}`);
1367- if (!new Set(["pending", "verified", "intentional-change"]).has(item.status)) fail(`invalid status for ${item.id}`);
1368-}
1369-
1370-const manage = readFileSync(resolve(sourceRoot, "src/manage.ts"), "utf8").split("export const METHODS", 2)[1];
1371-const methods = [...manage.matchAll(/^ ([A-Za-z][A-Za-z0-9_]*):/gm)].map((m) => m[1]);
1372-const listedMethods = ledger.registryInventories.managementMethods.items;
1373-const missingMethods = methods.filter((name) => name !== "claim" && !listedMethods.includes(name));
1374-const extraMethods = listedMethods.filter((name) => !methods.includes(name));
1375-if (missingMethods.length || extraMethods.length) fail(`management registry drift missing=${missingMethods} extra=${extraMethods}`);
1376-
1377-const compareFiles = (key, directory, suffix) => {
1378- const actual = readdirSync(resolve(sourceRoot, directory)).filter((name) => name.endsWith(suffix)).sort();
1379- const listed = [...ledger.registryInventories[key].items].sort().map((name) => key === "conformanceTests" ? name : `${name}${suffix}`);
1380- if (actual.join("\n") !== listed.join("\n")) fail(`${key} drift`);
1381-};
1382-compareFiles("relayTemplates", "relay-templates", ".jsonc");
1383-compareFiles("connectionTemplates", "connection-templates", ".jsonc");
1384-compareFiles("conformanceTests", "test/conformance", ".test.ts");
1385-console.log(`parity ledger OK: ${ledger.items.length} retained families, ${listedMethods.length} retained methods`);
1386diff --git a/scripts/report-git-performance.py b/scripts/report-git-performance.py
1387deleted file mode 100755
1388index 10e2843..0000000
1389--- a/scripts/report-git-performance.py
1390+++ /dev/null
1391@@ -1,468 +0,0 @@
1392-#!/usr/bin/env python3
1393-"""Render reproducible Git benchmark artifacts as a plain HTML/Markdown report."""
1394-
1395-import argparse
1396-import datetime as dt
1397-import html
1398-from functools import lru_cache
1399-import json
1400-import math
1401-import pathlib
1402-import statistics
1403-
1404-
1405-PHASES = [
1406- ("initial push", "initial_push"),
1407- ("clone ×3 median", "fresh_clone_"),
1408- ("fetch", "noop_fetch_"),
1409- ("incremental state ACK", "incremental_state_ack"),
1410- ("incremental push", "incremental_push"),
1411- ("concurrent ×2 wall", "concurrent_clone_2"),
1412- ("concurrent ×4 wall", "concurrent_clone_4"),
1413-]
1414-
1415-
1416-def read_json(path):
1417- try:
1418- return json.loads(path.read_text(encoding="utf-8"))
1419- except (OSError, json.JSONDecodeError):
1420- return None
1421-
1422-
1423-def raw(value):
1424- return "—" if value is None else str(value)
1425-
1426-
1427-def display(value, kind="number"):
1428- if value is None:
1429- return "—"
1430- if isinstance(value, str):
1431- if value.startswith("Command failed") or value.startswith("Nostr request") or value.startswith("error:"):
1432- return "FAILED: " + value.splitlines()[0][:160] if value else "FAILED"
1433- return value
1434- if kind == "mib":
1435- value = value / (1024 * 1024)
1436- if kind == "kb_mib":
1437- value = value / 1024
1438- if isinstance(value, (int, float)):
1439- magnitude = abs(value)
1440- if magnitude >= 1000:
1441- return f"{value:,.0f}"
1442- if magnitude >= 100:
1443- return f"{value:,.1f}"
1444- return f"{value:.3g}"
1445- return str(value)
1446-
1447-
1448-def median(values):
1449- return statistics.median(values) if values else None
1450-
1451-
1452-def percentile(values, p):
1453- if not values:
1454- return None
1455- values = sorted(values)
1456- index = (len(values) - 1) * p
1457- low, high = math.floor(index), math.ceil(index)
1458- if low == high:
1459- return values[low]
1460- return values[low] + (values[high] - values[low]) * (index - low)
1461-
1462-
1463-def parse_stat(text):
1464- result = {}
1465- if not text:
1466- return result
1467- for line in text.splitlines():
1468- parts = line.split()
1469- if len(parts) >= 2:
1470- try:
1471- result[parts[0]] = int(parts[1])
1472- except ValueError:
1473- continue
1474- return result
1475-
1476-
1477-def phase_value(phases, prefix, baseline=False):
1478- rows = []
1479- for row in phases:
1480- name = row.get("name", "")
1481- if baseline != name.startswith("baseline_"):
1482- continue
1483- candidate = name[len("baseline_"):] if baseline else name
1484- if candidate == prefix or (prefix.endswith("_") and candidate.startswith(prefix)):
1485- if row.get("ok") is False:
1486- return {"error": row.get("error", "failed")}
1487- rows.append(row.get("ms"))
1488- if prefix.endswith("_"):
1489- return median([v for v in rows if isinstance(v, (int, float))])
1490- return rows[0] if rows else None
1491-
1492-
1493-def method_name(set_name):
1494- lower = set_name.lower()
1495- if "private" in lower:
1496- return "private Tiny (NIP-98)"
1497- if "stream" in lower or "after" in lower:
1498- return "streaming Tiny"
1499- if "native" in lower:
1500- return "native Git HTTP baseline"
1501- return "buffered Tiny"
1502-
1503-
1504-@lru_cache(maxsize=None)
1505-def load_samples(directory):
1506- path = directory / "samples.jsonl"
1507- rows = []
1508- try:
1509- for line in path.read_text(encoding="utf-8").splitlines():
1510- try:
1511- rows.append(json.loads(line))
1512- except json.JSONDecodeError:
1513- pass
1514- except OSError:
1515- pass
1516- return rows
1517-
1518-
1519-def fixture_bounds(directory, fixture):
1520- starts = ends = None
1521- all_starts = []
1522- try:
1523- for line in (directory / "progress.jsonl").read_text(encoding="utf-8").splitlines():
1524- row = json.loads(line)
1525- if row.get("event") == "fixture_start":
1526- all_starts.append((row.get("fixture"), row.get("at")))
1527- if row.get("fixture") == fixture:
1528- starts = row.get("at")
1529- elif row.get("event") == "fixture_end":
1530- if row.get("fixture") == fixture:
1531- ends = row.get("at")
1532- except (OSError, json.JSONDecodeError):
1533- pass
1534- if starts and not ends:
1535- later = [stamp for name, stamp in all_starts if stamp and stamp > starts]
1536- if later:
1537- ends = min(later)
1538- return starts, ends
1539-
1540-
1541-def resource_summary(directory, metadata, fixture, baseline=False):
1542- process_status = directory / "process.status"
1543- text = process_status.read_text(encoding="utf-8") if process_status.exists() else ""
1544- vmhwm = None
1545- for line in text.splitlines():
1546- if line.startswith("VmHWM:"):
1547- try:
1548- vmhwm = int(line.split()[1])
1549- except (ValueError, IndexError):
1550- pass
1551- containers = metadata.get("containers", [])
1552- container = containers[1] if baseline and len(containers) > 1 else (containers[0] if containers else None)
1553- fixture_start, fixture_end = fixture_bounds(directory, fixture)
1554- try:
1555- start_time = dt.datetime.fromisoformat(fixture_start.replace("Z", "+00:00")).timestamp() if fixture_start else None
1556- end_time = dt.datetime.fromisoformat(fixture_end.replace("Z", "+00:00")).timestamp() if fixture_end else None
1557- except (TypeError, ValueError):
1558- start_time = end_time = None
1559- samples = []
1560- for row in load_samples(directory):
1561- try:
1562- row_time = dt.datetime.fromisoformat((row.get("time") or "").replace("Z", "+00:00")).timestamp()
1563- except (TypeError, ValueError):
1564- row_time = None
1565- if start_time is not None and row_time is not None and row_time < start_time:
1566- continue
1567- if end_time is not None and row_time is not None and row_time > end_time:
1568- continue
1569- for item in row.get("containers", []):
1570- if item.get("container") == container:
1571- mem = parse_stat(item.get("memory.stat"))
1572- for line in (item.get("process.status") or "").splitlines():
1573- if line.startswith("VmHWM:"):
1574- try:
1575- vmhwm = max(vmhwm or 0, int(line.split()[1]))
1576- except (ValueError, IndexError):
1577- pass
1578- samples.append({
1579- "time": item.get("time") or row.get("time"),
1580- "cpu": parse_stat(item.get("cpu.stat")).get("usage_usec"),
1581- "anon": mem.get("anon"), "file": mem.get("file"),
1582- "total": item.get("memory.peak"),
1583- })
1584- values = lambda key: [x[key] for x in samples if isinstance(x.get(key), (int, float))]
1585- return {"vmhwm_kb": vmhwm, "vmhwm_scope": "sampled process status", "container": container,
1586- "anon_peak": max(values("anon"), default=None),
1587- "file_peak": max(values("file"), default=None),
1588- "total_peak": max(values("total"), default=None),
1589- "cpu_usec": max(values("cpu"), default=None)}
1590-
1591-
1592-def run_vmhwm(directory, metadata, baseline=False):
1593- containers = metadata.get("containers", [])
1594- target = containers[1] if baseline and len(containers) > 1 else (containers[0] if containers else None)
1595- peak = None
1596- for row in load_samples(directory):
1597- for item in row.get("containers", []):
1598- if item.get("container") != target:
1599- continue
1600- for line in (item.get("process.status") or "").splitlines():
1601- if line.startswith("VmHWM:"):
1602- try:
1603- peak = max(peak or 0, int(line.split()[1]))
1604- except (ValueError, IndexError):
1605- pass
1606- return peak
1607-
1608-
1609-def phase_cpu(directory, metadata, phase):
1610- samples = load_samples(directory)
1611- containers = metadata.get("containers", [])
1612- target = containers[0] if containers else None
1613- if phase.get("name", "").startswith("baseline_") and len(containers) > 1:
1614- target = containers[1]
1615- points = []
1616- for row in samples:
1617- for item in row.get("containers", []):
1618- if item.get("container") == target:
1619- usage = parse_stat(item.get("cpu.stat")).get("usage_usec")
1620- if usage is not None:
1621- try:
1622- stamp_raw = item.get("time") or row.get("time")
1623- if not stamp_raw:
1624- continue
1625- stamp = dt.datetime.fromisoformat(stamp_raw.replace("Z", "+00:00")).timestamp()
1626- points.append((stamp, usage))
1627- except (KeyError, TypeError, ValueError):
1628- pass
1629- if len(points) < 2:
1630- return None
1631- try:
1632- start = dt.datetime.fromisoformat(phase["started"].replace("Z", "+00:00")).timestamp()
1633- end = dt.datetime.fromisoformat(phase["finished"].replace("Z", "+00:00")).timestamp()
1634- except (KeyError, TypeError, ValueError):
1635- return None
1636- before = min(points, key=lambda p: abs(p[0] - start))
1637- after = min(points, key=lambda p: abs(p[0] - end))
1638- if abs(before[0] - start) > 0.5 or abs(after[0] - end) > 0.5:
1639- return None
1640- if after[0] <= before[0]:
1641- return None
1642- return after[1] - before[1]
1643-
1644-
1645-def probe_summary(result, baseline=False):
1646- idle_publish, idle_query, busy_publish, busy_query = [], [], [], []
1647- idle_errors = busy_errors = 0
1648- for probe in result.get("probes", []):
1649- phase = probe.get("phase", "")
1650- is_baseline = phase.startswith("baseline_")
1651- if is_baseline != baseline or phase == "between_phases":
1652- continue
1653- publish_target, query_target = (idle_publish, idle_query) if phase == "idle" else (busy_publish, busy_query)
1654- if isinstance(probe.get("publishMs"), (int, float)):
1655- publish_target.append(probe["publishMs"])
1656- if isinstance(probe.get("queryMs"), (int, float)):
1657- query_target.append(probe["queryMs"])
1658- if any(key.endswith("Error") for key in probe):
1659- if phase == "idle":
1660- idle_errors += 1
1661- else:
1662- busy_errors += 1
1663- summary = []
1664- for values in (idle_publish, idle_query, busy_publish, busy_query):
1665- summary.extend((median(values), percentile(values, 0.95), max(values, default=None)))
1666- return (*summary, idle_errors, busy_errors)
1667-
1668-
1669-def cells_for_result(directory, result, fixture, baseline=False):
1670- values = []
1671- for label, prefix in PHASES:
1672- value = phase_value(result.get("phases", []), prefix, baseline)
1673- if isinstance(value, dict):
1674- values.append(value["error"])
1675- else:
1676- values.append(value)
1677- meta = read_json(directory / "metadata.json") or {}
1678- resources = resource_summary(directory, meta, fixture, baseline)
1679- probes = probe_summary(result, baseline)
1680- clone_n = sum(1 for row in result.get("phases", []) if row.get("name", "") == ("baseline_" if baseline else "") + "fresh_clone_1" or row.get("name", "").startswith(("baseline_" if baseline else "") + "fresh_clone_"))
1681- return values, resources, probes, clone_n
1682-
1683-
1684-def finding_phase(result, prefix):
1685- if not result:
1686- return None
1687- value = phase_value(result.get("phases", []), prefix, False)
1688- return value.get("error") if isinstance(value, dict) else value
1689-
1690-
1691-def table(headers, rows, classes="", kinds=None):
1692- out = [f'<table class="{classes}"><thead><tr>']
1693- out.extend(f"<th>{html.escape(str(h))}</th>" for h in headers)
1694- out.append("</tr></thead><tbody>")
1695- for row in rows:
1696- out.append("<tr>" + "".join(f"<td>{html.escape(display(x, kinds[i] if kinds and i < len(kinds) else 'number'))}</td>" for i, x in enumerate(row)) + "</tr>")
1697- out.append("</tbody></table>")
1698- rendered = "\n".join(out)
1699- if classes == "wide" and len(headers) > 4:
1700- rendered = rendered.replace('class="wide"', 'class="desktop"', 1)
1701- mobile = ['<table class="mobile"><tbody>']
1702- for row in rows:
1703- mobile.append('<tr><th colspan="2">' + html.escape(str(row[0])) + '</th></tr>')
1704- for i, value in enumerate(row[1:], 1):
1705- kind = kinds[i] if kinds and i < len(kinds) else "number"
1706- mobile.append('<tr><th>' + html.escape(str(headers[i])) + '</th><td>' + html.escape(display(value, kind)) + '</td></tr>')
1707- mobile.append('</tbody></table>')
1708- rendered += "\n" + "\n".join(mobile)
1709- return rendered
1710-
1711-
1712-def markdown_table(headers, rows, kinds=None):
1713- lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
1714- for row in rows:
1715- cells = [display(x, kinds[i] if kinds and i < len(kinds) else "number") for i, x in enumerate(row)]
1716- lines.append("| " + " | ".join(c.replace("|", "\\|") for c in cells) + " |")
1717- return "\n".join(lines)
1718-
1719-
1720-def main():
1721- parser = argparse.ArgumentParser()
1722- parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path("artifacts/git-performance"))
1723- parser.add_argument("--html", type=pathlib.Path)
1724- parser.add_argument("--markdown", type=pathlib.Path, default=pathlib.Path("docs/git-performance.md"))
1725- args = parser.parse_args()
1726- root = args.root
1727- html_path = args.html or root / "git-performance.html"
1728- corpus = read_json(root / "corpus.json") or []
1729- sets = []
1730- allowed_sets = {"results-before", "results-after", "results-before-atlas-concurrency", "results-private-atlas"}
1731- for directory in sorted(root.glob("results-*")):
1732- if directory.name not in allowed_sets:
1733- continue
1734- report = read_json(directory / "git-performance.json")
1735- if not report:
1736- continue
1737- meta = read_json(directory / "metadata.json") or {}
1738- sets.append((directory, report, meta))
1739- phase_rows, resource_rows, probe_rows, status_rows = [], [], [], []
1740- findings_rows = []
1741- highlight_rows = []
1742- raw_links = []
1743- raw_seen = set()
1744- for directory, report, meta in sets:
1745- for result in report.get("results", []):
1746- fixture = result.get("fixture", "?")
1747- failed = [p.get("name", "?") for p in result.get("phases", []) if p.get("ok") is False]
1748- status_rows.append([fixture, method_name(directory.name), "failed" if failed or result.get("status") not in (None, "complete", "ok", "passed") else "complete", ", ".join(failed) or "—"])
1749- for baseline in (False, True):
1750- if baseline and not any(p.get("name", "").startswith("baseline_") for p in result.get("phases", [])):
1751- continue
1752- method = "native Git HTTP (" + directory.name.removeprefix("results-") + ")" if baseline else method_name(directory.name)
1753- values, resources, probes, clone_n = cells_for_result(directory, result, fixture, baseline)
1754- phase_rows.append([fixture, method, clone_n, *values])
1755- phase_rows_raw = result.get("phases", [])
1756- cpu_values = []
1757- for prefix in ("initial_push", "incremental_push", "concurrent_clone_2", "concurrent_clone_4"):
1758- match = next((p for p in phase_rows_raw if (p.get("name", "").removeprefix("baseline_") == prefix) and (p.get("name", "").startswith("baseline_") == baseline)), None)
1759- cpu_values.append(phase_cpu(directory, meta, match) if match else None)
1760- resource_rows.append([fixture, method, resources["vmhwm_kb"], resources["anon_peak"], resources["file_peak"], resources["total_peak"], *cpu_values, resources["container"]])
1761- probe_rows.append([fixture, method, *probes])
1762- raw_path = directory / "git-performance.json"
1763- if raw_path not in raw_seen:
1764- raw_links.append((directory.name, raw_path))
1765- raw_seen.add(raw_path)
1766- corpus_rows = [[x.get("name"), x.get("all_commits"), x.get("refs"), x.get("pack_bytes")] for x in corpus]
1767- set_by_name = {directory.name: (directory, report, meta) for directory, report, meta in sets}
1768- before_set = set_by_name.get("results-before")
1769- after_set = set_by_name.get("results-after")
1770- before_mem = run_vmhwm(before_set[0], before_set[2]) if before_set else None
1771- after_mem = run_vmhwm(after_set[0], after_set[2]) if after_set else None
1772- highlight_rows.append(["Peak Go memory (MiB)", before_mem / 1024 if before_mem is not None else None, after_mem / 1024 if after_mem is not None else None])
1773- result_maps = {}
1774- for label, item in (("before", before_set), ("after", after_set)):
1775- if item:
1776- result_maps[label] = {r.get("fixture"): r for r in item[1].get("results", [])}
1777- for fixture in sorted(set(result_maps.get("before", {})) | set(result_maps.get("after", {}))):
1778- row = [fixture]
1779- for label in ("before", "after"):
1780- result = result_maps.get(label, {}).get(fixture)
1781- row.append(finding_phase(result, "fresh_clone_"))
1782- row.append(finding_phase(result, "concurrent_clone_4"))
1783- row.append(finding_phase(result, "incremental_state_ack"))
1784- row.append(finding_phase(result, "incremental_push"))
1785- findings_rows.append(row)
1786- before_strudel = result_maps.get("before", {}).get("strudel.git")
1787- after_strudel = result_maps.get("after", {}).get("strudel.git")
1788- highlight_rows.append(["Tag-heavy strudel incremental push (ms)", finding_phase(before_strudel, "incremental_push"), finding_phase(after_strudel, "incremental_push")])
1789- headers = ["repo", "method", "clone n"] + [(("clone median" if x[1] == "fresh_clone_" else x[0]) + (" ms" if x[1] != "fresh_clone_" else " ms")) for x in PHASES]
1790- html_doc = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Git performance</title><style>body{font:14px system-ui,sans-serif;width:360px;margin:1rem auto;line-height:1.45}table{border-collapse:collapse;margin:1rem 0;width:100%;table-layout:fixed}th,td{border:1px solid;padding:.4rem;text-align:left;overflow-wrap:anywhere}summary{cursor:pointer;font-weight:bold}.desktop{display:none}.mobile{display:table}@media(min-width:1160px){body{width:1120px}.desktop{display:table}.mobile{display:none}}</style></head><body>'
1791- html_doc += '<h1>Git performance</h1><p>Six repositories from ~/Developer, tested on Slate. All six completed public push, clone, fetch and concurrent clone workloads after the fixes. Every background relay probe succeeded.</p><p>Git responses now stream as they are produced. Ref authorization and object checks use batches, and idle connections stay alive through WebSocket pings.</p>'
1792- html_doc += table(["finding", "before", "after"], highlight_rows, "wide", ["number", "number", "number"])
1793- clone_rows = []
1794- for fixture, result in result_maps.get("after", {}).items():
1795- before_result = result_maps.get("before", {}).get(fixture)
1796- native = phase_value(result.get("phases", []), "fresh_clone_", True)
1797- values = [finding_phase(before_result, "fresh_clone_"), finding_phase(result, "fresh_clone_"), native]
1798- clone_rows.append([fixture.removesuffix(".git"), *[f"{v / 1000:.2f}" if isinstance(v, (int, float)) else "—" for v in values]])
1799- html_doc += "<h2>Clone comparison</h2><p>Median of three fresh clones, in seconds. Git is the native HTTP baseline measured alongside the after run.</p>"
1800- html_doc += table(["Repository", "Before", "After", "Git"], clone_rows)
1801- private_set = set_by_name.get("results-private-atlas")
1802- tail_rows = []
1803- public_atlas = result_maps.get("after", {}).get("atlas.git")
1804- if public_atlas:
1805- for label, baseline in (("Public Atlas", False), ("Native Git Atlas phases", True)):
1806- probes = probe_summary(public_atlas, baseline)
1807- tail_rows.append([label, probes[7], probes[8], probes[10], probes[11]])
1808- if private_set:
1809- for result in private_set[1].get("results", []):
1810- probes = probe_summary(result)
1811- tail_rows.append(["Private " + result.get("fixture", "?"), probes[7], probes[8], probes[10], probes[11]])
1812- if tail_rows:
1813- html_doc += "<h2>Remaining relay latency</h2><p>All probes succeeded, but durable event acknowledgements still showed occasional stalls under large Git transfers. Similar stalls occurred while the native Git baseline was busy on the same host. The comparison does not establish the cause; disk latency and scheduling need a focused capture before choosing another optimization.</p>"
1814- html_doc += table(["Workload", "ACK P95 ms", "ACK max ms", "Query P95 ms", "Query max ms"], tail_rows, "wide")
1815- if private_set:
1816- private_rows = []
1817- for result in private_set[1].get("results", []):
1818- private_rows.append([result.get("fixture"), result.get("status"), finding_phase(result, "initial_push"), finding_phase(result, "fresh_clone_"), finding_phase(result, "concurrent_clone_4")])
1819- peak = run_vmhwm(private_set[0], private_set[2])
1820- html_doc += "<details><summary>Private repository follow-up</summary><p>Private Git requests verify NIP-98 payload hashes before Git receives them. Uploads spool to disk so memory does not grow with pack size. The client used an ephemeral signing proxy that also spooled requests; these timings include that client overhead and have no private native-Git baseline. The process first completed a small private nzip smoke test.</p>"
1821- html_doc += f"<p>Private-run Go RSS high-water: {display(peak, 'kb_mib')} MiB.</p>"
1822- html_doc += table(["repo", "status", "initial push ms", "clone median ms", "concurrent4 ms"], private_rows, "wide") + "</details>"
1823- html_doc += "<details><summary>Push and concurrent clone comparisons (ms)</summary>"
1824- html_doc += table(["repo", "before clone", "before concurrent4", "before state ACK", "before push", "after clone", "after concurrent4", "after state ACK", "after push"], findings_rows, "wide", ["number"] * 9)
1825- html_doc += "</details><details><summary>Repository characteristics</summary>"
1826- html_doc += table(["repo", "all commits", "refs", "pack MiB"], corpus_rows, "wide", ["number", "number", "number", "mib"])
1827- html_doc += "</details><details><summary>All phase timings (milliseconds)</summary>"
1828- html_doc += table(headers, phase_rows, "wide", ["number", "number", "number"] + ["number"] * len(PHASES))
1829- html_doc += "</details>"
1830- mobile_rows = []
1831- for row in phase_rows:
1832- for index, (label, _) in enumerate(PHASES, start=2):
1833- mobile_rows.append([row[0], row[1], label, row[index + 1]])
1834-
1835- resource_headers = ["repo", "method", "Go RSS high-water MiB", "cgroup anon peak MiB", "cgroup file peak MiB", "cgroup total peak MiB", "CPU initial µs", "CPU incremental µs", "CPU concurrent2 µs", "CPU concurrent4 µs", "container"]
1836- html_doc += "<details><summary>Process, container memory and CPU</summary>"
1837- html_doc += table(resource_headers, resource_rows, "wide", ["number", "number", "kb_mib", "mib", "mib", "mib", "number", "number", "number", "number", "number"])
1838- html_doc += "</details>"
1839- probe_headers = ["repo", "method", "idle publish P50 ms", "idle publish P95 ms", "idle publish max ms", "idle query P50 ms", "idle query P95 ms", "idle query max ms", "busy publish P50 ms", "busy publish P95 ms", "busy publish max ms", "busy query P50 ms", "busy query P95 ms", "busy query max ms", "idle errors", "busy errors"]
1840- html_doc += "<details><summary>Relay responsiveness during Git operations</summary>"
1841- html_doc += table(probe_headers, probe_rows, "wide", ["number", "number"] + ["number"] * 14)
1842- html_doc += "</details><details><summary>Failures and completion by run</summary>"
1843- html_doc += table(["repo", "method", "status", "failed phases"], status_rows, "wide")
1844- html_doc += "</details><details><summary>Method and limits of comparison</summary><p>Linux Slate: 16 logical CPUs, Git 2.50.1 client and 2.39.5 server, Go 1.27.1. Client and server shared one host over loopback; SSH/Tailscale was used to orchestrate the run. Each server container had a 10 GiB test ceiling, no CPU cap and no swap. These are test containment settings, not product limits.</p><p>Fixtures contain committed local branches and tags. Fresh clones used new destination directories; the operating system page cache was not cleared. Three sequential clones and one group each at concurrency two and four were measured. The incremental push adds one commit with an unchanged tree. Native Git uses the same git-http-backend with a minimal streaming HTTP wrapper. Differences around the native baseline can reflect cache and host variation. No Worker-hosted bindws runtime was benchmarked.</p><p>Go VmHWM is the whole-run peak resident memory of tiny itself. Container memory also includes Git subprocesses and file cache. Those components must not be confused. Cgroup CPU samples include background relay probes and Git children, at roughly 500 ms resolution. The first 60 seconds have a Go CPU profile, which does not include Git child CPU. The failed initial runs are retained; no missing phase counts as a pass.</p><p>Run manifests, hashes, logs, profiles and 500 ms resource samples accompany the raw JSON below. Timings are wall-clock milliseconds unless labeled otherwise. The before run includes the gzip/HEAD compatibility fixes; the after run adds streaming, batching and connection/state correctness fixes.</p></details>"
1845- html_doc += "<h2>Raw results</h2><ul>" + "".join(f'<li><a href="{html.escape(str(path.relative_to(root)))}">{html.escape(label)}</a></li>' for label, path in raw_links) + "</ul>"
1846- if (root / "results-after-first/git-performance.json").exists():
1847- html_doc += '<p><a href="results-after-first/git-performance.json">Earlier successful streaming run</a> retained separately. The main comparison uses the final public run.</p>'
1848- html_doc += '<p>Per-repository resource rows show the sampled cumulative process/cgroup high-water values through that fixture; anonymous and file-memory peaks are sampled within the fixture. The headline Go peak covers the complete public run.</p>'
1849- html_doc += "</body></html>"
1850- html_path.parent.mkdir(parents=True, exist_ok=True)
1851- html_path.write_text(html_doc, encoding="utf-8")
1852- md = ["# Git performance", "", "Phase wall times come from the recorded harness. Missing or failed phases remain visible. This report does not compare against the bindws Worker runtime.", "", "Method: Linux Slate, 16 CPUs, 10 GiB test-container ceiling, no CPU cap, no swap; client/server loopback; Git 2.50.1 client and 2.39.5 server; fixed local heads and tags with no cold page-cache reset. Phase CPU deltas are total container CPU inclusive of Git children and background probes, and values below sampler resolution are shown as missing.", "", "Methods: native Git HTTP baseline, buffered Tiny, and streaming Tiny where result artifacts exist.", "", "## Findings", "", markdown_table(["finding", "before", "after"], highlight_rows, ["number", "number", "number"]), "", markdown_table(["repo", "before clone", "before concurrent4", "before state ACK", "before push", "after clone", "after concurrent4", "after state ACK", "after push"], findings_rows, ["number"] * 9), "", "## Phase results", "", markdown_table(headers, phase_rows, ["number", "number", "number"] + ["number"] * len(PHASES)), "", "## Resource results", "", markdown_table(resource_headers, resource_rows, ["number", "number", "kb_mib", "mib", "mib", "mib", "number", "number", "number", "number", "number"]), "", "## Probe results", "", markdown_table(probe_headers, probe_rows, ["number", "number"] + ["number"] * 14), "", "## Status", "", markdown_table(["repo", "method", "status", "failed phases"], status_rows), "", "Profile finding from the captured before run: CPU samples identify memory clearing/copying as the visible hot path; the independent Go RSS reading is 6093.9 MiB. Keep the raw profile and process/cgroup captures beside any interpretation.", "", "Raw JSON artifacts are linked from the HTML report."]
1853- md[md.index("## Phase results"):md.index("## Phase results")] = ["## Remaining relay latency", "", "Every probe succeeded, but durable event acknowledgements still stalled occasionally during large Git transfers, including native Git baseline phases on the same host. These results do not identify the cause. Capture disk latency and scheduling before selecting another optimization.", "", markdown_table(["workload", "ACK P95 ms", "ACK max ms", "query P95 ms", "query max ms"], tail_rows), ""]
1854- args.markdown.parent.mkdir(parents=True, exist_ok=True)
1855- args.markdown.write_text("\n".join(md) + "\n", encoding="utf-8")
1856-
1857-
1858-if __name__ == "__main__":
1859- main()

About

ownerc01503e8035f
visibilitypublic
head71d8d53c8edf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs