dev / docs / CLJS.md
   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.

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/dev.git