File size: 51,837 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
051f280
 
 
 
 
 
bf8519f
 
 
051f280
 
bf8519f
 
 
 
 
051f280
 
bf8519f
 
051f280
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// ---------------------------------------------------------------------------
// shell/nav.ts β€” X6: the shell's nav, shaped from `GET /api/v1/nav`.
//
// WHAT CHANGED, AND WHY IT MATTERS MORE THAN IT LOOKS. The wave-4 nav was a
// STATIC array in Shell.tsx β€” a hand-kept mirror of `core/registry.py` that
// nobody's build would notice going stale, and that showed every surface to
// everybody. It is now rendered from a SERVER-FILTERED payload: the API applies
// `may_open` (incl. `_LEGACY_KEYS` migration-on-read) and drops archived
// modules, so what a user sees IS what the server granted. Registry-driven at
// one end, permission-filtered at the other.
//
// ⚠ THERE IS NO CLIENT-SIDE FALLBACK LIST, deliberately. If `/api/v1/nav`
// fails, the shell renders an honest empty nav β€” never a hard-coded one. A
// fallback would put surfaces on screen that the server never authorised, which
// is precisely the fail-closed rule the wave is built on ("an undeclared
// surface is denied"), and it would mask a broken API as a working one.
//
// No `import.meta` here either: `appBase` is a PARAMETER so this module
// compiles and runs under node in `verify_login.py` (see session.ts).
// ---------------------------------------------------------------------------

import { API_V1, CREDENTIALS } from "../apiContract";
// ⚠ WAVE 19 R8 / C1 β€” the icon vocabulary is the GRID's, imported not redefined.
// A VALUE import (not type-only) because the whitelist has to exist at runtime:
// `FolderMark` indexes `FOLDER_SHAPE_PATHS[shape]` and maps the result, so one
// unrecognised shape off the wire is `undefined.map` β€” a white screen, from a
// stored preference. `customer-grid/types` imports only `./windows`, which
// imports nothing, so this stays runnable under plain node (verify_login
// compiles this module and executes it there β€” see the header note on
// `appBase` being a parameter for exactly that reason).
import { FOLDER_SHAPES, FOLDER_TONES } from "../customer-grid/types";
import type { FolderIcon } from "../customer-grid/types";

/** One entry of X2's nav payload. `source` is the registry's connector fact
 *  (wave-9 I8: "Sales Β· Odoo"); `parent` names a sub-module's family head. */
export interface NavPage {
  key: string;
  label: string;
  source?: string;
  parent?: string;
  /**
   * The registry's `group_only`: this key names a FOLDER, never a destination
   * (`customers` is the only one today β€” the head of customer_data + cohort).
   *
   * ⚠ ADDED BY S1 2026-07-30, additive, present only when true. It supersedes
   * the client's has-children DERIVATION, which stays as the fallback for a
   * payload that predates the flag: the registry knows this fact, and a client
   * re-deriving a fact the server holds is drift waiting to happen.
   */
  group_only?: boolean;
  /**
   * `core.perms.nav_pages`' placement flag: `'main'` is the module list,
   * `'utility'` is a live surface the HOST renders outside it (Analyst,
   * Settings, the Metric Dictionary). The payload has carried it since X6;
   * rendering utility rows in the main list was this client's own gap β€”
   * `nav_pages`' docstring says "the client places them where the host does".
   */
  chrome?: "main" | "utility";
  /**
   * WAVE 19 R8 / C1 β€” the mark this database wears, from the tenant-wide
   * `nav_meta` bucket. Absent β‡’ the default cylinder, which is every database
   * that has never been given one.
   *
   * ⚠ TENANT-WIDE, not per-user, and that is the whole difference between this
   * and `nav_prefs` two fields down. Folders and placement are one person's
   * arrangement of their own rail; an icon and a name are what the DATABASE is
   * called, and a workspace where two people call the same table different
   * things has no shared vocabulary to hold a conversation in.
   */
  icon?: FolderIcon;
  /**
   * WAVE 19 R14 β€” may THIS session change this row's icon (and, for a `ut_` key,
   * its name)? The SERVER answers, because it is the only end that can: the
   * client cannot see who created a user table.
   *
   * β›” FAIL-CLOSED BY ABSENCE, like every other flag on this payload. A row that
   * does not say `manage: true` offers no rename and no icon picker β€” and the
   * route re-checks regardless, so this is the courtesy half of "the client
   * hides, the server forbids", never the wall.
   */
  manage?: boolean;
  /**
   * WAVE 21 item 6 (ruling R3, contract C3) β€” may THIS session DELETE this
   * database?
   *
   * β›” A SEPARATE FLAG FROM `manage`, deliberately, and reusing that one would
   * have been the bug. `manage` rides `user_tables.may_open` β€” which R14 widened
   * to include everyone a table has been SHARED with (D-32, `user_tables.py:511`).
   * That is the right reach for renaming and re-iconing; it is far too wide for
   * a verb that destroys ten artifact families. R3 scopes delete to the CREATOR
   * or a tenant admin, so the server answers a second, narrower question and
   * this is where the answer lands.
   *
   * Absent β‡’ no, like every flag here. Connector-backed databases never carry
   * it (R3: "NO delete verb for connector-backed databases β€” Pause stays their
   * only off-switch"), so the entry is absent rather than refused.
   */
  canDelete?: boolean;
  /**
   * ⭐ WAVE 27 item 3 (contract C9) β€” IS THIS A **LOCKED DATABASE**?
   *
   * The owner's item-4 vocabulary, and it means exactly one thing (DESIGN.md Β§4, THE THREE
   * LOCKS): RECORDS cannot be added, deleted or edited β€” **FIELDS STILL CAN BE**. It is not
   * "read-only", and a UI that hid the add-field door on the strength of this flag would be a
   * defect, not caution.
   *
   * Answered server-side from `user_tables.records_mutable`'s own condition (`routes_nav.py`),
   * because the client cannot see a table's `recordMode`.
   *
   * ⚠ ABSENT β‡’ UNLOCKED, which is the OPPOSITE direction from `manage`/`canDelete` above and
   * is deliberate: those two gate CONTROLS, so absence must deny. This one draws a HINT beside a
   * name, while the real refusal is the route's 403. A store blip should cost a missing padlock,
   * never a control that silently disappears.
   */
  locked?: boolean;
}

