File size: 57,139 Bytes
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2f7ccf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
f2f7ccf
 
 
609fb78
 
 
 
 
f2f7ccf
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""AIOS web API β€” the ONE shared, stateless process the Streamlit exit is aimed at.

It REUSES the `platform/` data layer verbatim (nothing re-implemented): the canonical,
reconciled Odoo model stays server-side and the browser only ever sees derived JSON. Serves the
JSON API under `/api/*` and the built React bundle (`aios-web/web/dist`) at `/`.

WHAT CHANGED IN EXIT WAVE 1 (2026-07-30) β€” three things, all of them load-bearing:

  * **Real sessions replace HTTP Basic.** The whole app used to sit behind one shared
    `APP_PASSWORD` with the browser's native Basic prompt, which means every caller was the same
    anonymous principal and no route could scope anything. Now a signed stateless cookie carries
    a real `core/users` identity (X3), and every v1 route resolves BU + own-book scope from the
    user RECORD. `/api/health` stays unauthenticated (liveness only).
  * **The overlay fork is deleted.** `aios-web/api/data/overlay.json` was a second writable home
    for user-owned fields the Streamlit app keeps in the tenant store. ONE store now (C1c).
  * **The SSL shim is env-gated.** It used to run at import, unconditionally.

⚠ TENANT RESOLUTION IS PER REQUEST (X7). Nothing tenant-shaped is held at module level; the
session's tenant claim resolves through `harness.runtime.get_runtime`, which is LRU-bounded. This
is the rule the ~30–80 MB/tenant target depends on β€” EXIT-0 measured the alternative at a ~0.6 GB
commit FLOOR per tenant, duplicated within 2% per tenant, nothing shared.
"""
import os
import sys
from pathlib import Path

# --- the local Odoo SSL quirk, now BEHIND A GATE -------------------------------------------------
# The Windows trust store reports the (valid) Odoo cert as expired, so local runs need an
# unverified default context; the HF Space and the container are unaffected. This used to run
# unconditionally at import β€” i.e. the shipped container disabled TLS verification for every
# outbound HTTPS call it ever made, to Odoo and to everything else, forever. That is a
# man-in-the-middle away from being someone else's data. It is now opt-in, must be set
# deliberately, and is never on by default.
# β›” NEVER set AIOS_INSECURE_SSL in a deployed environment. It exists for one developer laptop.
if os.environ.get("AIOS_INSECURE_SSL") == "1":
    import ssl
    ssl._create_default_https_context = ssl._create_unverified_context   # noqa: S323

# --- reuse the tenant #0 data layer verbatim. RI_DIR overrides the location in the container
# (where the layout differs from the local sibling-dir default). ---
_HERE = Path(__file__).resolve()
_RI = Path(os.environ.get("RI_DIR") or (_HERE.parents[2] / "platform"))
for _p in (str(_RI), str(_HERE.parent)):
    if _p not in sys.path:
        sys.path.insert(0, _p)

from dotenv import load_dotenv  # noqa: E402
load_dotenv(_RI / ".env")       # Odoo creds + APP_PASSWORD, git-ignored, never committed/printed

from fastapi import Body, Depends, FastAPI, Request  # noqa: E402
from fastapi.middleware.gzip import GZipMiddleware  # noqa: E402
from fastapi.responses import JSONResponse  # noqa: E402
from fastapi.staticfiles import StaticFiles  # noqa: E402
from starlette.exceptions import HTTPException as StarletteHTTPException  # noqa: E402

import aios_session  # noqa: E402
import routes_admin  # noqa: E402
import routes_alerts  # noqa: E402  (wave 20 item 25 β€” the Alerts inbox)
import routes_assets  # noqa: E402  (wave 18 C2-ASSET β€” catalog product imagery)
import routes_auth  # noqa: E402
import routes_automation  # noqa: E402  (wave 18 C4-AUTO β€” SESSION D's router, mounted by A)
import routes_changes  # noqa: E402  (wave 29 item 20 / C6 β€” F's change token, mounted by A)
import routes_customers  # noqa: E402
import routes_grid  # noqa: E402
import routes_keychain  # noqa: E402  (wave 18 C7 β€” keychain + connectors admin surfaces)
import routes_statements  # noqa: E402  (EXIT-6 β€” the statement sender, off Streamlit)
import routes_nav  # noqa: E402
import routes_pages  # noqa: E402
import routes_platform_admin  # noqa: E402  (wave 19 R3/R4 β€” the Loopable cross-tenant plane)
import routes_products  # noqa: E402  (wave 15 C-TOPIC β€” gated, not yet in the nav)
import routes_records  # noqa: E402
import routes_shares  # noqa: E402  (wave 20 R10 β€” grants for views, folders and databases)
import routes_uploads  # noqa: E402  (wave 21 C5 β€” tabular preview for Select-from-file)
import routes_tables  # noqa: E402  (wave 18 C3-UT β€” user-created databases over the wire)
import routes_connectors  # noqa: E402  (wave 23 C11 β€” the connectors directory; SESSION A's router)
import routes_forms  # noqa: E402  (wave 23 C9 β€” the PUBLIC form door; SESSION D's router)
import routes_templates  # noqa: E402  (wave 23 C12 β€” template apply doors; SESSION E's router)
import routes_odoo_tables  # noqa: E402  (wave 27 item 17 β€” Odoo relational; SESSION E's router)
import routes_connected_tables  # noqa: E402  (wave 31 T49/C4 β€” the source-neutral grid door; D's)
import routes_web_agent  # noqa: E402  (wave 31 R10/C5 β€” the web-browsing agent; E's router, A's line)
import routes_query  # noqa: E402  (wave 32 R1/C5 β€” the Query module; E's router, A's line)
from core import grid_events  # noqa: E402
from deps import Session, module_gate  # noqa: E402

_WEB_DIST = _HERE.parents[1] / "web" / "dist"

# --- THE CANONICAL FIELD CONTRACT: a startup assertion, and the referee gate's subject ----------
# Every field tags its semantic TYPE and its SOURCE. `source='odoo'` is READ-ONLY (Odoo is never
# written); `source='overlay'` is the editable stratum that lives OUTSIDE Odoo. Loaded from the
# ONE canonical file shared with the embedded host (`platform/aios_grid_fields.json`), so
# embed == standalone by construction.
#
# TWO REASONS THIS IS HERE and not folded into the routes:
#   1. It FAILS LOUDLY at import when the file is missing β€” which means the deploy or the RI_DIR
#      layout is broken, and discovering that from a 500 on the first customer request instead of
#      at startup costs a debugging session. (The pre-wave main.py had this guard; the EXIT-2a
#      rewrite dropped it and this restores it.)
#   2. `aios-web/verify_fields_contract.py` β€” the cross-side REFEREE β€” reads `FIELDS` and
#      `_PASSTHROUGH_KEYS` from this module to prove the canonical file, `aios_grid.py` and this
#      API have not drifted. The rewrite removed them and the referee went red; retargeting the
#      gate would have been the wrong repair ([[gate-can-report-green-on-nothing]]: retarget, do
#      not delete β€” but only when the subject genuinely moved. Here it should not have moved).
#
# ⚠ THE ROUTES SERVE A SUPERSET OF THIS. `routes_customers._payload` derives its field list from
# `aios_grid.fields_from_workspace(ws)`, which is `FIELDS` PLUS the session user's own custom_ and
# measure_ columns β€” the same list the Streamlit host renders. That is the point: the standalone
# shell now sees the user's own columns instead of the bare base contract. `FIELDS` is the
# canonical FLOOR, asserted below to be exactly what `aios_grid` starts from.
import json as _json_contract  # noqa: E402
_FIELDS_PATH = _RI / "aios_grid_fields.json"
if not _FIELDS_PATH.is_file():
    raise FileNotFoundError(
        f"AIOS web API: canonical field contract missing at {_FIELDS_PATH}. Set RI_DIR to the "
        "platform root (it also carries the data layer this API imports)."
    )
_contract = _json_contract.loads(_FIELDS_PATH.read_text(encoding="utf-8"))
FIELDS = _contract["fields"] if isinstance(_contract, dict) else _contract
# text/status/select/date pass through untouched; every OTHER odoo field is numeric -> rounded.
# Derived from field TYPE (not a hand-kept key list) so a new text/date field can never be
# wrongly rounded. `select` joined 2026-08-02 (dba) β€” a choice label rounded would be garbage.
_PASSTHROUGH_KEYS = {f["key"] for f in FIELDS
                     if f["source"] == "odoo" and f["type"] in ("text", "status", "select",
                                                                "date")}

app = FastAPI(title="AIOS web API")

# The customers payload measured 1.15 MB of JSON on the live Space, shipped UNCOMPRESSED β€” with
# the 754 KB bundle behind it, most of "the app is slow" was bytes on the wire. gzip takes the
# payload to ~10–15% of that. minimum_size spares the tiny acks the overhead.
app.add_middleware(GZipMiddleware, minimum_size=1024)


@app.exception_handler(StarletteHTTPException)
def _error_shape(request: Request, exc: StarletteHTTPException):
    """ONE error shape for every non-2xx (X2): `{"error": {"code", "message"}}`.

    `deps.err` already raises detail in that shape; anything FastAPI raises on its own (a 404, a
    422 from a malformed path param) is wrapped here so a client never has to branch on two
    different error bodies.
    """
    detail = exc.detail
    if isinstance(detail, dict) and "error" in detail:
        body = detail
    else:
        body = {"error": {"code": f"http_{exc.status_code}", "message": str(detail)}}
    return JSONResponse(body, status_code=exc.status_code,
                        headers=getattr(exc, "headers", None))


@app.exception_handler(grid_events.StoreUnavailable)
def _store_unavailable(request: Request, exc: grid_events.StoreUnavailable):
    """THE SAFETY NET for "the store is down" β†’ 503, from anywhere.

    ⚠ WHY THIS IS APP-LEVEL AND NOT A `try` PER ROUTE. It was a try per route first, and a
    `StoreUnavailable` raised while BUILDING THE PAYLOAD β€” before the route reached its own
    try/except β€” surfaced as a 500. A store outage is a normal operational state and every route
    here touches the store at least twice (the workspace read, then the write), so "remember to
    wrap it" is a rule that gets forgotten once and then reports the wrong thing. One handler
    means a store outage can only ever be a 503, whichever call raised it.

    The seam raises this only when the caller passed no `fallback_ws` β€” i.e. exactly on this
    adapter, which has no durable session dict to degrade into. A 200 over a write that
    evaporated is the failure the whole rule exists to prevent.
    """
    return JSONResponse(
        {"error": {"code": "store_unavailable",
                   "message": "the tenant store is unavailable β€” no change was saved"}},
        status_code=503)


@app.get("/api/health")
def health():
    """Unauthenticated LIVENESS only β€” it must answer before anyone can sign in, so it may not
    reveal anything about the deployment beyond "the process is up". No version, no tenant list,
    no config: a health endpoint is the one URL every scanner finds first.

    β›” THE VERSION DOES NOT GO HERE, and it was asked to (2026-08-04, when LIVE became a pinned
    release and "what is LIVE running" needed an answer). A build identifier tells an unauthenticated
    caller exactly which commit's known issues apply. It rides `GET /api/v1/settings` instead, behind
    a session β€” and the authoritative copy is the `VERSION` file in the Space repo, which
    `deploy_web.space_version()` reads without needing the app to be up at all."""
    return {"ok": True}


app.include_router(routes_auth.router)
app.include_router(routes_nav.router)
app.include_router(routes_customers.router)
app.include_router(routes_products.router)
app.include_router(routes_assets.router)
app.include_router(routes_tables.router)
app.include_router(routes_grid.router)
app.include_router(routes_records.router)
# EXIT wave 2: the Y1 page-data envelope (one route for every ported dashboard) and Y4's user
# administration. `routes_pages` imports `pages`, which lazily imports each `pages_*` builder β€” so
# a new page is a builder module plus one registry line, and nothing here changes.
app.include_router(routes_pages.router)
app.include_router(routes_admin.router)
app.include_router(routes_automation.router)
app.include_router(routes_keychain.router)
app.include_router(routes_statements.router)
# Wave 19 (owner item 13, R3): the LOOPABLE admin plane β€” the platform's own cross-tenant view.
# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
# platform-operator route ever shadowing each other. Every path it declares is gated by
# `core.platform_admin.is_platform_admin`, and `verify_api` enumerates this router to prove it.
app.include_router(routes_platform_admin.router)
# Wave 20 (owner items 25 + 18/23/26): the Alerts inbox and the manage-access surface. Both are
# session-gated rather than admin-gated β€” an alert is a person's own subscription, and sharing is
# something every user does with their own views/folders/databases.
app.include_router(routes_alerts.router)
app.include_router(routes_shares.router)
app.include_router(routes_uploads.router)
# ⭐ WAVE 23 β€” THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this
# file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by:
# a router included AFTER it answers 404 forever while importing fine, type-checking fine and
# passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause.
#
# β›” ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit β€” three finished
# features that would have shipped dead. The workers each posted a mount ask and said they would
# signal "ready" first; C waited for a signal that never came while the files landed anyway. **The
# lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the
# control is that the enumeration must NAME every router in this file.**
app.include_router(routes_templates.router)   # item 7 / C12 β€” template registry, session-gated
app.include_router(routes_connectors.router)  # item 10 / C11 β€” the connectors directory
# ⚠ routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its
# two paths are DELIBERATELY unauthenticated β€” a form is filled in by someone with no account β€” so
# it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token
# by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot
# distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug.
app.include_router(routes_forms.router)       # item 8 / C9 β€” the PUBLIC form door
# WAVE 27 item 17 β€” E's Odoo relational doors. Mounted in the SAME change that E's module landed,
# because the wave-23 scar is exactly this: three finished routers shipped with no include_router
# line β€” complete, gated, type-clean and 404 for every caller. `verify_api.section_mounts` walks
# `main.app.routes` and pins these two paths by NAME, so an unmounted router now goes RED.
app.include_router(routes_odoo_tables.router)
# ⭐⭐ WAVE 31 Β· T49 / C4 β€” THE SOURCE-NEUTRAL DOOR TO THE SAME CAPABILITY.
# `/api/v1/connected-tables/{key}/rows` is an ALIAS: every request lands in
# `routes_odoo_tables.odoo_table_rows`, so "the Odoo path behaves byte-identically" holds by
# CONSTRUCTION rather than by two implementations that agree on the day they were written. R2 puts
# Meta Ads on the same mirror, and a Meta campaign served from a URL with `odoo` in it is a name
# that lies to every network tab, log line and bug report.
# ⚠ MOUNTED IN THE SAME CHANGE AS THE MODULE, per the line above and for the same wave-23 scar.
app.include_router(routes_connected_tables.router)
# ⭐⭐ WAVE 29, item 20 / R11 / contract C6 β€” F's CHANGE TOKEN. `GET /api/v1/changes?scope=<scope>`
# answers "did this bucket change" for ~zero cost (an in-memory counter; ZERO `store.get()` deep
# copies), which is what lets a filtered view pick up a row created in another tab, by an automation
# or by a connector sync without re-downloading the world.
# β›” THIS LINE IS THE ARTIFACT THIS PROTOCOL LOSES MOST RELIABLY, AND IT WAS ALREADY LOST ONCE HERE:
# F's router and the CLIENT half both shipped complete, so the poller was calling `/api/v1/changes`
# six times a minute and taking a 404 while every one of F's own gates was green. `verify_api`'s D-48
# leg caught it (`unmatched: [('/api/v1/changes', 'apiBridge.ts')]`) β€” a client fetch path with no
# mounted route β€” which is precisely the control the wave-23 scar above was written to install.
# ⚠ Mounted is NOT callable: `section_changes_callable` in `verify_api.py` SIGNS IN and CALLS this
# route, because a route can be mounted and still raise before its own `try:` (D-107's plain-text 500).
app.include_router(routes_changes.router)     # item 20 / C6 β€” F's router, A's line
# β›” WAVE 31 (R10 / C5) β€” E's router, taken by the INTEGRATOR under W31-T08's own done-when
# ("no router ships unmounted") because `main.py` is D's fence and D's queue did not reach it.
# It was written, gated and 404-dead: `verify_web_agent.py` asserts THIS LINE and was red at
# 60/61 for it. Four waves of the same defect β€” three routers in wave 23, four features in
# wave 29 β€” is why the assertion exists and why the line is not left for later.
app.include_router(routes_web_agent.router)   # R10 / C5 β€” E's router, A's line
# ⭐⭐ WAVE 32 (R1 / C5, cross-fence wiring 6) β€” THE QUERY MODULE. E's router, A's line, and the
# FIFTH consecutive wave in which this exact line is the artifact the protocol nearly loses.
# β›” MOUNTED HERE, IN THIS BLOCK, AND NOT AT THE END OF THE FILE β€” measured by SESSION E in its own
# gate before it front-inserted: `include_router` APPENDS, and `app.mount("/", _AppStatic(...),
# html=True)` swallows everything reached after it, so a router added below that mount answers
# **405 on POST and 404 on GET** while every one of its own tests passes. The comment at :213 states
# the rule; E's measurement is what turns it from advice into a number.
# ⚠ `verify_api` asserts `/api/v1/query` in `app.openapi()["paths"]` β€” NEVER `{r.path for r in
# app.routes}`, which finds nothing in this app because FastAPI wraps included routers (W31).
app.include_router(routes_query.router)       # R1 / C5 β€” E's router, A's line


# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
# They are the v1 handlers with the v1 session requirement β€” NOT the old unauthenticated Basic
# behavior. An alias that kept the old auth would be a bypass of everything above it.
_GATE = module_gate(routes_customers.MODULE)


@app.get("/api/customers", deprecated=True)
def _customers_alias(session: Session = Depends(_GATE)):
    return routes_customers._payload(session)


@app.patch("/api/customers/{pid}", deprecated=True)
def _patch_alias(pid: int, body: dict = Body(default=None),
                 session: Session = Depends(_GATE)):
    return routes_customers.patch_customer(pid, body, session)


def _assert_contract_floor():
    """The canonical FIELDS must be exactly what `aios_grid` starts an empty workspace from.

    This is what stops `FIELDS` becoming a constant that exists only to satisfy a gate. If the
    canonical JSON and `aios_grid.fields_from_workspace({})` ever disagree, the standalone API and
    the embedded host are serving two different schemas and the grid's own contract has forked β€”
    the exact drift `verify_fields_contract.py` was written to catch, now also caught at startup
    on whatever machine is actually running.
    """
    import aios_grid
    base = [f for f in aios_grid.fields_from_workspace({}) if not f.get("custom")]
    if [f["key"] for f in base] != [f["key"] for f in FIELDS]:
        raise RuntimeError(
            "AIOS web API: the canonical field contract and aios_grid.fields_from_workspace({}) "
            "disagree on the base field set β€” embed and standalone would serve different schemas. "
            "Run aios-web/verify_fields_contract.py.")


def _startup_notes():
    """Say the two things an operator must know, once, at import β€” never in a response body."""
    if aios_session.EPHEMERAL_SECRET:
        print("[aios-api] AIOS_SESSION_SECRET is not set β€” signing with a random per-process "
              "key. Sessions will not survive a restart and will not work across workers. "
              "Set it in production.")
    if os.environ.get("AIOS_INSECURE_SSL") == "1":
        print("[aios-api] AIOS_INSECURE_SSL=1 β€” TLS verification is DISABLED for outbound "
              "requests. Local development only; never in a deployed environment.")


_assert_contract_floor()
_startup_notes()


# β›” THE STATIC MOUNT IS NOW UNAUTHENTICATED, and that is a change this wave made on purpose.
# Before EXIT-3a, `BasicAuth` middleware gated the WHOLE app including this mount. A branded login
# page cannot live behind a password prompt, so the shell's own assets must be public β€” which they
# are: `index.html`, the JS/CSS bundle and the favicon reveal nothing.
#
# ⚠ WHAT THAT SILENTLY DE-GATED, caught in review rather than in production. `web/dist/` also
# carries `sample_customers.json`, a DEV FIXTURE copied from `web/public/`. Its 8 customers are
# synthetic, but the `agent` column holds REAL EMPLOYEE NAMES, and it went from Basic-gated to
# publicly fetchable in this commit. Nothing needs it: both the API bridge and `useCustomerData`
# deleted their sample fallback on purpose ("NOTHING HERE FALLS BACK TO sample_customers.json"),
# and it survives only because it sits in `web/public/`. So it is refused here β€” 404, the same
# answer as any other path that is not part of the app.
#
# The right long-term fix is deleting it from `web/public/` (S2's lane β€” flagged in the mailbox);
# this guard is what makes the API safe regardless of what the bundle happens to contain.
_DEV_FIXTURES = {"sample_customers.json"}


class _AppStatic(StaticFiles):
    async def get_response(self, path, scope):
        if Path(path).name in _DEV_FIXTURES:
            raise StarletteHTTPException(status_code=404, detail="Not Found")
        resp = await super().get_response(path, scope)
        # Vite content-hashes everything under assets/ (a change is a NEW url), so those are
        # immutable β€” a repeat visit re-downloads zero bytes instead of the whole 750 KB bundle.
        # index.html must stay revalidated or a deploy would strand returning browsers on the old
        # bundle; ETag/304 makes that revalidation a header exchange, not a transfer.
        # ⚠ Normalised first: on a Windows host StaticFiles hands this path with backslashes,
        # and `startswith("assets/")` silently skipped every asset (measured on the local probe).
        if path.replace("\\", "/").lstrip("/").startswith("assets/"):
            resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
        else:
            resp.headers["Cache-Control"] = "no-cache"
        return resp


# static bundle LAST so /api/* wins; html=True serves index.html at / plus the built assets
if _WEB_DIST.is_dir():
    app.mount("/", _AppStatic(directory=str(_WEB_DIST), html=True), name="web")


# --- boot prewarm (AIOS_PREWARM=1 β€” set by the Dockerfile, never by tests) -----------------------
# Without this, the first visitor after every deploy/restart pays the full Odoo pool build in
# their request. The thread warms the CONSOLIDATED scope (None, None) + every registered page's
# default envelope; scoped users still pay their own scope's first build, once.
# Env-gated rather than a startup event so importing `api.main` in a gate (verify_api and friends
# run against fakes) can never fire a live Odoo pull.
def _seed_and_sync_store():
    """Bring the analytical store (harness.datastore) LIVE for this container β€” the app.py
    bootstrap, mirrored (2026-07-31, owner item 1).

    `harness.datastore` powers every measure column/condition, and ONLY app.py used to call
    `ensure_seed()` β€” so on a fresh Space disk this container resolved measures against a store
    that never existed and every measure cell served blank. Seeding alone was NOT enough either,
    and that was measured live the same day: "datastore seeded in 1.7s" followed by an endless
    `api:measure-column: ModelError the data cache is still warming up` β€” `ready()` demands
    EVERY entity at phase 'live', and a seed that predates a newer entity leaves it un-synced
    forever in a process with no sync loop. The SYNC SPRINT after the seed is what closes the
    write_date gap and backfills anything the seed lacks (app.py:7779's exact pattern, bounded
    passes). Fail-quiet throughout: no seed/token/Odoo β†’ the columns stay blank, the rows still
    serve.
    """
    import time as _t
    t0 = _t.time()
    try:
        from harness import datastore as _ds
        if _ds.ensure_seed():
            print(f"[aios-api] datastore seeded in {_t.time() - t0:.1f}s")
        # DEDICATED Odoo connection for this thread (the W6 postmortem rule): the sync's
        # search_reads must never interleave on the shared client's xmlrpc transport.
        import core.odoo as _odoo
        try:
            _odoo._tlocal.client = _odoo.OdooClient()
        except Exception:
            pass
        res = {}
        for i in range(12):
            res = _ds.sync_all(log=lambda *a, **k: None)
            print(f"[aios-api] datastore sync pass {i + 1}: "
                  + ", ".join(f"{k}={v.get('phase')}" for k, v in sorted(res.items())))
            if all(v.get("phase") == "live" for v in res.values()):
                break
        # ⭐ Wave 21 (item 2, "make sure the metrics are correct"): a cursor sync can never see
        # a HARD DELETE, and the downloaded seed carries whatever was deleted since it was cut β€”
        # one reconcile pass at boot removes both classes of phantom row before the first
        # measure is served. MEASURED 2026-08-05: five deleted sale_order_line rows = $284.25 of
        # phantom YTD revenue, stable across re-syncs, zero the moment reconcile ran.
        try:
            _ds.reconcile_deletes(log=lambda *a, **k: None)
        except Exception:
            pass
        print(f"[aios-api] datastore sync done in {_t.time() - t0:.1f}s "
              f"(ready={_ds.ready()})")
        # ⭐⭐ 2026-08-09 (wave 28, D-107) β€” THE RELATIONAL REBUILD RUNS AT BOOT, HERE.
        #
        # β›” IT DID NOT BEFORE, AND NOTHING SAID SO. The rebuild lived only inside
        # `_store_resync_loop`, whose very first statement is `sleep(1800)` β€” so the earliest a
        # freshly booted container could spawn the four locked databases was T+30 MINUTES. The
        # symptom was read as "the boot path is silent": `/odoo-tables/status` polled every 30 s
        # across a ~20-minute window over two boots returned the pre-wave schema on all 20
        # samples. It was not silent, it had not been asked yet. Both halves of D-107 were like
        # this β€” a thing that never ran, mistaken for a thing that ran and failed.
        #
        # ⚠ WHY HERE AND NOT IN `_prewarm`: the tables are DERIVED FROM THE MIRROR, and this is
        # the exact line where the mirror has finished advancing β€” seed, up to twelve sync passes,
        # then the delete reconcile. Calling it from the other thread would race the seed and hit
        # either `ro_con()`'s "still warming" RuntimeError or, worse, a HALF-SYNCED mirror, which
        # is D-107's own hypothesis 3: a partial population trips `MAX_SHRINK` and the rebuild
        # refuses β€” correctly, but for a reason that reads like a data loss scare.
        # ⚠ Same thread on purpose: it is already a daemon and nothing serves requests behind it.
        _pull_meta("boot")
        _rebuild_odoo_relational("boot")
        # ⭐⭐ W32-T07 β€” owner items 13 and 15, DELIVERED. See `_sweep_automation_schemas`.
        # ⚠ AFTER the two above and not before: those advance the mirror and can take minutes, and
        # this sweep is unrelated to it β€” putting it last means a slow Odoo sync cannot delay the
        # one thing on this path that fixes a grid the owner has asked about twice.
        _sweep_automation_schemas("boot")
    except Exception as e:                                    # noqa: BLE001
        print(f"[aios-api] datastore seed/sync skipped: {e}")


def _pull_meta(why):
    """Pull Meta Ads into THIS container's mirror, before the relational rebuild reads it.

    β›” WHY IT HAS TO HAPPEN HERE AND NOT ON A LAPTOP. The mirror is a FILE that lives beside the
    process; the Space's copy is seeded from the HF dataset and knows nothing about a DuckDB on a
    developer's box. Running `meta_store --sync` locally populates the local mirror and the LIVE
    product stays empty β€” which is the whole difference between "the loader works" and "the
    product has the data". Odoo is already arranged this way (`sync_all` runs in the container);
    this is the same arrangement for the second connector.

    ⚠ FAIL-QUIET AND SILENT WHEN THERE IS NOTHING TO DO. No token => no Meta => one line, no
    error: a tenant that has not connected Meta is a normal state, and this runs on every boot.
    ⚠ The window is deliberately SHORT here (`META_INSIGHTS_DAYS`, default 7 at boot) because boot
    is not the place for a 90-day backfill β€” the resync pass widens it.
    """
    try:
        from harness import meta_store as _meta
        if not _meta.token():
            print(f"[aios-api] meta sync skipped ({why}): no META_ADS_ACCESS_TOKEN in this "
                  f"deployment - the connector is idle, not broken")
            return
        # β›” PASSED, NOT SET IN THE ENVIRONMENT. `meta_store.INSIGHTS_DAYS` binds at
        # IMPORT, so an `os.environ.setdefault` here executed after the module was
        # already loaded and changed NOTHING: every boot pulled 90 days instead of 7,
        # which is the slow path that trips the per-ad-account rate limit and never
        # finishes. A knob read at import cannot be turned by a caller at runtime.
        rep = _meta.sync("royal-imports", log=lambda *_a: None, insights_days=7)
        for p in rep.get("problems") or []:
            print(f"[aios-api] meta sync PROBLEM ({why}): {p}")
        print(f"[aios-api] meta sync done ({why}): "
              + ", ".join(f"{k}={v['in_mirror']}" for k, v in sorted(rep["tables"].items())))
    except Exception as e:                                    # noqa: BLE001
        print(f"[aios-api] meta sync FAILED ({why}): {type(e).__name__}: {e}")


def _rebuild_meta_relational(why, rt):
    """Spawn/refresh the `ut_meta_*` locked databases off the SAME mirror the Odoo half just used.

    ⭐ CALLED FROM INSIDE `_rebuild_odoo_relational`, ON PURPOSE, and the reason is D-29 rather than
    tidiness: `harness/datastore` is a ONE-FILE-AT-A-TIME process global, and that caller has just
    established which tenant's file this process holds open. Spawning Meta here inherits that
    binding instead of rebinding it under live readers β€” which is the documented way to serve one
    tenant's rows to another with nothing raised.

    ⚠ SILENT WHEN THERE IS NOTHING TO DO. A tenant that never connected Meta has no `meta_*` tables
    in its mirror; `refresh` returns `{}` and says so once. That is a normal state, not a failure,
    and it must not print an error every 30 minutes for every tenant that does not use Meta.
    β›” Its own try/except for the reason the two passes above have theirs: a Meta refusal must not
    cancel an Odoo rebuild that already succeeded.
    """
    try:
        import meta_relational as _meta
        counts = _meta.refresh(rt, why)
        if counts:
            print(f"[aios-api] meta relational rebuild done ({why}): "
                  + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
            try:
                import automation_engine as _engine
                _engine.refresh_relations(rt, log=lambda *_a: None)
            except Exception as e:                            # noqa: BLE001
                print(f"[aios-api] meta relation cells failed ({why}): {type(e).__name__}: {e}")
    except Exception as e:                                    # noqa: BLE001
        print(f"[aios-api] meta relational rebuild FAILED ({why}): {type(e).__name__}: {e}")


def _sweep_automation_schemas(why):
    """β›”β›” WAVE 32 Β· `W32-T07` β€” MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE THE TABLES.

    Owner items 13 and 15 β€” the TikTok comments lock, and the comment CONTENT column he says he has
    asked for *"many times"*. **Both were already correct in the source.** `TT_COMMENT_FIELDS`
    carries `field_def("text", "Comment")` and `TT_LOCKED_TABLES` already contains the comments
    table, both since wave 31, with his words quoted in the comment beside them. So this ticket is
    not a schema change and there is nothing to design: it is DELIVERY.

    β›” THE MECHANISM, WHICH IS THE WHOLE OF IT. `ut_ensure` MERGES fields into an existing table and
    stamps `recordMode` β€” but only **when something calls it**, and the only callers are automation
    runs. A tenant whose `ut_tt_comments` was spawned before the declaration changed keeps the old
    shape until an automation happens to run against it. Nothing sweeps existing tenants. That is
    [[a-migration-that-runs-on-the-next-write]], and it is this wave's stated thesis: a declaration
    that never reaches a tenant is indistinguishable from one that was never written.

    β›”β›” AND IT MUST RUN **IN THE CONTAINER**, WHICH IS WHY THIS IS IN `main.py` AND NOT A SCRIPT.
    D-195, measured three times: a developer's CLI write to the tenant store is reverted by the
    running Space within a minute (download-modify-upload, last-write-wins) β€” and **the write
    reports success every time**, then a FRESH read confirms it, and it is gone by the next poll.
    A connector's tables must be spawned BY THE CONTAINER; a CLI spawn is a dry run that lies.

    ⚠ EVERY TENANT, unlike `_rebuild_odoo_relational` below β€” and the asymmetry is deliberate
    rather than an oversight. That function is scoped to royal because it derives from the DuckDB
    mirror, and `harness/datastore` is a ONE-FILE-AT-A-TIME process global (D-29): rebinding it per
    tenant in a daemon thread can serve one tenant's rows to another with nothing raised. This
    sweep touches only `user_tables` through each tenant's own `rt`, which has no such global β€” so
    the hazard that scopes that one does not exist here, and TikTok automations run in tenants
    other than #0.

    ⚠ CHEAP ON A CORRECT TENANT: `ut_ensure` short-circuits when nothing changed, so this is a read
    per child table on a tenant that is already right, and the whole delivery on one that is not.
    ⚠ ONE TENANT'S FAILURE MUST NOT STOP THE NEXT. Each is wrapped: a tenant whose store is
    unreachable at boot is reported and skipped, never allowed to abort the sweep for everyone.
    """
    try:
        import automation_engine as _eng
        from harness import runtime as _runtime
    except Exception as e:                                            # noqa: BLE001
        print(f"[aios-api] schema sweep ({why}) SKIPPED β€” import failed: {e}")
        return
    try:
        schemas = _eng.platform_schemas()
    except Exception as e:                                            # noqa: BLE001
        print(f"[aios-api] schema sweep ({why}) SKIPPED β€” no declarations: {e}")
        return
    tenants = []
    try:
        tenants = _runtime.known_tenants()
    except Exception as e:                                            # noqa: BLE001
        print(f"[aios-api] schema sweep ({why}) SKIPPED β€” tenant list unreadable: {e}")
        return
    for slug in tenants:
        try:
            rt = _runtime.get_runtime(slug)
            # β›”β›” FIXED 2026-08-13 β€” THIS SWEEP WAS SPAWNING EIGHT DATABASES IN EVERY TENANT.
            # Owner: *"Database for Royal Imports, why we have fucking IG and TIktok databases."*
            # `ut_ensure`'s first line is *"Create the table if it is missing"*, and this loop fed
            # it every child of every platform schema for every tenant β€” so tenant #0, a floral and
            # giftware importer with ZERO automations, woke up on 2026-08-13 at 12:05 UTC owning
            # `ut_ig_posts`, `ut_ig_comments`, `ut_ig_snapshots`, `ut_ig_post_snapshots` and the
            # four TikTok twins, all empty, all in his nav flyout. **The bug is one word wide:**
            # this function's own title says *"MAKE D'S DECLARATIONS REACH TENANTS THAT ALREADY HAVE
            # THE TABLES"* and its body called a CREATE-OR-MERGE function to do a MERGE-ONLY job.
            # [[reuse-and-delete-are-hypotheses]] β€” `ut_ensure` was the right function for the
            # merge and brought a second behaviour nobody wanted with it.
            #
            # ⭐ THE PREDICATE IS "DOES THIS TENANT ALREADY HAVE THE TABLE", read ONCE per tenant
            # rather than per child β€” `rt.get` deep-copies the whole tenant document (28.6 MB on
            # tenant #0), so asking eight times is eight copies to answer one question.
            # ⚠ AND IT MUST NOT WEAKEN THE DELIVERY: a tenant that HAS `ut_tt_comments` still gets
            # the `text` column and the `recordMode` stamp, which is the entire point of T07. The
            # sweep now delivers to tables that exist and mints none, which is what it always
            # claimed to do.
            have = set(rt.get("user_tables") or {})
            ensured, skipped = [], []
            for s in schemas:
                for key, child in (s.get("children") or {}).items():
                    if key not in have:
                        skipped.append(key)
                        continue
                    got = _eng.ut_ensure(rt, child["label"], child["fields"], "automation",
                                         key=key, lock_fields=True,
                                         record_mode=child["record_mode"])
                    if got:
                        ensured.append(got)
            if skipped:
                # ⭐ SAID OUT LOUD, never silently skipped β€” this repo's "no silent caps" rule. A
                # sweep that quietly does nothing looks identical to a sweep that is not running,
                # which is how the previous behaviour survived review in the first place.
                print(f"[aios-api] schema sweep ({why}) {slug}: {len(skipped)} child table(s) not "
                      f"present in this tenant, so nothing was created for them "
                      f"({', '.join(sorted(skipped))}) β€” they are spawned by an automation that "
                      f"needs them, never by this sweep")
            # ⭐ THE RETRACTION (D's `retract_foreign_presets`, D-152), AFTER the children loop.
            # β›” THE SWEEP ABOVE MAKES COLUMNS **ARRIVE** AND CANNOT MAKE STALE ONES **LEAVE**, and
            # T07's `done-when` asserts both ("no `ut_tt_*` grid carries an Instagram column"). On a
            # tenant that ran TikTok before W30-T08 the 26 machine-authored IG columns are still
            # there β€” the detector was fixed, the damage never was.
            # ⚠ `kept` IS THE HONEST HALF: a foreign column that HOLDS DATA is REPORTED, never
            # deleted. If it is non-empty, the screenshot shows a column and the report is the
            # answer (W30/R6's second sentence).
            st = {}
            try:
                st = _eng.retract_foreign_presets(rt, log=lambda *a, **k: None) or {}
            except Exception as e:                                    # noqa: BLE001
                print(f"[aios-api] schema sweep ({why}) {slug}: retraction FAILED: {e}")
            print(f"[aios-api] schema sweep ({why}) {slug}: ensured={len(ensured)} "
                  f"tables={st.get('tables', 0)} columns={st.get('columns', 0)} "
                  f"cells={st.get('cells', 0)} flags={st.get('flags', 0)} "
                  f"kept={st.get('kept') or []}")
        except Exception as e:                                        # noqa: BLE001
            print(f"[aios-api] schema sweep ({why}) {slug}: FAILED: {e}")
    # ⭐ A SUCCESS MARKER, for `_rebuild_odoo_relational`'s stated reason: D-107 was chased for a
    # day on ABSENT log markers, which cannot tell "it ran and was fine" from "it was never
    # reached". Three failure markers and no success marker makes silence ambiguous.
    print(f"[aios-api] schema sweep ({why}) done over {len(tenants)} tenant(s)")


def _rebuild_odoo_relational(why):
    """Spawn/refresh the four locked Odoo databases, then compute their cells. `why` is 'boot' or
    'resync' and rides every log line, because "it failed" and "it failed at boot, before anyone
    could have asked" are different diagnoses.

    ⭐ THE SUCCESS LINE IS NOT DECORATION β€” it is the control this path lacked. D-107 was chased
    for a day on the strength of *absent* log markers, which cannot distinguish "the rebuild ran
    and was fine" from "the rebuild was never reached". Three failure markers and no success
    marker means silence is ambiguous; now it is not.

    ⚠ SCOPED TO ROYAL-IMPORTS, deliberately, and it is NOT the D-29 shortcut it resembles. The
    caller has just advanced whichever DuckDB file this process holds open β€” tenant #0's β€” and
    royal is the only tenant with an Odoo mirror to derive from (R1). Iterating tenants here walks
    straight into D-29's documented hazard: `harness/datastore` is a ONE-FILE-AT-A-TIME
    process-global, and rebinding it in a daemon thread under live readers can serve one tenant's
    rows to another with nothing raised. `is_royal` stays the authority on which slugs qualify.
    """
    try:
        import odoo_relational as _rel
        from harness import runtime as _runtime
        if not _rel.is_royal("royal-imports"):
            return
        _rt = _runtime.get_runtime("royal-imports")
        counts = _rel.refresh(_rt, "royal-imports")
        # ⭐⭐ AND THEN COMPUTE THE CELLS, which `refresh` does NOT do.
        #
        # β›” THE FAILURE THIS CLOSES IS THE WORST-LOOKING KIND. `refresh` writes rows and field
        # DEFINITIONS; every Link and Rollup cell comes from a separate pass. Those passes used to
        # live only on the automation `tick`, which fires from an EXTERNAL EventBridge cron β€” so a
        # fresh boot landed 71,954 rows with eleven fully-configured relational columns and every
        # one of them BLANK until an unrelated scheduler happened to run. Nothing errors; the
        # tables simply look finished and answer nothing.
        #
        # ⚠ `refresh_relations`, NOT `compute_relation_cells`. The latter computes a change count
        # over a blob it was handed and PERSISTS NOTHING; the former walks the tenant and writes.
        # Calling the inner one here would return a plausible number and change no cell.
        #
        # ⚠ ONE try/except PER PASS, for the reason `tick` states at its own copies: a source
        # rollup REFUSES loudly on a truncated group set, and a shared block would let that honest
        # refusal silently cancel a relational pass that had already succeeded.
        try:
            import automation_engine as _engine
            _engine.refresh_relations(_rt, log=lambda *_a: None)
        except Exception as e:                                # noqa: BLE001
            print(f"[aios-api] odoo relation cells failed ({why}): {type(e).__name__}: {e}")
        try:
            import rollup_sql as _rollup
            for _b, _key, _l, _f in _rel.TABLES:
                _rollup.compute(_rt, _key)
        except Exception as e:                                # noqa: BLE001
            print(f"[aios-api] odoo source rollups failed ({why}): {type(e).__name__}: {e}")
        print(f"[aios-api] odoo relational rebuild done ({why}): "
              + ", ".join(f"{k}={v}" for k, v in sorted((counts or {}).items())))
        _rebuild_meta_relational(why, _rt)
    except Exception as e:                                    # noqa: BLE001
        # ⚠ The TYPE is named here as it is in the two inner handlers. The original printed only
        # `{e}`, and a bare message is exactly what made D-107 unreadable from the outside: a
        # `BinderException` about a missing column is a different action from a timeout or an auth
        # failure, and the text alone often does not say which it was.
        print(f"[aios-api] odoo relational rebuild failed ({why}): {type(e).__name__}: {e}")


def _store_resync_loop():
    """Keep the analytical mirror current β€” the API-process stand-in for the re-sync the
    Streamlit app piggybacks on page renders. Measure memos key on the pool stamp, so a
    refreshed pool re-reads the freshly synced store."""
    import time as _t
    passes = 0
    while True:
        # ⭐⭐ WAVE 32 Β· OWNER ITEM 11 / R11 β€” THE TENANT'S OWN CADENCE, NOT A HARDCODED 1800.
        #
        # β›” THIS LINE IS WHY THE SETTING WAS NOT A SETTING. SESSION B built the whole of item 11 β€”
        # the 30m/1h/4h/daily/manual presets, the server-side clamp, the config door and
        # `odoo_relational.sync_seconds()` which turns the stored preset into seconds β€” and its own
        # ticket said the one-line change belonged to A because `main.py` is A's file. That ASK was
        # never sent, so the value was stored, displayed, clamped and IGNORED: a person could pick
        # "every 4 hours" and the loop would keep resyncing every 30 minutes with nothing anywhere
        # reporting the disagreement. Found by `verify_reachability` naming `sync_seconds` as a
        # function whose ONLY caller was its own gate [[artifact-with-no-importer]].
        #
        # ⚠ `None` MEANS MANUAL AND MUST NOT MEAN ZERO. R11's `manual` preset returns None from
        # `sync_seconds`; treating that as a falsy interval would spin this loop with no sleep at
        # all. It parks at the default cadence instead and simply does no work β€” the tenant asked
        # not to be synced automatically, not for the server to stop breathing.
        # ⚠ RE-READ EVERY PASS, deliberately: a cadence changed in Settings takes effect on the
        # next cycle rather than at the next container restart, which is what makes it a setting.
        # ⚠ FAIL-SAFE TO 1800 β€” an unreadable config must not become a tight loop. The floor is
        # enforced in `sync_seconds` as well as at the write door, for the same reason.
        _every = 1800
        try:
            import odoo_relational as _rel_cad
            from harness import runtime as _rt_cad
            _secs = _rel_cad.sync_seconds(_rt_cad.get_runtime("royal-imports"))
            _every = 1800 if _secs is None else max(int(_secs), 60)
        except Exception:                                     # noqa: BLE001
            pass
        _t.sleep(_every)
        passes += 1
        try:
            from harness import datastore as _ds
            import core.odoo as _odoo
            try:
                _odoo._tlocal.client = _odoo.OdooClient()
            except Exception:
                pass
            _ds.sync_all(log=lambda *a, **k: None)
            # Wave 21 β€” every 4th pass (~2h): purge hard-deleted rows the cursor sync cannot
            # see (the boot pass's comment has the measured case). Cheap id-sweep per entity;
            # without it deleted Odoo lines inflate every sum on the mirror FOREVER.
            if passes % 4 == 0:
                _ds.reconcile_deletes(log=lambda *a, **k: None)
        except Exception as e:                                # noqa: BLE001
            print(f"[aios-api] store resync failed: {e}")
        # ⭐ WAVE 27 item 17 (E's ASK ->A, resolved): the Odoo RELATIONAL tables are rebuilt
        # AFTER the mirror they are derived from, in the same pass and in that order β€” deriving
        # from a mirror this loop is about to advance would publish a worklist one cycle stale
        # every single time.
        #
        # β›” OUTSIDE the try/except above, NOT folded into it. A failing relational rebuild must
        # not swallow the sync's error message, and β€” worse the other way β€” a sync failure must
        # not skip a rebuild that had nothing wrong with it. Two independent failures, two
        # independent logs. (`_rebuild_odoo_relational` carries its own handlers.)
        #
        # ⚠ THE REBUILD IS NOT THE FRESHNESS GUARANTEE β€” the rows carry a visible `_refreshed`
        # stamp for that. This loop is a daemon thread whose failure path is a `print` (D-29), so
        # "the wiring exists" and "the data is current" are different claims and only the stamp
        # can tell a user which one they are looking at.
        #
        # ⭐ ONE IMPLEMENTATION, TWO CALLERS (wave 28). This block used to be the only copy, which
        # is what made the boot path silent for 30 minutes after every restart; it is now the
        # SECOND caller of the same function `_seed_and_sync_store` calls at boot. A copy here
        # would be a second thing to keep in step, and the two would answer differently on the
        # next ruling β€” the exact shape the Views top-up was just fixed for on the other side of
        # this wave.
        # β›” THE META PULL RIDES THE RESYNC TOO, AND LEAVING IT OUT WAS A REAL GAP β€” caught by
        # reading the deploy's own boot log rather than by any gate. `_pull_meta` was wired into
        # `_seed_and_sync_store` ALONE, i.e. it ran exactly once per container, at boot, behind a
        # full Odoo sync. So a boot where the Graph call was rate-limited, slow or simply after the
        # thread died left the mirror empty with NOTHING to retry it: the relational rebuild below
        # would then find no `meta_*` tables every 30 minutes forever and skip, silently and
        # correctly. A connector that can only ever be established at boot is one bad boot away
        # from being permanently absent.
        # ⚠ Cheap when there is nothing to do: no token => one line and return.
        _pull_meta("resync")
        _rebuild_odoo_relational("resync")


def _prewarm():
    import time as _t
    t0 = _t.time()
    # ⚠ The store sync runs in its OWN thread, never ahead of the pool warm: a stale seed can
    # take many minutes of XML-RPC to close, and the first live probe of this arrangement
    # showed the pool warm (42s) silently queued behind it β€” the whole app cold for every
    # visitor while a background column channel caught up. Measure cells retry via the
    # transient rule until the sync lands; nothing else waits on it.
    import threading as _th
    _th.Thread(target=_seed_and_sync_store, daemon=True, name="store-seed-sync").start()
    try:
        from harness import runtime as _runtime
        rt = _runtime.get_runtime("royal-imports")
        routes_customers.warm_default(rt)
        import pages as _pages
        _pages.warm_default(rt)
        # ⭐⭐ WAVE 30 · T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent
        # from this list, so its memo was always filled by a visitor rather than by boot: call 1 of
        # `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB
        # ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path β€” two
        # waves of it β€” could not touch that, because the cold call is the one that fills the memo.
        # ⚠ It elects a STRING and keeps no document; see `warm_default`'s own note on why caching
        # the bucket would trade a latency for memory this tier does not have.
        import routes_automation as _rauto
        _rauto.warm_default(rt)
        print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
    except Exception as e:                                    # noqa: BLE001 β€” boot must not die on a warm-up
        print(f"[aios-api] prewarm skipped: {e}")


# ═════════════════════════════════════════════════════════════════════════════════════════════
# ⭐⭐ W31-T46 / D-160 β€” THE MIRROR IS SEEDED WHETHER OR NOT `AIOS_PREWARM` IS SET.
# ═════════════════════════════════════════════════════════════════════════════════════════════
#
# THE DEFECT, and it has cost a release once already (owner item 12, wave 20). `_prewarm()` was
# the ONLY caller of `_seed_and_sync_store()`, which is the ONLY caller of
# `datastore.ensure_seed()`. So on a fresh Space disk with `AIOS_PREWARM` anything but `1`:
# no `royal.duckdb` β‡’ `datastore.ready()` False forever β‡’ `ro_con()` refuses β‡’ every measure
# column blank AND β€” since wave 30 put the Odoo grids on the mirror β€” **two user-facing grids
# serve nothing**, with the app RUNNING, the deploy green and the tag correct. Last time the
# symptom was read as a pinned tag and a store problem for days.
#
# β›” AND THE FLAG IS EASIER TO LOSE THAN IT LOOKS: `deploy_web.py` pushes `AIOS_PREWARM=1`
# EXPLICITLY (to overwrite a stale `0`, because a Space secret survives a redeploy) β€” but that
# push sits under `if TARGET:`, so an ordinary bare `python deploy_web.py` skips it. A guard
# written for the dangerous case that the ordinary case walks straight past.
#
# ⭐ SO THE SEED IS SPLIT OFF AND MADE UNCONDITIONAL, which is T46's first branch rather than its
# fallback. It is the right half to move because it is the CHEAP, SAFE one: `ensure_seed()`
# touches no Odoo (it is one `hf_hub_download` of a snapshot), returns immediately when the file
# is already there, and returns immediately without `HF_TOKEN`. The EXPENSIVE, live half β€”
# `sync_all()`'s XML-RPC passes, the pool warm, the resync loop β€” stays exactly where it was,
# because the env gate's stated reason is still true: importing `api.main` in a gate must never
# fire a live Odoo pull.
#
# ⚠ THE `DB_PATH.exists()` PRE-CHECK IS WHAT KEEPS THIS FREE. It is a stat, and it is False only
# on a genuinely fresh disk β€” so on every developer box and in every gate run the thread is never
# started at all, and on a fresh Space it does exactly the thing whose absence blanks the grids.
#: What the boot seed did, so a surface can REPORT it instead of an operator inferring it from a
#: blank grid. R6's second sentence: a limit that cannot be removed is reported with its cause.
MIRROR_SEED = {"attempted": False, "seeded": False, "cause": "", "recommendation": ""}


def _seed_mirror_if_absent():
    """Hydrate the analytical mirror when this container has none β€” INDEPENDENT of `AIOS_PREWARM`.

    Fail-quiet by design, and it records WHY rather than only whether: "there is no mirror and no
    HF_TOKEN to fetch one" and "there is no mirror and the fetch failed" are different operator
    actions, and a blank grid cannot tell them apart.
    """
    from harness import datastore as _ds
    MIRROR_SEED["attempted"] = True
    try:
        if _ds.ensure_seed():
            MIRROR_SEED["seeded"] = True
            print("[aios-api] analytical mirror seeded at boot (independent of AIOS_PREWARM)")
            return True
        if not _ds.DB_PATH.exists():
            MIRROR_SEED["cause"] = (
                "this container has no analytical mirror and the seed snapshot could not be "
                "fetched (no HF_TOKEN, or the dataset was unreachable)")
            MIRROR_SEED["recommendation"] = (
                "set HF_TOKEN on the deployment; until then every connected grid and every "
                "measure column served from the mirror is empty")
            print(f"[aios-api] NO ANALYTICAL MIRROR: {MIRROR_SEED['cause']}")
    except Exception as e:                                    # noqa: BLE001 β€” boot must not die
        MIRROR_SEED["cause"] = f"the boot seed raised {type(e).__name__}: {e}"
        MIRROR_SEED["recommendation"] = "check HF_TOKEN and the seed dataset's availability"
        print(f"[aios-api] mirror seed skipped: {e}")
    return False


try:
    from harness import datastore as _ds_boot
    if not _ds_boot.DB_PATH.exists():
        import threading as _threading_seed
        _threading_seed.Thread(target=_seed_mirror_if_absent, daemon=True,
                               name="mirror-seed").start()
except Exception as e:                                        # noqa: BLE001
    print(f"[aios-api] mirror seed not scheduled: {e}")

if os.environ.get("AIOS_PREWARM") == "1":
    import threading as _threading
    _threading.Thread(target=_prewarm, daemon=True, name="prewarm").start()
    _threading.Thread(target=_store_resync_loop, daemon=True, name="store-resync").start()