fsanyoto commited on
Commit
c98e1b1
Β·
verified Β·
1 Parent(s): ea7b176

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ web/_qa_live_rail/1_before.png filter=lfs diff=lfs merge=lfs -text
37
+ web/_qa_live_rail/debug.png filter=lfs diff=lfs merge=lfs -text
api/routes_grid.py CHANGED
@@ -134,9 +134,18 @@ def workspace(scope: str = "customer",
134
  raise err(503, "store_unavailable", "the tenant store is unavailable")
135
  workspace = g["workspace"]
136
  workspace["overlays"] = g["ws"].get("overlays") or {}
 
 
 
 
137
  workspace["measures"] = g["measures"]
138
  workspace["measureSets"] = g["measure_sets"]
139
- workspace["derived"] = {}
 
 
 
 
 
140
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
141
  try:
142
  from core import users as _users
@@ -169,11 +178,18 @@ def workspace(scope: str = "customer",
169
  raise err(503, "store_unavailable", "the tenant store is unavailable")
170
  workspace = g["workspace"]
171
  workspace["overlays"] = g["ws"].get("overlays") or {}
 
 
 
172
  # The measure channel is EMPTY on this topic (customer-grain descope) β€” stated
173
  # explicitly so the client's pickers grey rather than guess.
174
  workspace["measures"] = g["measures"]
175
  workspace["measureSets"] = g["measure_sets"]
176
- workspace["derived"] = {}
 
 
 
 
177
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
178
  try:
179
  from core import users as _users
@@ -217,6 +233,21 @@ def workspace(scope: str = "customer",
217
  # (verify_api's overlay probe) without paying the pool call. Per-user by construction β€”
218
  # `table_workspace` is this session's workspace.
219
  workspace["overlays"] = g["ws"].get("overlays") or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  # ── the STANDALONE measure channel (owner item 1, 2026-07-31) ────────────────────────────
221
  # The embed receives these as top-level render args; standalone lifts them off THIS route
222
  # (useCustomerData merges them into the payload slots the grid already reads). They ride
 
134
  raise err(503, "store_unavailable", "the tenant store is unavailable")
135
  workspace = g["workspace"]
136
  workspace["overlays"] = g["ws"].get("overlays") or {}
137
+ # The field contract rides the cheap re-read on EVERY topic β€” see the customer branch
138
+ # below for why (a user table's first cohort changes its contract too: `_cohorts(ctx)`
139
+ # is scope-parameterized, so `ut_` surfaces have their own sets).
140
+ workspace["fields"] = g["fields"]
141
  workspace["measures"] = g["measures"]
142
  workspace["measureSets"] = g["measure_sets"]
143
+ # ⚠ `g["derived"]`, NOT `{}` (2026-08-04). R9 gave every topic its own cohort sets, and
144
+ # `ut_assembly` has been building this topic's cohort CELLS since β€” but this route threw
145
+ # them away, so the Locked-views column arrived on the cheap re-read with permanently
146
+ # empty values. A column that exists and can never have one is worse than an absent
147
+ # column: it reads as "this record is in no locked view", which is a claim.
148
+ workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
149
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
150
  try:
151
  from core import users as _users
 
178
  raise err(503, "store_unavailable", "the tenant store is unavailable")
179
  workspace = g["workspace"]
180
  workspace["overlays"] = g["ws"].get("overlays") or {}
181
+ # R9 made cohorts per-topic, so the PRODUCT surface has its own sets and its own
182
+ # first-cohort contract change. Same reason as the customer branch below.
183
+ workspace["fields"] = g["fields"]
184
  # The measure channel is EMPTY on this topic (customer-grain descope) β€” stated
185
  # explicitly so the client's pickers grey rather than guess.
186
  workspace["measures"] = g["measures"]
187
  workspace["measureSets"] = g["measure_sets"]
188
+ # `g["derived"]` β€” the same correction as the user-table branch above, for the same
189
+ # reason: `product_assembly` builds this topic's cohort cells and this route discarded
190
+ # them. The measure half stays empty on this topic by descope, which is a different
191
+ # statement and one the offer already makes.
192
+ workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
193
  workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
194
  try:
195
  from core import users as _users
 
233
  # (verify_api's overlay probe) without paying the pool call. Per-user by construction β€”
234
  # `table_workspace` is this session's workspace.
235
  workspace["overlays"] = g["ws"].get("overlays") or {}
236
+ # ⭐ THE FIELD CONTRACT, 2026-08-04 β€” and it is NOT decoration.
237
+ #
238
+ # `fields_from_workspace(ws, cohorts=bool(cohort_lists))` appends the derived "Locked
239
+ # views" column ONLY when the caller owns at least one cohort. So a user's FIRST cohort
240
+ # CHANGES THE FIELD CONTRACT β€” and the rows call that used to be the only carrier of
241
+ # `fields` is deliberately never re-fetched on a write (it is a 15-minute-cached Odoo
242
+ # pull; this route is the cheap re-read). The client therefore had no way to learn about
243
+ # that column short of a remount, which is the second half of the owner's "it only shows
244
+ # up when I switch modules and come back".
245
+ #
246
+ # ⚠ `g["fields"]` is the SAME list `_payload` serves on `/customers` β€” same assembly,
247
+ # same permission wall (`hidden_keys` already applied) β€” so the two wires cannot disagree
248
+ # about what a column is. Taking it from anywhere else would fork the contract that
249
+ # verify_fields_contract.py exists to keep single-sourced.
250
+ workspace["fields"] = g["fields"]
251
  # ── the STANDALONE measure channel (owner item 1, 2026-07-31) ────────────────────────────
252
  # The embed receives these as top-level render args; standalone lifts them off THIS route
253
  # (useCustomerData merges them into the payload slots the grid already reads). They ride
platform/core/store_pg.py CHANGED
@@ -10,9 +10,11 @@ Same names, same signatures, same return types, same failure semantics β€” so sw
10
  an env var (`STORE_BACKEND=hf|pg`) and not a rewrite of every caller. `core/store_backend.py`
11
  does the selection; nothing above it needs to know which store it is talking to.
12
 
13
- β›” NOT VERIFIED AGAINST A REAL SERVER β€” owner blocker **B-3** (choose + provision managed
14
- Postgres, US-region, co-located with Ashburn). `verify_store_pg.py` is written and it SKIPS LOUDLY
15
- without `DATABASE_URL`; it is not reported as passing, and this module is not the default backend.
 
 
16
  The schema is `harness/pg/schema.sql`. Sequenced around the blocker, never stubbed past it.
17
 
18
  WHAT POSTGRES FIXES, precisely (C1c β€” these are the reasons, not a preference):
 
10
  an env var (`STORE_BACKEND=hf|pg`) and not a rewrite of every caller. `core/store_backend.py`
11
  does the selection; nothing above it needs to know which store it is talking to.
12
 
13
+ βœ… VERIFIED against a real managed Postgres 2026-08-04 (W19: Neon us-east-2; `verify_store_pg.py`
14
+ 80/80 INCLUDING the integration half β€” schema apply, tenant provisioning, jsonb/bytea round-trips,
15
+ 80 concurrent FOR-UPDATE writes β€” run from HF egress; the owner's local network resets TLS:5432).
16
+ The CUTOVER remains parked behind C1e's triggers and this module is not the default backend.
17
+ Without `DATABASE_URL` the gate still SKIPS its integration half LOUDLY, never silently.
18
  The schema is `harness/pg/schema.sql`. Sequenced around the blocker, never stubbed past it.
19
 
20
  WHAT POSTGRES FIXES, precisely (C1c β€” these are the reasons, not a preference):
platform/harness/pg/schema.sql CHANGED
@@ -8,11 +8,11 @@
8
  -- billing mirror) is transactional and needs joins; the ANALYTICAL mirror stays in DuckDB,
9
  -- one file per tenant, because file-per-tenant IS the isolation model there.
10
  --
11
- -- β›” NOT YET RUN AGAINST A REAL SERVER. Owner blocker **B-3** (choose + provision managed
12
- -- Postgres, US-region, co-located with Ashburn). This file and `core/store_pg.py` are the
13
- -- sequenced-around work; the integration test is written but PENDING and is NOT reported as
14
- -- passing. Nothing here is claimed to be verified beyond "it parses and the shapes match the
15
- -- interface `core/store.py` already exposes".
16
  --
17
  -- ⚠ CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
18
  -- the reason the market pivot happened.
 
8
  -- billing mirror) is transactional and needs joins; the ANALYTICAL mirror stays in DuckDB,
9
  -- one file per tenant, because file-per-tenant IS the isolation model there.
10
  --
11
+ -- βœ… RUN AGAINST A REAL SERVER 2026-08-04 (W19): applied end-to-end by verify_store_pg's
12
+ -- integration half against the owner's Neon (psycopg executes everything ABOVE the
13
+ -- "-- Optional:" marker; the tail below it is psql-variable syntax). Tenant provisioning,
14
+ -- jsonb/bytea round-trips and 80 concurrent FOR-UPDATE writes all proven. The CUTOVER remains
15
+ -- parked behind C1e's triggers; `STORE_BACKEND` still defaults to hf.
16
  --
17
  -- ⚠ CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
18
  -- the reason the market pivot happened.
web/_qa_live_rail/1_before.png ADDED

Git LFS Details

  • SHA256: ca520eaac323d28762f8b5d04c8d788f7504f90a4e49527e8f72d912432bc276
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
web/_qa_live_rail/2_menu.png ADDED
web/_qa_live_rail/3_prompt.png ADDED
web/_qa_live_rail/4_after.png ADDED
web/_qa_live_rail/5_fields.png ADDED
web/_qa_live_rail/debug.png ADDED

Git LFS Details

  • SHA256: ca520eaac323d28762f8b5d04c8d788f7504f90a4e49527e8f72d912432bc276
  • Pointer size: 131 Bytes
  • Size of remote file: 117 kB
web/dist-embed/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/CustomerGrid.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/OverlaySurface.tsx CHANGED
@@ -80,7 +80,24 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
80
  );