/**
 * The wire's icon, validated against the vocabulary the renderer can actually
 * draw. Anything unrecognised reads as ABSENT (the default mark) rather than as
 * an error: a stored preference is not worth a broken rail, and the server
 * applies the same whitelist on the way in, so a mismatch here means the two
 * ends have drifted β€” which shows up as an icon quietly reverting, the loudest
 * safe symptom available.
 */
export function parseNavIcon(raw: unknown): FolderIcon | undefined {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
  const r = raw as { shape?: unknown; tone?: unknown };
  if (!(FOLDER_SHAPES as readonly string[]).includes(String(r.shape))) return undefined;
  if (!(FOLDER_TONES as readonly string[]).includes(String(r.tone))) return undefined;
  return { shape: r.shape as FolderIcon["shape"], tone: r.tone as FolderIcon["tone"] };
}

/**
 * WAVE 20 item 4 (R8) β€” the class list for the universal database header's icon
 * chip: `shell-db-chip`, plus a tone modifier when the database wears a mark.
 *
 * R8 asks for "an icon chip on a bold colour background on EVERY database", and
 * that sentence hides a decision the JSX would otherwise bury: what colour does a
 * database that never chose one get? Three cases, and they are here rather than
 * inline so a gate can hold them (and so the answer is written once, not once per
 * render branch):
 *
 *   Β· no icon at all β€” every built-in and every un-styled table β€” takes the BASE
 *     chip, which the stylesheet paints in the brand primary. That is the
 *     "sensible default where unset" the ruling asks for, and it is why the base
 *     class carries a colour instead of leaving the chip transparent.
 *   Β· an icon takes its own TONE at the bold `-deep` weight, so the header agrees
 *     with the mark the same database wears in the rail.
 *   Β· an UNRECOGNISED tone falls back to the base, never `--unknown`: emitting a
 *     class no stylesheet defines would paint a chip with no background at all,
 *     and a white glyph on white is an invisible header. Same fail-safe posture
 *     as `parseNavIcon` above β€” a drifted vocabulary reverts to the default mark
 *     rather than breaking the frame.
 */
export function dbChipClass(icon?: FolderIcon): string {
  const base = "shell-db-chip";
  const tone = icon?.tone;
  if (!tone || !(FOLDER_TONES as readonly string[]).includes(tone)) return base;
  return `${base} ${base}--${tone}`;
}

/**
 * Read X2's `{pages:[…]}`. An entry with no key or no label is DROPPED rather
 * than rendered: a nav row with no name is a door with no sign on it, and
 * guessing the sign from the key would put an internal identifier in the UI.
 */
export function parsePages(body: unknown): NavPage[] {
  const raw = (body as { pages?: unknown } | null)?.pages;
  if (!Array.isArray(raw)) return [];
  const out: NavPage[] = [];
  for (const item of raw) {
    if (!item || typeof item !== "object") continue;
    const p = item as Record<string, unknown>;
    const key = typeof p.key === "string" ? p.key.trim() : "";
    const label = typeof p.label === "string" ? p.label.trim() : "";
    if (!key || !label) continue;
    out.push({
      key,
      label,
      ...(typeof p.source === "string" && p.source ? { source: p.source } : {}),
      ...(typeof p.parent === "string" && p.parent ? { parent: p.parent } : {}),
      ...(p.group_only === true ? { group_only: true } : {}),
      ...(p.chrome === "main" || p.chrome === "utility" ? { chrome: p.chrome } : {}),
      ...(parseNavIcon(p.icon) ? { icon: parseNavIcon(p.icon)! } : {}),
      ...(p.manage === true ? { manage: true } : {}),
      // C3 (W-5): read with the same `=== true` strictness as `manage` β€” a
      // truthy-but-not-true value from a drifted server must not open a delete.
      ...(p.canDelete === true ? { canDelete: true } : {}),
      // ⭐ WAVE 27 item 3 / C9 β€” same `=== true` strictness. A locked database is a padlock
      // beside a name, so a drifted server sending a truthy string must not paint one.
      ...(p.locked === true ? { locked: true } : {}),
    });
  }
  return out;
}

/**
 * Split the payload by placement. `main` feeds `shapeNav`; `utility` rows are
 * placed where the host places them (the Analyst slot above the list, the rest
 * behind the account menu). An absent flag reads as `main` β€” a payload that
 * predates the flag keeps today's behaviour rather than losing rows.
 */
export function splitChrome(pages: NavPage[]): { main: NavPage[]; utility: NavPage[] } {
  const main: NavPage[] = [];
  const utility: NavPage[] = [];
  for (const p of pages) (p.chrome === "utility" ? utility : main).push(p);
  return { main, utility };
}

/**
 * WAVE 23 (C10 / R7) β€” one entry of the Home landing's recents.
 *
 * `at` is EPOCH SECONDS, and the type says so because the alternative bit this product once:
 * a formatted naive-local stamp read by a browser in another zone (D-18). An integer instant
 * has one reading everywhere, and the two things Home renders from it β€” "Opened N minutes ago"
 * and the Today / Past 7 days / Older bucket β€” are both statements about THE READER'S OWN
 * CLOCK, which is the one case where using it is correct rather than forbidden (contrast
 * `alertsModel.stampText`, which must NOT touch a clock because it re-states a server event).
 */
export interface Recent {
  key: string;
  at: number;
}

/** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a
 *  freshly provisioned tenant with no modules and no databases YET β€” a legitimate starting
 *  state, not a broken account. Absent means "no reason given", which the shell reads as the
 *  misconfigured-account case it always did. Distinguishing them is what stops the rail saying
 *  "no surfaces are available" beside a hero saying "welcome, create your first database". */
export type NavResult =
  | {
      ok: true;
      pages: NavPage[];
      recents: Recent[];
      empty?: string;
      /**
       * ⭐ W31-T11 (owner item 6b) β€” REGISTRY KEYS THIS WORKSPACE'S CATALOGUE DOES NOT INCLUDE.
       *
       * A DELIBERATE absence. Every provisioned tenant carries a restricted `modules` list
       * (`gtmlab`/`loopable`/`nurilab` are all `['analyst','automation']`), so this is normal and
       * the rail draws nothing for these keys β€” as it always has. The value of naming them is
       * that it makes the OTHER absence distinguishable.
       */
      omitted?: string[];
      /**
       * β›” W31-T11 β€” PARTS OF THIS PAYLOAD THE SERVER COULD NOT READ.
       *
       * `["databases"]` means the `ut_*` merge raised and every database is missing from a
       * **200 OK**. Before this the two absences were identical on the wire and the shell
       * rendered `null` for both β€” which is pixel-identical to still-loading, with no timeout on
       * `fetchNav` at all. That is the owner's *"Connectors and Automation still disappear"*
       * report: not a permission bug, an unmarked failure.
       */
      degraded?: string[];
    }
  | { ok: false; status: number };

