1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338 | # ClojureScript in the ardegazu suite — the canon
The suite is migrating to ClojureScript: the kits ("our protocol") become
CLJS libraries that TypeScript/JavaScript clients keep consuming, new and
migrated apps are CLJS, and the bots are CLJS on Node. This document is the
canon for every CLJS repo.
Much of what follows is load-bearing — for the wire, for the release gates,
for the publish gates. Some of it is only how a hand transliteration from
TypeScript happened to type things. For a long time this document stated both
in the same voice, which is how the dialect ("ClojureScript as a syntax over
JavaScript semantics") became the thing every new scaffold reproduces.
**"What is constrained, and what is only how it was typed" below draws that
line — read it before treating anything here as a rule.**
Migration order (done in this order): train-kit → id-kit → social-kit →
game1 (neon-grid, the CLJS app canon) → bot → peer-kit → rooms-kit, with
the games following game1's pattern. **Every kit is now CLJS and no kit
vendors another kit's sources** (the apps' own vendoring line is a separate
story — see "Vendored canon files"). **All six games are CLJS**
— valley-blocks (game2) was the last, and with it the TypeScript
`client/src/net/` and `id/boot.ts` are gone from the suite entirely.
**The migration is complete: every app and kit in the suite is
ClojureScript** — chat (v27, the rooms-stack CLJS canon), board (v15) and
home (v13, the hub) were the last three. chat's own lib layer was
*derived from rooms-kit's* CLJS rather than ported fresh, board's from
chat's, and as of 2026-08-28 **neither copy exists any more**: both apps
compile `ardegazu.rooms.*` straight off rooms-kit's sources on their own
classpath — see "Deriving a browser app from a Node kit" below.
Kept for the record, because it is the trap this sequencing existed to
avoid: peer-kit and rooms-kit used to hold `src/vendor/{id,social}`
copies — *vendored*, not depended on (no pins existed) — and their
`deploy/check-vendor.sh` diffed against the **local workspace checkout**
of id-kit/social-kit, silently SKIPping any upstream file that no longer
existed. So the moment id-kit/social-kit replaced `src/*.ts` with
`.cljs`, that gate disarmed itself with zero coverage. The id-kit wave
therefore switched both gates to a checksum-freeze mode first (a
committed sha256 manifest verified instead of the upstream diff) so the
frozen copies stayed guarded until each kit was ported. If a vendored
copy is ever reintroduced, do that again.
**peer-kit's vendoring is RETIRED (2.0.0, CLJS).** `src/vendor/`,
`deploy/check-vendor.sh` and `deploy/vendor.sha256` are deleted; it
consumes social-kit via a relative `deps.edn` path into
`node_modules/ardegazu-social-kit/src` and **id-kit as its compiled dist,
exactly as social-kit does** — classpathing id-kit too would put two
`Identity` classes in one process and break `instanceof` across them.
What retiring it bought, concretely: a **missing `fsync` self-origin
guard** (inbox room ids are derivable from a public identity pub, so any
stranger could seal a validly-signed fsync into a headless peer's inbox
and replace its friend list and block tombstones), the self-sync change
gate (the copy published on every call), and `profileSync`/`appState`/
`syncNow()` which the copy never had. Zero regressions the other way.
**rooms-kit's vendoring is RETIRED too (2.0.0, CLJS).** It consumes
id-kit as a sha-pinned npm dep and **re-exports id-kit's own `Identity`
class object** as its root export; `deps.edn` stays `:paths ["src"]`
(classpathing id-kit would compile a second copy and recreate exactly the
problem being retired) — and it still does, at 2.2.0. What changed is the
*consumer* side, not this file: 2.2.0 made every file under `lib/` free of
Node-only package names, so **chat and board now put
`node_modules/ardegazu-rooms-kit/src` on their own `deps.edn` classpath** and
compile those namespaces themselves instead of vendoring them. The npm sha pin
is still the only cross-repo dependency mechanism; the classpath entry just
points at what the pin already installed. Unlike peer-kit's, this retirement
closed no
vulnerability — the frozen copies were byte-identical to id-kit's
last-TypeScript state, and 147 executable comparisons of the copy against
the real kit's golden vectors (b64/b64url primitives, seed→pub,
fingerprints, deterministic `sueta-id|v2` assertions, raw signatures,
cross-verification both ways, seed-rejection messages, profile
helpers/clamping, and the never-imported xkey layer including
cross-implementation wrap/unwrap) found **zero mismatches in either
direction**. The freeze had held. What it removed instead: **two
`Identity` classes in one process** — the copy stored its private key in
an ES2022 `#priv` slot and the kit uses a `_priv` string key, so the
classes were never interchangeable and passing one kit's `Identity` to
the other's `signXCert` throws — plus a dead direct `@noble/curves`
dependency that only the unused vendored `xkey.ts` needed.
Consumer-side flip that already happened: id-kit and social-kit *were*
source-only packages (`"." → ./src/index.ts`; nine apps compiled that TS
themselves via Vite's `optimizeDeps.exclude` + `fs.allow`). Their first
CLJS releases (both v2.0.0) flipped every app to consuming **compiled ESM
dist + shipped `.d.ts`** — a change in bundling semantics, not a drop-in
sha bump. The apps' `optimizeDeps.exclude`/`fs.allow` entries went with
Vite itself (no app builds with Vite any more), and type-checking switched
from structural TS compilation to the hand-authored `types/*.d.ts`.
## Toolchain
- **shadow-cljs builds, deps.edn classpath.** Humans work in Emacs +
CIDER; every CLJS repo commits a `deps.edn` and its `shadow-cljs.edn`
uses `:deps {:aliases [:dev]}` so `cider-jack-in-cljs` (shadow flavor)
works out of the box. The `:dev` alias carries cider-nrepl middleware.
- **Versions are exact-pinned in deps.edn** — `org.clojure/clojurescript`
and `thheller/shadow-cljs` with full versions, no ranges, and **as a
known-good pair** (shadow releases are tightly coupled to a specific
clojurescript version — pin the version shadow's release notes name).
This pins the Closure compiler transitively and is half of the
deterministic-dist story.
- JVM ≥ 17 and the `clojure` CLI on the **dev machine only** (`ardz
doctor` checks both). The VPS never builds — committed `dist/` ships
via git, as always. Node ≥ 22 for everything that runs JS.
## deps.edn rules (publish-gate critical)
- **Relative paths only. Never `:local/root`, never an absolute path.**
The idpat publish gate scans every decompressed git object for local
path fragments and aborts the publish on a hit.
- **npm git-sha pins remain the ONLY cross-repo dependency mechanism.**
deps.edn does not grow a second pin. A CLJS consumer of a CLJS kit
reaches its *sources* through the npm install:
```clojure
{:paths ["src" "node_modules/ardegazu-train-kit/src"]}
```
(the npm pin in package.json controls which sha sits there; one bump
path, one lockfile, `ardz kit-release` keeps working unchanged).
## Repo shapes
**Kit** (consumed by TS/JS *and* CLJS):
```
deps.edn shadow-cljs.edn package.json
src/ardegazu/<kit>/*.cljs the implementation (also shipped, for CLJS consumers)
dist/ committed, deterministic ESM (for TS/JS consumers)
types/*.d.ts hand-authored, one file per subpath export
test/vectors/*.json golden vectors extracted from the TS impl
test/*.test.mjs black-box node tests, run against dist/
test/types/consumer.ts TS smoke-compile of the exported API
deploy/check-dist.sh deploy/publish-repo.sh
```
package.json: `"files": ["src","dist","types"]`, exports like
`".": {"types": "./types/index.d.ts", "import": "./dist/index.js"}` plus
`"./src/*": "./src/*"`; scripts `check` (shadow compile + `tsc --noEmit`
of the consumer smoke) and `build` (see determinism recipe). `ardz build`
runs `check` for kits; `ardz kit-release` requires it green.
**App** (everything under `client/`, so `ardz` needs zero changes):
```
client/deps.edn client/shadow-cljs.edn client/package.json
client/src/<name>/**/*.cljs
client/scripts/dev.mjs client/scripts/build.mjs
client/public/index.html client/public/icons/
client/dist/ the deploy artifact (ardz ships this dir)
```
package.json scripts: `dev` and `build` (plus `release` = bump-version +
build for `versioned=y` apps). The scripts are thin node wrappers that
read env and invoke shadow-cljs — ardz only ever calls `npm run <script>`.
**Namespaces:** kits `ardegazu.<kit>.<ns>` (files
`src/ardegazu/<kit>/*.cljs`), apps `<name>.*`. Munging note: namespace
segments use `_` on disk for `-` in code, as usual.
## .gitignore (BEFORE the first CLJS commit — gate-critical)
Add to every repo growing CLJS:
```
.shadow-cljs/
.cpcache/
target/
.nrepl-port
*.js.map
```
shadow's cache, tools.deps' cache and nREPL port files all embed absolute
local paths; source maps embed them too. Any of these tracked = publish
abort (or worse, a leak). House rule stays: targeted `git add <files>`,
never `git add -A`.
## Deterministic committed dist (the check-dist contract)
`deploy/check-dist.sh` = `npm run build` then `git status --porcelain
dist` must be empty. For shadow-cljs that means, all of:
- exact-pinned shadow-cljs + clojurescript in deps.edn (see above), and a
committed package-lock.json;
- release builds only (`shadow-cljs release <build>`), with
`:compiler-options {:source-map false}` — and note `:optimizations`
must live INSIDE `:compiler-options` (a top-level `:optimizations` is
silently ignored and the build falls through to the target default);
- never `:module-hash-names`;
- **gensym normalization**: shadow/CLJS release output is not natively
byte-deterministic — protocol `…$dyn_<N>` vars are numbered by a
JVM-global counter that varies across cold builds. Each kit's build
script runs the tracked `scripts/normalize-gensyms.mjs` (canon copy:
train-kit) to renumber those module-local vars per file in
first-occurrence order; semantics-preserving, keeps the byte-equality
gate at full strength;
- the build script ends with `rm -f dist/manifest.edn` (and never lets
dev-target `cljs-runtime/` into dist);
- **`:parallel-build false`** for any build with many namespaces (peer-kit
needed it; the other kits did not). CLJS numbers gensym'd names from a
JVM-global counter, parallel namespace compilation varies those numbers,
and the numbers then change what Closure's variable coalescing decides —
one cold build had 38 module locals, another 41, with `var x=` collapsing
to bare `x=`. That is a *structural* difference, so the gensym normalizer
below **cannot** repair it (verified: a generalized renumbering still
left two cold builds unequal). Serial compilation fixes it; only cold
builds pay the time (~45 s vs ~4 s for peer-kit). The normalizer is
necessary but not sufficient for a `:simple` build of many namespaces;
- **commit dist only from a cold-cache build**: a warm `.shadow-cljs`
incremental build assigns Closure property renames from a slightly
different pool than a cold build — always `rm -rf .shadow-cljs` before
the build whose dist you commit;
- `check-dist.sh` starts with `rm -rf .shadow-cljs` (same reason — a stale
in-tree cache can make check-dist pass while a clean-clone build differs)
and
additionally greps `dist` for the absolute-home path prefix — an embedded
local path is a failure even when byte-stable. Build that grep pattern
from string pieces (as train-kit's `check-dist.sh` does) so the script
itself never contains the literal: the publish gate's private patterns
include path fragments, and a script carrying one aborts its own repo;
- acceptance for any build-affecting change: build **twice from a clean
clone** and diff.
## Test-harness notes
- **`node --test` cannot stub an ESM namespace export** —
`Object.defineProperty` on a module namespace throws `Cannot redefine
property`. Where a vector needs a seam inside a kit, use `module.register()`
with a resolve hook scoped by `context.parentURL` so only the compiled
`test-dist/` gets the shim. Two gotchas: the shim must be
`export * from "<real>"` plus an explicit local override (explicit wins over
star), and it must resolve the real package itself — which the parentURL
scoping guarantees.
- **A DOM stub must reflect IDL properties to attributes.** `a.href = x` then
`a.getAttribute("href")` is how a router reads its own tab hrefs; without
reflection every `aria-current` assertion is silently empty and *looks like
a pass*. Filed under "check for the coverage you assumed you had" — home
found it by eyeballing `current: []` across all 16 router steps.
- **A shared dom stub proves TS ≡ CLJS, not CLJS ≡ browser.** A shared
misunderstanding of real DOM semantics passes both sides. Worth stating in
the test file rather than discovering later.
- **The source-hygiene lint does not check `fn`/`defn` PARAMETERS against core
*vars*** — only against core macros. `name`, `count`, `key`, `type` as
parameter names sail through, and they are everywhere in a whiteboard or a
catalog. Worth extending.
- **Retiring vite silently breaks the e2e suites**: they pointed at
`npm run dev -- --port <n>`, which no longer exists, and a suite that cannot
start is worse than one that fails. Port chat's `e2e/serve.mjs` static
server — and note the sharper problem, that env-driven endpoints were
*dev-server* values under vite and are **build-time goog-defines** now, so
an e2e run must be given a dist built with its stub baked in. Gate it: grep
the built chunk for the stub URL and fail loudly rather than "passing" with
the feature disabled.
- **Closure's cross-module variable renaming means a one-namespace change
dirties sibling modules.** Editing only `board.cljs` moved `dist/chat.js`
(6 lines) and `dist/shared.js` (150) — pure local-variable renames,
byte-stable across cold builds. Expected; say so in the commit so nobody
chases it.
## What is constrained, and what is only how it was typed
The suite was ported from TypeScript by hand, statement by statement, and that
was the right call: a literal port is reviewable against the original, and for
most of this code the original was the only spec there was. The cost is that
the transliteration's *habits* — `unchecked-get`/`unchecked-set` for
everything, objects assembled by sequential mutation, promise ladders twenty
levels deep, no atoms, no keywords, no destructuring — now read as house
style, because this document listed them beside the rules that are genuinely
load-bearing and drew no line between the two. Undifferentiated rules do not
make the real constraints safer; they make them invisible, and they mean the
next `ardz new-app` regenerates the dialect along with the protocol.
The line exists, and the codebase already draws it in places.
`chat/client/src/sueta/app/ui.cljs` opens with "Nothing here is on the wire,
so nothing here needs key-order care". game2's `game/render/renderer.cljs`
says the same about painting, and uses plain `js-obj` literals on the strength
of it. And `i18n/runtime.cljs` — the single most-copied file in the suite,
byte-identical in all nine apps — has *always* been written the other way:
state in an atom, keyword destructuring, `get-in`, `some`, `doseq`,
`map-indexed`, and exactly one `unchecked-get` (reading a JS object a kit
callback handed in). It ships in every app, crosses no wire, and has never
cost anything. So the dialect was a per-file choice, not a requirement.
Before writing a line, know which of the three buckets it is in.
### Constrained — by the wire, by storage, or by a gate
Non-negotiable. No rewrite touches these, and a change here needs a golden
vector, not a review.
- **Key ORDER in anything canonicalised.** Any object `js/JSON.stringify`'d
before signing or sealing, every LogOp appended to an OrbitDB entry, every
mailbox envelope, presence beacon, hello and draw preview. `#js {}` /
`js-obj` silently switches to hash order at nine pairs (the hazard is
documented in full below, along with the three times it bit us), so these
are built with sequential `unchecked-set` — in the rooms stack through
`ardegazu.rooms.js/ordered`, which does exactly that and nothing else — and
pinned byte-exact by `test/vectors/*.json`.
- **`js/JSON.stringify`, never `pr-str`.** `pr-str` is not the wire's
serializer and its output is not the bytes anything signed.
- **Domain tags and protocol ids are the deployed literals**, not the tidy
ones: `"sueta-id|v2"`, `"board-x25519|v1"`, `/sueta/2/msg/1.0`, the OrbitDB
provider type `"sueta"`, and each game's protocol prefix (`/game2/2/` for
valley-blocks — the codename is not the prefix).
- **JS truthiness wherever the original leaned on it.** `0`, `""` and `NaN`
are falsy in JS and truthy in CLJS, and the TypeScript used that
load-bearingly: `lastBeacon` of 0 means "never proved membership", a timer
id of 0 means "no redial scheduled". `truthy?`, `nn` and `js-object?` in
`ardegazu.rooms.js` are the exact JS tests. These are semantics, not
syntax — see the `!!x` / `or` / `??` bullet below.
- **Wire validators use the JS forms** — `Array.isArray`, `=== null`. A
validator *is* the contract.
- **Decrypt-or-drop and every other receive-side check.** Not refactorable
into friendlier leniency.
- **Prototype installation and its call site move TOGETHER.** chat's
`:advanced` build rests on an `externs.js` whose 21 entries are exactly the
methods installed string-keyed with `(unchecked-set proto "x" …)` and called
by dot-access. Move one half toward `defclass`/dot-access without the other
and you get a green build, clean tests, and a dead app at room boot. Of
everything in this section this is the one a rewrite is most likely to trip,
and the one nothing catches.
- **The published class API** of a kit: `types/*.d.ts` and
`test/types/consumer.ts` are the contract, and getters/statics are
string-named on purpose so `:advanced` cannot rename them.
- **The build and publish rules** — deterministic dist, the `.gitignore` list,
relative paths only in `deps.edn`, no local filesystem path in any tracked
file. Different subject, same "not a preference" status.
### Constrained by the TESTS, even where the protocol does not care
Some internals are pinned not by the wire but by the fixtures extracted to
prove the port. `test/helpers/load.mjs` (in rooms-kit, chat and board) exports
export const priv = TS_MODE ? (o, n) => o.__t[n] : (o, n) => o[`_${n}`].bind(o);
so every private member a transcript reaches through `priv` is part of a
test's surface, `_`-prefix and all. Today that is: `net`'s `dialPeer`,
`onConnChange`, `onTopicMessage`, `onMsgFrame`, `sweep`, `ensureRelay` and
`scheduleRedial`; the rooms clients' `applyEntry`, `onLive` and `onPeerState`;
and chat's `ChatStore.idFields`. Changing that decomposition means
re-recording transcripts — which is the evidence the whole port rests on — so
treat it as constrained until you have decided to re-extract deliberately.
The same files' *other* internals are free, and the helpers say so out loud.
`chatOpsScript` drives `ChatStore` through `sendText`, `applyEntry`,
`toggleReaction`, `setName`, `onChannelOpen` and its event callbacks;
`projectorScript` takes no `priv` at all; `logScript` accepts it and then
`void priv`s it. Anything those scripts do not name can change shape without a
fixture moving.
### Not constrained by anything
Nothing in the protocol, the gates or the fixtures has an opinion about:
- **how promises are composed.** A twenty-deep ladder and a flat composition
of the same steps are the same wire behaviour — and the flat one is the
*safer* of the two. The outage that took chat and board down for the whole
life of chat v24 and board v12 was a paren-depth error that only a deep
ladder can hide; the bullet below ends "flattening those ladders is the
durable fix", and it means it.
- **where internal state lives** — a field set with `unchecked-set`, an atom,
a closure — provided it is not a `_`-named test seam and not half of a
prototype/call-site pair.
- **iteration style.** `loop`/`recur`, `doseq`, `reduce`, `map`, `for`:
interchangeable wherever the original's loop was.
- **keyword vs string** for a local, a map key or a dispatch value, as long as
no keyword reaches `JSON.stringify` and no map is a wire object.
- **destructuring, threading macros, `let` shape, helper extraction, naming**
(subject to the core-shadowing lint, which is a real trap, not a style rule).
- **the representation of anything built and consumed inside one namespace**
and then thrown away.
### The practical test
*Does this value cross the wire, land in a log entry or in
localStorage/IndexedDB, or get named by a fixture?* If no to all three, its
representation is free. Whole layers are in that bucket by construction: the
DOM builders and renderers, the lobby, the HUD, overlays and toasts, the i18n
runtime and catalogs, config plumbing, and everything a `ui.cljs` does. When a
file is genuinely in that bucket, **say so in its header the way `ui.cljs` and
game2's renderer do** — one line, and the next reader does not have to
re-derive it.
Two things that are *not* the test: whether a namespace lives under `lib/`,
and whether the TypeScript happened to do it that way.
### The view layer is the biggest free bucket — the renderer is replicant
Everything above says the view is unconstrained, which invites the obvious next
question: which rendering library. **Decided 2026-08-29: replicant.** It was
held open until a spike measured it, because nine apps inherit this by
imitation, and the hold is now discharged.
The spike built the chat lobby three ways — reagent+re-frame, replicant, and a
hand-rolled hiccup→DOM renderer — and the deciding arguments were not the ones
expected. **Bundle size was explicitly not the reason**, and the earlier
rejection of React on size grounds was withdrawn as too thin.
- **re-frame's model is right and its implementation does not fit.** The
rewrite's target architecture — pure `(state, event) -> [state', effects]`,
effects as data, derived views — *is* re-frame's model, so "we don't need it"
was never an available argument. What does not fit is that re-frame requires
all truth to reach the view through `app-db`, while this suite's truth lives
in `Net`, `RoomLog`, `MediaManager` and `MailboxSync` — objects with identity
and lifecycles, driven by network callbacks and timers. Every such callback
would have to dispatch its change into `app-db` first: a second copy of the
truth, with its own staleness window, existing only because the renderer
demanded it. It also contradicts *one atom per subsystem, derive at render
time*, and you cannot hold both.
- **Synchronous render.** `replicant.dom/render` reconciles inline, so the
message list's `near-bottom?` → render → pin `scrollTop` is three lines in one
tick. Reagent batches through its own rAF loop and React 18's `createRoot` is
concurrent, which splits the measurement from the fix-up.
- **The snapshot cannot lie.** A reagent snapshot renders subcomponents as
`#object[Function]`, so a golden-vector test could not distinguish two states
differing only in which row is being renamed — measured, it failed, while a
deliberately charitable reformulation passed. In a suite that has produced six
gates reporting success while testing nothing, a view whose test power depends
on incidental phrasing is the wrong trade.
**The argument against, recorded because it is real:** re-frame is the
best-documented architecture in ClojureScript and replicant is essentially one
person's library, and this project's history is a story of paying for
idiosyncratic infrastructure nobody else understands. The mitigation is that the
API in use is four functions plus two keywords, and the failure mode is
"upstream stops" — leaving a small library whose source can be read in an
afternoon. That is a different risk from a vendored core that silently drifted.
If a future maintainer reverses this on those grounds, they are not wrong.
**Hand-rolling was rejected on evidence**, not taste: written carefully, the
113-line renderer had three defects on first execution and only one was visible
to reading — `flatten` destroying nested hiccup nodes, `:class` clobbering the
classes carried by the tag keyword (which survived first render and broke on the
first patch), and unconditional attribute writes moving the caret.
Two rules that come with the decision:
- **Streams and other non-serialisable handles never enter view data.** Keep
them in a registry keyed by id; the view emits the id; a mount hook looks it
up. That is what keeps even the call screen snapshot-testable, and it is why
`<video>`'s `srcObject` survives a re-render.
- **A view's golden vector must include a discrimination case** — two states
that must produce different snapshots — not merely a non-empty assertion.
That case is exactly what caught the reagent limitation above.
### The `#js {}` lint stays — and is deliberately stricter than the rule
`source-hygiene.test.mjs` forbids `#js {…}` and `(js-obj k v …)` outright in
the rooms tree (bare `(js-obj)` with no pairs is fine — there is no order to
lose), not merely at nine pairs. That is on purpose. The wire rule is about
width; the lint is about never having to think about width, in a tree that is
the widest wire surface in the suite and is edited by people counting other
things. It costs one call to `j/ordered` and removes an entire class of silent
scrambling. Keep it. The same goes for every other check in those files — each
one was written from a bug that shipped, and several were written from a bug
that shipped *twice*.
- **"Clojure data above the decode boundary" is free in an app and expensive in
a kit. Measure it before assuming.** It is one cliff, not a gradient: in a
dist that contains *no* Clojure collection, `:advanced` has eliminated the
whole cljs.core seq/collection/keyword/protocol runtime, and the **first**
collection literal brings it back. social-kit measured **+19 KB gz for the
first one and ~240 bytes for everything after** — landing in `:shared`, which
`./join` loads at first paint in nine apps, so it rewrote the decoder as a
predicate over the JS value and pinned the decision with a module-size budget
test. The same rule is free in chat, where cljs.core is already 43% of the
bundle. Do not carry a number over from another repo — that is exactly what
made the promesa rule wrong the first time it was written here.
## Crypto and wire compatibility
- **Never reimplement crypto.** CLJS calls the *identical* npm primitives
through interop: WebCrypto `crypto.subtle` (Ed25519 with the PKCS8
prefix trick, AES-GCM) and `@noble/curves` (X25519). `Uint8Array` at
every boundary. Domain tags are the deployed literals — `"sueta-id|v2"`,
`"board-x25519|v1"` — never "fixed". Spec of record:
`chat/docs/PROTOCOL.md`.
- **Golden vectors before porting.** Extract fixtures from the TS
implementation while it is still canon (deterministic outputs byte-exact
in `test/vectors/*.json`; randomized-IV sealing proven by
cross-implementation round trips: TS seals → CLJS unseals, and reverse).
Vector tests run against `dist/`, never `src/`.
Two hard-won points of method:
**splice the real TS modules into a harness (DOM stubbed) rather than
transcribing their logic into a generator** — it costs ~20 minutes and
removes the entire class of "my fixture encodes my misreading"; and
**extract and commit the vectors BEFORE swapping `package.json` to the
CLJS one**, because the CLJS install drops vite/esbuild, which the
generators need.
A third, subtler one: **never advance a fake clock by "one tick" — step
it until the machine under test has actually moved** (e.g.
`tickUntil(() => bellCount() > before)`). Séance's first fixture pass
recorded three identical stale reveals because the captured timer queue
fired the host's audio callbacks instead of its next state step. A
fixture that encodes your harness's bug is worse than no fixture, and
only eyeballing the data caught it.
A fourth, from valley-blocks: **decide the fixture FORMAT before you
swap package.json too, not just extract the values.** Vectors recorded
with a stride, or driven by closures, are not *replayable* — fixing that
needed esbuild back, which `npm install` had already removed. Record the
op script alongside the snapshots on the first pass.
A fifth, same port, same family as the fake-clock trap: **snapshot by
value, never by reference.** A `snap()` that captured a mutable array
(`game.snake.segments`, which the engine mutates in place with
`pop`/`unshift`) made every "spawn" fixture serialize the *final*
position. Caught only by eyeballing the data — a spawn showed an L-shape
where spawn is always a straight line. Relatedly: **check your fixtures
for coverage you assumed you had.** Every long trace in that port topped
out before completing a single row, so scoring, the level curve and the
line-clear callbacks had *zero* coverage until deliberate white-box
scenarios were added — which then turned up a real edge case (a
completely full board clears 20 rows and scores 0, because the
line-points table has no entry for 20).
A sixth, the construction-side twin of snapshot-by-reference:
**when a script driver hands the implementation an object the
implementation may mutate, clone per replay.** rooms-kit's `BoardClient`
stores `op.el` by reference and an `edit` mutates that very object, so a
shared entry array fed the second script the first one's mutated element —
a fixture that was internally consistent and simply wrong.
`structuredClone(entry)` at the apply boundary.
A seventh: **`await Promise.resolve()` rounds do not settle
`crypto.subtle`.** WebCrypto resolves on a *platform* task, so a
microtask-only flush records a state in which nothing was verified — this
bit home (name lines without fingerprints) and board (an empty member set
with zero identity events), both silently. Flush with `setTimeout(0)`
rounds.
An eighth, cheap and worth making routine: **order-independence pairs.**
Feed the same entry set in two arrival orders and assert `JSON.stringify`
equality. One line per pair, and it would have caught both of rooms-kit's
fold divergences instantly.
A ninth: **a vector generated from the implementation it is meant to pin
proves nothing.** Once the TS is retired, any *new* fixture is necessarily
generated from the CLJS build and is circular. Break the circle by
asserting the values the *other* implementation independently produces,
hard-coded (rooms-kit pins board's `projector.json` results beside its own
generated vector) — and **give cross-repo fixtures the same scenario
names**, which turns "does the kit agree with the browser" into a two-file
diff.
And script the i18n catalogs — write a throwaway line-by-line
transliterator from the TS files rather than retyping hundreds of lines
of Romanian and Hungarian; the key-parity and byte-snapshot tests then
passing first try is the signal that you changed no strings.
- Canonical serialization ports build JS objects in the exact key order of
the TS original and serialize with `js/JSON.stringify` — **never
`pr-str`** — and are byte-diff tested.
- **`js-obj` / `#js {}` preserves literal key order only up to EIGHT
pairs — and this is not theoretical: it was LIVE in shipped bytes.**
social-kit 2.1.0's dist emitted friends.cljs's ten-pair record in hash
order, `{xs,addedTs,petTs,x,name,lastApp,pub,pet,state,lastSeenTs}`. It was
inert only because every reader re-sanitizes and every signature is over
`canon()`, which sorts — but it stood next to the wire for a release.
Note it is **`js-obj` too**, not only the `#js {}` literal; a lint that
checks one and not the other misses half the surface. At nine or more it routes through a `PersistentHashMap`
(array-map's threshold) and emits in **hash order**, at every
optimization level, with **no warning**. This has already bitten three
times: id-kit's IdRecord, the bot's config defaults, and game3's `mk-sn`
snapshot frame — which compiled to a structurally valid frame with
scrambled keys that would have desynced every deployed client. Wherever
key order is on the wire or in storage, build the object with explicit
sequential `unchecked-set` (or `set!`) calls and leave a comment saying
why; then pin it with a byte-exact golden vector. Any frame or record
wider than eight fields is a live hazard — neon-grid's seven-pair `mk-sn`
is why game1 never hit it, while séance's is **ten** keys.
**Count the keys in every frame builder before you write it, not after.**
Séance's porter did exactly that and never shipped a scrambled frame;
tessera's found it only because a vector failed.
Better still, do not count: the rooms tree's `source-hygiene` forbids the
construct outright and routes everything through `ardegazu.rooms.js/ordered`
(see "What is constrained, and what is only how it was typed" — the lint is
deliberately stricter than this rule). Note the converse too: this bullet is
about objects whose key order is *observed*. game2's renderer builds its
paint objects with plain `js-obj` literals and says why in its header.
- **A threading macro silently stops threading if the form before it
over-closes by one paren — and the indentation still looks right.** This
took chat and board DOWN IN PRODUCTION for the whole life of chat v24 and
board v12. `lib/mailbox.cljs`'s `do-replay` ended with a long
`js/undefined))))))))))` run that closed the enclosing `(-> …)` one paren
early, so the trailing `(.catch …)` and `(.then …)` sat at depth 5 where
the `->`'s children sit at depth 4. They were never threaded; they
compiled into a method call on the *function literal itself* —
function(c){console.warn("mailbox replay failed",c);return null}.catch()
which throws the instant the function is called. `mailbox/start` calls it
during boot, boot's promise rejected, `finish-boot` never ran, and the room
UI was never built. Every user, every room. A second casualty rode along:
`_replaying` is set true on entry and cleared *only* by that dead `.then`,
so replay was latched off permanently.
Nothing caught it. Not review — the indentation was correct, only the depth
was wrong. Not the 45-case chat suite or the 54-case board suite, both of
which passed unchanged before and after the fix because neither drives
`do-replay`'s happy path. Not any release gate.
**When a `->`/`->>`/`some->` body ends in a paren run longer than about
four, count it.** Better, keep the check mechanical: `source-hygiene`
now flags a `(.method …)` whose target position resolves to a `fn`/`defn`
literal, which is always this bug and never anything legitimate.
The general lesson is worse than the specific one: a deeply nested
promise ladder makes the failure both easy to write and impossible to see.
Flattening those ladders is the durable fix.
- **`!!x` is not `(boolean x)`, and CLJS `or` is not JS `||`.** Only `nil`
and `false` are falsy in CLJS, so `(boolean 0)` is **true** where `!!0` is
false, and `(boolean "")` is true where `!!""` is false. valley-blocks'
round-over standings had the `out` flag true for every player because of
exactly this — twelve referee scenarios failed on it. Where you need JS
truthiness, write `(js* "!!(~{})" x)`. The same asymmetry hits `or`: CLJS
`or` returns a falsy-but-not-nil left side where JS `||` replaces it.
Three real sites in one app: `Number("") || 5000` (an emptied target field
would have set 0, clamped to 100, instead of the 5000 default), an empty
identity-mirror string failing to fall through to the app-local name, and
`devicePixelRatio || 1`. Note also that TS `??` maps to a nil check and
TS `||` does not — porting `??` and `||` the same way is a bug.
**Make a grep for `(or (` and `(boolean ` a standard step of every port**,
and audit each hit against the original's `||` / `!!` / `??`.
- **Shadowing core names silently breaks things, at two levels.** A *local*
named after a core macro shadows it: game6's local `when` (an AudioContext
instant) broke every noise drum voice with zero warnings and was caught
only by running the app. A *`defn`* named after a core var does the same
one level up, namespace-wide — one port's HUD `defn reset!` shadowed
`cljs.core/reset!`; shadow *does* emit a `:redef` warning, but the build
does not fail on warnings so it scrolls past. `when` is the famous one;
the ones you write without thinking are `time`, `name`, `type`, `count`,
`key`, `val` (a lint flagged `time` twice in freshly written code).
**Copy `game6/client/test/source-hygiene.test.mjs` into every port** and
extend it with a core-*var* check over `defn`/`defn-`/`def` names
alongside its core-*macro* check over locals.
- **CLJS's two-argument `<` / `>` inline to the raw JS operators**, so they
are correct *string* comparisons — which is what makes a mailbox
`seqLt` and a reaction fold's `(> hash-a hash-b)` tiebreak work. It
reads like a numeric-coercion bug and is not one; say so in a comment
rather than "fixing" it.
- **`new Date()` ignores a patched `Date.now`** — V8 reads the system clock
directly. A fixture recording anything that goes through `new Date()`
(a default room label via `Intl`, say) silently expires at midnight.
Replace the whole `Date` constructor in the harness, not just `now`.
- **`await f(); g()` must stay a `.then` chain — while PORTING.** Porting the
two statements as a `do` block silently reorders them. In home a confirm card
was prepended *before* an async `replaceChildren` instead of after, which
would have wiped it — the TS got the ordering for free from `await
renderIdentity(rec)`. Grep the original for every `await` whose *following*
statement touches what the awaited call painted.
**This is a faithfulness rule for a transliteration, not the house async
idiom** — see the next entry, which supersedes it for anything written or
rewritten from here on.
- **The async idiom is promesa's `p/let` — in `:advanced` BROWSER builds, below
the first-paint module. Not in a `:simple` Node kit.**
The optimization level decides this, and the gap is 65×. chat (`:advanced`)
pays ~1.8 KB gz, because Closure removes everything `promesa.core` does not
reach. rooms-kit measured **one `p/let` in one function at +12,272 gz** on
`dist/shared.js` — a `:simple` build DCEs nothing, and `:simple` is itself
deliberate there because nearly every line is interop with untyped ESM.
Two further reasons a Node kit should decline it, both specific and both
worth checking before adopting anywhere: promesa ships its own `nextTick`, so
a transcript that settles on a **fixed microtask-turn count** moves when the
hop count changes — and a golden vector may only be added, never regenerated;
and every promise such a kit returns crosses a library boundary, where a
thenable that is not `instanceof Promise` and whose rejections skip the global
handler is a behaviour change with no gate to catch it.
**Measure it in the target build before adopting it. Do not infer the cost
from another repo's number.**
`shadow.cljs.modern/js-await` is pure `.then` sugar — the macro expands to
`(-> thenable (.then (fn [name] body)))`, with no `async`/`await` — so every
awaiting step nests one level deeper than the last. That is not a style
problem, it is why chat's `boot-room` reached **29 levels of nesting and a
26-paren closing run**, and it is the shape both of the paren-nesting defects
that took chat and board down in production were hiding in. Flattening the
ladder is the durable fix for that whole class. Rewritten with `p/let`:
depth 29 → 8, closing run 26 → 6, and `finish-boot`'s 13 positional
parameters became one context map.
Four things come with it:
- **Module discipline.** promesa costs ~1.8 KB gz. It may be required only
from the module that already carries the heavy stack (`:room` in the rooms
line), never from the first-paint module. Gate it: walk the `:require`
graph from each module entry and assert promesa is absent from one closure
**and present in the other**, so the test cannot pass by promesa vanishing.
Also assert the shared-helper namespace stays promesa-free — that is the
route by which it sneaks into first paint.
- **`p/let` does not return a `js/Promise`.** It returns promesa's own
`PromiseImpl`: thenable, assimilated by native `await` and `.then`, but
**not** `instanceof Promise`, and an unhandled rejection on one does **not**
fire `window.onunhandledrejection`. Before adopting it in a namespace, grep
the consumers — including the kits — for `instanceof Promise` and for any
reliance on the global rejection handler.
- **Binding lints do not see it.** A shadow-check matching `\(let\s` does not
match `(p/let `, so it silently stops covering the file the moment you
convert it — which is exactly what happened to chat's boot path, the most
dangerous file in the app, for sixteen bindings. Name the binding forms
explicitly (`let`, `p/let`, `p/plet`, `p/loop`, `p/doseq`, `loop`, `doseq`)
and probe that the bare pattern does not match them.
- **Sequencing is the point; `p/all` is not.** `p/let` sequences, so the
protocol-mandated side-effect order in the mailbox replay/deposit path
survives. Do not introduce concurrency where the original was sequential.
Scaffolders and templates: a rooms-line skeleton must not ship a `js-await`
ladder for new code to grow inside.
- **An i18n catalog is only half the contract; the argument map is the other
half, and nothing type-checks the pair.** board's catalog interpolates
`{v}`/`{cur}`, chat's and home's `{live}`/`{v}`. All three catalogs were
ported verbatim — correct — but board's *call site* was copied from chat,
the rooms canon, so it passed `{"live" … "v" …}`: the wrong number in the
first slot and a raw `{cur}` on every returning user's screen. **Add a test
asserting that every `(t "key" {…})` supplies exactly the placeholders its
English string uses** — no missing, no extra (canon copy: board's
`source-hygiene.test.mjs`). Exclude `tn`, which supplies `{n}`
positionally.
- **A render with two branches must do the attaching in both.** board's
lobby row loop has a normal row and an in-place rename row; the TypeScript
appended the row to the list in each (`boards.ts:270` and `:306`) and the
port kept only the rename branch's append. Every saved board was built,
wired with its handlers, and never inserted — the list rendered empty, and
the "no boards yet" hint was skipped too (the list is not empty), so
returning users' boards looked **deleted**. It was completely silent: the
render runs inside a `js/Promise.` executor, so the failure surfaced as
neither an exception nor an unhandled rejection, and nothing in the suite
renders a lobby. **Count the attach points in the original and assert that
count.**
- **Wire validators must use the JS forms**, not the CLJS ones:
`Array.isArray` rather than `array?` (which is `instance? js/Array` on the
browser target) and `=== null` rather than `nil?` (which also accepts
`undefined`). Small, but a validator *is* the wire contract.
## Compilation targets
- Browser kits: `:target :esm`, `:optimizations :advanced`,
`:infer-externs :auto` + `^js` hints on every interop'd external object.
Two externs pitfalls (cost social-kit a silent `:advanced` rename, caught
only by its selftest): a `^boolean` (or other) type meta placed on an
interop CALL form suppresses externs inference for that call with no
warning — put the `^js` on the object, not the call; and `^js` hints on
macro forms (`unchecked-get` etc.) are lost — bind the object to a
`^js`-tagged local first. **A `^js` tag is likewise dropped by
MACROEXPANSION**, so `(.deposit ^js (oget st "mbx") …)` reads as tagged and is
not; carrying `(meta &form)` through the macro does not restore it. Bind on a
symbol. This one is quiet: the tag only matters for names outside Closure's
default externs, so a whole file of untagged `.get`/`.set`/`.has` compiles
with nothing to see.
The practical consequence, worth internalising: `(.someMethod
(unchecked-get self "field") …)` can **never** be inferred, and shadow
only warns for method names absent from its default externs — so
`.forEach`/`.get`/`.then` look clean while `.getConnections`/`.joinEntry`
warn, which makes the warning list a poor proxy for the risk. The two
fixes that actually work: bind to a `^js`-tagged local first, or read a
callback as a *property* and call it rather than making an interop
method call (peer-kit's `ev-call` idiom).
npm deps stay **external ESM imports**
(`:js-options {:js-provider :import}`) so consumer bundlers dedupe them
and optional peerDeps stay optional.
- Node artifacts (train-kit, bot): `:target :esm`,
`:optimizations :simple`, and the same
`:js-options {:js-provider :import}` — native/optional npm deps
(tfjs-node, the libdatachannel builds inside peer/rooms-kit) must never
be bundled. `:simple` removes the externs risk class entirely.
- **Subpath exports are real split points**: one `:modules` entry per
subpath with a shared base module, so consumers' dynamic
`import("ardegazu-social-kit")` boundaries and chunk budgets survive.
Per kit today: id-kit exports `.`, `./xkey`, `./selftest`; social-kit
exports `.`, `./net`, `./join`, `./selftest`. Trap: a `:modules` entry
with empty `:entries` that only re-exports other modules' vars does not
build — give the package-root module a real alias namespace (see
train-kit's `ardegazu.train.index`).
Its sibling trap, which cost rooms-kit time: **the dependency arrow runs
from the package-root module TO the subpath modules, not the reverse.**
rooms-kit's root re-exports `ChatClient`/`BoardClient`, so `:index` must
`:depends-on #{:shared :chat :board}`. Point it the other way and shadow
hoists `ardegazu.rooms.chat` up into `:index`, the `:chat` module loses
its only entry, and you get `Module Entry ... was moved out of module`.
- **Class-API fidelity for TS consumers**: `shadow.cljs.modern/defclass`
covers fields, constructor, extends and protocol methods — it has **no
syntax for statics, getters or async methods**. Statics are attached
after the class form: `(set! (.-fromSeed Identity) (fn [seed] ...))`
(a "static async factory" is just a static returning a promise; use
`js-await` inside). Getters via
`(js/Object.defineProperty (.-prototype Identity) "pub" #js {:get (fn [] ...)})`
— string-named, so rename-safe under `:advanced`. Where even that gets
awkward, a thin handwritten `src/js/facade.mjs` is compiled into dist.
The `test/types/consumer.ts` smoke file must exercise exactly the
constructs real consumers use (`import type`, inline
`import("pkg").Type`, deep subpaths, `new`, static factories, getters) —
it is the tripwire for both `.d.ts` drift and advanced-rename breakage.
## Deriving a browser app from a Node kit (the rooms-stack shortcut)
rooms-kit was ported from chat's TypeScript lib, so when chat's turn came
its ~1950-line vendored core was **derived from rooms-kit's already-tested
CLJS** instead of ported again. The premise was established by diffing
rooms-kit's *pre-CLJS* TypeScript against chat's
(`git -C rooms-kit show <pre-port-sha>:src/lib/<f>.ts`): six files
byte-identical, `orbit-identity` 2 lines, `log` 19, `net` 10. Eight of ten
CLJS files then came across byte-identical modulo the namespace rename, and
the two remaining diffs contained nothing but three sanctioned browser
deltas.
**All three are now history — as of 2026-08-28 there are NO sanctioned
browser-vs-kit deltas left in the rooms core, and no copies either.** Driving
the last delta to zero was the precondition; once `bin/canon-drift` read 0 for
all ten shared files in both apps, the copies were **deleted** rather than
kept in sync. rooms-kit 2.2.0 made every file under `lib/` free of Node-only
package names, chat and board added
`"node_modules/ardegazu-rooms-kit/src"` to `client/deps.edn`, and all ten —
`js.cljs` plus
`lib/{access,crypto,descriptor,encryption,log,net,orbit-identity,protocol,turn}`
— now have exactly one source in the suite.
`canon.tsv` went from 24 tracked copies to 5 (chat→board `selftest` and
`mailbox`, rooms-kit→board `protocol`, and the two `i18n/runtime` rows) — a
manifest row is the wrong tool for a thing that is not duplicated any more.
The three resolutions below are kept because the *shape* of each is the
reusable lesson, and because a future kit/app pair will face the same three
questions:
- `log`, store packages: the answer was **neither**. A namespace shared with
a browser may not name `blockstore-fs`/`datastore-fs` (drags `node:fs` into
the bundle) *or* `blockstore-idb`/`datastore-idb` (does not exist outside a
tab). So `open-helia` takes stores that are **already open** and names no
store package at all; each host supplies its own opener — the kit's
`ardegazu.rooms.node.stores`, the apps' `<app>.stores`. Both keep the
published store-*names*/*paths* signature under the historical `openHelia`
export, so no consumer and no black-box test helper had to change.
Generalises: when a shared file needs a platform package, hoist the
platform out of the file rather than branching inside it.
- `log`, the OrbitDB `directory`: the browser hardcoded `"sueta/" + roomId`,
the kit honoured a **nullish** `hooks.directory` defaulting to `"sueta"`.
Adopting the kit's form is behaviour-identical for the browser because no
app passes the hook — and it had to be, since a changed base orphans every
room already on disk. Generalises: a superset default is adoptable for free;
prove the default is what the hardcoded value was, at the level of the
emitted code, not the source.
- `net`, `selfPeerIds` and `wsOrigin`: superseded first (rooms-kit 39e6e0f)
by making the registry a plain `globalThis.__ardegazuSelfPeers` in
`lib/net.cljs` rather than an option — an option obliges the browser copy
to keep *not* passing it, which is a divergence you cannot see, and it
carries public API surface for a behaviour no browser wants. The remaining
26 lines were four pure-addition hunks, all no-ops in a tab (the set holds
only our own peer id, which `my-id` excludes one clause earlier;
`wsOrigin` undefined hits `@libp2p/websockets`' `init = {}` default), and
were adopted verbatim.
- `orbit-identity`: nothing — the Node kit already consumes id-kit as npm.
**Do this whenever a kit and an app share an ancestor**, and verify it the
same way: reverse the rename, diff per file, and report identical /
differs-and-exactly-why. Then validate *executably*, not just textually —
rooms-kit's `helpers/fakes.mjs` transcripts (membership machine, relay
redial backoff, log projection / author binding / ingest) replayed against
the derived copies unchanged.
Corollary worth having: **two independent extractions of the same golden
vector should be byte-equal, and checking that is nearly free.** chat's
`orbit-address.json`, extracted in chat from chat's TypeScript with an IDB
blockstore, came out byte-identical to rooms-kit's, extracted in rooms-kit
from rooms-kit's TypeScript with a filesystem blockstore. That equality is
much stronger evidence than either fixture alone.
And when you finally delete the copies, **ask what was scanning them
yesterday**. chat's and board's `source-hygiene.test.mjs` each walk their own
`src/` tree, so the moment the vendored core left those trees ~1450 lines of
`lib/log.cljs` and `lib/net.cljs` were linted by nobody — including by the
paren-depth check that exists *because* of the outage whose defect lived in
that layer. rooms-kit grew its own `test/source-hygiene.test.mjs` (f8f9176) to
close it. The general form is in "Gates" below.
## Apps: config, ports, PWA
- **Three npm-bundling blockers the rooms stack hit** (apps only — kits are
immune, `:js-provider :import` never parses npm). Blockers 1 and the
`__esModule` one below both live in `scripts/patch-npm.mjs`, which since
2026-08-30 carries a rule roster and GATES ITSELF: each rule fails the run
loudly if it covers no file in the tree (its target moved) or if a looser
detector still finds the hazard in a shape the rewrite missed. `dev.mjs`
checks its exit status — it used not to, which is how a rewriting script
could print a count and let a broken watcher start anyway:
1. **shadow's npm inspector cannot parse `export * as NS from "…"`** and
*aborts the whole build* with `{:message "'from' expected"}`. Several
files in the helia / modern-libp2p tree are written that way (two
copies of `@libp2p/crypto/ciphers`, which `@libp2p/keychain` pulls,
plus `@peculiar/utils`). Vite, rollup and esbuild all accept it, so no
TS build ever saw it. Fix that works: a tracked
`scripts/patch-npm.mjs` that desugars the form in `node_modules`
before every shadow run — idempotent, semantics-preserving, and
node_modules is generated anyway. Fixes that do **not** work:
aliasing the specifier to a local `:target :file` shim (such
resources have no package context, so the shim cannot reach the leaf
module), and pinning an older `@libp2p/crypto` (it is written the same
way).
2. **`helia`'s root entry drags in an entire second libp2p tree** via
`@libp2p/config`, purely for a default-libp2p builder a rooms app
never uses (`open-helia` passes Net's own node, so
`isLibp2p(init.libp2p)` is always the branch taken). A tiny
`src/js/libp2p-config-shim.mjs` that throws loudly, plus a `:resolve`
alias, keeps ~200 modules out.
3. **A namespace's npm imports are not DCE'd just because nothing calls
it.** `lib/selftest` requires `lib/access`, which imports
`@orbitdb/core`; requiring selftest from the first-paint module put
the whole of OrbitDB in that chunk. It moved into the room namespace
under `goog.DEBUG` (a deliberate dev-only behaviour change: the
self-test now runs on entering a room, not at boot).
- **Apps BUNDLE their npm deps** (`:js-provider :shadow`, the `:browser`
default) — unlike kits. One landmine, hit by game1: shadow's CJS→ESM
conversion marks `__esModule` *enumerable*, so multiformats-style
namespace spreads (`{...base32, ...}`) absorb the marker and crash at
boot. The build script flips the markers non-enumerable post-compile
with an assertion tripwire (canon copy: `game1/client/scripts/build.mjs`
step 2b — a whole-bundle flip is safe: the only legitimate consumer is
shadow's own `esmDefault` property read).
**That flip is RELEASE-ONLY, and for two years that was the whole bug
report.** It post-processes a finished `dist`; `shadow-cljs watch` never
has one, so `ardz dev` got no fix at all and every room app booted to a
blank screen — `Object.values(bases)[0]` is `true`, `true.decoder` is
undefined, `decoders[0].or(...)` throws, `@multiformats/multiaddr` aborts,
and `ardegazu.rooms.lib.{log,net}` load as empty namespaces
(`new_session_key is not a function`). A bare `npx shadow-cljs release
app` — no build.mjs — was broken the same way while still compiling
clean, which is why "the release is fine" kept looking true.
The fix that makes the two pipelines agree lives at the SOURCE, in
`scripts/patch-npm.mjs` rule 2 (`namespace-spread-esmodule`): it rewrites
`export const bases = {...ns}` in `node_modules` into a temp + `delete
tmp.__esModule` + the export. Under a real ESM loader the key is not
there and the `delete` is a no-op, so dev and release compile the same
sources. patch-npm runs before EVERY shadow invocation, dev included.
Step 2b stays as the broader belt-and-braces for the release bundle.
**The live/game stack (game1–6 + home) carries the same script since
2026-08-30**, with the SAME two-rule roster — the "games need rule 2
only" worry turned out to be wrong on inspection: rule 1's targets do
not need helia/keychain to be present. `@libp2p/crypto` (with its
`export * as`-written `ciphers/index.js`) is a direct dep of libp2p and
`@chainsafe/libp2p-noise`, and `@peculiar/utils` arrives via
`@libp2p/webrtc` → `@peculiar/webcrypto` — measured: every live tree
holds exactly 3 star-as files, so rule 1's coverage gate is satisfied in
all seven repos. Today's live build graph happens not to *reach* those
files (which is why unpatched game builds compiled), so rule 1 is
prophylactic there, but it is real coverage, not a silenced gate — the
first dep bump that reaches one of them would otherwise abort the build.
The roster therefore stays byte-identical across the rooms and live
stacks (only tree-specific header prose differs); if a future tree ever
truly loses a rule's targets, shrink that tree's roster EXPLICITLY (drop
the rule from its RULES array with a comment saying why) — never ship a
rule whose coverage gate is expected to fail, and never soften the gate.
game1's copy is the canon the other games vendor; home carries the same
file. Wiring matches the rooms stack: dev.mjs runs it first and checks
the exit status; build.mjs runs it as step 0 and keeps step 2b (the dist
post-pass) as the belt to these suspenders.
- **Assets are unhashed** (`:module-hash-names` is forbidden by the
determinism rule) — update delivery rides the SW precache revisions plus
gateway etag revalidation, not vite-style hashed filenames.
- **`:simple` does not DCE `cljs.core`**, and on a `:target :browser` app
that lands in the first-paint (base) module. chat's lobby chunk went
240 KB gz against a 103 KB TS baseline, total ~879 KB vs ~560 KB — the
app code was not the cost, `cljs.core` was.
**chat has since reversed that call, on a measurement that inverted its
premise** (chat f143213): the argument for `:simple` was that `:advanced`
would rest entirely on externs inference over `unchecked-get`-reached
objects — but Closure never renames a *string-keyed* property access, so
`(unchecked-get o "ts")` is `o["ts"]` and needs no inference at all, and this
tree reaches nearly everything that way (~1200 `unchecked-get`, ~470
`unchecked-set`). The whole untyped-ESM surface — libp2p, helia,
`@orbitdb/core`, both kit dists — is renaming-*immune*; `externs.js` names no
library. What was actually at risk was our own code, and it is the mirror
image: `lib/net`, `lib/log` and `lib/media` install prototype methods
string-keyed with `(unchecked-set proto "handleFrames" …)` and then *call*
them by dot-access, where the `^js` hint sits on a macro form and inference
loses it. `:advanced` renamed the call and not the definition; the first
build died at room boot with `a.net.gi is not a function`. The 21 entries in
chat's `externs.js` are exactly the distinct names from the build's own
`:infer-warning` list. Result: main.js 236 KB gz → 91 KB gz (-61.6%), total
861 → 707 KB gz. Board has not been re-argued and has no `externs.js`, so it
is still `:simple` — a decision, not a rule. **The maintenance rule this
buys is load-bearing for any refactor: prototype installation and call site
move TOGETHER**, and a new `:infer-warning` means a new externs entry (a
floor, not a proof — shadow only warns for names absent from its default
externs, so `.then`/`.get`/`.forEach` look clean either way).
Related: under `:simple` `goog.DEBUG` survives as a real runtime var
(`goog.DEBUG=!1`) instead of being folded away as it is under
`:advanced` — harmless, but do not read its presence in a bundle as
proof that a release build is somehow in debug mode.
- Measured cost of consuming kit dists instead of sources (game1): initial
gz 520 KB vs the 295 KB TS baseline — each kit dist carries its own
compiled CLJS runtime and Closure can't DCE into them. The lever when a
budget demands it: source-level kit consumption
(`node_modules/<kit>/src` on the classpath) for one shared cljs.core
and full DCE.
- **A `goog-define` cannot be nil, and its `""` sentinel is a live bug
waiting for a nullish default.** This shipped a chat build that did not
boot at all: `config.cljs` passed `VITE-ID-BRIDGE-URL`'s `""` straight
into `new IdBridge({bridgeUrl: …})`, id-kit defaults that option with
`(if (some? u) u DEFAULT-BRIDGE-URL)` — and **`some?` is true for `""`** —
so `""` survived into `new URL("")`, which throws `Invalid URL` and took
the whole room boot with it. TypeScript never hit it because
`import.meta.env.VITE_X` is `undefined` when unset, never `""`.
**Every goog-define must be converted back through an `or-default`
(to the real default, or explicitly to `nil`) before it reaches any API
whose default is a nullish check.** Audit all of them, per app. Note the
games escape this only because game1's `id/boot.cljs` guards the call
site with `(if (seq config/ID-BRIDGE-URL) …)` — and the rooms apps
deliberately do NOT vendor that boot (it auto-adopts on identity
conflict, which a log-bearing app must never do), so they lose the guard
with it. **No test in the suite caught this; only the browser did.**
- **Config plumbing**: Vite's `import.meta.env.VITE_*` becomes
`goog-define` in a `<name>.config` namespace. `client/scripts/dev.mjs`
and `build.mjs` read the *same* env var names ardz already exports
(e.g. `VITE_ID_BRIDGE_URL`) and pass them via `--config-merge
'{:closure-defines {...}}'`. Production relay/TURN/descriptor values are
hardcoded in the config namespace, exactly as the TS apps do.
- **Dev ports are 4173/5173 only** (relay origin allowlist): shadow's
default 8080 will silently fail to reach the relay and the mailbox mint.
Both ports serve `{:roots ["public" "dist-dev"]}`, and `scripts/dev.mjs`
redirects the watch to `dist-dev/assets` via `--config-merge
'{:output-dir "dist-dev/assets"}'`. **`dist/` is `ardz build`'s RELEASE
tree and no dev port ever serves it**: the old shape (`4173 {:roots
["dist"]}`, plus the watch writing `dist/assets`) handed a dev tab a
production-network client — goog.DEBUG=false, the production relay, a
service worker — whenever the watch hadn't overwritten `dist/` yet or the
browser had cached `assets/main.js` from such a moment; two tabs of one
drill then sat on different relays and never converged, silently.
Separate trees (`dist-dev/` is gitignored) make the mixture impossible.
Smoke a release build with a standalone static server over `dist/`,
never through the dev ports.
**Every `:dev-http` entry carries `:use-index-files true`.** Without it
shadow's undertow file handler has NO welcome files (shadow only sets
them from that key), so a directory request (`/`, `/id/`) falls through
to the push-state handler — which serves `index.html` only to requests
whose `Accept` header contains `text/html`, and 404s the rest (`curl`,
`fetch`). With the key, `/` serves `index.html` from the first root that
has one, whatever the client sends.
**The dev identity bridge is served locally.** `ardz dev <name>` stages
the hub's built `/id/` bridge (home's `public/id/index.html` +
`dist/id/assets`, building `:bridge` once if home was never built) into
the app's `client/dist-dev/id/`, so the default
`VITE_ID_BRIDGE_URL=http://localhost:4173/id/` resolves to the REAL
bridge on the app's own dev server — before this, any non-home app owning
4173 SPA-fell-back `/id/` to its own index.html, the IdBridge boot timed
out, and every dev tab silently ran a per-origin fallback identity. The
record lives in the `localhost:4173` origin's localStorage: one real
bridged identity shared by every app's dev session, and production is
never touched (the production bridge was not an option — localhost
embedding `ardegazu.ro` is cross-site, so third-party storage
partitioning would hand that iframe an isolated or empty record, and a
third local port would need an id-kit allowlist change). `ardz dev`
prints the bridge URL dev tabs will use; a `VITE_ID_BRIDGE_URL` override
skips the staging.
**Rig gotcha when smoke-testing a release build**: a service worker may
fail to register over plain-HTTP `localhost` under a static server
(`An unknown error occurred when fetching the script`, even though
`sw.js` and its `workbox-*.js` both fetch 200 with the right MIME type),
while registering perfectly on the deployed HTTPS origin. Do not read
that as a PWA regression — confirm against the real origin after
release, and note the same static server can serve a stale `main.js`
from the HTTP cache, so the version in the UI may lag `version.json`.
Useful during a serialised wave: **`shadow-cljs compile <build>` binds no
port and leaves no process behind** (verified with `lsof` on 4173/5173),
so syntax checks are always safe; only `watch`/`server` claim ports.
**Orchestration consequence**: because the allowlist has only these two
ports, and a `shadow-cljs server` claims both, **browser verification
serialises across the whole suite** — several app ports can be developed
in parallel, but only one can be smoke- or interop-tested at a time.
Plan waves accordingly, and check `lsof -nP -iTCP:4173 -sTCP:LISTEN`
before assuming a port is free.
- **Relative asset paths**: `:asset-path "./assets"` (+ hand-written
`index.html` with relative refs) — one build must serve at
`https://<host>/` and `/ipfs/<cid>/` alike. An absolute `/assets/...`
path is broken on IPFS gateways.
- **Unhashed module names turn a shared base module into a service-worker
exclusion bypass.** This is the reason home's `/id/` bridge is a **separate
shadow build, not a `:modules` entry**, and it generalises to any
path-scoped SW exclusion. A `:bridge` module of `:app` puts the base it
shares with the hub at `dist/assets/<name>.js` — outside
`globIgnores: ["id/**"]`, outside the `/\/id(\/|$)/` route and outside the
navigate denylist — so it gets precached. Vite survived that shape only
because it content-hashes: a stale precache simply has no entry for the new
url and the request falls through. **CLJS cannot hash**
(`:module-hash-names` is forbidden by the determinism rule), so the url is
stable, and with `skipWaiting: false` a waiting worker keeps serving the
*old* base to a page whose own code came fresh off the network. **Check
where the base module lands before trusting a path-scoped exclusion.**
- **`:modules` placement is by lowest common ancestor, which silently breaks
a lazy boundary.** shadow puts a namespace in the LCA of its consumers, so
two lazily-loaded modules that both need a kit's root export hoist that kit
into the base — the obvious shape (everything `:depends-on #{:main}`) is
the wrong one. home needed an intermediate `:share` module
(`:main <- :share <- {:bots, :social, :lb}`), which is exactly the graph
rollup had produced on its own. Fast way to find the right DAG: read the
old dist's chunk import graph (`grep -o 'from"[^"]*"'`). Gate the boundary
with a marker string per module. `shadow.lazy` resolves a two-level chain
itself — `shadow$modules.infos` carries the dependency graph, not just uris
(game1 had only ever proved one level).
- **`resolveJsonModule` has no shadow equivalent; a closure-define *string*
is the clean replacement.** `--config-merge '{:closure-defines {ns/DEF
"<json>"}}'` with `JSON.stringify` — every JSON escape is a valid EDN
string escape, so a 7 KB blob rides through verbatim, the data file stays
the single source of truth, and no generated file is tracked.
- **shadow does not bundle CSS.** A kit's `import "kit/theme.css"` becomes a
build-script copy out of the sha-pinned dep plus a `<link>` — and the
**cascade order the old import order gave you must be preserved by the link
order**. Sanity-check a few tokens from the copied file in the build script.
- **PWA**: `workbox-build` (`generateSW`) invoked from `build.mjs`.
- live/game stack: `skipWaiting: true`, `clientsClaim: true`,
`navigateFallback: "index.html"`, globPatterns
`**/*.{js,css,html,png,svg,webmanifest}`.
- rooms/versioned stack: `skipWaiting: false`, `clientsClaim: true`,
version.json banner flow; globPatterns must **omit `.json`** so
`version.json` (and `catalog.json`) are never precached.
- home's rule-4 `/id/` trinity (globIgnores + NetworkOnly route +
navigateFallbackDenylist) applies when home migrates — all three,
always.
## Verifying a rename — the check that can pass vacuously
The byte-identity diff is the strongest guarantee the vendoring story has,
so it is worth knowing how it produces a false green. board's first rename
used `sed -e 's/\bsueta\./board./g'` — **BSD sed has no `\b`**, so it
matched nothing, and the reverse-rename diff then reported all eleven
vendored files "IDENTICAL", which was *vacuously* true. It was caught only
because a stray `game1.i18n.runtime` surfaced in an unrelated diff.
Do the substitution in a language with real word boundaries, and **print
every rewritten line** for eyeballing — board's redone rename printed all 35
and every one was an `ns` form, a `:require` entry or a comment, which is
also how you confirm no wire literal moved (`sueta/2/`, `sueta-id|v2`,
`"sueta"`, `sueta/<roomId>` have no dot after `sueta` and were never
candidates).
## Vendored canon files (CLJS apps)
There are **two lines with different canons**, plus one file shared by both —
and they no longer work the same way: the live line is still vendored copies,
while the rooms line's core has been promoted to a kit and only the two files
the kit does not ship are still copied. Where a file *is* vendored, fixes
happen in the canon and replicate —
never edit a vendored file in an app. `dev/canon.tsv` + `bin/canon-drift` are
the gate, and they hold five rows: three rooms-line (`selftest`, `mailbox`,
`protocol`) and the two `i18n/runtime` ones. **Note what is NOT in the
manifest**: the live line's `net/` and `id/boot.cljs`, replicated by hand
across six games, have never been drift-checked by anything but a person.
**Live/game line — canon `game1`.** `net/`, `id/boot.cljs` and
`i18n/runtime.cljs` are **byte-identical to game1 modulo the namespace
substitution** (`game1.` → `<name>.`, `src/game1/` → `src/<name>/`)
**plus the one protocol-prefix line** in `net/peers.cljs`
(`/neon-grid/2/` → `/<name>/2/`; and see rule 6 — that prefix is whatever
the deployed build speaks, not the codename).
**Rooms/durable-log line — the shared core is a KIT now, not a vendored
copy.** Since 2026-08-28 `js.cljs` and
`lib/{access,crypto,descriptor,encryption,log,net,orbit-identity,protocol,turn}`
exist only in rooms-kit. chat and board reach them by putting
`node_modules/ardegazu-rooms-kit/src` on `client/deps.edn`'s `:paths` and
requiring them under their real `ardegazu.rooms.*` names — no rename, no copy,
nothing to replicate, one place to fix. What is *still* copied from chat and
still gate-tracked in `canon.tsv`: `lib/selftest.cljs` (delta 0) and
`lib/mailbox.cljs` (delta 15 — board raises `QUEUE-CAP` to 2000 and adds a
`queueCap` option; an offline drawing session exceeds 500 ops and drop-oldest
would lose the earliest strokes), neither of which the kit ships. board's
`lib/protocol.cljs` is a bespoke op union and now tracks the *kit's*
`lib/protocol.cljs` at delta 92 — safe only because both files are
documentation-only (strip the comments and each is a bare `(ns …)`) and
nothing in the shared set requires a `lib.protocol`. `src/js/`, `client/scripts/`
and the package files are scaffold-time copies, not gate-tracked, and do drift
once an app is real. None of this touches the wire: `/sueta/2/msg/1.0`, the
OrbitDB provider type `"sueta"` and the domain tags stay as deployed so a fork
is wire-compatible with the rooms core, and `APP-SALT` is what makes its rooms
unreachable instead.
**Shared by both lines:** `i18n/runtime.cljs`, canon `game1`, byte-identical
everywhere (chat's and game1's copies were verified equal modulo the ns
line).
`ardz new-app <name> <host> <live|rooms>` vendors and substitutes at
scaffold time via its `_ns_rename` helper (`live-cljs`/`rooms-cljs` survive
as aliases). Both arms are ClojureScript; the TypeScript arms and their
`app-live`/`app-rooms` templates are **retired** — they vendored from paths
that no longer exist and died *after* half-scaffolding a repo.
The rooms arm caught that same disease when the core moved to the kit, and the
fix hardened the shape rather than just the list: `_rooms_vendor_list` is the
single source of truth for what is copied, `_doctor_templates` and `cmd_new_app`
both read it, and `cmd_new_app` now checks every precondition (the list, plus
chat's `ardegazu-rooms-kit` sha pin) **before its first `mkdir`** — so a stale
canon is an abort with nothing on disk, not a half-repo. Scaffolded rooms apps
get `deps.edn`'s `node_modules/ardegazu-rooms-kit/src` from the template and
require `ardegazu.rooms.*` directly; the vendor list is `lib/{mailbox,selftest}`,
`stores.cljs`, `src/js/`, `scripts/`, `test/source-hygiene.test.mjs`,
`.cljfmt.edn` and the package files. `lib/media.cljs` is deliberately not on it
— it is chat's call feature, board never had it, and no `canon.tsv` row tracks
it, so scaffolding it would mass-produce an ungoverned copy.
**A template hazard this created, and it is a real one:** a template's
*bespoke* files (`config.cljs`, `main.cljs`, the app skeleton) are copied
from the canon by a human once, not vendored — so **a canon-side fix does
not reach them, and the scaffolder then mass-produces the bug.** The
rooms template was written from chat's `config.cljs` one commit before the
`""`-sentinel boot-killer was fixed there, and carried it. Whenever a canon
fixes one of its bespoke files, **re-diff every template that mirrors it.**
## Bots
The two-stack law is unchanged and is enforced *by build structure*: one
shadow build per process entry (`:main` → `dist/main.js`, peer-kit only;
`:worker` → `dist/worker.js`, rooms-kit only; `:train`, `:link`) — four
builds, separate module graphs, npm deps external. Shared behavior
namespaces take kit objects as **arguments** and require neither kit
(the CLJS replacement for TS's `import type`). `deploy/check-dist.sh`
greps the emitted bundles for the wrong kit's specifier and aborts.
systemd/install-fleet paths (`dist/main.js`, `dist/rl/train.js`,
`dist/fleet/link.js`) are fixed contracts.
## Gates: write them, then try to break them
Three of the four bugs that reached a deployed build in the final wave were
invisible to a green test suite, and two of the gates written to catch them
were themselves broken. So:
- **Probe every gate by deliberately breaking what it guards.** home's
rule-4 gate was probed with six mutations (drop `globIgnores`, add `.json`
to globPatterns, move the catalog route first, downgrade `/id/` to
`CacheFirst`, drop the denylist, set `skipWaiting: true`) and caught all
six — which is what turned its two-builds decision from a judgement into a
demonstration. An unprobed gate is a claim, not a check.
- **A gate's own regex can blind it.** home's route scan consumed an 80-char
window per `registerRoute(`, which in minified output swallowed the next
one: it saw 2 routes where there were 3 and would not have noticed a third
jumping the queue. Use zero-width lookahead *and* an exact expected count.
- **A source-scanning test must not reuse a `strip()` that blanks string
literals.** board's `source-hygiene.test.mjs` strips strings so the
`#js {}` check only sees code; a later check for i18n call sites reused it,
found zero `(t "key" …)` matches with every string erased, and **passed
vacuously**. It was written, committed-green, and only the break-probe
exposed it. Strip comments only when the thing you are matching *is* a
string.
- **Assert an exact count, not "at least one".** The lobby bug below is a
two-branch render where one branch was fine; anything short of "exactly 2"
would have stayed green.
- **When code moves repos, its gates do not follow.** Nothing announces it:
every suite stays green, because each one is still passing over the tree it
was pointed at. When chat and board stopped vendoring the rooms core, their
`source-hygiene` tests kept scanning `src/sueta` and `src/board` and kept
passing, while the ~1450 lines that moved to rooms-kit's classpath were
linted by nobody — the interop and paren-depth checks that exist because of
two production outages in exactly that layer had stopped reaching it.
rooms-kit now carries its own copy. **Ask of every move: what was scanning
this yesterday, and does it still reach?** The same question applies to a
file promoted out of an app into a kit, a namespace split across modules,
and anything the vendoring-vs-kits follow-up eventually decides.
## Release/publish checklist for a CLJS repo
1. `npm run check` green (shadow compile + consumer.ts smoke).
2. Vector/black-box tests green against `dist/`.
3. `deploy/check-dist.sh` green (twice from a clean clone after
build-affecting changes).
4. `deploy/publish-repo.sh` with `NO_PUBLISH=1` — the idpat gate over
every object; also confirms no `.map`, no `.shadow-cljs/`, and no
absolute-home path fragments.
5. **Take a rollback pin first** for an app release:
`ird ipfs pin <live-cid> --name "rollback:<app>:<why>" --yes`.
`_deploy` unpins the previous CID immediately after publishing, so
without this the build you would roll back to is GC-eligible. `ardz
release` now prints `rollback target: <cid>` on every release, so the
CID is in the log even when no pin was taken.
6. Kits: `ardz kit-release <kit>`. Apps: `ardz release <name>` +
`ardz publish-src <name>`.
Rollback is not instant: IPNS propagation plus the service worker's
version.json check (≤5 min, `registerType: "prompt"`, `skipWaiting: false`)
puts it around 15 minutes. A forward fix pays the same 15 minutes and does
not leave two protocol versions in the field, so reserve rollback for "the
build does not boot" — which, as the mailbox outage showed, is a real
category rather than a hypothetical one.
## Deferred follow-ups (not yet done — pick up in order of need)
- **Ports: DONE.** All six games, chat, board and home are ClojureScript.
- Decide the receive-side sanitisation divergence in rooms-kit's
`BoardClient` (needs a human): board rebuilds every element and patch
through `sanitize-element`/`sanitize-patch` (id format, kind whitelist,
unknown-field drop, coord/dim/`pts` clamps, colour round+clamp, a
13-field patch whitelist, `id`/`k` not patchable); rooms-kit validates only
`typeof el.id === "string"`. Shipping the colour clamp alone fixes one
field of ~20; the full port is breaking (it *rejects* elements accepted
today and drops unknown fields from `elements()`). Today's behaviour is
pinned by `test/vectors/board.json` with board's value beside each field,
so the future diff is mechanical.
- Two further rooms-kit/browser convergence gaps, both changing `elements()`
for existing input, both needing a human: **no pending buffer** for an
`edit`/`del` arriving before its `add` (board buffers and replays; the kit
drops, so a bot loses edits every browser applies), and **`elements()` is
in arrival order, not `(addClock, addHash)` z-order**, so any consumer
reasoning about occlusion disagrees with every canvas.
- **`ardz new-app <name> <host> rooms`: DONE** (2026-08-28). It scaffolds the
classpath shape — the template's `deps.edn` carries
`node_modules/ardegazu-rooms-kit/src`, the sources require `ardegazu.rooms.*`,
and only `lib/{mailbox,selftest}`, `stores.cljs` and the build/test plumbing
are copied from chat. Verified by scaffolding a throwaway app and running
`npm install` / `npm run build` / `npm test` green, and the doctor check was
probed by breaking four of its preconditions. `NEW-APP-PROMPT.md` and the
`app-cljs-rooms` template took the same pass.
- **A fresh rooms scaffold's `npm test`: DONE**, by making it honest rather than
green. The canon's script wants a `:testlib` build, `test/vectors/*.json` and
the libdatachannel prebuild, none of which a day-zero app has; the scaffolder
now rewrites it down to `cljfmt check src` plus a vendored
`test/source-hygiene.test.mjs`, which are real gates that really pass, and
ships `client/test/README.md` saying what to add and in what order. **The live
arm still inherits the broken script** — same fix, not yet applied.
- **The bot scaffold: DONE** (2026-08-31). The TS `templates/bot` — the last
TypeScript anywhere in the suite's scaffolds — is deleted; `templates/bot`
is now the CLJS tree (the former `bot-cljs`, refreshed to bot.git's current
lessons: `:parallel-build false` on all four builds, the cold
`.shadow-cljs` wipe in `scripts/build.mjs`, the `dist/rl/train.js`
stack grep in check-dist, current kit pins) and `ardz new-bot <name>` takes
no variant (a trailing `cljs` is accepted as a no-op). Verified by
scaffolding a throwaway bot and running `npm install` / `npm run check` /
`npm run build` / `npm test` / `deploy/check-dist.sh` green.
- **`_consumers_bump` and root-level package.json kit deps: DONE**
(2026-08-29). It resolves the package dir (`client/` or the repo root) and no
longer returns early on `kind=kit`, so a kit release now reaches `bot`,
`peer-kit` and `train-kit` — for a peer-kit release the old selection matched
**no repo at all**, which is why the bot sat on a stale pin through two kit
rewrites. Where the repo tracks `dist/` it rebuilds COLD and commits it too:
peer-kit compiles social-kit from classpath source, so a pin bump alone
changes nothing in the shipped bytes. A bumped kit gets a warning that it
needs its own `kit-release`. Selection was probed against the real manifest,
old vs new.
`kit-release` also runs `deploy/check-dist.sh` now — it used to run `check`,
which compiles but never builds, so it would publish whatever dist happened
to be committed.
- **Vendoring-with-CLJS-canon vs promoting the vendored layers to real kits —
ANSWERED for the rooms line, still open for the live line.** The rooms core
went to the kit on 2026-08-28 (see "Deriving a browser app from a Node kit"),
and the answer turned out to be cheaper than this bullet feared: consuming
the kit's *sources* off the classpath, not its dist, so there is no extra
dist to keep deterministic and no indirection at all — the app compiles the
same namespaces it used to own. What it did cost is one gate that had to be
rebuilt in the new home (rooms-kit's own `source-hygiene`), and nineteen
`canon.tsv` rows that simply stopped existing.
Still by hand, still needing a human: `game1`'s `net/` and `id/boot.cljs`
across the six games, and `i18n/runtime.cljs` across all nine apps. The
games' case is the harder one — the byte-identity gate and zero-indirection
debugging are worth more there, and the one sanctioned protocol-prefix line
has no obvious home in a shared kit.
|