File size: 50,010 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
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
"""routes_grid.py β€” X2's write seam over HTTP: the SECOND adapter on `core.grid_events` (EXIT-1c).

`POST /api/v1/grid/events` takes the component's event objects VERBATIM β€” the same objects the
Streamlit host receives through the component value slot, unchanged β€” and runs them through the
same `core.grid_events.handle_events` the Streamlit adapter runs. That is the whole point of
EXIT-1a: one implementation of every permission wall, two transports. A route that re-validated
anything here would be a second wall to keep in step, and the two would drift on the first change.

DEDUP IS PER REQUEST, and that is a deliberate limit, not an oversight. The Streamlit adapter's
`seen_ids` lives in `st.session_state` β€” genuinely per user session β€” because the client resends
its recent 24-event window on every emit inside one page session. A stateless API has no such
dict, and inventing a per-user server-side one would be exactly the resident per-tenant state
EXIT-4a exists to remove. So: ids are deduped WITHIN a request body (the resend window's whole
purpose β€” a batch that repeats an id processes it once), and a genuinely replayed request is
handled by the operations being idempotent. The one event where a replay is observable is
`add_to_list` (it would add the same pids to the same cohort twice β€” a set union, so the
membership is unchanged, but the toast count repeats). Noted rather than papered over; a
server-side idempotency key belongs with D2's session mirror (C-2).

STORE DOWN = 503. `fallback_ws` is None on this adapter, so `core.grid_events` raises
`StoreUnavailable` rather than writing to an in-memory workspace no API request could ever read
back. A 200 over a write that evaporated is the failure this rule exists to prevent.
"""
import datetime as dt
import time

from fastapi import APIRouter, Body, Depends

from deps import Session, err, module_gate, perms, require_session

router = APIRouter(prefix="/api/v1")

MODULE = "customer_data"

#: The client's own resend window (`app.py`'s `[-24:]`). A body larger than this is not a
#: legitimate client β€” refuse it rather than doing 500 store writes on one request.
_MAX_EVENTS = 24