/**
 * How long the shell waits for `/nav` before calling it unavailable.
 *
 * β›” THE POINT IS NOT SPEED, IT IS THAT `phase:"loading"` CAN END. `fetchNav` had no
 * `AbortSignal`, no deadline and no retry, so a request that never resolved left a rail that
 * looked *almost* complete β€” the four static rows plus a spinner β€” for ever, and the owner read
 * that screen as modules disappearing. **20 seconds is chosen to sit ABOVE the measured worst
 * case, not near it**: `/nav` measured a 9.5–14.0 s band on tenant #0 before W31-T10, so a
 * shorter deadline would have converted a slow success into a manufactured failure. It is a
 * backstop for "never", not a performance budget.
 */
export const NAV_TIMEOUT_MS = 20_000;

/** Our encoding for "the deadline passed", distinct from 0 (transport) and any HTTP status. */
export const NAV_TIMEOUT_STATUS = -1;

/**
 * `{recents:[…]}` β†’ the list, fail-closed. An entry with no key or an unreadable stamp is
 * DROPPED rather than rendered at the epoch, which would file it under "Older" for ever and
 * put a tile on Home for something nobody opened.
 */
export function parseRecents(body: unknown): Recent[] {
  const raw = (body as { recents?: unknown } | null)?.recents;
  if (!Array.isArray(raw)) return [];
  const out: Recent[] = [];
  for (const item of raw) {
    if (!item || typeof item !== "object") continue;
    const r = item as Record<string, unknown>;
    const key = typeof r.key === "string" ? r.key.trim() : "";
    const at = typeof r.at === "number" && isFinite(r.at) ? Math.floor(r.at) : 0;
    if (!key || at <= 0) continue;
    out.push({ key, at });
  }
  return out;
}

/**
 * WAVE 23 (C10) β€” stamp a page as opened. FIRE AND FORGET, by design.
 *
 * β›” IT RETURNS `void` AND SWALLOWS EVERYTHING. A recents stamp is the least important write in
 * the product: it decorates a landing page. Nothing the user is doing may wait on it, and no
 * failure of it may reach a screen β€” a toast saying "we could not record that you opened this
 * table" is noise about a feature nobody asked for, printed over the table they successfully
 * opened. The server answers 503 honestly when the store is down; this end simply does not
 * care, which is the only correct posture for telemetry-shaped state.
 */
export function postOpened(key: string): void {
  if (!key) return;
  void fetch(`${API_V1}/nav/opened`, {
    method: "POST",
    credentials: CREDENTIALS,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key }),
  }).catch(() => {});
}

/**
 * X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the
 * two apart that must not be conflated: **401 means the session is gone** (drop
 * to the login screen), anything else means the nav is unavailable (stay signed
 * in, say so). `status: 0` is our encoding for a transport failure, as in
 * session.ts.
 */
export function parseKeyList(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  const out: string[] = [];
  for (const k of raw) if (typeof k === "string" && k.trim()) out.push(k.trim());
  return out;
}

export async function fetchNav(timeoutMs: number = NAV_TIMEOUT_MS): Promise<NavResult> {
  // ⚠ `setTimeout` + `AbortController`, not `AbortSignal.timeout` β€” the latter is unavailable in
  // the plain-node context `verify_login.py` compiles and RUNS this module in (see the header
  // note on `appBase` being a parameter for exactly that reason). A helper that throws on import
  // in the gate's environment is a helper that silently stops being tested.
  const ctl = typeof AbortController === "function" ? new AbortController() : null;
  let timedOut = false;
  let timer: ReturnType<typeof setTimeout> | null = null;
  // β›” THE DEADLINE IS A RACE, NOT ONLY AN ABORT β€” and this is the shape a probe found the hour
  // it was written. Aborting the request tells the TRANSPORT to stop; it does not, on its own,
  // make this function's promise settle. A `fetch` that ignores its signal therefore leaves the
  // caller awaiting for ever, which is precisely the state the deadline exists to end: the shell
  // would sit in `phase:"loading"` exactly as it did before, with an abort controller in the
  // source to prove it had been handled ([[flag-shipped-without-its-writer]]). Racing settles
  // this promise whatever the transport does, and the abort still fires so the request is not
  // left running behind it.
  const deadline =
    timeoutMs > 0
      ? new Promise<null>((resolve) => {
          timer = setTimeout(() => {
            timedOut = true;
            ctl?.abort();
            resolve(null);
          }, timeoutMs);
        })
      : null;
  try {
    const call = fetch(`${API_V1}/nav`, {
      credentials: CREDENTIALS,
      ...(ctl ? { signal: ctl.signal } : {}),
    });
    const res = deadline ? await Promise.race([call, deadline]) : await call;
    if (res === null) return { ok: false, status: NAV_TIMEOUT_STATUS };
    if (!res.ok) return { ok: false, status: res.status };
    const body = await res.json();
    const empty = typeof body?.empty === "string" ? body.empty : undefined;
    const omitted = parseKeyList((body as { omitted?: unknown } | null)?.omitted);
    const degraded = parseKeyList((body as { degraded?: unknown } | null)?.degraded);
    return {
      ok: true,
      pages: parsePages(body),
      recents: parseRecents(body),
      ...(empty ? { empty } : {}),
      ...(omitted.length ? { omitted } : {}),
      ...(degraded.length ? { degraded } : {}),
    };
  } catch {
    // β›” THE DEADLINE IS ITS OWN STATUS. A timeout reported as 0 (transport) would be told apart
    // from a dead network by nothing, and the two want different words on screen: one is "we gave
    // up waiting", the other is "we could not reach the server at all".
    return { ok: false, status: timedOut ? NAV_TIMEOUT_STATUS : 0 };
  } finally {
    if (timer !== null) clearTimeout(timer);
  }
}

// ── C-SCHEMA (wave 2026-08-02): per-user folders over the database list ─────────────────────

export interface NavFolder {
  id: string;
  name: string;
}

