blockyard 0.0.1 → 0.1.0
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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -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.
|