def _ctx(session: Session, fields, pids, **kw):
    from core import grid_events
    import aios_grid
    import core.perm_scope as perm_scope

    # Wave 16 C-TOPIC: the ctx is TOPIC-SHAPED. The product scope swaps all three of the
    # things a write is validated against β€” the module the permission wall reads, the canonical
    # contract the hidden-field closure runs over, and the TABLE OPS the write lands in.
    # Getting any one of them from the other topic is the "validated against the wrong field
    # contract" near-miss the wave-15 routes_products header warned about.
    scope_key = str(kw.get("scope_key") or "")
    if scope_key == "product":
        import modules.product_data as pd
        from routes_products import MODULE as _PMOD, pd_fields

        module, canonical = _PMOD, pd_fields(consolidated=True)
        kw.setdefault("table", pd.TABLE_OPS)
        hidden = perm_scope.hidden_keys(session.user, module, canonical)
    elif scope_key.startswith("ut_"):
        # Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
        # `user_tables.may_open`, already applied by the assembly this route ran first), so
        # the hidden-field closure is EMPTY rather than borrowed from another topic's contract.
        import core.table_store as table_store

        kw.setdefault("table",
                      table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
        hidden = frozenset()
    else:
        module, canonical = MODULE, aios_grid.FIELDS
        hidden = perm_scope.hidden_keys(session.user, module, canonical)
    return grid_events.EventCtx(
        uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
        # C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
        # component's event objects verbatim, so a hidden key would otherwise arrive here with
        # nothing between it and the store.
        hidden_keys=hidden,
        # ⭐ WAVE 25 (R6b, closes D-16) β€” THE TENANT HANDLE, on EVERY scope. This is the line
        # D-16's exit condition names, and without it the rest of the fix is inert: the handler
        # falls back to the module-global `core.store`, which is tenant #0's repo, so a Nurilab
        # user's documents, cohorts and β€” through `_tops` β€” their whole customer/product table
        # workspace were written into Royal Imports' dataset. Note it is set for the CUSTOMER
        # and PRODUCT branches too, not only `ut_`: those two never passed a scoped `table`, so
        # they were the ones actually resolving to tenant #0 on every request.
        st=session.runtime,
        fallback_ws=None, seen_ids={}, **kw)


#: The surfaces the ONE grid serves. `cohort` is the same table over hand-curated SETS rather
#: than over the whole scoped pool β€” `app.py:page_cohort` calls the same `_table_grid` with
#: `cohort_mode=True, scope_key='cohort'`. `product` (wave 16 C-TOPIC) is the SKU table:
#: same engine, its own pool, field contract and workspace BUCKET (routes_products).
_SCOPES = ("customer", "cohort", "product")


def _scope_or_400(raw):
    """β›” REFUSE AN UNKNOWN SCOPE, never default it. A typo silently served as `customer` would
    hand a user the whole book on a page they opened to see one cohort β€” the widening direction,
    which is the one that must fail closed.

    Wave 18 (C3-UT): a `ut_`-prefixed scope names a USER TABLE and passes through here β€”
    existence and the per-table wall are enforced by `routes_tables.ut_assembly` (404/403),
    which every consumer of such a scope goes through. Passing an unknown ut key therefore
    still fails closed, just one layer down where the store can actually be consulted."""
    scope = (raw or "customer").strip().lower()
    if scope.startswith("ut_"):
        return scope
    if scope not in _SCOPES:
        raise err(400, "bad_scope",
                  f"scope must be one of {', '.join(_SCOPES)} β€” refusing to guess")
    return scope


@router.get("/workspace")
def workspace(scope: str = "customer",
              session: Session = Depends(require_session)):
    """The durable table workspace for this session: views, fields, overlays, folders.

    β›” WAVE 21 (C4): the wall is TOPIC-SHAPED, so the DEPENDENCY is session-only and each scope
    asserts its own gate below. The old `module_gate("customer_data")` dependency 403'd a
    `ut_*` workspace for any tenant whose catalogue omits the customer module (loopable/
    nurilab/gtmlab ship modules w/o it) β€” and the client then fell back to the shared
    localStorage bucket + demo data, which is exactly the "new database shows RI's customer
    fields" defect. A user table's real wall is `user_tables.may_open`, enforced inside
    `ut_assembly` (404/403, one layer down where the store can be consulted).

    ⚠ WHY THIS EXISTS (S1↔S2, 2026-07-30 β€” an X2 AMENDMENT, see the split doc). X2 fixes
    `/customers` at the exact shape `verify_fields_contract.py` referees, so the workspace cannot
    ride along in it without forking the thing that gate keeps single-sourced. Without a
    workspace route the standalone shell's write path would be WRITE-ONLY: events persist
    server-side and nothing reads them back on reload, so a saved view looks lost to the user
    even though the store has it. This route closes that read-back gap at its own URL.

    `allowed_pids` is passed so a SHARED view's `memberPids` are re-scoped to THIS reader β€” the
    wave-9 leak rule. Omitting it would hand a Fisch-scoped user a member list built by a
    full-access user.
    """
    from core import grid_events
    from routes_customers import grid_assembly

    scope = _scope_or_400(scope)
    # Wave 21 C4 β€” the per-scope gate the dependency no longer asserts: customer/cohort need
    # the customer module; product asserts PRODUCT_MODULE in its branch; ut_* needs only a
    # session (its wall is the table's own, inside the assembly).
    if not (scope == "product" or scope.startswith("ut_")):
        session.require(MODULE)
    # ── Wave 18 C3-UT: a USER TABLE'S workspace β€” the third topic through the one wire. The
    # storage key has no (bu, agent) because a user table has no Odoo scope; per-user by the
    # table's own wall (creator or admin, `user_tables.may_open`).
    if scope.startswith("ut_"):
        from routes_tables import ut_assembly

        storage_key = f"{session.tenant}:{scope}:{session.uname}"
        try:
            # ⭐⭐ W31-T20 / D-174 β€” `with_rows=False`. THIS ROUTE RENDERS NO ROW: the grid fetches
            # them from `/tables/{key}/rows` or `/odoo-tables/{key}/rows` in the same paint, and
            # `useCustomerData.load` fires both calls in one `Promise.all`. Building the table here
            # bought nothing and cost everything β€” it is why `?scope=ut_odoo_gl_lines` answered
            # `409 window_required` six times out of six on the live deploy while the ROWS door for
            # the same table served a page in ~150 ms, i.e. a grid that could not be opened at all.
            g = ut_assembly(session, scope, storage_key=storage_key, with_rows=False)
        except grid_events.StoreUnavailable:
            raise err(503, "store_unavailable", "the tenant store is unavailable")
        workspace = g["workspace"]
        workspace["overlays"] = g["ws"].get("overlays") or {}
        # The field contract rides the cheap re-read on EVERY topic β€” see the customer branch
        # below for why (a user table's first cohort changes its contract too: `_cohorts(ctx)`
        # is scope-parameterized, so `ut_` surfaces have their own sets).
        workspace["fields"] = g["fields"]
        workspace["measures"] = g["measures"]
        workspace["measureSets"] = g["measure_sets"]
        # ⚠ `g["derived"]`, NOT `{}` (2026-08-04). R9 gave every topic its own cohort sets, and
        # `ut_assembly` has been building this topic's cohort CELLS since β€” but this route threw
        # them away, so the Locked-views column arrived on the cheap re-read with permanently
        # empty values. A column that exists and can never have one is worse than an absent
        # column: it reads as "this record is in no locked view", which is a claim.
        workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
        workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
        try:
            from core import users as _users
            workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
            workspace["userAvatars"] = {k: v for k, v in
                                        _users.avatar_map(tenant=session.tenant).items()
                                        if str(v).startswith("data:image/")}
        except Exception:
            workspace["userOptions"] = []
            workspace["userAvatars"] = {}
        workspace["scopeKey"] = scope
        # ⭐ W31-T20 β€” R6's SECOND SENTENCE REACHES THE BROWSER. On a read-through grid too large
        # for one window the pid set is empty, so cohort membership and a shared view's
        # `memberPids` are UNRESOLVED here (both fail closed, and `workspace_wire` already stamps
        # `missing` on a shortened cohort). An unannounced empty scope is the silent limit R6
        # forbids; this is the announcement, and W31-T22 is the ticket that renders it.
        workspace["limits"] = g.get("limits") or []
        return {"workspace": workspace}

    # ── Wave 16 C-TOPIC: the PRODUCT surface has its own assembly (own pool, own field
    # contract, own workspace BUCKET) and the contract's own storage key. It branches before
    # the customer derivation because the customer storage key embeds (bu, agent) while the
    # product one is deliberately `all:all` β€” one product workspace per user (the wave doc's
    # C-TOPIC line), since BU narrows the product VALUES, not which workspace you own.
    if scope == "product":
        from routes_products import MODULE as PRODUCT_MODULE, product_assembly

        # β›” THE GATE CHANGES WITH THE SCOPE (wave 21 C4: the dependency is session-only now,
        # so this line IS the product wall β€” not a second one on top of customer_data).
        session.require(PRODUCT_MODULE)
        storage_key = f"{session.tenant}:product-list:{session.uname}:all:all"
        try:
            g = product_assembly(session, scope=scope, storage_key=storage_key)
        except grid_events.StoreUnavailable:
            raise err(503, "store_unavailable", "the tenant store is unavailable")
        workspace = g["workspace"]
        workspace["overlays"] = g["ws"].get("overlays") or {}
        # R9 made cohorts per-topic, so the PRODUCT surface has its own sets and its own
        # first-cohort contract change. Same reason as the customer branch below.
        workspace["fields"] = g["fields"]
        # The measure channel is EMPTY on this topic (customer-grain descope) β€” stated
        # explicitly so the client's pickers grey rather than guess.
        workspace["measures"] = g["measures"]
        workspace["measureSets"] = g["measure_sets"]
        # `g["derived"]` β€” the same correction as the user-table branch above, for the same
        # reason: `product_assembly` builds this topic's cohort cells and this route discarded
        # them. The measure half stays empty on this topic by descope, which is a different
        # statement and one the offer already makes.
        workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
        workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
        try:
            from core import users as _users
            workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
            workspace["userAvatars"] = {k: v for k, v in
                                        _users.avatar_map(tenant=session.tenant).items()
                                        if str(v).startswith("data:image/")}
        except Exception:
            workspace["userOptions"] = []
            workspace["userAvatars"] = {}
        workspace["scopeKey"] = scope
        return {"workspace": workspace}

    # The storage key mirrors the host's own convention byte-for-byte (app.py's `_table_grid`
    # call sites) with the session's TENANT in the host's hardcoded slot, so localStorage state
    # carries across embed ⇄ standalone on the same browser.
    # Wave 15 R1 β€” the SAME derivation the pool uses (`routes_customers._team_agent`), so the
    # storage key cannot drift from the scope it names. Value-identical for every migrated
    # record (verify_perm_scope section D proves the derivation reproduces the legacy scope
    # exactly), so nobody's saved localStorage state moves when the migration runs.
    import core.perm_scope as perm_scope
    team_id, agent = perm_scope.derive_pool_scope(session.user, MODULE)
    bu = team_id if team_id is not None else "all"
    if scope == "cohort":
        storage_key = f"{session.tenant}:cohort:{session.uname}:{bu}"
    else:
        storage_key = f"{session.tenant}:customer-list:{session.uname}:{bu}:{agent or 'all'}"

    # β›” THE WIRE SHAPE IS THE SHARED PROJECTION (`aios_grid.workspace_wire`), not the store
    # shape. The first version returned the store dict with no `storageKey` β€” and the client
    # validator requires one, so the standalone shell DISCARDED the whole workspace: saved views
    # never rendered, `cohortMode` never arrived, and the Cohort route silently drew the Customer
    # surface. One projection for both servers is the fix that cannot drift.
    try:
        g = grid_assembly(session, scope=scope, storage_key=storage_key)
    except grid_events.StoreUnavailable:
        raise err(503, "store_unavailable", "the tenant store is unavailable")
    workspace = g["workspace"]
    # ADDITIVE to the wire: the caller's own overlay cells. The grid reads overlays from the
    # /customers rows, but this route is the cheap read-back a write can be verified against
    # (verify_api's overlay probe) without paying the pool call. Per-user by construction β€”
    # `table_workspace` is this session's workspace.
    workspace["overlays"] = g["ws"].get("overlays") or {}
    # ⭐ THE FIELD CONTRACT, 2026-08-04 β€” and it is NOT decoration.
    #
    # `fields_from_workspace(ws, cohorts=bool(cohort_lists))` appends the derived "Locked
    # views" column ONLY when the caller owns at least one cohort. So a user's FIRST cohort
    # CHANGES THE FIELD CONTRACT β€” and the rows call that used to be the only carrier of
    # `fields` is deliberately never re-fetched on a write (it is a 15-minute-cached Odoo
    # pull; this route is the cheap re-read). The client therefore had no way to learn about
    # that column short of a remount, which is the second half of the owner's "it only shows
    # up when I switch modules and come back".
    #
    # ⚠ `g["fields"]` is the SAME list `_payload` serves on `/customers` β€” same assembly,
    # same permission wall (`hidden_keys` already applied) β€” so the two wires cannot disagree
    # about what a column is. Taking it from anywhere else would fork the contract that
    # verify_fields_contract.py exists to keep single-sourced.
    workspace["fields"] = g["fields"]
    # ── the STANDALONE measure channel (owner item 1, 2026-07-31) ────────────────────────────
    # The embed receives these as top-level render args; standalone lifts them off THIS route
    # (useCustomerData merges them into the payload slots the grid already reads). They ride
    # the workspace rather than /customers because a durable write re-reads exactly this route
    # (WORKSPACE_STALE), so a new measure column populates without refetching the heavy pool.
    workspace["measures"] = g["measures"]
    workspace["measureSets"] = g["measure_sets"]
    # Derived cells (cohort column + measure columns), keyed by pid. JSON object keys are
    # strings; the client indexes with String(pid).
    workspace["derived"] = {str(pid): cells for pid, cells in g["derived"].items()}
    # Who is looking (permissions verdicts) + the assignee choices for `user`-typed columns β€”
    # the two other host-only render args the shell was missing (fail-closed without them:
    # restricted fields uneditable, assignee picker empty).
    workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
    try:
        from core import users as _users
        workspace["userOptions"] = _users.assignable_people(tenant=session.tenant)
        # Wave 14 C-AVATAR β€” the options vocabulary's companion: display name -> data URL.
        # Absent entries fall back to the client's initials disc; a non-data: value is
        # dropped (defence in depth beside the write-side wall in routes_auth).
        workspace["userAvatars"] = {k: v for k, v in
                                    _users.avatar_map(tenant=session.tenant).items()
                                    if str(v).startswith("data:image/")}
    except Exception:
        workspace["userOptions"] = []
        workspace["userAvatars"] = {}

    # THE SURFACE STAMP β€” a MIRROR of the host's own (`_table_grid`'s hide/cohort stamps).
    # ⚠ Deliberately NOT `hideViews`: `cohortMode` is what swaps the Views panel for the cohort
    # list, and two switches for one behaviour would drift.
    workspace["scopeKey"] = scope
    if scope == "cohort":
        workspace["cohortMode"] = True
        # The create pane offers "this cohort table only" (default) vs "all customer tables".
        workspace["scopeChoice"] = True
    return {"workspace": workspace}


# ─────────────────────────────────────────────── the TIME-SERIES channel (C-TS, 2026-08-02)
_TS_BUCKETS = ("week", "month", "quarter", "year")
_TS_MAX_BUCKETS = 120
_TS_MAX_FIELDS = 12
_TS_MAX_PIDS = 5000
_TS_MAX_LAST_N = 120
#: C-TSWIN (wave 14): sliding windows cost ONE aggregate query per (metric, bucket) β€” the
#: price of "bucket N == the grid cell at bucket N's end". The product cap keeps a legal
#: 120-bucket Γ— 12-metric ask from becoming 1,440 queries in one request; typical panels
#: (12 Γ— 3) sit two orders of magnitude under it. Dated amendment in the split doc.
_TS_MAX_CELLS = 720


def _ts_start_of(bucket, d):
    """The calendar START of the bucket holding `d` (week = Monday, the vocabulary rule)."""
    if bucket == "week":
        return d - dt.timedelta(days=d.weekday())
    if bucket == "month":
        return d.replace(day=1)
    if bucket == "quarter":
        return d.replace(month=((d.month - 1) // 3) * 3 + 1, day=1)
    return d.replace(month=1, day=1)


def _ts_next(bucket, d):
    if bucket == "week":
        return d + dt.timedelta(days=7)
    if bucket == "year":
        return d.replace(year=d.year + 1)
    step = 1 if bucket == "month" else 3
    m = d.month + step
    return dt.date(d.year + (m - 1) // 12, (m - 1) % 12 + 1, 1)


def _ts_prev(bucket, d):
    if bucket == "week":
        return d - dt.timedelta(days=7)
    if bucket == "year":
        return d.replace(year=d.year - 1)
    step = 1 if bucket == "month" else 3
    y, m = d.year, d.month - step
    while m < 1:
        m += 12
        y -= 1
    return dt.date(y, m, 1)


def _ts_label(bucket, start):
    if bucket == "week":
        return f"Wk of {start.strftime('%b %d')}"
    if bucket == "month":
        return start.strftime("%b %Y")
    if bucket == "quarter":
        return f"Q{(start.month - 1) // 3 + 1} {start.year}"
    return str(start.year)


@router.post("/grid/timeseries")
def grid_timeseries(body: dict = Body(default=None), scope: str = "customer",
                    session: Session = Depends(module_gate(MODULE))):
    """Pooled measure values per calendar bucket β€” the time-series view's data channel.

    C-TSWIN (wave 14, ruling R1): AS-OF semantics. Each metric's OWN stored window is
    re-resolved per bucket with `today := min(bucket end, real today)` β€” `ytd` is cumulative
    from Jan 1, `last_90_days` trailing, `all_time` cumulative ever; bucket N equals what the
    grid's measure column would show if today were bucket N's end. Fixed-range (`custom`)
    windows cannot slide and are dropped per field as `window_fixed`. Rows carry
    `window: {kind, label}` so a cumulative row cannot be misread as periodic, and there is
    NO `total` column β€” sliding windows overlap, so a sum of columns would double-count.

    The client sends the pids its view currently matches (the filter IS the scope); the server
    intersects them with the session's book, so the request can only ever NARROW. Values are
    POOLED aggregates computed by the semantic layer's own expression (the additivity law: an
    average is computed at pool grain, never averaged over per-customer answers). A bucket
    with no rows is 0 for a sum/count and null for anything else; a bucket that has not
    STARTED yet is null for every kind β€” an unstarted period's YTD is unanswered, not 0.
    """
    from core import measure_resolve
    from harness import windows as _wn
    from routes_customers import _pool_stamp, _team_agent, allowed_pids

    # Wave 16 C-TOPIC: the channel is CUSTOMER-GRAIN (measure_resolve's own grain), so the
    # product surface is refused in words rather than answered with an id-space accident β€”
    # product pids are CRC32 hashes and would mostly fall outside the customer book anyway,
    # but "mostly" is not a wall. The client hides the mode for this topic; this is the
    # server's half of the same refusal.
    _sc = _scope_or_400(scope)
    if _sc == "product" or _sc.startswith("ut_"):
        raise err(400, "bad_timeseries",
                  "this surface has no time-series channel β€” measures are customer-grain")

    body = body or {}
    bucket = body.get("bucket")
    if bucket not in _TS_BUCKETS:
        raise err(400, "bad_timeseries", "bucket must be one of week, month, quarter, year")
    raw_fields = body.get("fields")
    if not isinstance(raw_fields, list) or not raw_fields:
        raise err(400, "bad_timeseries", "fields must be a non-empty list of field keys")
    if len(raw_fields) > _TS_MAX_FIELDS:
        raise err(400, "bad_timeseries", f"at most {_TS_MAX_FIELDS} fields per request")
    raw_pids = body.get("pids")
    if not isinstance(raw_pids, list) or not raw_pids:
        raise err(400, "bad_timeseries", "pids must be a non-empty list")
    if len(raw_pids) > _TS_MAX_PIDS:
        raise err(400, "bad_timeseries", f"at most {_TS_MAX_PIDS} pids per request")
    try:
        wanted = {int(p) for p in raw_pids}
    except (TypeError, ValueError):
        raise err(400, "bad_timeseries", "pids must be integers")
    pool = wanted & {int(p) for p in allowed_pids(session)}
    if not pool:
        # 403, not an empty 200: the caller asked about customers outside their book, and an
        # all-zero series over nobody would read as "no activity", not "not yours".
        raise err(403, "out_of_scope", "none of those customers are in your book")

    span = body.get("span")
    if not isinstance(span, dict):
        raise err(400, "bad_timeseries", "span must be {'lastN': n} or {'from': .., 'to': ..}")
    today = time.strftime("%Y-%m-%d")
    t = dt.date.fromisoformat(today)
    n = span.get("lastN")
    starts = []
    if n is not None:
        if not isinstance(n, int) or isinstance(n, bool) or not (1 <= n <= _TS_MAX_LAST_N):
            raise err(400, "bad_timeseries", f"lastN must be 1..{_TS_MAX_LAST_N}")
        cur = _ts_start_of(bucket, t)
        starts = [cur]
        for _ in range(n - 1):
            cur = _ts_prev(bucket, cur)
            starts.append(cur)
        starts.reverse()
    else:
        try:
            d_from = dt.date.fromisoformat(str(span.get("from")))
            d_to = dt.date.fromisoformat(str(span.get("to")))
        except (TypeError, ValueError):
            raise err(400, "bad_timeseries", "span.from/to must be ISO dates (YYYY-MM-DD)")
        if d_from > d_to:
            d_from, d_to = d_to, d_from
        cur = _ts_start_of(bucket, d_from)
        while cur <= d_to:
            starts.append(cur)
            if len(starts) > _TS_MAX_BUCKETS:
                raise err(400, "bad_timeseries",
                          f"that span is more than {_TS_MAX_BUCKETS} {bucket} buckets - "
                          f"narrow it")
            cur = _ts_next(bucket, cur)
        if not starts:
            raise err(400, "bad_timeseries", "the span holds no buckets")
    ends = [_ts_next(bucket, s) - dt.timedelta(days=1) for s in starts]

    rt = session.runtime
    if not rt.available():
        raise err(503, "store_unavailable", "the tenant store is unavailable")
    import modules.customer_data as cl_mod
    # Read-only route: it must not consume a one-shot label-correction ack the browser has
    # not seen yet (the same rule event validation follows).
    ws = cl_mod.table_workspace(session.uname, consume_corrections=False)
    fdefs = ws.get("fields") or {}
    keys, dropped, seen = [], [], set()
    for k in raw_fields:
        k = str(k or "")
        if not k or k in seen:
            continue
        seen.add(k)
        fd = fdefs.get(k)
        if not isinstance(fd, dict) or not isinstance(fd.get("measure"), dict):
            # Named, never silent: v1 eligibility is measure-backed fields only (C-TS).
            dropped.append({"field": k, "reason": "not_a_measure_field"})
            continue
        keys.append(k)
    # ⚑ WAVE 17 R9 (amendment 2026-08-03, GRID's cross-fence ask) β€” A SHEET WITH NO MEASURE
    # FIELDS IS NO LONGER A 400. It answers with the BUCKET GRID and no rows.
    #
    # Why this is the honest direction and not a loosening: the bucket starts and their labels
    # are SERVER math (`_ts_start_of` / `_ts_next` / `_ts_label`), and R9 has the client
    # synthesizing snapshot rows for preset columns that have no window to slide. Those rows
    # must be painted under the SAME headings as everything else, so the client needs the
    # columns even when the server has no series to put in them. Refusing the whole request
    # meant the panel could offer nothing at all on this tenant β€” the shipped contract has zero
    # measure-backed presets (`aios_grid.py`: "this branch is currently MEMBERLESS"), which is
    # what item 5 is actually about.
    #
    # Nothing is invented by this: `rows` is empty and `meta.dropped` NAMES every field and why.
    # A 400 is still returned for a request that is malformed (bad bucket, bad span, no fields
    # at all) β€” this only stops treating "I asked about columns you cannot serve" as an error.
    mfields, slid_keys = [], []
    for k in keys:
        m = dict(fdefs[k]["measure"])
        if (m.get("window") or {}).get("kind") == "custom":
            # C-TSWIN: a fixed date range cannot slide across buckets β€” named, never silent,
            # the same posture as not_a_measure_field.
            dropped.append({"field": k, "reason": "window_fixed"})
            continue
        mfields.append({"key": k, "measure": m})
        slid_keys.append(k)
    # Same amendment as above: every metric being fixed-range is a sheet with no SERIES, not a
    # broken request. The columns still stand, and `window_fixed` still names each refusal.
    if len(starts) * len(mfields) > _TS_MAX_CELLS:
        raise err(400, "bad_timeseries",
                  "that ask is too wide - narrow the span or pick fewer metrics")

    team_id, agent = _team_agent(session)
    stamp = _pool_stamp(rt, team_id, agent)
    problems = []
    bucket_bounds = [(s.isoformat(), e.isoformat()) for s, e in zip(starts, ends)]
    # No slidable metrics = nothing to resolve. Skipping the call rather than asking the
    # resolver about an empty list keeps the memo free of a meaningless key.
    answers = measure_resolve.series_values(
        mfields, bucket, bucket_bounds,
        team_id, frozenset(pool), today, stamp, rt.series_memo,
        on_error=lambda tag, e: problems.append(str(e)[:200])) if mfields else {}

    columns = []
    for s, e in zip(starts, ends):
        col = {"key": s.isoformat(), "label": _ts_label(bucket, s),
               "from": s.isoformat(), "to": e.isoformat()}
        if e > t:
            col["partial"] = True
        columns.append(col)
    rows = []
    for k in slid_keys:
        ans = answers.get(k)
        if ans is None:
            dropped.append({"field": k, "reason": "unresolvable"})
            continue
        vals_by = ans.get("values") or {}
        agg_kind = str(ans.get("agg") or "sum")
        zero_fill = agg_kind in ("sum", "count")
        vals = []
        for s in starts:
            if s > t:
                # C-TSWIN: an unstarted period is unanswered for EVERY agg kind β€” a future
                # month's YTD zero-filled to 0 would read as "the year reset".
                vals.append(None)
            else:
                vals.append(vals_by.get(s.isoformat(), 0 if zero_fill else None))
        wspec = (fdefs[k].get("measure") or {}).get("window")
        wnorm = _wn.normalize(wspec) or {}
        rows.append({"field": k, "label": str(fdefs[k].get("label") or k)[:120],
                     "agg": agg_kind, "values": vals,
                     "window": {"kind": str(wnorm.get("kind") or ""),
                                "label": _wn.label(wspec)}})
    meta = {"pool": len(pool), "today": today, "bucket": bucket}
    if dropped:
        meta["dropped"] = dropped
    if problems:
        meta["problems"] = problems[:5]
    return {"columns": columns, "rows": rows, "meta": meta}


#: C-CAL caps (wave 17, owner item 9 / ruling R4). A month of days, a handful of metrics.
#: Every one of these is a 400 WITH ITS REASON, never a silent trim: a calendar that quietly
#: answered 20 of the 31 days asked about would paint eleven blank cells that look like days
#: with no activity.
_CAL_MAX_GROUPS = 31
_CAL_MAX_FIELDS = 6
_CAL_MAX_CELLS = 186


@router.post("/grid/calendar_metrics")
def grid_calendar_metrics(body: dict = Body(default=None), scope: str = "customer",
                          session: Session = Depends(module_gate(MODULE))):
    """Per-DAY measure values with each metric's own window slid to that day (C-CAL / R4).

    β›” WHY THIS EXISTS AT ALL. The calendar's summary cells used to aggregate the row VALUES the
    grid already held β€” which for a measure column means "every member's YTD **as of today**",
    summed and printed under a date in March. The number was arithmetically fine and semantically
    a lie: it answered a question about today while sitting in a cell labelled with another day.
    R4: a metric in a day cell is computed AS OF THAT DAY.

    The shape differs from the time-series channel in exactly one way, and it is the reason this
    is a separate route rather than a parameter: **every group carries its OWN pid set**. A
    calendar day holds the records the date field placed there, so day-to-day the subject
    changes. One `allowed_pids` for the whole request β€” the TS channel's shape β€” would compute
    each day over everybody, which is a different question again.

    Static (non-measure) fields are NOT served here. They have no window to slide, so the
    client's own per-day aggregation over row values stays correct for them; sending them would
    invite a second implementation of arithmetic that already works.
    """
    from core import measure_resolve
    from harness import windows as _wn
    from routes_customers import _pool_stamp, _team_agent, allowed_pids

    # The same refusal the TS channel makes, for the same reason: measures are customer-grain
    # and product pids are CRC32 hashes of SKU codes. "Mostly outside the book" is not a wall.
    _sc_cal = _scope_or_400(scope)
    if _sc_cal == "product" or _sc_cal.startswith("ut_"):
        raise err(400, "bad_calendar_metrics",
                  "the product surface has no measure channel yet β€” measures are customer-grain")

    body = body or {}
    raw_groups = body.get("groups")
    if not isinstance(raw_groups, list) or not raw_groups:
        raise err(400, "bad_calendar_metrics",
                  "groups must be a non-empty list of {key: 'YYYY-MM-DD', pids: [...]}")
    if len(raw_groups) > _CAL_MAX_GROUPS:
        raise err(400, "bad_calendar_metrics",
                  f"at most {_CAL_MAX_GROUPS} days per request (one month)")
    raw_fields = body.get("fields")
    if not isinstance(raw_fields, list) or not raw_fields:
        raise err(400, "bad_calendar_metrics", "fields must be a non-empty list of field keys")
    if len(raw_fields) > _CAL_MAX_FIELDS:
        raise err(400, "bad_calendar_metrics", f"at most {_CAL_MAX_FIELDS} metrics per request")
    if len(raw_groups) * len(raw_fields) > _CAL_MAX_CELLS:
        raise err(400, "bad_calendar_metrics",
                  "that ask is too wide - fewer days or fewer metrics")

    book = {int(p) for p in allowed_pids(session)}
    groups, seen_days = [], set()
    for g in raw_groups:
        if not isinstance(g, dict):
            raise err(400, "bad_calendar_metrics", "every group must be an object")
        day = str(g.get("key") or "")
        try:
            d = dt.date.fromisoformat(day)
        except (TypeError, ValueError):
            raise err(400, "bad_calendar_metrics",
                      "every group key must be an ISO date (YYYY-MM-DD)")
        if day in seen_days:
            raise err(400, "bad_calendar_metrics", f"day {day} appears twice")
        seen_days.add(day)
        raw_pids = g.get("pids")
        if not isinstance(raw_pids, list):
            raise err(400, "bad_calendar_metrics", "every group needs a pids list")
        try:
            wanted = {int(p) for p in raw_pids}
        except (TypeError, ValueError):
            raise err(400, "bad_calendar_metrics", "pids must be integers")
        # NARROW-ONLY, per group. The client sends what its calendar placed; the server can
        # only ever remove from that, never add.
        groups.append((day, d, frozenset(wanted & book)))
    if sum(len(p) for _, _, p in groups) > _TS_MAX_PIDS:
        raise err(400, "bad_calendar_metrics",
                  f"at most {_TS_MAX_PIDS} customer references per request")

    rt = session.runtime
    if not rt.available():
        raise err(503, "store_unavailable", "the tenant store is unavailable")
    import modules.customer_data as cl_mod
    ws = cl_mod.table_workspace(session.uname, consume_corrections=False)
    fdefs = ws.get("fields") or {}
    keys, dropped, seen = [], [], set()
    for k in raw_fields:
        k = str(k or "")
        if not k or k in seen:
            continue
        seen.add(k)
        fd = fdefs.get(k)
        if not isinstance(fd, dict) or not isinstance(fd.get("measure"), dict):
            # The TS channel's vocabulary, deliberately reused rather than re-coined: the client
            # already knows how to say these three words to a reader.
            dropped.append({"field": k, "reason": "not_a_measure_field"})
            continue
        if ((fd["measure"].get("window") or {}).get("kind") == "custom"):
            dropped.append({"field": k, "reason": "window_fixed"})
            continue
        keys.append(k)
    if not keys:
        raise err(400, "bad_calendar_metrics",
                  "none of the requested fields are measure fields with a window that can "
                  "slide to a day")

    today = time.strftime("%Y-%m-%d")
    t = dt.date.fromisoformat(today)
    team_id, agent = _team_agent(session)
    stamp = _pool_stamp(rt, team_id, agent)
    problems = []
    values = {k: {} for k in keys}
    for day, d, pids in groups:
        # A day that has not happened is unanswered for EVERY aggregate kind β€” the unstarted
        # bucket law at day grain. Answering 0 would say "we sold nothing", which is a claim
        # about a day nobody has lived through yet.
        if d > t or not pids:
            for k in keys:
                values[k][day] = None
            continue
        answers = measure_resolve.series_values(
            [{"key": k, "measure": dict(fdefs[k]["measure"])} for k in keys],
            "day", [(day, day)], team_id, pids, today, stamp, rt.series_memo,
            on_error=lambda tag, e: problems.append(str(e)[:200]))
        for k in keys:
            ans = answers.get(k)
            if ans is None:
                values[k][day] = None
                continue
            vals_by = ans.get("values") or {}
            zero_fill = str(ans.get("agg") or "sum") in ("sum", "count")
            values[k][day] = vals_by.get(day, 0 if zero_fill else None)

    for k in keys:
        if all(v is None for v in values[k].values()):
            # Every day unanswerable is a FIELD-level failure, and saying so is the difference
            # between "no activity that month" and "this metric could not be computed".
            dropped.append({"field": k, "reason": "unresolvable"})
    out = {"values": values, "today": today,
           "windows": {k: {"kind": str((_wn.normalize(
               (fdefs[k].get("measure") or {}).get("window")) or {}).get("kind") or ""),
               "label": _wn.label((fdefs[k].get("measure") or {}).get("window"))}
               for k in keys}}
    if dropped:
        out["dropped"] = dropped
    if problems:
        out["problems"] = problems[:5]
    return out


@router.post("/grid/events")
def grid_events_route(body: dict = Body(default=None),
                      session: Session = Depends(require_session)):
    """`{events: [<component event objects, verbatim>]}` β†’ `{results, doc?, toast?}`.

    Wave 21 C4: session-only dependency, per-scope gate below β€” the write door must admit the
    same sessions the read door (`/workspace`) admits, or a tenant without the customer module
    can SEE its own user tables and not write to them."""
    from core import grid_events
    from routes_customers import grid_assembly

    events = (body or {}).get("events")
    if events is None and isinstance(body, dict) and body.get("type"):
        events = [body]                    # a single event object, the legacy shape
    if not isinstance(events, list):
        raise err(400, "bad_events", "expected {events: [...]}")
    if len(events) > _MAX_EVENTS:
        raise err(400, "too_many_events",
                  f"at most {_MAX_EVENTS} events per request (the client's resend window)")

    # Validated with the SAME predicate as the read route. An unrecognised scopeKey used to be
    # passed through verbatim, and `core.grid_events` only ever compares it to 'cohort' β€” so a
    # typo degraded silently to customer-scope behaviour on a WRITE. Read and write must agree on
    # what a scope is, or the surface you read is not the surface you wrote.
    scope = _scope_or_400((body or {}).get("scopeKey"))
    # Wave 21 C4 β€” same per-scope gate as /workspace (read and write doors must agree).
    if not (scope == "product" or scope.startswith("ut_")):
        session.require(MODULE)
    # This assembly validates the write; it is not a payload the browser will render. Leave
    # one-shot field-name correction acks queued for the subsequent /workspace refresh.
    # Wave 16 C-TOPIC: the PRODUCT topic gets the product assembly β€” product field contract,
    # product pids, and (below) the product TABLE OPS, so a product event is validated against
    # and lands in the product bucket. The measure/cohort context is honestly EMPTY there:
    # `clean_measure_field` refuses measure creates on this surface by construction (the
    # customer-grain descope), which is the fail-closed shape, not an accident.
    if scope == "product":
        from routes_products import MODULE as PRODUCT_MODULE, product_assembly

        session.require(PRODUCT_MODULE)   # the product wall (dependency is session-only, C4)
        g = product_assembly(session, consume_corrections=False)
    elif scope.startswith("ut_"):
        # Wave 18 C3-UT β€” the user-table wall (creator/admin) is inside the assembly; the
        # measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
        # ⭐⭐ WAVE 30 / W30-T30 (owner item 2: *"when I click hide fields it crash… no matter the
        # size"*). This used to be `ut_assembly(...)`, which builds the whole table to validate a
        # write it then throws away: `scoped_pool` allocates a dict per row and sorts them, so ONE
        # hide-fields checkbox rebuilt ~33k order rows before the event was even dispatched.
        # β›” NOTHING IS VALIDATED LESS. The comment above still holds β€” this assembly is the
        # permission wall and the admission context β€” and `ut_write_ctx` returns the SAME six keys
        # this route reads, with a pid set derived from exactly the row ids `scoped_pool` would
        # have kept. What it does not do is materialise the rows nobody here looks at.
        from routes_tables import ut_write_ctx

        g = ut_write_ctx(session, scope)
        # ⭐⭐ W31-T20 / D-174 β€” A WRITE THAT NEEDS THE ROW SET REFUSES OUT LOUD, and this is the
        # half that is easy to skip because the code already "fails closed" without it. When a
        # read-through grid's population exceeds one window the pid set is EMPTY, and every
        # pid-bearing handler in `grid_events` then answers `False` β€” `overlay_patch` and
        # `add_to_list` both `return False` for a pid not in `allowed_pids`. On the wire that is
        # `rerender: false` and HTTP 200: a write the user watched succeed, that did nothing
        # ([[lost-write-looks-like-failed-read]]). Schema-only events β€” hide a field, save a view,
        # rename a column β€” name no pid and are untouched, which is D-170.
        # ⚠ THE TEST IS THE EVENT'S OWN KEYS, not a kind list: a new pid-bearing event type would
        # otherwise inherit the silent no-op the day it is added.
        if g.get("limits"):
            named = [e for e in events
                     if isinstance(e, dict) and (e.get("pid") is not None or e.get("pids"))]
            if named:
                lim = g["limits"][0]
                raise err(409, "pid_scope_unresolved",
                          f"this database is served through the connector mirror and its rows "
                          f"cannot be listed in one window, so a change addressed to particular "
                          f"records ({len(named)} of {len(events)} here) cannot be admitted β€” "
                          f"{lim.get('cause') or 'the row set is unresolved'}. "
                          f"{lim.get('recommendation') or ''}".strip())
    else:
        g = grid_assembly(session, scope=scope, consume_corrections=False)
    # ⚠ THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
    # `clean_measure_field` had an empty admission list and every measure-column create over
    # HTTP was silently refused; without `measure_keys`, `clean_filter_tree` stripped every
    # measure CONDITION out of a saved view. The embed always passed these; the API adapter
    # simply had not been given them β€” the standalone shell could read measures it could
    # never write.
    ctx = _ctx(session, g["fields"], g["pids"], scope_key=scope,
               measure_keys=frozenset(m["key"] for m in g["measures"]),
               resolved_ids=frozenset(g["measure_sets"]),
               cohort_ids=frozenset(c["id"] for c in g["lists"]),
               measure_offer=tuple(g["measures"]),
               visible_views=tuple(g["views"]))

    # Per-event results so the client can tell which of a batch landed β€” the component's own
    # bridge has no response channel at all, so this is strictly more than the embed gets.
    results = []
    try:
        for one in events:
            eid = str(one.get("id") or "") if isinstance(one, dict) else ""
            rerender = grid_events.handle_one(one, ctx)
            results.append({"id": eid, "rerender": bool(rerender)})
    except grid_events.StoreUnavailable:
        raise err(503, "store_unavailable",
                  "the tenant store is unavailable β€” none of your changes were saved")

    out = {"results": results, "rerender": any(r["rerender"] for r in results)}
    if ctx.out.doc is not None:
        # ⭐ C4 / W30-T27 β€” `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
        # carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
        # matches it against the fetch it is waiting on β€” while this route has been answering
        # `doc`, which `apiBridge.ts` deliberately drops. One object, emitted under the name the
        # consumer looks for, so F's wiring needs no translation step to get wrong.
        # ⚠ `doc` stays for one wave: nothing in the client reads it, but a gate might, and
        # removing a key to save six bytes is not worth a red nobody predicted.
        out["doc"] = out["docPayload"] = ctx.out.doc
    if ctx.out.toast is not None:
        out["toast"] = ctx.out.toast

    # ── ⭐ owner item 2 (2026-08-03): THE NEW MEASURE COLUMN'S VALUES, ONE ROUND TRIP SOONER ──
    #
    # Creating a measure column cost the browser TWO sequential trips before a single number
    # appeared: this one to persist the field, then a whole `/workspace` to compute it. The
    # second cannot start until the first lands (the resolver reads the PERSISTED field), so the
    # wait was structural, not slow code β€” the owner's "it takes some time for the data to
    # populate". The values are computed here instead, immediately after the write, and ride
    # this response.
    #
    # ⚠ IT COSTS NOTHING EXTRA TO COMPUTE. The expensive part is one DuckDB aggregate over the
    # book, and `rt.measure_memo` is keyed on (pool stamp, scope, pool, measure, window) β€” so
    # the `/workspace` re-read that still follows HITS the memo instead of doing this work. The
    # query happens once either way; only its position moved.
    #
    # ⚠ NARROW ON PURPOSE. Gated to an actual measure-column write, so an overlay edit or a
    # cohort add β€” the overwhelming majority of events β€” never pays for a second assembly.
    #
    # ⚠ AND IT IS A SHORTCUT, NOT A PATH. Any failure is swallowed: `WORKSPACE_STALE` still
    # fires from `rerender`, and the re-read still delivers these values exactly as it does
    # today. Nothing depends on this having worked.
    if out["rerender"] and scope in ("customer", "cohort") and any(
            isinstance(e, dict) and e.get("type") == "field_upsert"
            and str(((e.get("field") or {}) if isinstance(e.get("field"), dict) else {})
                    .get("key") or "").startswith("measure_")
            for e in events):
        try:
            fresh = grid_assembly(session, scope=scope, consume_corrections=False)
            out["derived"] = {str(pid): cells for pid, cells in fresh["derived"].items()}
        except Exception:
            pass
    # ⚠ NOTHING IS INVALIDATED HERE, on purpose. The runtime cache holds ONLY the scope-shaped
    # Odoo pool (see `routes_customers._pool_rows`), and no event on this route can change an
    # Odoo column β€” Odoo is read-only. Everything an event DOES change (overlays, fields, views,
    # folders, cohorts) is re-read from the store on the next request. An earlier version cleared
    # `pool_cache` after an `overlay_patch`, which threw away an expensive Odoo pull to refresh
    # data that was never in it.
    return out