/** Per-user, cosmetic, server-validated β€” placement never grants or hides a surface. */
export interface NavPrefs {
  folders: NavFolder[];
  placement: Record<string, string>;
}

export const EMPTY_NAV_PREFS: NavPrefs = { folders: [], placement: {} };
export const MAX_NAV_FOLDERS = 16;

/** Mirror of the server's `_clean_nav_prefs` pruning (minus the page-key check β€” the client
 *  prunes against folders only; a stale page key is harmless and drops on the next save). */
export function parseNavPrefs(body: unknown): NavPrefs {
  const raw = (body as { prefs?: unknown } | null)?.prefs ?? body;
  if (!raw || typeof raw !== "object") return EMPTY_NAV_PREFS;
  const p = raw as { folders?: unknown; placement?: unknown };
  const folders: NavFolder[] = [];
  const seen = new Set<string>();
  if (Array.isArray(p.folders)) {
    for (const f of p.folders.slice(0, MAX_NAV_FOLDERS)) {
      if (!f || typeof f !== "object") continue;
      const id = String((f as NavFolder).id ?? "").trim().slice(0, 40);
      const name = String((f as NavFolder).name ?? "").trim().replace(/\s+/g, " ").slice(0, 40);
      if (!id || seen.has(id) || !name) continue;
      seen.add(id);
      folders.push({ id, name });
    }
  }
  const placement: Record<string, string> = {};
  if (p.placement && typeof p.placement === "object") {
    for (const [k, v] of Object.entries(p.placement as Record<string, unknown>)) {
      const fid = String(v ?? "").slice(0, 40);
      if (k && seen.has(fid)) placement[k.slice(0, 60)] = fid;
    }
  }
  return { folders, placement };
}

export async function fetchNavPrefs(): Promise<NavPrefs> {
  try {
    const res = await fetch(`${API_V1}/nav/prefs`, { credentials: CREDENTIALS });
    if (!res.ok) return EMPTY_NAV_PREFS;
    return parseNavPrefs(await res.json());
  } catch {
    return EMPTY_NAV_PREFS;
  }
}

/** Wholesale replace, like the table folder stratum. Resolves false on any failure so the
 *  caller can revert its optimistic copy and say so. */
export async function saveNavPrefs(prefs: NavPrefs): Promise<boolean> {
  try {
    const res = await fetch(`${API_V1}/nav/prefs`, {
      method: "POST",
      credentials: CREDENTIALS,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(prefs),
    });
    return res.ok;
  } catch {
    return false;
  }
}

// ── WAVE 19 R8 / C1: `nav_meta` β€” the TENANT-WIDE name + icon overrides ────────────────────

/** One database's overrides. `name` is `ut_*`-only and the SERVER enforces that
 *  (a built-in label is a compiled registry literal); `icon` rides every key. */
export interface NavMetaPatch {
  icon?: FolderIcon | null;
  name?: string;
}

/**
 * `POST /api/v1/nav/meta`. A PATCH of ONE key, not a wholesale replace β€” the
 * opposite posture to `saveNavPrefs` above, and deliberately so. Prefs are one
 * user's complete picture of their own rail, so replacing it whole is how a
 * deleted folder stays deleted. `nav_meta` is TENANT-WIDE and every admin edits
 * the same document: sending a whole picture there means the last writer erases
 * whatever the previous one named while their tab was open. Explicit `null`
 * clears the icon; an absent field is untouched, which is what makes a
 * rename-and-an-icon two independent writes instead of a race.
 *
 * Resolves false on any failure so the caller reverts its optimistic copy and
 * says so, rather than leaving a name on screen that the store never took.
 */
export async function saveNavMeta(key: string, patch: NavMetaPatch): Promise<boolean> {
  try {
    const res = await fetch(`${API_V1}/nav/meta`, {
      method: "POST",
      credentials: CREDENTIALS,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ key, ...patch }),
    });
    return res.ok;
  } catch {
    return false;
  }
}

// ───────────────────────────── WAVE 21 item 6 (R3, contract C3): delete a database
/**
 * What deleting this database would actually destroy.
 *
 * β›” EVERY FIELD IS A COUNT THE USER CAN ACT ON, which is the whole reason the
 * confirm face exists. R3: "Confirm dialog discloses the footprint (rows/fields/
 * views/shared users/bound automations) before DELETE." A dialog that said only
 * "are you sure?" would be asking about a thing whose size nobody can see β€” and
 * the thing that makes a user-table delete different from a view delete is that
 * nine other artifact families go with it (cohorts, comments, docs + their
 * dataset bytes, asset refs, grants, nav meta, alerts, automations).
 *
 * `automations` are NAMED, not counted, and that asymmetry is deliberate: they
 * are the only footprint entry that keeps existing after the delete (C3 pauses
 * them and stamps "target deleted" rather than removing them), so the user has
 * to be able to go and find them.
 */
export interface TableFootprint {
  rows: number;
  fields: number;
  views: number;
  sharedUsers: number;
  automations: { id: string; name: string }[];
}

const intOf = (v: unknown): number =>
  typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;

/**
 * `GET /api/v1/tables/{key}/footprint` (C3, A serves).
 *
 * Returns null when the server would not or could not answer. The caller shows
 * the confirm face WITHOUT counts in that case rather than substituting zeros:
 * "0 rows" is a specific, checkable claim about the user's data, and inventing
 * it to keep a dialog tidy is the fabrication this repo's counts rule forbids
 * ([[no-unverifiable-aggregates]]).
 */
export async function fetchTableFootprint(key: string): Promise<TableFootprint | null> {
  try {
    const res = await fetch(`${API_V1}/tables/${encodeURIComponent(key)}/footprint`, {
      credentials: CREDENTIALS,
    });
    if (!res.ok) return null;
    const raw = (await res.json()) as Record<string, unknown> | null;
    if (!raw || typeof raw !== "object") return null;
    const autos: { id: string; name: string }[] = [];
    for (const item of Array.isArray(raw.automations) ? raw.automations : []) {
      if (!item || typeof item !== "object") continue;
      const a = item as Record<string, unknown>;
      const id = typeof a.id === "string" ? a.id : String(a.id ?? "");
      const name = typeof a.name === "string" && a.name.trim() ? a.name.trim() : id;
      if (id) autos.push({ id, name });
    }
    return {
      rows: intOf(raw.rows),
      fields: intOf(raw.fields),
      views: intOf(raw.views),
      sharedUsers: intOf(raw.sharedUsers),
      automations: autos,
    };
  } catch {
    return null;
  }
}