81
  }
82
 
83
- function anchorRect(anchor: Anchor): AnchorRect {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  if ("getBoundingClientRect" in anchor) {
85
  const rect = anchor.getBoundingClientRect();
86
  return {
@@ -109,10 +126,10 @@ interface OverlayLayerOptions {
109
  panelRef: RefObject<HTMLElement | null>;
110
  onDismiss: () => void;
111
  dismissOnOutside?: boolean;
112
- initialFocus?: "first" | "none" | string;
113
- restoreFocus?: boolean;
114
- trapFocus?: boolean;
115
- outsideElements?: Array<HTMLElement | null>;
116
  }
117
 
118
  /** Shared dismissal/focus contract for anchored menus and fixed drawers. */
@@ -121,10 +138,10 @@ export function useOverlayLayer({
121
  panelRef,
122
  onDismiss,
123
  dismissOnOutside = true,
124
- initialFocus = "first",
125
- restoreFocus = true,
126
- trapFocus = false,
127
- outsideElements = [],
128
  }: OverlayLayerOptions): void {
129
  const id = useId();
130
  const stack = useContext(OverlayStackContext);
@@ -147,47 +164,47 @@ export function useOverlayLayer({
147
  )
148
  document.activeElement.blur();
149
  dismissRef.current();
150
- };
151
- const onKeyDown = (event: KeyboardEvent) => {
152
- if (stack && !stack.isTop(id)) return;
153
- if (event.key === "Escape") {
154
- event.preventDefault();
155
- event.stopPropagation();
156
- if (
157
- document.activeElement instanceof HTMLElement &&
158
- panelRef.current?.contains(document.activeElement)
159
- )
160
- document.activeElement.blur();
161
- dismissRef.current();
162
- return;
163
- }
164
- if (event.key !== "Tab" || !trapFocus) return;
165
- const focusable = Array.from(
166
- panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
167
- ).filter((element) => element.getClientRects().length > 0);
168
- if (!focusable.length) {
169
- event.preventDefault();
170
- panelRef.current?.focus();
171
- return;
172
- }
173
- const first = focusable[0];
174
- const last = focusable.at(-1)!;
175
- const active = document.activeElement;
176
- if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
177
- event.preventDefault();
178
- last.focus();
179
- } else if (!event.shiftKey && active === last) {
180
- event.preventDefault();
181
- first.focus();
182
- }
183
- };
184
  document.addEventListener("pointerdown", onPointerDown, true);
185
  document.addEventListener("keydown", onKeyDown, true);
186
  return () => {
187
  document.removeEventListener("pointerdown", onPointerDown, true);
188
  document.removeEventListener("keydown", onKeyDown, true);
189
  };
190
- }, [dismissOnOutside, id, panelRef, stack, trapFocus]);
191
 
192
  useLayoutEffect(() => {
193
  const previous =
@@ -210,7 +227,10 @@ export function BodyPortal({ children }: { children: ReactNode }) {
210
  }
211
 
212
  interface AnchoredOverlayProps {
213
- anchor: Anchor;
 
 
 
214
  className: string;
215
  children: ReactNode;
216
  onDismiss: () => void;
@@ -248,7 +268,7 @@ export function AnchoredOverlay({
248
  top: 0,
249
  visibility: "hidden",
250
  });
251
- const anchorElement = "getBoundingClientRect" in anchor ? anchor : null;
252
 
253
  useOverlayLayer({
254
  panelRef,
 
80
  );
81
  }
82
 
83
+ /**
84
+ * ⚠ NULL IS A REACHABLE VALUE HERE, and it must not be fatal.
85
+ *
86
+ * Half the call sites pass `someRef.current`, which is legitimately null on the render
87
+ * before the ref attaches, and `Anchor` does not include null β€” so every one of them was
88
+ * one ordering accident away from `"getBoundingClientRect" in null`, a TypeError thrown
89
+ * during RENDER, which unmounts the whole tree. That is not a hypothetical: it is what
90
+ * `_qa_live_rail.py` reproduced on the shipped build when the saved-view menu's anchor
91
+ * came back null (2026-08-04).
92
+ *
93
+ * A missing anchor is a positioning problem, not a reason to lose the application. The
94
+ * panel degrades to a zero-rect at the viewport origin β€” visible, dismissible, obviously
95
+ * wrong β€” while the root causes stay fixable at their own call sites.
96
+ */
97
+ const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
98
+
99
+ function anchorRect(anchor: Anchor | null | undefined): AnchorRect {
100
+ if (!anchor) return NO_RECT;
101
  if ("getBoundingClientRect" in anchor) {
102
  const rect = anchor.getBoundingClientRect();
103
  return {
 
126
  panelRef: RefObject<HTMLElement | null>;
127
  onDismiss: () => void;
128
  dismissOnOutside?: boolean;
129
+ initialFocus?: "first" | "none" | string;
130
+ restoreFocus?: boolean;
131
+ trapFocus?: boolean;
132
+ outsideElements?: Array<HTMLElement | null>;
133
  }
134
 
135
  /** Shared dismissal/focus contract for anchored menus and fixed drawers. */
 
138
  panelRef,
139
  onDismiss,
140
  dismissOnOutside = true,
141
+ initialFocus = "first",
142
+ restoreFocus = true,
143
+ trapFocus = false,
144
+ outsideElements = [],
145
  }: OverlayLayerOptions): void {
146
  const id = useId();
147
  const stack = useContext(OverlayStackContext);
 
164
  )
165
  document.activeElement.blur();
166
  dismissRef.current();
167
+ };
168
+ const onKeyDown = (event: KeyboardEvent) => {
169
+ if (stack && !stack.isTop(id)) return;
170
+ if (event.key === "Escape") {
171
+ event.preventDefault();
172
+ event.stopPropagation();
173
+ if (
174
+ document.activeElement instanceof HTMLElement &&
175
+ panelRef.current?.contains(document.activeElement)
176
+ )
177
+ document.activeElement.blur();
178
+ dismissRef.current();
179
+ return;
180
+ }
181
+ if (event.key !== "Tab" || !trapFocus) return;
182
+ const focusable = Array.from(
183
+ panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
184
+ ).filter((element) => element.getClientRects().length > 0);
185
+ if (!focusable.length) {
186
+ event.preventDefault();
187
+ panelRef.current?.focus();
188
+ return;
189
+ }
190
+ const first = focusable[0];
191
+ const last = focusable.at(-1)!;
192
+ const active = document.activeElement;
193
+ if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
194
+ event.preventDefault();
195
+ last.focus();
196
+ } else if (!event.shiftKey && active === last) {
197
+ event.preventDefault();
198
+ first.focus();
199
+ }
200
+ };
201
  document.addEventListener("pointerdown", onPointerDown, true);
202
  document.addEventListener("keydown", onKeyDown, true);
203
  return () => {
204
  document.removeEventListener("pointerdown", onPointerDown, true);
205
  document.removeEventListener("keydown", onKeyDown, true);
206
  };
207
+ }, [dismissOnOutside, id, panelRef, stack, trapFocus]);
208
 
