blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
package/docs/RULES.md ADDED
@@ -0,0 +1,681 @@
1
+ # Rules
2
+
3
+ Rules earned by breaking things in this repo. Each cites the defect that produced
4
+ it, in the house style of the node project's own engineering rules:
5
+ a rule without its scar tissue gets argued away by the next session.
6
+
7
+ ---
8
+
9
+ ## 1. Measure the node before you poll it
10
+
11
+ **Never choose a timeout, a poll interval or a concurrency from intuition about
12
+ how fast a Bitcoin node "should" be.**
13
+
14
+ Chosen on intuition first, then measured: `getblockcount` against the bench node
15
+ during initial block download took **40.4 s** (`docs/MEASUREMENTS.md` §1). The
16
+ default timeout at the time was 20 s and the fast tier was 4 s — so the monitor
17
+ would have reported a healthy-but-busy node as unreachable, tripped its own
18
+ circuit breaker repeatedly, and stacked an unbounded queue behind a call that was
19
+ already too slow.
20
+
21
+ Three code changes came out of that one measurement: 90 s ceiling, adaptive tier
22
+ cadence (`monitor.effectiveTierMs`), and coalesce-or-drop for poll jobs.
23
+
24
+ **In practice:** new numbers go in `docs/MEASUREMENTS.md` with a date and a
25
+ reproduce command before they go in `server/config.js`.
26
+
27
+ ## 2. Zero dependencies, and no CDN
28
+
29
+ Nothing is `npm install`ed. Node builtins, `node:test`, hand-written canvas.
30
+
31
+ This box serves a LAN, gets no outbound access from browsers on the wrong VLAN,
32
+ and an install step is also a supply-chain step for a tool that holds a path to
33
+ the node's RPC cookie. It also means the charts are ~400 lines instead of a
34
+ dependency tree.
35
+
36
+ If a dependency ever looks necessary, the answer is to write less UI, not to add
37
+ one. (`zmq` is not even importable on this box — Python-side ZMQ was not an
38
+ option either.)
39
+
40
+ ## 3. Never accept a CSRF token from the cookie
41
+
42
+ The first version read `header ?? body ?? cookies.blockyard_csrf`. Testing it with
43
+ `curl -b cookies.txt` — i.e. a request carrying **only** the cookie — returned
44
+ 200. The check was decorative: a cross-site request carries the cookie just as
45
+ happily, and the whole point of double-submit is the part a cross-origin page
46
+ cannot do, which is read the cookie and echo it in a header.
47
+
48
+ Fixed to header-or-body only, compared against the server-side session value.
49
+ **A credential is never validated against a value the attacker also controls.**
50
+
51
+ ## 4. A Map key is not a dedupe key
52
+
53
+ `pending` became a keyed Map so a fresh poll could supersede a stale one. Unkeyed
54
+ jobs were inserted with a generated Map key but the code deleted
55
+ `pending.delete(first.key)` — `key` was `null` for unkeyed jobs, so nothing was
56
+ removed, and `_drain` re-read the same stale entry forever:
57
+
58
+ ```
59
+ RangeError: Maximum call stack size exceeded
60
+ at Lane._drain (client.js:104) x 40 frames
61
+ ```
62
+
63
+ `node --check` passed. Every unit test passed. Only booting it found this.
64
+ **Store the map key on the entry; never derive a deletion key from an optional
65
+ argument.**
66
+
67
+ ## 5. Boot it; syntax checks and unit tests find none of the interesting bugs
68
+
69
+ Three defects in one evening survived `node --check` and a green test suite and
70
+ appeared only on the first run: rule 4's recursion, `m.id` undefined in the boot
71
+ banner (the monitor had `state.id` but no `id` getter), and a
72
+ `if (self_blocks_ahead: true)` placeholder left in `scripts/fake-node.js`.
73
+
74
+ **`npm test` is necessary and nowhere near sufficient. `npm run dev` plus a curl
75
+ of the real endpoints is the actual gate.** `scripts/smoke.sh` exists to make that
76
+ one command.
77
+
78
+ ## 6. Chart datasets do not belong in the live frame
79
+
80
+ The SSE snapshot carried the mempool scatter (1,500 points) and 120 blocks in
81
+ every 1-second push: **112,665 bytes per frame per client**. After moving the
82
+ scatter to `/api/mempool` and trimming block history to 40: **25,509 bytes**.
83
+
84
+ With eight tabs open that was ~900 KB/s for almost no information gain, on a box
85
+ whose node was busy syncing 300 GB.
86
+
87
+ **Ask of every field: does this change every second?** If not it rides the 20 s
88
+ `series` event or an on-demand endpoint.
89
+
90
+ ## 7. Parse against real lines, never against remembered ones
91
+
92
+ Written from the shapes in the terminal scrollback, then checked against the
93
+ actual file, the parser was wrong twice:
94
+
95
+ | assumed | real |
96
+ |---|---|
97
+ | `-- peers banned: N of M --` | `-- peers banned this run: N of M --` |
98
+ | tags are `[a-z0-9]+` | `[tx_accept]` has an underscore — the tag silently vanished |
99
+
100
+ Both were caught by `test/logparse.test.js` against `test/fixtures/log-samples.txt`
101
+ (22 frozen real lines). Also found there: a genuine new line shape worth parsing
102
+ (`[config] conns: max=256 outbound=11 (...) inbound=245`).
103
+
104
+ **Fixture files come from `zcat the real log`, not from typing.**
105
+
106
+ ## 8. Absent beats zero
107
+
108
+ Three places the node cannot answer, each of which a naive panel would report as a
109
+ number:
110
+
111
+ - `getnettotals` → 0 bytes: an "upload: 0 B/s" chart would imply an idle uplink
112
+ when the truth is "this build does not publish the counter".
113
+ - `getpeerinfo` → `[]`: "0 peers" while `getconnectioncount` says 13.
114
+ - `estimatesmartfee` → no feerate: a cold estimator says "unset"; Core does too.
115
+
116
+ Every one is rendered as `–` plus a reason, and named in `health.quality`. The
117
+ node's own docs use exactly this standard ("omitted rather than reported low …
118
+ a short count that looks real is worse than an absent one"). **A monitor is the
119
+ one place a plausible fake is unforgivable, because it is what you trust when you
120
+ decide the node is fine.**
121
+
122
+ **The corollary the first cut missed: a zero can also be a *ratio*'s fault.**
123
+ Measured 2026-09-08 on the bench build: `getnettotals` reported **12,896,531,244
124
+ bytes received against 1,129 sent, with 21 peers connected**. `Δ(sent)/Δt` is
125
+ arithmetically `0 B/s` and is still a lie — no node receives 11 million times what
126
+ it sends while pulling 12.9 GB of blocks it must have requested. So the guard is
127
+ not "is the counter zero" but "is this number possible", and the check that caught
128
+ it is a sentence in the flag text: *impossible ratio, upload absent not zero*
129
+ (`upload-unmeasurable`). Absent-beats-zero needs a magnitude sibling.
130
+
131
+ ## 9. Two similar numbers are not one number
132
+
133
+ `blocks/headers` and `verificationprogress` both read ~67% during the observed
134
+ IBD, and the temptation is one `pct` field, or `Math.max(...)` for a "safety"
135
+ margin. They measure different things — blocks held vs announced headers, and
136
+ difficulty-weighted work done. The bar fills with the first; the second is drawn
137
+ as a separately labelled tick on the same track, and the caveat appears only when
138
+ they actually disagree.
139
+
140
+ Same rule for the ETA: recent-window rate drives it, the 10-minute rate is kept
141
+ separately as `etaOptimistic`, and a falling rate is reported as `rateTrend` with
142
+ a caveat saying the ETA will get **longer**.
143
+
144
+ ## 10. The test expectation can be the bug
145
+
146
+ Two failures in `test/sync.test.js` were my arithmetic, not the code:
147
+
148
+ - expected `50.0001%` for 482,996/965,993 — that is 49.9999%, being 0.5 below half.
149
+ - expected uptime `21,081,000 ms` for `00:14:15:21` — I read the field order wrong.
150
+ The code's `51,321,000 ms` (= 51,321 s) matched the node's own `uptime` RPC
151
+ **exactly**, which is the cross-check that settled it.
152
+
153
+ **When a test fails, prove which side is wrong before editing either.** Fixing
154
+ code to match a bad expectation would have baked in an inverted uptime format —
155
+ exactly the class of bug the parent project's rule 1 exists to stop.
156
+
157
+ ## 11. Beware `pkill -f` in a shell script
158
+
159
+ `pkill -f "server/main.js"` inside a `bash -c` block matched the block's **own**
160
+ command line (it contains that literal string) and killed the shell mid-script,
161
+ silently discarding the rest of the test run and producing no output at all.
162
+
163
+ Kill the listener, not a pattern: `kill $(ss -tlnp | grep <port> | grep -oE 'pid=[0-9]+')`.
164
+
165
+ ## 12. A cap is a ceiling, never a floor
166
+
167
+ `effectiveTierMs` stretched a poll tier to at least 2x observed RPC latency,
168
+ capped so a wedged node was not hammered:
169
+
170
+ ```js
171
+ const cap = Math.min(Math.max(base * 8, 30_000), 180_000);
172
+ return Math.min(cap, Math.max(base, stretched));
173
+ ```
174
+
175
+ For the rare tier `base` is 900,000 ms (15 min), so `cap` became 180,000 and the
176
+ `Math.min` returned **180,000** -- the most expensive tier (getpeerinfo,
177
+ getdeploymentinfo, getrpcinfo) was being polled five times as often as
178
+ configured, on the one box where RPC cost is the whole design constraint.
179
+
180
+ A ceiling that can fall below the thing it caps is a floor wearing its clothes.
181
+ **Cap above the base, always:** `Math.max(base, Math.min(...))`.
182
+
183
+ The same bug hid itself in the UI: the cadence panel looked up
184
+ `tierIntervalMs['rareMs']` where the key written was `'rare'`, so it always
185
+ displayed the configured value and reported `stretched: false` while the poller
186
+ was stretching. A dashboard that echoes its own settings back rather than its
187
+ actual behaviour is worse than no dashboard, because it makes the setting look
188
+ verified. Both now covered by tests in `test/rpc-lane.test.js`.
189
+
190
+ ## 13. Assert that an edit landed; `replace()` reports nothing
191
+
192
+ Several file edits in this session were `python3 - <<'PY' … s.replace(old, new)`.
193
+ One of them failed to match — the file contained an em-dash `—` where the pattern
194
+ had `--` — and `str.replace` does not complain about matching nothing. The script
195
+ printed "patched", the diff looked plausible in the next `git status`, and the
196
+ stale text went into a commit **and was pushed**.
197
+
198
+ What was stale was not cosmetic: `docs/DEFECTS.md` claimed `panels.js` did not
199
+ exist and that every page but Overview was blank, after all eight pages had been
200
+ written and verified against a live node. A doc that contradicts the code is
201
+ worse than no doc, because the next session trusts the doc and goes looking for a
202
+ bug that is not there.
203
+
204
+ **In practice:** any scripted edit carries `assert old in s` (or the edit tool,
205
+ which fails loudly). After scripted doc edits, `grep` for the *new* string and
206
+ for the *old* one — the second must be gone. Prefer one edit call per file over a
207
+ script that patches four files and prints a cheerful summary.
208
+
209
+ ## 14. A destructured parameter is not in scope from the caller's head
210
+
211
+ `caveatsOf()` builds the explanation lines under the sync bar. Three separate
212
+ times, in that one function, a new caveat read a value that was never passed to
213
+ it:
214
+
215
+ | read | declared as a parameter | passed at the call site |
216
+ |---|---|---|
217
+ | `rateTrend` | no | no → `ReferenceError` at first test |
218
+ | `etaBestSec` / `etaWorstSec` | no | no → `ReferenceError` |
219
+ | `reason` | **yes** | **no** → silently `null`, no crash, caveat never appeared |
220
+
221
+ The third is the dangerous one: adding a parameter to the signature without
222
+ adding it to the call site is not an error in JS — it is just `undefined`, so the
223
+ caveat quietly stopped firing and the test suite was the only thing that noticed.
224
+
225
+ **In practice:** when a helper's body grows a new input, change the signature and
226
+ the call site in the same edit, and give the new input a test that fails if the
227
+ value is absent. `test/sync.test.js` now asserts the caveat text itself, not just
228
+ that `caveats` is a non-empty array — an assertion of "some caveat exists" would
229
+ have passed all three times.
230
+
231
+ ## 15. A parsed source needs a canary, not only a parser
232
+
233
+ The monitor's own docs say the node's log is a *primary* source, and for two hours
234
+ on 2026-09-08 that source was read by nobody. Two independent failures, neither of
235
+ which produced a single warning:
236
+
237
+ 1. the bench node's `logFile` pointed at `<datadir>/main/debug.log`, a 144-byte
238
+ stub written at boot, while the node wrote to `console.log`;
239
+ 2. the bench build rewrote `[dlc]`, so even the live file yielded **1 parsed line
240
+ in 1,006**.
241
+
242
+ Every panel simply stopped changing. `exists: true` was true, the fd was open,
243
+ `readErrors` was 0 — the data was being read from the wrong place, and absence of
244
+ data looks exactly like absence of activity.
245
+
246
+ **In practice:** any source you parse rather than call needs three things, and
247
+ none of them is a parser:
248
+
249
+ - **a coverage ratio that is published**, not just computed — `log.health.ratio`
250
+ says what share of the last window of lines a rule actually claimed;
251
+ - **a threshold on a frozen real sample** — `test/bench-log.test.js` asserts
252
+ ≥0.85 over every 12th line of a real 90-minute run. It cannot pass on a fixture
253
+ of hand-picked lines, which is why there are two fixture files with two jobs;
254
+ - **a stall flag that names what it checked** — `log-silent` reports the block
255
+ delta observed during the silence, because "the log is stale" is a guess and
256
+ "the chain advanced 15,000 blocks while the file sat still" is a diagnosis.
257
+
258
+ The corollary applies to the numbers a parser *does* return: when the node prints
259
+ `(median 353.4)` with no unit, keep the text and leave the number null (rule 8).
260
+ A unit guessed to fill a gap is the same mistake as the file guessed to fill a
261
+ config field.
262
+
263
+ **Update the same day, the same rule failing again — and the gate being too coarse.
264
+ At 05:42 the build added one field** (`| staged 1`) **to the `[dlc]` tick, and 26 of
265
+ 26 tick lines stopped parsing.** `log-unparsed` did not fire, because it is a <5%
266
+ gate across all lines and that run still parsed ~24% overall: a ratio over every
267
+ line hides the death of one rule. Two fixes are owed, and DEFECTS records both:
268
+
269
+ - coverage must be reported **per rule** — "the bandwidth rule last matched 12 min
270
+ ago while 26 tick-shaped lines arrived", not one blended percentage;
271
+ - a labelled line should be scanned **field by field**, not matched end-to-end, so a
272
+ new or reordered field costs that field and not the line.
273
+
274
+ The general form: a coverage metric averaged over a corpus measures the corpus. The
275
+ failure is always local to one shape.
276
+
277
+ ## 16. A parser that matches a whole line is a parser that loses the whole line
278
+
279
+ Rule 15 said a parsed source needs a canary. Four times on 2026-09-08 the same
280
+ mechanism then proved the second half of the lesson: **match a labelled line end to
281
+ end, and one new word is a total loss.**
282
+
283
+ | time | the one thing that changed | lines matched |
284
+ |---|---|---|
285
+ | 05:42 | `[dlc] --` gained `\| staged 1` | 0 of 26 |
286
+ | 06:14 | it gained `commit 6288` too | 0 of 30 |
287
+ | ~06:22 | `[dlc] ==` became `(no gap, 100.00% landed)`; `eta` became `--:--:--:--` | 0 of 185 |
288
+ | 06:22 | a whole new tag, `[serve] inbound …` | 0 of 646 |
289
+
290
+ Three of those four are the *most important* figures on the page — bandwidth, the
291
+ node's IBD progress and its own eta — and in each case the pattern still matched
292
+ *something*, so nothing looked broken from the outside: the corpus ratio stayed at
293
+ 24%, 68%, 74%. What died was a single rule.
294
+
295
+ **In practice:**
296
+
297
+ - **Scan labelled lines field by field.** Split on the label separator, match each
298
+ labelled field independently, and require two recognisable fields before claiming
299
+ the line. Then a new field costs that field: it lands in `extraFields`/
300
+ `extraValues` verbatim (`staged: "26"`, `commit: "6484"`) and the numbers keep
301
+ flowing. `MEASUREMENTS 17` has the before/after coverage.
302
+ - **Delete the rigid rule when the scanner replaces it.** Keeping the old one "as a
303
+ fallback" is how you end up with two paths, one of which silently stops being
304
+ taken. One path, tested against both grammars in the fixtures.
305
+ - **A placeholder the node itself prints is information.** `eta --:--:--:--` means
306
+ *no estimate*; decode it to `null`. Coercing it to `0` would have meant "arrives
307
+ immediately", and to a large number would have meant "never" — both are invented.
308
+ - **Aggregate the repetitive, forward the rare.** 323 `v2 handshake failed` lines are
309
+ one sentence ("323 inbound connections, all from 127.0.0.1, dropped before BIP324
310
+ completed"), and 323 feed rows are that sentence buried. The shutdown line happens
311
+ once and belongs in the feed verbatim.
312
+
313
+ ## 17. A warning is a claim, and it can be wrong in two directions
314
+
315
+ Rule 16 ended with "scan labelled lines field by field". Shipping that produced the
316
+ per-shape liveness flag — and then the flag produced two of its own defects, both
317
+ caught by testing it against the live node rather than against my expectation of it:
318
+
319
+ 1. **It accused a synced node of losing its bandwidth figure.** I had inferred "is
320
+ this node in IBD?" from log shapes, and the tailed history contained `[utxo_live]
321
+ catchup progress` from the post-boot catch-up — which a synced node also emits.
322
+ The node had said `initialblockdownload: false, verificationprogress: 1,
323
+ blocks == headers` in the same second. **A source that answers directly outranks a
324
+ source you interpret**, and where neither answers, say unknown and do not watch.
325
+ 2. **It announced a restart that had happened four hours earlier.** The tail backfill
326
+ replays history, so `[serve] shutting down (signal 15)` arrived with an old
327
+ timestamp and produced a present-tense warning. Age-gated, and cleared the moment
328
+ `getblockchaininfo` answers. The event still goes to the feed, where its timestamp
329
+ speaks for itself — the difference between a record and a claim is that a record
330
+ carries when.
331
+
332
+ Both directions matter. A flag that never fires is decoration; a flag that fires on a
333
+ healthy node is worse than no flag, because the fix is someone learning to ignore it.
334
+ The two devices that keep this one honest are both boring:
335
+
336
+ - **Gates from measured cadences** — p95 × 8, clamped — with shapes listed by name in
337
+ `MEASUREMENTS 19`, including the two (`updating utxo`: 460/1868/3669 s, `header
338
+ mirror`: 502/2054/3669 s) that are parsed and **deliberately not watched** because
339
+ they are bursty. Irregular + watched = ignored.
340
+ - **Arm on first sight** — a shape is only watched once its rule has matched, so a
341
+ build that never emits `heartbeat` (the bench build) or emits a bandwidth tick twice
342
+ in three hours (production, synced) is excused without maintaining a table of
343
+ builds. That table would have been stale by this afternoon; four grammars changed
344
+ today.
345
+
346
+ Same principle decided a smaller thing in the same file: silence is measured against
347
+ **event time, not wall clock**, because the bench node block-buffers its stdout and
348
+ delivers minutes of history in one burst. Judged by arrival time that is a 4-minute
349
+ silence every few minutes; judged by the log's own timestamps it is zero, which is
350
+ what actually happened.
351
+
352
+ ## 18. A hermetic run must not inherit the machine's config
353
+
354
+ `scripts/smoke.sh` pinned its data dir, its fake-node port, its admin password and its
355
+ log level — everything except **which address to bind**. That was fine while the default
356
+ was `0.0.0.0`. Then a *deployment* decision went into `config/local.json` ("bind this
357
+ box's LAN + tailnet addresses"), and the next smoke run curled `http://127.0.0.1:18099`
358
+ against a server listening somewhere else. Result:
359
+
360
+ ```
361
+ passed: 2 failed: 54
362
+ FAIL weak password refused
363
+ FAIL audit recorded the admin action
364
+ ```
365
+
366
+ Nothing about passwords or audit was wrong. Nothing had answered. Two things made that
367
+ expensive, and both are now fixed in code:
368
+
369
+ - **The machine config had no opt-out.** `BLOCKYARD_CONFIG=none` now exists, and both
370
+ `scripts/smoke.sh` and `npm run dev` set it *and* pin `BLOCKYARD_BIND=127.0.0.1` — so
371
+ neither inherits a laptop's or a server's notion of where it should be reachable.
372
+ Asserted in `test/config-nodes.test.js` against the scripts' own source, because a
373
+ convention nobody checks is a wish.
374
+ - **The readiness loop fell through.** It waited for `/api/health`, gave up after 18
375
+ seconds, and let the script continue into 54 contract assertions against a port that
376
+ never answered. It now stops with: *"server never answered at $BASE — not a contract
377
+ failure, a wiring one."* A cascade of downstream failures is not a diagnosis; it is
378
+ the removal of one.
379
+
380
+ Same incident, quieter lesson: the log line that would have explained all of it —
381
+ *"not on 127.0.0.1 either"* — was logged at **info**, and smoke runs at
382
+ `BLOCKYARD_LOG_LEVEL=warn`, so it was invisible in exactly the situation it existed for.
383
+ A diagnostic's level is part of the diagnostic.
384
+
385
+ <!-- Renumbered 2026-09-09: this heading said "15", which is already the log-canary
386
+ rule above, and both were cited as "rule 15" in three files. A citation that can mean
387
+ two rules is a citation that proves nothing. -->
388
+ ## 19. Force-pushing a rewrite does not remove the old commits from GitHub
389
+
390
+ After scrubbing the machine username and host addresses out of history with
391
+ `git filter-repo` and force-pushing, the branch and its tarball were verifiably
392
+ clean -- 61 files, 27 commit messages, zero hits. That check would have let me
393
+ report "wiped", and it would have been wrong.
394
+
395
+ The pre-rewrite commits were still served. Requested by their old SHAs, they
396
+ returned HTTP 200 with (a) the original author name and email, and (b) a file body
397
+ still containing the unit's `User=` line naming the operator's login account. The
398
+ `raw.githubusercontent.com` route for the same commit did return 404, which is
399
+ exactly the kind of partial signal that makes a check pass for the wrong reason.
400
+
401
+ So a rewrite removes the values from the *branch*, not from the *repository*.
402
+ Objects left dangling by a force-push stay reachable by SHA until GitHub's
403
+ housekeeping collects them, which is not something to wait on or assume.
404
+
405
+ **In practice:** when a secret or personal identifier has actually been pushed,
406
+ verifying the branch is not the check. Request the old SHA; if it answers, the fix
407
+ is delete-and-recreate the repository (or GitHub support for anything genuinely
408
+ sensitive), not another force-push. Ask what the check can actually see -- "is it
409
+ in HEAD" and "is it in the repository" are different questions, and only one of
410
+ them is the question you care about.
411
+
412
+ The other lesson, from the attempt right after this one: writing this rule with the
413
+ leaked values quoted verbatim re-introduced them into a fresh commit, and the sweep
414
+ caught it only because the sweep ran before the next push. Document a leak by
415
+ describing it -- `User=<login>`, a redacted SHA, a documentation-range address --
416
+ because prose is content too, and it gets published on the same terms as the config
417
+ file you just cleaned up.
418
+
419
+ ## 20. An env var is a string until code decides otherwise
420
+
421
+ `server/config.js` had a table of env vars, each with a cast. `env()` implemented
422
+ `Number` and `Boolean` and returned the raw string for everything else — including
423
+ every entry whose cast was a *function*. Nothing failed. The values were simply the
424
+ wrong shape, in the direction that matters:
425
+
426
+ | variable | intended shape | what it actually became | consequence |
427
+ |---|---|---|---|
428
+ | `BLOCKYARD_ALLOW_CIDRS` | list of networks | the string `"203.0.113.0/24"` | the gate iterated it character by character, parsed nothing, and refused **every** address — the documented way to restrict the monitor to a LAN was a deny-all that also locked out the operator |
429
+ | `BLOCKYARD_ACTIONS` | list of action names | a string | `allow.includes(name)` became a substring test on a string, i.e. permissions decided by substring matching instead of set membership |
430
+ | `BLOCKYARD_HOST` / `_BIND` | list of addresses | a string | survived by luck only, because `validate()` re-splits a string there |
431
+
432
+ Found by `test/cidr.test.js` on 2026-09-09, which was written to test CIDR matching,
433
+ not config parsing. That is typical: the bug surfaced as a side effect of testing the
434
+ layer *below* the one where the mistake was.
435
+
436
+ **In practice:** any value whose type is a list, a set, or a parsed thing must be
437
+ constructed by code, and the construction must be asserted. A cast table where half
438
+ the casts are silently ignored is worse than no table, because it documents the
439
+ intents that the code does not carry out. When a config value gates a permission or a
440
+ firewall, assert the failure direction too: a malformed list must deny (or refuse to
441
+ boot), never admit.
442
+
443
+ ## 21. A guard that compares two documents proves they are wrong together
444
+
445
+ The documented test count drifted out of sync three times on 2026-09-08. The fix at
446
+ the time was a test asserting that `README.md` and `AGENTS.md` stated the same
447
+ number. That test passed on 2026-09-09 while the docs said 180 and `npm test` printed
448
+ 183.
449
+
450
+ Self-consistency is not truth. Two documents can agree to be wrong, a refactor can be
451
+ "consistent" with a spec nobody follows, and an assertion that compares a file to
452
+ itself — or to its sibling — has no way to notice reality.
453
+
454
+ **In practice:** derive the number from the artifact it describes (here: count the
455
+ declarations in `test/**/*.test.js`), then validate the *derivation* — `scripts/doc-counts.js`
456
+ counts declarations, and the test runs three real files through `node --test` and
457
+ compares the reporter's count with the scanner's. If a scanner can silently undercount,
458
+ the derived number is a nicer-looking version of the same bug. `npm run counts:fix`
459
+ writes the docs from the suite, so the number is never typed twice.
460
+
461
+ The same shape appeared twice more the same day: a grep-based assertion that matched
462
+ nothing and "passed", and an end-to-end check whose positive result came from a route
463
+ that 404s for the wrong reason (rule 19). Ask of every green check: *what would this
464
+ print if the thing were broken?* If the answer is "nothing", it is not a check.
465
+
466
+ ## 22. Do not hijack a shared global to observe it
467
+
468
+ To assert that the monitor warns at boot when it is serving plaintext, a test replaced
469
+ `process.stdout.write` for the duration of a boot. It captured the warning, and also
470
+ captured the test runner's own TAP output — because Node runs a file's top-level tests
471
+ concurrently and the runner writes to the same stream. The result was a file where one
472
+ test's *result line disappeared entirely*: the test ran (a probe `console.log` proved
473
+ it), other tests reported around it, and the summary counted six tests when seven were
474
+ registered. Debugging that meant suspecting the code under test, the app's boot path,
475
+ and the TLS stack, in that order, all innocent.
476
+
477
+ **In practice:** a test that needs to observe something gets a seam in the code — here
478
+ `boot({ log })`, a logger injection with the production default kept. Same for clocks
479
+ (`loadConfig({ now })`, which is how the expired-certificate branch is tested without
480
+ shipping a certificate that expires on a Tuesday) and interfaces
481
+ (`loadConfig({ ifaces })`, rule 18). Never capture `process.stdout`, `process.env`
482
+ en-masse, timers, or the DOM from the outside when one parameter would do: an outside
483
+ observer of shared state competes with everything else using it, and its failures look
484
+ like the failures it is watching.
485
+
486
+ A related trap from the same session: making a failure impossible in a test by making
487
+ a file read-only does not work — rename is governed by the *directory's* permissions,
488
+ so the rotation under test succeeded anyway. Make the obstacle the kind the code
489
+ actually hits (a non-empty directory where the rotated file must go), and confirm the
490
+ obstacle throws by itself before trusting a test that depends on it.
491
+
492
+ ## 23. Open by default is a posture; ship it with a ceiling and a loud line
493
+
494
+ The ask was "no login required, unless we set the config to support it" — a block
495
+ explorer. That is a reasonable posture for a read-only monitor on a LAN. The version of
496
+ it that ships by accident is not, because the obvious implementation has a hole shaped
497
+ like this:
498
+
499
+ ```js
500
+ if (route.auth !== 'none') {
501
+ if (!session) return 401; // skipped entirely when auth is off
502
+ if (route.auth === 'admin' && user.role !== 'admin') return 403;
503
+ }
504
+ ```
505
+
506
+ Move the session check behind a flag and the admin check goes with it. "No login
507
+ required" becomes "no login required, and anyone may create accounts" — the first
508
+ anonymous visitor makes themselves admin. The fix is structural, not a careful `if`:
509
+
510
+ - **A ceiling that is not a config value.** `viewer`, frozen, with no credential able
511
+ to raise it. The admin routes keep asking for `admin`, and the role check runs on
512
+ **both** branches — authenticated and anonymous.
513
+ - **Refuse the vacuous gate.** With no roles to check, `role: 'admin'` on an action is
514
+ a comment. So `BLOCKYARD_ENABLE_ACTIONS=1` while accounts are off is a **boot error**,
515
+ and the deliberate way around it (`BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1`) has to be
516
+ chosen twice.
517
+ - **Re-derive the checks that assumed a cookie.** CSRF exists because a cross-site
518
+ request can ride a session cookie; with no session there is nothing to ride, and
519
+ keeping the check only breaks the console. Say why in the comment, then assert the
520
+ two things that made skipping it safe (the ceiling, and writes refused).
521
+ - **Meter per address, not per identity.** Every anonymous client sharing one `user.id`
522
+ means one chatty tab spends the whole LAN's rate-limit budget.
523
+
524
+ And say it out loud: the boot line names the addresses that are now readable, what
525
+ "read" actually grants (including the read-only RPC console), and the one switch that
526
+ closes it. "Anyone on the LAN can read your node" must never be discovered from a
527
+ screenshot.
528
+
529
+ The tail of this one is the test suite's version of the same mistake: the open-mode
530
+ tests are mostly *not* about what open mode allows. They assert the ceiling —
531
+ `/api/users` 403, `/api/audit` disabled, `/api/login` answering
532
+ `accounts_disabled` rather than `bad_credentials`, writes refusing even with
533
+ `confirm` matching. A test that only checks "the dashboard loads without a login"
534
+ would have passed against the vulnerable version above.
535
+
536
+ ## 24. Node runs a file's tests concurrently; do not let them share `process.env`
537
+
538
+ Two test files "proved" the server was broken and neither bug existed.
539
+
540
+ `test/open-access.test.js` had a test asserting the default config, which sets
541
+ `BLOCKYARD_AUTH=1` for the duration. A sibling test in the same file booted the server
542
+ and got **401** — it had read the other test's environment. Same variable, same
543
+ process, overlapping in time. Verified directly:
544
+
545
+ ```
546
+ test('a', async () => { process.env.PROBE='a'; await sleep(60); }); // b sees "a"
547
+ test('b', async () => { await sleep(20); }); // while b is mid-await
548
+ ```
549
+
550
+ `--test-concurrency` does not change this: it governs files, not tests within a file.
551
+
552
+ The fix is not to serialise the tests, it is to give them nothing shared to fight
553
+ over. `test/helpers/http.js` now touches **no** environment: configuration goes into a
554
+ temp config file passed explicitly to `boot({ configFile })` (which is also what keeps
555
+ this box's `config/local.json` out of a run — rule 18), and the bootstrap admin's
556
+ password is read back from `app.bootstrap` instead of injected via
557
+ `BLOCKYARD_ADMIN_PASSWORD`. Env-var assertions live in `test/config-env.test.js`, which
558
+ boots nothing at all, so nothing can read the wrong config.
559
+
560
+ Two related traps hit on the same path:
561
+
562
+ - **A helper that throws before its `finally` leaks state.** The old helper mutated
563
+ `process.env`, then validated the config *outside* the `try`: an invalid-config test
564
+ left the variables set for the rest of the file, and the next two failures named an
565
+ unrelated test's config error. Anything that mutates global state must establish the
566
+ `finally` first.
567
+ - **Env is also how a test disables TLS verification**, and nothing built-in replaces
568
+ that one (`fetch` has no per-request dispatcher reachable from `node:`), so
569
+ `test/tls.test.js` sets and restores that single variable itself, around its own
570
+ body, with the reason written down.
571
+
572
+ And the shell equivalent, since it bit the same afternoon: `trap cleanup EXIT` followed
573
+ by a second `trap cleanup_open EXIT` does **not** stack — the second replaces the
574
+ first. Smoke stopped reaping its main server, which then held port 18099; the next run's
575
+ readiness loop found *that* answering and asserted 100-odd checks against a leftover
576
+ instance whose login bucket was already drained. One handler, on `EXIT INT TERM`, and a
577
+ preflight that refuses to run against a busy port — because a suite silently testing
578
+ yesterday's process is the check that passes for the wrong reason (rule 21).
579
+
580
+ ## 25. A browser harness that cannot reload is measuring the previous build
581
+
582
+ `scripts/browser-check.mjs` navigated with `Page.navigate({url})`, and the URL differed
583
+ from the page already loaded only in its `#fragment`. That is not a navigation: the old
584
+ document, with its pre-deploy JS and CSS, keeps running and answers every question you ask
585
+ it. Every alarming reading this harness produced against a warm browser was that — a block
586
+ map reporting `401 rects laid out, 0 painted pixels`, a CSS pass that appeared to evaluate
587
+ an older function than the bytes the server was sending, `applyMiningStyles` writing
588
+ `--pool` into `style="--pool: ..."` where the committed source uses `setProperty`.
589
+
590
+ None of it was in the app. The map painted 100% of its pixels the moment the harness was
591
+ made to actually load the current build (`?t=<ms>` on the URL, verified 2026-09-10,
592
+ `gnTreemap` and `gnMempoolTreemap` both `paintedPixelsPct: 100`).
593
+
594
+ This is rule 21's instrument-error failure mode in a new costume, and it cost hours: a
595
+ "bug" that reproduces only in the tool reproduces nowhere. Before believing any claim
596
+ about a rendered page — especially a claim that contradicts the source you are reading —
597
+ prove the browser is running the bytes you served, by reading a build marker in the page,
598
+ not by trusting the URL bar. The same discipline already exists for the test suite, which
599
+ refuses to run against a busy port for exactly this reason.
600
+
601
+ ## 26. A view that must not move may depend only on constants
602
+
603
+ The block-space viewer had to keep its bottom-left corner pinned while blocks
604
+ animated over it. Three attempts failed in a row, and all three failed the same
605
+ way: each fixed the *framing* and left something *varying*.
606
+
607
+ 1. **Fit to the measured scene.** The scene includes blocks in flight, whose
608
+ extent changes every frame. Obviously wrong once stated.
609
+ 2. **Fit to the grid's measured corners.** Better, but the growth expansion was
610
+ applied about a vanishing point computed as positive while the flipped board's
611
+ projected `y` runs negative, so the bounds skewed and the picture sank.
612
+ 3. **Map the board's corners to the panel's corners.** No measurement at all, so
613
+ row 0 sits on the canvas floor by construction and bottom-left *cannot* move.
614
+ The operator still reported "it shift down on first transition", and they were
615
+ right: the mapping's **scale** was `ph / (gridH * unit)`, and `gridH` was the
616
+ packed extent. A packing one row taller shrank the vertical scale, compressed
617
+ the picture toward the floor, and walked the top edge down the panel.
618
+
619
+ The corner that was pinned had been pinned for two rounds. The thing that moved
620
+ was never the framing.
621
+
622
+ The same defect had a second head. The perspective constant `risePerUnit` was
623
+ derived from *this round's* lane stack, so a quiet round and a busy round on the
624
+ same board got cameras nearly 3x apart in depth scaling. The first paint is a
625
+ no-op self-transition with one lane, so it took the shallow-stack value and drew
626
+ every cube's dark front lip almost three times too tall — and either side of the
627
+ vanishing line that lip flips from pointing up to pointing down through a
628
+ degenerate triangle. The operator saw "dark triangles on load that go away after
629
+ the first animation", which is an exact description of a camera swap.
630
+
631
+ **The rule.** When a view is required to hold still, enumerate every input to its
632
+ transform and require each one to be a constant of the *board*, not of the round.
633
+ Write the enumeration down. Both defects here are one sentence once the inputs are
634
+ listed: `gridH` varies, and `risePerUnit` varies.
635
+
636
+ **Testing it.** Do not assert on the source text of the framing — a test that
637
+ matched `BOTTOM-LEFT IS FLUSH WITH THE PANEL` and the literal `setTransform`
638
+ arguments passed happily while the scale underneath it moved, because it pinned
639
+ the *approach* rather than the *property*. Assert the property directly: render
640
+ two pools that differ as much as the data ever will, and require the recorded
641
+ transform to be identical. That test fails against all three earlier attempts.
642
+
643
+ **The cost of making a bound constant.** The old `risePerUnit` was honest — it
644
+ bounded growth using the lane stack actually built. A constant must assume a
645
+ worst case, so restate the bound rather than deleting it: it holds while the
646
+ stack fits under the compression ceiling, and past that blocks fly beyond the
647
+ panel edge. Say which, in the code, at the line that made the choice.
648
+
649
+ ## 27. Measure a slow node alone before blaming what runs beside it
650
+
651
+ **When a node turns slow the moment something new starts running next to it,
652
+ stop the new thing and measure the node bare before tuning anything.** A
653
+ coincidence in time is a lead, not a cause.
654
+
655
+ The first fresh install (2026-09-14: a Mac, Core 29.1, block files on a platter
656
+ array). The address index build started, and within minutes the monitor showed
657
+ 18 s RPC answers, 90 s timeouts, the mempool read dropped and an empty
658
+ block-space board. The build was the obvious suspect and was throttled four ways
659
+ over an hour — its own RPC connection, a pacer on the node's latency, half the
660
+ workers, smaller batches — each change followed by "still slow". Nobody had
661
+ measured the node with nothing running. When `npm run check` finally did, it
662
+ answered in **10 ms / 921 ms / 1.0 s** for chain info, a full block and the
663
+ verbose mempool. The cause was `utxoStatsWanted` reading a missing
664
+ `coinstatsindex` key as "not assumed unindexed" and sending `gettxoutsetinfo` —
665
+ a walk of 165 M outputs without the index — on the slow tier every 60 s, past the
666
+ timeout, with Core still walking and holding its chain lock. It had been there
667
+ since the first boot, before any build existed (`docs/DEFECTS.md`, Security /
668
+ correctness, 2026-09-14).
669
+
670
+ The tuning done under the wrong diagnosis was itself wrong: the pacer was set to
671
+ hold at one second of latency, and a healthy build on a node whose heavy reads
672
+ take a second when perfectly well ran at a sixth of its speed until the threshold
673
+ was moved to the monitor's own notion of slow (`rpc.slowLatencyMs`, 5 s). A fix
674
+ aimed at the wrong cause does not merely miss; it adds a second defect with a
675
+ plausible story attached.
676
+
677
+ **In practice:** `npm run check` times every call it makes — three lines that
678
+ would have saved the hour — and is the first thing to run against a slow node,
679
+ with everything else stopped. The throttling stays, because it is right on a
680
+ shared disk; it was simply not the fault. Rule 1 says measure before choosing a
681
+ number; this is its sibling: measure before choosing a culprit.