/**
 * `DELETE /api/v1/tables/{key}` (C3). The route has existed since wave 18 with
 * NO client caller at all β€” this is that caller.
 *
 * Resolves the server's own message on failure so the rail can say why, and
 * `true` only on a 2xx. The nav is refetched by the caller either way: a delete
 * that half-succeeded server-side must not leave a row on screen whose absence
 * is the only evidence anything happened.
 */
export async function deleteTable(key: string): Promise<{ ok: boolean; error?: string }> {
  try {
    const res = await fetch(`${API_V1}/tables/${encodeURIComponent(key)}`, {
      method: "DELETE",
      credentials: CREDENTIALS,
    });
    if (res.ok) return { ok: true };
    let detail = "";
    try {
      const body = (await res.json()) as { detail?: unknown; error?: unknown } | null;
      const d = body?.detail ?? body?.error;
      if (typeof d === "string") detail = d;
    } catch {
      /* a non-JSON error body is not itself an error worth surfacing */
    }
    return { ok: false, error: detail || `The server answered ${res.status}.` };
  } catch {
    return { ok: false, error: "Cannot reach the server." };
  }
}

/** One rendered row of the folded nav: a folder head, or an entry (optionally inside one). */
export type NavRow =
  | { kind: "folder"; folder: NavFolder; count: number; open: boolean }
  | { kind: "entry"; entry: NavEntry; folderId?: string };

/**
 * Fold the shaped entries under the user's folders. A BLOCK (a top-level entry plus its
 * depth-1 children) moves as one unit β€” filing a family head files the family. Folders render
 * in prefs order first, loose blocks after in registry order; a folder with no members still
 * renders (it was just created β€” it must exist on screen to receive its first member).
 * A closed folder contributes only its head row.
 */
export function foldNav(
  entries: NavEntry[],
  prefs: NavPrefs,
  closed: ReadonlySet<string>
): NavRow[] {
  interface Block {
    head: NavEntry;
    children: NavEntry[];
  }
  const blocks: Block[] = [];
  for (const e of entries) {
    if (e.depth > 0 && blocks.length > 0) blocks[blocks.length - 1].children.push(e);
    else blocks.push({ head: e, children: [] });
  }
  const byFolder = new Map<string, Block[]>();
  const loose: Block[] = [];
  const ids = new Set(prefs.folders.map((f) => f.id));
  for (const b of blocks) {
    const fid = prefs.placement[b.head.key];
    if (fid && ids.has(fid)) {
      const list = byFolder.get(fid);
      if (list) list.push(b);
      else byFolder.set(fid, [b]);
    } else {
      loose.push(b);
    }
  }
  const out: NavRow[] = [];
  const emit = (b: Block, folderId?: string) => {
    out.push({ kind: "entry", entry: b.head, ...(folderId ? { folderId } : {}) });
    for (const c of b.children)
      out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) });
  };
  for (const f of prefs.folders) {
    const members = byFolder.get(f.id) ?? [];
    const open = !closed.has(f.id);
    out.push({ kind: "folder", folder: f, count: members.length, open });
    if (open) for (const b of members) emit(b, f.id);
  }
  for (const b of loose) emit(b);
  return out;
}

// ── C-SCHEMA: the schema drawer payload ─────────────────────────────────────────────────────

export interface SchemaField {
  key: string;
  label: string;
  type: string;
  source: string;
  description: string;
  options?: string[];
}

export interface SchemaPayload {
  key: string;
  label: string;
  source: string;
  fields: SchemaField[];
  measures: { key: string; label: string; type: string }[];
  note?: string;
}

export function parseSchema(body: unknown): SchemaPayload | null {
  if (!body || typeof body !== "object") return null;
  const b = body as Record<string, unknown>;
  if (typeof b.key !== "string" || typeof b.label !== "string") return null;
  const fields: SchemaField[] = [];
  if (Array.isArray(b.fields)) {
    for (const f of b.fields) {
      if (!f || typeof f !== "object") continue;
      const r = f as Record<string, unknown>;
      if (typeof r.key !== "string" || typeof r.label !== "string") continue;
      fields.push({
        key: r.key,
        label: r.label,
        type: String(r.type ?? ""),
        source: String(r.source ?? ""),
        description: String(r.description ?? ""),
        ...(Array.isArray(r.options) ? { options: r.options.map(String) } : {}),
      });
    }
  }
  const measures: SchemaPayload["measures"] = [];
  if (Array.isArray(b.measures)) {
    for (const m of b.measures) {
      if (!m || typeof m !== "object") continue;
      const r = m as Record<string, unknown>;
      if (typeof r.key !== "string" || !r.key) continue;
      measures.push({ key: r.key, label: String(r.label ?? r.key), type: String(r.type ?? "") });
    }
  }
  return {
    key: b.key,
    label: b.label,
    source: String(b.source ?? ""),
    fields,
    measures,
    ...(typeof b.note === "string" && b.note ? { note: b.note } : {}),
  };
}

export async function fetchSchema(key: string): Promise<SchemaPayload | null> {
  try {
    const res = await fetch(`${API_V1}/nav/schema/${encodeURIComponent(key)}`, {
      credentials: CREDENTIALS,
    });
    if (!res.ok) return null;
    return parseSchema(await res.json());
  } catch {
    return null;
  }
}

/**
 * What the shell does with an entry:
 *   native   β€” this tree renders it (a hash route)
 *   handoff  β€” a labelled link into the current application (`?page=<key>`)
 *   group    β€” a FOLDER head, not a destination: a label row, never a link
 */
export type NavKind = "native" | "handoff" | "group";