209
  useLayoutEffect(() => {
210
  const previous =
 
227
  }
228
 
229
  interface AnchoredOverlayProps {
230
+ /** ⚠ Nullable BY DECLARATION as of 2026-08-04. Several call sites pass `ref.current`,
231
+ * which is null before the ref attaches, and the old non-null type made that a
232
+ * render-time TypeError instead of a type error. See `anchorRect`. */
233
+ anchor: Anchor | null | undefined;
234
  className: string;
235
  children: ReactNode;
236
  onDismiss: () => void;
 
268
  top: 0,
269
  visibility: "hidden",
270
  });
271
+ const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null;
272
 
273
  useOverlayLayer({
274
  panelRef,
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -717,13 +717,15 @@ export default function ViewSidebar({
717
  className="cg-view-more"
718
  aria-label={`Actions for folder ${group.folder.name}`}
719
  aria-haspopup="menu"
720
- onClick={(e) =>
 
 
 
 
721
  setFolderMenu((cur) =>
722
- cur?.id === group.folder!.id
723
- ? null
724
- : { id: group.folder!.id, anchor: e.currentTarget }
725
- )
726
- }
727
  >
728
  Β·Β·Β·
729
  </button>
@@ -850,13 +852,29 @@ export default function ViewSidebar({
850
  aria-label={`Actions for ${view.name}`}
851
  aria-haspopup="menu"
852
  aria-expanded={menu?.viewId === view.id}
853
- onClick={(event) =>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
854
  setMenu((current) =>
855
- current?.viewId === view.id
856
- ? null
857
- : { viewId: view.id, anchor: event.currentTarget }
858
- )
859
- }
860
  >
861
  Β·Β·Β·
862
  </button>
 
717
  className="cg-view-more"
718
  aria-label={`Actions for folder ${group.folder.name}`}
719
  aria-haspopup="menu"
720
+ onClick={(e) => {
721
+ // β›” HOISTED OUT OF THE UPDATER β€” see the view menu's twin below. React
722
+ // nulls `currentTarget` when the handler returns, and a functional
723
+ // updater can be re-invoked AFTER that.
724
+ const anchor = e.currentTarget;
725
  setFolderMenu((cur) =>
726
+ cur?.id === group.folder!.id ? null : { id: group.folder!.id, anchor }
727
+ );
728
+ }}
 
 
729
  >
730
  Β·Β·Β·
731
  </button>
 
852
  aria-label={`Actions for ${view.name}`}
853
  aria-haspopup="menu"
854
  aria-expanded={menu?.viewId === view.id}
855
+ onClick={(event) => {
856
+ /**
857
+ * β›” THE ANCHOR IS READ HERE, NOT INSIDE THE UPDATER β€” and that one line
858
+ * is the difference between this rail working and the whole app going
859
+ * white. Found by _qa_live_rail.py on 2026-08-04, reproduced on the
860
+ * shipped build.
861
+ *
862
+ * React sets `event.currentTarget = null` the moment this handler
863
+ * returns (`executeDispatch`'s finally). A FUNCTIONAL updater is not
864
+ * guaranteed to run inside the handler: the eager-state path evaluates
865
+ * it once, synchronously, while `currentTarget` is still the button β€”
866
+ * but React re-invokes it when it processes the queue for real, and by
867
+ * then it is null. The menu therefore opened correctly and only died
868
+ * later, on a re-render triggered by something else entirely β€” which is
869
+ * why a white screen appeared one interaction AFTER the click that
870
+ * caused it. `AnchoredOverlay` then did `"getBoundingClientRect" in
871
+ * null` and took the tree down with it.
872
+ */
873
+ const anchor = event.currentTarget;
874
  setMenu((current) =>
875
+ current?.viewId === view.id ? null : { viewId: view.id, anchor }
876
+ );
877
+ }}
 
 
878
  >
879
  Β·Β·Β·
880
  </button>
web/src/customer-grid/liveWorkspace.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // customer-grid / liveWorkspace.ts
3
+ // The workspace that arrives AFTER mount β€” pure and React-free, so
4
+ // verify_live_workspace.py can run it under node.
5
+ //
6
+ // β›” THE DEFECT THIS EXISTS FOR (owner, 2026-08-04). "Creating a new Cohort or
7
+ // Locked list under Product/Customer only shows up when I click a different
8
+ // module first and come back."
9
+ //
10
+ // It was exact. `CustomerGrid`'s init effect is gated on
11
+ // `initializedKey.current === storageKey`, so it reads `payload.workspace`
12
+ // EXACTLY ONCE per mount. Under Streamlit that was invisible β€” a rerun replaces
13
+ // the iframe, so every host round trip WAS a remount and init ran again. The
14
+ // standalone shell has no rerun: `CustomerGrid` stays mounted, and the only
15
+ // thing that remounts it is `key={active.key}` in Shell.tsx β€” i.e. switching
16
+ // modules, which is precisely the workaround the owner found.
17
+ //
18
+ // So the write path was complete and the READ path stopped at the door:
19
+ // `add_to_list` β†’ the host creates the set β†’ `rerender: true` β†’
20
+ // WORKSPACE_STALE_EVENT β†’ `reread()` β†’ `payload.workspace` genuinely carries the
21
+ // new locked view (wave 17 R1 projects every cohort as a view) β€” and `views`
22
+ // state, seeded once at init, never heard about it.
23
+ //
24
+ // TWO STRATA GO STALE TOGETHER, and fixing only the first would have looked
25
+ // fixed while staying broken:
26
+ // views the projected locked view = the rail row the owner is looking for.
27
+ // fields `fields_from_workspace(ws, cohorts=bool(cohort_lists))` β€” the
28
+ // derived "Locked views" column EXISTS ONLY ONCE A COHORT DOES. The
29
+ // user's FIRST cohort therefore changes the field contract, and an
30
+ // init-once `fields` would have left that column out of the Fields
31
+ // menu until the very remount we are removing the need for.
32
+ //
33
+ // ⭐ ADD-ONLY, AND THAT IS A DESIGN DECISION, NOT AN OMISSION. Everything the
34
+ // user already holds is left BY IDENTITY: an in-flight filter tree, a config
35
+ // mid-autosave (420 ms debounce), a rename waiting on its echo. This module is
36
+ // the answer to "what has APPEARED since we mounted", and nothing else. Taking
37
+ // host copies of things we already hold would re-introduce every blip
38
+ // optimism.ts / viewEcho.ts / folders.ts were written to remove β€” the whole
39
+ // no-blip layer rests on "this browser's copy is the newest truth".
40
+ //
41
+ // ⚠ TOMBSTONES ARE NOT OPTIONAL HERE, and an add-only merge without them is
42
+ // WORSE than the bug it fixes. The event queue sends ONE batch at a time
43
+ // (apiBridge `drain`), so this interleaving is ordinary:
44
+ // batch 1 [add_to_list] in flight
45
+ // user deletes a view β†’ removed optimistically, queued behind batch 1
46
+ // batch 1 answers β†’ rerender β†’ reread β†’ the workspace STILL lists
47
+ // the deleted view (its delete has not been sent)
48
+ // Without a tombstone the row comes back β€” and since this merge never removes,
49
+ // it would stay back until a remount. That is a delete that visibly failed.
50
+ // Same window and same rule as folders.ts and optimism.ts: ECHO_RECENT_MS, and
51
+ // anything past it yields to the host, because divergence is not an echo.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ import { ECHO_RECENT_MS } from "./viewEcho";
55
+ import type { Field, SavedView, ViewConfig } from "./types";
56
+
57
+ /** id -> when THIS browser deleted it. Browser-clock arithmetic on purpose:
58
+ * both sides of the comparison come from this machine, so this is not the
59
+ * tenant-day contract ([[date-window-vocabulary]]) β€” that one is about two
60
+ * ENGINES agreeing on a date. */
61
+ export type Tombstones = Record<string, number>;
62
+
63
+ /** Upper bound on a tombstone map, matching FOLDER_STAMP_MAX. A long-lived tab
64
+ * must not accumulate an archive of everything it ever deleted. */
65
+ export const TOMBSTONE_MAX = 64;
66
+
67
+ const isRecent = (t: number | undefined, now: number): boolean =>
68
+ typeof t === "number" && now - t <= ECHO_RECENT_MS;
69
+
70
+ /** Drop entries past the echo window. Called at every stamp AND before every
71
+ * persist, so the blob stays a recent window rather than a growing log. */
72
+ export function pruneTombstones(stamps: Tombstones | undefined, now: number): Tombstones {
73
+ const kept = Object.entries(stamps ?? {}).filter(
74
+ ([, t]) => typeof t === "number" && isRecent(t, now)
75
+ );
76
+ return Object.fromEntries(kept.slice(-TOMBSTONE_MAX));
77
+ }
78
+
79
+ /** Record one deletion. Pure so the caller's ref update stays a one-liner. */
80
+ export function stampTombstone(stamps: Tombstones | undefined, id: string, now: number): Tombstones {
81
+ return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
82
+ }
83
+
84
+ /**
85
+ * Views that have APPEARED on the host since this browser last looked.
86
+ *
87
+ * Returns `current` BY IDENTITY when there is nothing to adopt β€” which is the
88
+ * load-bearing half of the contract, not an optimisation. `withWorkspace` mints
89
+ * a fresh payload object on every re-read, so `hostViews` changes identity each
90
+ * time whether or not its contents did; a merge that always returned a new
91
+ * array would re-render the grid (and re-write localStorage) on every echo.
92
+ *
93
+ * `normalize` is injected rather than imported because it needs the FIELD LIST
94
+ * the caller is about to commit β€” a locked view's projected `config.order` names
95
+ * the derived cohort column, and normalizing against a stale field array would
96
+ * quietly drop the very key that arrived with it. The caller therefore adopts
97
+ * fields FIRST and hands the result down (see CustomerGrid's live effect).
98
+ */
99
+ export function adoptNewViews(
100
+ current: SavedView[],
101
+ hostViews: SavedView[] | undefined,
102
+ tombstones: Tombstones | undefined,
103
+ now: number,
104
+ normalize: (config: Partial<ViewConfig> | undefined) => ViewConfig
105
+ ): SavedView[] {
106
+ if (!Array.isArray(hostViews) || hostViews.length === 0) return current;
107
+ const held = new Set(current.map((v) => v.id));
108
+ const fresh: SavedView[] = [];
109
+ for (const view of hostViews) {
110
+ if (!view || typeof view.id !== "string" || view.id === "") continue;
111
+ if (held.has(view.id)) continue;
112
+ // Deleted here seconds ago and the echo has not caught up. Resurrecting it β€”
113
+ // even for one round trip β€” is the delete blip, and this merge never removes,
114
+ // so it would be a permanent one.
115
+ if (isRecent(tombstones?.[view.id], now)) continue;
116
+ held.add(view.id); // a host list with a duplicate id adds once
117
+ fresh.push({ ...view, config: normalize(view.config) });
118
+ }
119
+ return fresh.length === 0 ? current : [...current, ...fresh];
120
+ }
121
+
122
+ /**
123
+ * Fields that have APPEARED on the host since this browser last looked β€” in
124
+ * practice the derived "Locked views" column, which the server emits only once
125
+ * the user owns at least one cohort.
126
+ *
127
+ * Appended in host order at the END, which is where `fields_from_workspace`
128
+ * puts the derived column anyway, and `reconcileOrder` folds any key missing
129
+ * from a saved `config.order` in for us β€” so nothing has to touch a stored view
130
+ * for the new column to become togglable in the Fields menu.
131
+ *
132
+ * ⚠ The tombstone map here is `FieldStamps.deleted`, the SAME one
133
+ * `reconcileFields` consults at mount, for the same reason: a column this
134
+ * browser dropped must not walk back in through a lagged echo.
135
+ */
136
+ export function adoptNewFields(
137
+ current: Field[],
138
+ hostFields: Field[] | undefined,
139
+ tombstones: Tombstones | undefined,
140
+ now: number
141
+ ): Field[] {
142
+ if (!Array.isArray(hostFields) || hostFields.length === 0) return current;
143
+ const held = new Set(current.map((f) => f.key));
144
+ const fresh: Field[] = [];
145
+ for (const field of hostFields) {
146
+ if (!field || typeof field.key !== "string" || field.key === "") continue;
147
+ if (held.has(field.key)) continue;
148
+ if (isRecent(tombstones?.[field.key], now)) continue;
149
+ held.add(field.key);
150
+ fresh.push(field);
151
+ }
152
+ return fresh.length === 0 ? current : [...current, ...fresh];
153
+ }
web/src/customer-grid/types.ts CHANGED
@@ -2109,6 +2109,17 @@ export interface GridWorkspace {
2109
  storageKey: string;
2110
  views: SavedView[];
2111
  activeViewId?: string;
 
 
 
 
 
 
 
 
 
 
 
2112
  /** C4 β€” the view rail's folders. Absent = no folders, the pre-wave-8 shape. */
2113
  folders?: GridFolder[];
2114
  /** C4 β€” the cohort rail's folders (the cohort page's workspace). */
 
2109
  storageKey: string;
2110
  views: SavedView[];
2111
  activeViewId?: string;
2112
+ /**
2113
+ * 2026-08-04 β€” the FIELD CONTRACT, so it rides the cheap re-read a durable write triggers
2114
+ * (WORKSPACE_STALE) instead of the heavy rows call, which is deliberately never refetched.
2115
+ *
2116
+ * ⚠ This is not a convenience copy. `fields_from_workspace(ws, cohorts=…)` emits the derived
2117
+ * "Locked views" column only once a cohort EXISTS, so a user's FIRST cohort changes the
2118
+ * contract β€” and before this key the client could not learn that without a remount. Same
2119
+ * list the rows route serves, from the same assembly, with the same permission wall applied.
2120
+ * Absent = an older host: the client keeps the fields it already has (`withWorkspace`).
2121
+ */
2122
+ fields?: Field[];
2123
  /** C4 β€” the view rail's folders. Absent = no folders, the pre-wave-8 shape. */
2124
  folders?: GridFolder[];
2125
  /** C4 β€” the cohort rail's folders (the cohort page's workspace). */
web/src/customer-grid/useCustomerData.ts CHANGED
@@ -64,6 +64,17 @@ function readHostPayload(): CustomersPayload | null {
64
  */
65
  function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
66
  const out: CustomersPayload = { ...payload, workspace: ws };
 
 
 
 
 
 
 
 
 
 
 
67
  if (Array.isArray(ws.measures)) out.measures = ws.measures;
68
  if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
69
  if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
 
64
  */
65
  function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
66
  const out: CustomersPayload = { ...payload, workspace: ws };
67
+ // 2026-08-04 β€” the FIELD CONTRACT rides this cheap call too. The derived "Locked views"
68
+ // column exists only once the caller owns a cohort, so creating the first one changes the
69
+ // contract; the rows call that used to be its only carrier is never refetched on a write.
70
+ //
71
+ // ⚠ IDENTITY IS PRESERVED WHEN NOTHING CHANGED. `payload.fields` is a hook dependency
72
+ // downstream (CustomerGrid's live adopt), and a fresh array on every echo would re-run
73
+ // that effect for no reason. An empty/absent list keeps what we already have rather than
74
+ // blanking the table β€” absent means "an older host", never "this table has no columns".
75
+ if (Array.isArray(ws.fields) && ws.fields.length > 0 &&
76
+ JSON.stringify(ws.fields) !== JSON.stringify(payload.fields))
77
+ out.fields = ws.fields;
78
  if (Array.isArray(ws.measures)) out.measures = ws.measures;
79
  if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
80
  if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;