export interface NavEntry {
  key: string;
  label: string;
  source?: string;
  kind: NavKind;
  /** 0 = top level, 1 = a sub-module rail entry (indented, like the app's rail). */
  depth: number;
  /** Absent for a group head β€” there is nowhere to go. */
  href?: string;
  /** WAVE 19 R8 β€” the tenant's chosen mark; absent β‡’ the default cylinder. */
  icon?: FolderIcon;
  /** WAVE 19 R14 β€” the server's answer to "may this session rename/re-icon it".
   *  Absent β‡’ no, and the route refuses independently. */
  manage?: boolean;
  /** WAVE 21 R3/C3 β€” the server's answer to "may this session DELETE it": the
   *  CREATOR or an admin, never the whole `may_open` set `manage` rides on (see
   *  the long note on `NavPage.canDelete`). Absent β‡’ no. */
  canDelete?: boolean;
  /** ⭐ WAVE 27 item 3 / C9 β€” a LOCKED database: no new records, fields still fine (see the
   *  long note on `NavPage.locked`). Absent β‡’ unlocked. */
  locked?: boolean;
  /**
   * ⭐ WAVE 25 (D-54) β€” THIS ROW IS A SURFACE, NOT A DATABASE.
   *
   * Absent β‡’ it is a database (or a folder head over some). Set by `shapeNav` from
   * `SURFACE_KEYS`, so the fact is decided ONCE, where rows are built, rather than
   * re-derived by every consumer that happens to remember.
   *
   * β›” THE DEBT THIS CLOSES IS NOT "Home had a bug", it is that the FILTER WAS A
   * LOCAL EXPRESSION. The rail has excluded Automation since wave 19 R10 with an
   * inline `entries.filter(e => e.key !== "automation")`; Home was written against
   * the raw list a wave later and drew the Automation SURFACE a tile under the
   * heading "Databases", beside an "Automations" section listing what it contains.
   * No gate could see it β€” rendering a tile per granted entry is exactly what that
   * code was asked to do. A rule every consumer must remember is not a control
   * ([[rules-need-gates]]), so it became a property of the row plus one accessor.
   */
  surface?: true;
}

/**
 * Granted nav rows that are NOT databases.
 *
 * ⚠ A CLIENT CAPABILITY FACT, NOT A PERMISSION ONE, exactly like `NATIVE_KEYS` two
 * screens down: the server decides what this account may open, and this decides
 * which of those rows a DATABASE list is entitled to draw. A key here that the
 * server never sends simply never matches.
 *
 * One member today. It is a `Set` rather than an `=== "automation"` so the next
 * non-database surface (an Analyst rebuild, a reports page) joins by adding a key,
 * in the one place that already carries the reasoning β€” which is the whole of what
 * D-54 asked for.
 */
export const SURFACE_KEYS: ReadonlySet<string> = new Set(["automation"]);

/**
 * A `NavEntry` that has been through {@link databaseEntries} β€” and the brand is the
 * point, not decoration.
 *
 * β›” IT MAKES THE WRONG LIST A COMPILE ERROR RATHER THAN A CODE REVIEW. A component
 * that draws databases declares `DatabaseEntry[]`, so handing it the raw `entries`
 * does not type-check and `npx tsc -b` says so on the day it is written. That is the
 * same lesson this repo learned about optional props (`verify_wiring.py`'s header:
 * an optional prop degrades to "the feature does not exist"), applied to a LIST: an
 * unfiltered one degrades to "one extra tile", which nobody notices for a wave.
 *
 * ⚠ `DatabaseEntry` IS a `NavEntry`, so anything taking the wider type still accepts
 * these. The brand only blocks the unsafe direction.
 */
export type DatabaseEntry = NavEntry & { readonly __isDatabase: true };

/**
 * The granted nav, minus the surfaces that are not databases. **The only supported
 * way to obtain a database list** (D-54).
 *
 * β›” IT NARROWS, IT NEVER WIDENS β€” `entries` stays the outer bound, so the server's
 * grant is never re-litigated here. Nothing is added, reordered or renamed; rows are
 * dropped, and only rows this module has declared to be surfaces.
 */
export function databaseEntries(entries: NavEntry[]): DatabaseEntry[] {
  return entries.filter((e) => !e.surface) as DatabaseEntry[];
}

/**
 * The surfaces THIS TREE can actually render:
 *   Β· `customer_data` / `cohort` β€” the Customer table, the same `customer-grid`
 *     component the Streamlit embed ships.
 *
 * ⚠ This is a CLIENT capability list, not a permission list, and the two must
 * not be confused. The server decides what a user MAY open; this decides what
 * the shell KNOWS HOW to draw. A key here that the server did not send is
 * simply never rendered β€” the payload is the outer bound, always.
 *
 * β­’ THE NEXT PORTED SURFACE (Collections / Procurement) JOINS BY ADDING ITS KEY
 * HERE + a server-side builder. That is the whole bet of the Y1 envelope: a
 * ported page is a registry entry plus a builder, never a new component tree.
 * `sales` was the proof (EXIT wave 2) and LEFT wave 16 (owner item 7, R3/R11):
 * the registry row is archived, the builder unregistered, and the owner
 * rebuilds sales views from the grid's chart/dashboard modes β€” so the envelope
 * machinery (PageView, ui/, viz/) is currently a capability with no shipped
 * page, kept warm by the render-smoke fixture.
 */
export const NATIVE_KEYS: ReadonlySet<string> = new Set<string>(
  // `cohort` joined 2026-07-30 and LEFT wave 16 (owner item 5, R10): every cohort now
  // projects as a LOCKED VIEW in the Customer rail's "Cohorts" section (wave-15 C-LOCK),
  // so the separate route is gone. The cohort MACHINERY stays β€” `scope=cohort`,
  // `workspace.cohortMode`, the events β€” reachable from the Customer surface.
  // `product_data` joined wave 16 (C-TOPIC, owner items 9+10): the SAME grid tree over the
  // SKU catalogue β€” Shell maps it to `scope="product"` and everything else is the topic's.
  // `automation` joined wave 18 (C-AUTONAV): SESSION D's surface, its own rail + editor.
  // User tables are native BY PREFIX (`ut_`), decided in `shapeNav`, not listed here.
  ["customer_data", "product_data", "automation"]);

/** The native keys the Y1 page envelope drives (as opposed to `customer_data`,
 *  which is its own component tree). Kept beside NATIVE_KEYS so the two cannot
 *  drift: everything here MUST also be native, asserted in the gate. EMPTY
 *  since wave 16 (sales retired) β€” the next builder's key lands here. */
export const ENVELOPE_KEYS: ReadonlySet<string> = new Set<string>([]);

/** The app's own deep link (`?page=<registry key>`) β€” the strangler hand-off
 *  lands on the exact page rather than a landing screen. */
export function appLink(appBase: string, key: string): string {
  return `${appBase}/?page=${encodeURIComponent(key)}`;
}

/**
 * Payload β†’ rendered rows, in registry order, children under their parent.
 *
 * THE GROUP RULE IS DERIVED, NOT RECEIVED. X2's nav item carries no
 * `group_only` flag, but `customers` IS one (registry: the folder head of
 * customer_data + cohort β€” "it has no PAGE_FUNCS entry, the nav never renders
 * it as a leaf"). So a page that is the `parent` of at least one page IN THE
 * SAME PAYLOAD renders as a group head. Derived from the contracted payload
 * alone; if the API later emits an explicit flag, prefer it (posted to S1 β€”
 * additive, no amendment).
 *
 * ⚠ AN ORPHANED CHILD RENDERS AT TOP LEVEL. If `may_open` granted `cohort` but
 * not its parent `customers`, the payload has the child and not the head β€” and
 * hiding it because its family head is missing would take away a surface the
 * server explicitly granted. Display never re-litigates a server grant.
 */
export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] {
  const present = new Set(pages.map((p) => p.key));
  const childrenOf = new Map<string, NavPage[]>();
  for (const p of pages) {
    if (!p.parent || !present.has(p.parent)) continue;
    const list = childrenOf.get(p.parent);
    if (list) list.push(p);
    else childrenOf.set(p.parent, [p]);
  }

  const entry = (p: NavPage, depth: number): NavEntry => {
    // Wave 18 (C3-UT): any `ut_`-prefixed key is a USER TABLE β€” drawable by the same grid
    // tree (Shell passes the key through as the scope), so it is native BY PREFIX rather
    // than by membership. The server only emits ut rows this session may open, so this is a
    // capability rule, never a permission one.
    const isNative = NATIVE_KEYS.has(p.key) || p.key.startsWith("ut_");
    // A native surface stays native even with children β€” the shell can draw it,
    // so it is a destination first and a family head second. Otherwise: the
    // server's flag wins, and has-children is the fallback for a payload that
    // predates it.
    const kind: NavKind = isNative
      ? "native"
      : p.group_only || childrenOf.has(p.key)
        ? "group"
        : "handoff";
    return {
      key: p.key,
      label: p.label,
      ...(p.source ? { source: p.source } : {}),
      ...(p.icon ? { icon: p.icon } : {}),
      ...(p.manage ? { manage: true } : {}),
      // C3 (W-5) β€” carried through `shapeNav` beside `manage`. THE SECOND HALF
      // OF THE SAME FLAG: `parseNav` reading it and this dropping it would put
      // the answer on the payload and never on the row the rail renders, which
      // is precisely how wave 20 shipped four wirings that went nowhere.
      ...(p.canDelete ? { canDelete: true } : {}),
      // ⭐ WAVE 27 item 3 / C9 β€” THE SECOND HALF, for the reason stated directly above: a flag
      // parsed onto the payload and dropped here reaches no row anybody renders.
      ...(p.locked ? { locked: true } : {}),
      // ⭐ D-54 β€” the databases/surfaces split, stamped at the ONE place rows are built.
      // Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the
      // existing discriminator cannot answer this question and widening it would have made
      // "can the shell draw it" and "is it a database" one field with two meanings.
      ...(SURFACE_KEYS.has(p.key) ? { surface: true as const } : {}),
      kind,
      depth,
      ...(kind === "group"
        ? {}
        : { href: isNative ? `#/${p.key}` : appLink(appBase, p.key) }),
    };
  };

  const out: NavEntry[] = [];
  for (const p of pages) {
    // Children are emitted under their head, not in payload position.
    if (p.parent && present.has(p.parent)) continue;
    const head = entry(p, 0);
    const children = childrenOf.get(p.key) ?? [];
    // ⚠ An EMPTY folder head is dropped. It is not a destination by definition,
    // so with nothing under it the row says only "here is something you cannot
    // reach" β€” which happens to a user granted `customers` but neither child.
    // This is not re-litigating a grant: a container is not a surface.
    if (head.kind === "group" && children.length === 0) continue;
    out.push(head);
    for (const c of children) out.push(entry(c, 1));
  }
  return out;
}

/**
 * The surface the shell lands on, in preference order.
 *
 * β›” IT IS AN EXPLICIT PREFERENCE, NOT "the first native entry". It used to be
 * the latter, and the result was that the shell opened on SALES β€” purely
 * because `sales` sorts first in the registry β€” which is the one module the
 * owner had said to leave alone. "Customer doesn't load on first
 * initialization" was that, exactly: Customer loaded fine, it just was not what
 * came up. A landing page is a product decision and belongs in a list somebody
 * can read, not in whatever the registry happens to order first.
 */
// (`cohort` left the list with its NATIVE_KEYS exit, wave 16 β€” a landing must be drawable.)
//
// ⭐ WAVE 23 (contract C10, ruling R7) β€” THE LANDING IS NOW `#/home`.
//
// β›” CHROME ROUTES ARE NOT GRANTED PAGES, AND THE :1039 LAW SURVIVES INTACT. This shell's
// oldest rule is that the nav is server-filtered and an undeclared surface is denied β€” which is
// why Alerts is a PANEL and not a route. Home and Connectors are routes, and they do not
// violate that rule for one reason that has to be true of every future member of this set:
//
//     A CHROME ROUTE RENDERS NOTHING THE SERVER DID NOT ALREADY GRANT.
//
// Home shows the entries `/nav` returned (its recents are RESOLVED against those entries and a
// key absent from them is dropped, `homeModel.groupRecents`) plus create affordances that were
// already in the rail. Connectors renders a SERVER-COMPOSED directory (`GET /connectors/
// directory`, session-gated) β€” it is a window onto a payload, not a surface with its own data.
// Neither can show a database, a module or a connector this session may not see; adding a route
// that could would be the hard-coded surface the frame refuses to have.
export const HOME_ROUTE = "home";
export const CONNECTORS_ROUTE = "connectors";
/**
 * ⭐⭐ WAVE 32 (ruling R7, contract C3) β€” INBOX IS THE THIRD CHROME ROUTE, AND IT PASSES THE LAW
 * ABOVE RATHER THAN BEING EXCEPTED FROM IT.
 *
 * `GET /notifications` and `GET /alerts` are session-scoped: they return THIS account's own
 * notifications about its own views, composed by the server. Like Connectors, the module is a
 * window onto a payload the server already decided this session may see β€” it cannot show a
 * database, a module or another user's notification. So the :1039 law holds unmodified.
 *
 * β›”β›” AND IT MUST **NOT** BE A `registry.py` MODULE, which is the trap this wave was warned about
 * by name. Every provisioned tenant carries a restricted `modules` list β€” `gtmlab`, `loopable` and
 * `nurilab` are all `['analyst','automation']` β€” and `/nav` silently OMITS a registry key outside
 * it. Registering Inbox as a module would have made it invisible in every tenant on the platform
 * while every gate in the battery stayed green. `alerts` was already chrome (it has no registry
 * row), so the rename inherits the right shape by construction.
 */
export const INBOX_ROUTE = "inbox";
/**
 * ⭐⭐ WAVE 32 (ruling R1, contract C5) β€” QUERY IS THE FOURTH CHROME ROUTE, and it satisfies the
 * law above THROUGH ITS REQUIRED PROP rather than despite it.
 *
 * `QueryPage` takes `granted: NavEntry[]` β€” the database entries the shell already holds from
 * `/nav`, which is to say the set the SERVER decided this session may see. It makes no listing
 * call of its own, so *"a chrome route renders nothing the server did not already grant"* is true
 * by construction here rather than by promise, and the AI cannot name a database the caller was
 * not already given (C5's permission clause, re-used rather than re-implemented).
 *
 * β›” NOT a `registry.py` module, for the reason stated on `INBOX_ROUTE` above: every tenant's
 * `modules` list is `['analyst','automation']`, so a registry key would be omitted from the rail
 * in every tenant while every gate stayed green.
 */
export const QUERY_ROUTE = "query";
export const CHROME_ROUTES: ReadonlySet<string> = new Set([
  HOME_ROUTE,
  CONNECTORS_ROUTE,
  INBOX_ROUTE,
  QUERY_ROUTE,
]);

/**
 * Old route key β†’ the key that replaced it. Applied to the hash before ANYTHING else reads it.
 *
 * ⭐ C3: *"the old `alerts` route redirects, it is not deleted β€” a stored nav preference naming
 * `alerts` must not orphan."* This is that clause, and it is the RENAME checklist's step 5
 * (*"grants and preferences migrate ON READ; never edit stored user records to migrate a
 * rename"*) expressed for a chrome route.
 *
 * ⚠ MEASURED, AND IT CHANGES WHAT THIS IS FOR: **there was no `#/alerts` route to redirect.**
 * Alerts was deliberately a PANEL β€” `Shell.tsx`'s bell is a `<button>`, and the comment beside it
 * says a client-invented `#/alerts` route *"would be exactly the hard-coded surface the frame
 * refuses to have"*. So no user has a stored `alerts` placement, and the clause was written
 * against a route that never existed. It is kept anyway, as forward insurance rather than
 * archaeology: `#/alerts` is the hash a person would GUESS, type, or have bookmarked from a
 * notification email, and answering it costs one map lookup. Retiring it would need evidence
 * nobody types it, which is not obtainable.
 */
export const LEGACY_ROUTES: Readonly<Record<string, string>> = { alerts: INBOX_ROUTE };

/** The current key for a hash that may name a retired one. Identity for everything else. */
export function canonicalRoute(route: string): string {
  return LEGACY_ROUTES[route] ?? route;
}

export const LANDING_PREFERENCE: readonly string[] = [HOME_ROUTE];

/**
 * The surface the shell lands on.
 *
 * ⚠ THE THREE LANDING SITES MOVE TOGETHER OR LOGINS STRAND (C10's own warning): this function,
 * `LANDING_PREFERENCE` above, and the post-login redirect in `Shell.tsx`. Half a move leaves a
 * signed-in user on a hash nothing renders.
 *
 * A chrome route needs no entry to be reachable β€” that is what makes it chrome β€” so the loop
 * below returns it without consulting `entries`, and the old preference chain survives beneath
 * it for the day a granted page becomes the landing again. `entries` stays a parameter for the
 * same reason: the fallback is still a real answer about a real payload.
 */
export function defaultRoute(entries: NavEntry[]): string {
  return firstDestination(entries, LANDING_PREFERENCE);
}

/**
 * The landing rule itself, over an arbitrary preference list.
 *
 * ⚠ EXTRACTED (wave 23) SO THE FALLBACK CHAIN STAYS UNDER TEST. Before C10 the chain WAS
 * `defaultRoute`, so `shell.test.ts` asserted it by calling that. With `home` at the head of the
 * preference the function now returns on its first line, and the whole "preferred native entry β†’
 * any native entry β†’ any destination" ladder became unreachable from the outside β€” three checks
 * would have had to be deleted rather than retargeted, which is how a gate quietly stops
 * asserting the thing it was written for ([[gate-can-report-green-on-nothing]]). It is one
 * function called two ways instead.
 */
export function firstDestination(
  entries: NavEntry[],
  preference: readonly string[]
): string {
  for (const key of preference) {
    // A chrome route needs no entry to be reachable β€” that is what makes it chrome.
    if (CHROME_ROUTES.has(key)) return key;
    const hit = entries.find((e) => e.key === key && e.kind === "native");
    if (hit) return hit.key;
  }
  return (
    entries.find((e) => e.kind === "native")?.key ??
    entries.find((e) => e.kind !== "group")?.key ??
    ""
  );
}

/**
 * Resolve a hash route against the nav.
 *
 * A GROUP is never a destination β€” a hand-typed `#/customers` resolves to the
 * family's first member, mirroring what the app itself does with a stale
 * `?page=customers` deep link ("redirects to the family's first visible
 * member"). An unknown route falls to the default. Both fall back rather than
 * erroring, because a URL is something a person can type or a bookmark can
 * outlive.
 *
 * ⭐ WAVE 23 (C10): the fallback is now `#/home`, which is CHROME and therefore matches no
 * entry β€” so this returns `undefined` for an unknown route, and the frame reads "no entry" as
 * "render Home". One behaviour change, stated plainly: `#/deleted_table` used to open the
 * Customer grid and now opens Home. That is the better answer (the reader asked for something
 * that is gone; the landing is where you choose again) and it is the SAME answer an empty hash
 * gets β€” which is what "Home is the default" has to mean in order to be true.
 */
export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined {
  const i = entries.findIndex((e) => e.key === route);
  if (i >= 0) {
    const hit = entries[i];
    if (hit.kind !== "group") return hit;
    // A group head: fall through to its first real member β€” the rows that
    // follow it DEEPER, stopping at the next row of its own level or shallower.
    for (const e of entries.slice(i + 1)) {
      if (e.depth <= hit.depth) break;
      if (e.kind !== "group") return e;
    }
  }
  const fallback = defaultRoute(entries);
  return entries.find((e) => e.key === fallback);
}