fsanyoto commited on
Commit
e9ce12c
·
verified ·
1 Parent(s): b7b409f

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
README.md CHANGED
@@ -1,13 +1,13 @@
1
- ---
2
- title: Loopable
3
- emoji: 🗂
4
- colorFrom: indigo
5
- colorTo: gray
6
- sdk: docker
7
- app_port: 7860
8
- pinned: false
9
- ---
10
-
11
- Loopable — the AIOS platform shell: a React front end (Airtable-grade grid + dashboard pages)
12
- over a FastAPI service reusing the platform data layer. Login-gated: a branded login page over an HMAC-signed session cookie, with per-user
13
- business-unit scope and module grants. Separate from the Streamlit app.
 
1
+ ---
2
+ title: Loopable
3
+ emoji: 🗂
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ Loopable — the AIOS platform shell: a React front end (Airtable-grade grid + dashboard pages)
12
+ over a FastAPI service reusing the platform data layer. Login-gated: a branded login page over an HMAC-signed session cookie, with per-user
13
+ business-unit scope and module grants. Separate from the Streamlit app.
RELEASES.json CHANGED
@@ -1,323 +1,323 @@
1
- {
2
- "current": "4579959",
3
- "releases": [
4
- {
5
- "version": "v53",
6
- "sha": "89ed2cb",
7
- "date": "2026-08-24",
8
- "subject": "release v53"
9
- },
10
- {
11
- "version": "v52",
12
- "sha": "8263493",
13
- "date": "2026-08-23",
14
- "subject": "release v52"
15
- },
16
- {
17
- "version": "v51",
18
- "sha": "5740d20",
19
- "date": "2026-08-23",
20
- "subject": "release v51"
21
- },
22
- {
23
- "version": "v50",
24
- "sha": "8027c83",
25
- "date": "2026-08-23",
26
- "subject": "release v50"
27
- },
28
- {
29
- "version": "v49",
30
- "sha": "9a14c01",
31
- "date": "2026-08-23",
32
- "subject": "release v49"
33
- },
34
- {
35
- "version": "v48",
36
- "sha": "3c69fd9",
37
- "date": "2026-08-23",
38
- "subject": "release v48"
39
- },
40
- {
41
- "version": "v47",
42
- "sha": "917f9a0",
43
- "date": "2026-08-23",
44
- "subject": "release v47"
45
- },
46
- {
47
- "version": "v46",
48
- "sha": "a8dc531",
49
- "date": "2026-08-23",
50
- "subject": "release v46"
51
- },
52
- {
53
- "version": "v45",
54
- "sha": "0b2b501",
55
- "date": "2026-08-23",
56
- "subject": "release v45"
57
- },
58
- {
59
- "version": "v44",
60
- "sha": "a7877aa",
61
- "date": "2026-08-23",
62
- "subject": "Hotfix: allow renaming the active Map view"
63
- },
64
- {
65
- "version": "v43",
66
- "sha": "e2ba461",
67
- "date": "2026-08-23",
68
- "subject": "release v43"
69
- },
70
- {
71
- "version": "v42",
72
- "sha": "25b885e",
73
- "date": "2026-08-23",
74
- "subject": "release v42"
75
- },
76
- {
77
- "version": "v41",
78
- "sha": "1d6adcc",
79
- "date": "2026-08-22",
80
- "subject": "v41 vertical route navigation"
81
- },
82
- {
83
- "version": "v40",
84
- "sha": "1451837",
85
- "date": "2026-08-22",
86
- "subject": "release v40 route UI correction"
87
- },
88
- {
89
- "version": "v39",
90
- "sha": "a78bdf0",
91
- "date": "2026-08-21",
92
- "subject": "W39 clean stale migration targets"
93
- },
94
- {
95
- "version": "v38",
96
- "sha": "a556631",
97
- "date": "2026-08-21",
98
- "subject": "W39 reversible Live store cutover"
99
- },
100
- {
101
- "version": "v37",
102
- "sha": "1ddd8cf",
103
- "date": "2026-08-21",
104
- "subject": "W39: coalesce derived workspace reads"
105
- },
106
- {
107
- "version": "v36",
108
- "sha": "51db3b7",
109
- "date": "2026-08-21",
110
- "subject": "W39 share-field oid repair"
111
- },
112
- {
113
- "version": "v35",
114
- "sha": "0f64212",
115
- "date": "2026-08-21",
116
- "subject": "W39 visual QA fixture"
117
- },
118
- {
119
- "version": "v34",
120
- "sha": "cb4caa1",
121
- "date": "2026-08-21",
122
- "subject": "W39 Staging PostgreSQL egress controls"
123
- },
124
- {
125
- "version": "v33",
126
- "sha": "2b115e5",
127
- "date": "2026-08-21",
128
- "subject": "W39: stop nonproduction Postgres background egress"
129
- },
130
- {
131
- "version": "v32",
132
- "sha": "b47b27c",
133
- "date": "2026-08-21",
134
- "subject": "W39: repair Staging Postgres projection"
135
- },
136
- {
137
- "version": "v31",
138
- "sha": "8880a49",
139
- "date": "2026-08-21",
140
- "subject": "v31: Wave 39 staging candidate"
141
- },
142
- {
143
- "version": "v30",
144
- "sha": "71776ca",
145
- "date": "2026-08-20",
146
- "subject": "v30: W39-T29 Member-owned View first-share hotfix"
147
- },
148
- {
149
- "version": "v29",
150
- "sha": "55ced50",
151
- "date": "2026-08-18",
152
- "subject": "v29: the build staging has been running since 2026-08-18 (55ced50)."
153
- },
154
- {
155
- "version": "v28",
156
- "sha": "2793e4b",
157
- "date": "2026-08-18",
158
- "subject": "release v28"
159
- },
160
- {
161
- "version": "v27",
162
- "sha": "c05a794",
163
- "date": "2026-08-18",
164
- "subject": "release v27"
165
- },
166
- {
167
- "version": "v25",
168
- "sha": "bc24829",
169
- "date": "2026-08-14",
170
- "subject": "v25 - wave 32's LIVE build (deployed 2026-08-13, bc24829)"
171
- },
172
- {
173
- "version": "v26",
174
- "sha": "6a642e1",
175
- "date": "2026-08-14",
176
- "subject": "v26 - wave 33's build (deployed 2026-08-14, 6a642e1)"
177
- },
178
- {
179
- "version": "v24",
180
- "sha": "514d829",
181
- "date": "2026-08-09",
182
- "subject": "release v24"
183
- },
184
- {
185
- "version": "v23",
186
- "sha": "44cc283",
187
- "date": "2026-08-09",
188
- "subject": "release v23"
189
- },
190
- {
191
- "version": "v22",
192
- "sha": "f14e199",
193
- "date": "2026-08-09",
194
- "subject": "release v22"
195
- },
196
- {
197
- "version": "v21",
198
- "sha": "5fdffce",
199
- "date": "2026-08-09",
200
- "subject": "release v21"
201
- },
202
- {
203
- "version": "v20",
204
- "sha": "442e4a1",
205
- "date": "2026-08-09",
206
- "subject": "release v20"
207
- },
208
- {
209
- "version": "v19",
210
- "sha": "94cb4a4",
211
- "date": "2026-08-09",
212
- "subject": "release v19"
213
- },
214
- {
215
- "version": "v18",
216
- "sha": "0106ef9",
217
- "date": "2026-08-09",
218
- "subject": "release v18"
219
- },
220
- {
221
- "version": "v17",
222
- "sha": "308240c",
223
- "date": "2026-08-09",
224
- "subject": "release v17"
225
- },
226
- {
227
- "version": "v16",
228
- "sha": "a804df5",
229
- "date": "2026-08-09",
230
- "subject": "release v16"
231
- },
232
- {
233
- "version": "v15",
234
- "sha": "6b84d72",
235
- "date": "2026-08-09",
236
- "subject": "release v15"
237
- },
238
- {
239
- "version": "v14",
240
- "sha": "bee5eaf",
241
- "date": "2026-08-07",
242
- "subject": "release v14"
243
- },
244
- {
245
- "version": "v13",
246
- "sha": "314c0e3",
247
- "date": "2026-08-06",
248
- "subject": "release v13"
249
- },
250
- {
251
- "version": "v12",
252
- "sha": "4908f39",
253
- "date": "2026-08-06",
254
- "subject": "release v12"
255
- },
256
- {
257
- "version": "v11",
258
- "sha": "3126617",
259
- "date": "2026-08-05",
260
- "subject": "release v11"
261
- },
262
- {
263
- "version": "v10",
264
- "sha": "43f8042",
265
- "date": "2026-08-05",
266
- "subject": "release v10"
267
- },
268
- {
269
- "version": "v9",
270
- "sha": "2e26f57",
271
- "date": "2026-08-05",
272
- "subject": "release v9"
273
- },
274
- {
275
- "version": "v8",
276
- "sha": "8073b3e",
277
- "date": "2026-08-05",
278
- "subject": "release v8"
279
- },
280
- {
281
- "version": "v7",
282
- "sha": "b1c5a7e",
283
- "date": "2026-08-05",
284
- "subject": "release v7"
285
- },
286
- {
287
- "version": "v6",
288
- "sha": "a344020",
289
- "date": "2026-08-05",
290
- "subject": "release v6"
291
- },
292
- {
293
- "version": "v5",
294
- "sha": "c0b0fbb",
295
- "date": "2026-08-05",
296
- "subject": "release v5"
297
- },
298
- {
299
- "version": "v4",
300
- "sha": "de5037f",
301
- "date": "2026-08-05",
302
- "subject": "release v4"
303
- },
304
- {
305
- "version": "v3",
306
- "sha": "6b9fa62",
307
- "date": "2026-08-05",
308
- "subject": "release v3"
309
- },
310
- {
311
- "version": "v2",
312
- "sha": "fd05861",
313
- "date": "2026-08-04",
314
- "subject": "release v2"
315
- },
316
- {
317
- "version": "v1",
318
- "sha": "5b4e2c4",
319
- "date": "2026-08-04",
320
- "subject": "release v1"
321
- }
322
- ]
323
  }
 
1
+ {
2
+ "current": "255773b",
3
+ "releases": [
4
+ {
5
+ "version": "v53",
6
+ "sha": "89ed2cb",
7
+ "date": "2026-08-24",
8
+ "subject": "release v53"
9
+ },
10
+ {
11
+ "version": "v52",
12
+ "sha": "8263493",
13
+ "date": "2026-08-23",
14
+ "subject": "release v52"
15
+ },
16
+ {
17
+ "version": "v51",
18
+ "sha": "5740d20",
19
+ "date": "2026-08-23",
20
+ "subject": "release v51"
21
+ },
22
+ {
23
+ "version": "v50",
24
+ "sha": "8027c83",
25
+ "date": "2026-08-23",
26
+ "subject": "release v50"
27
+ },
28
+ {
29
+ "version": "v49",
30
+ "sha": "9a14c01",
31
+ "date": "2026-08-23",
32
+ "subject": "release v49"
33
+ },
34
+ {
35
+ "version": "v48",
36
+ "sha": "3c69fd9",
37
+ "date": "2026-08-23",
38
+ "subject": "release v48"
39
+ },
40
+ {
41
+ "version": "v47",
42
+ "sha": "917f9a0",
43
+ "date": "2026-08-23",
44
+ "subject": "release v47"
45
+ },
46
+ {
47
+ "version": "v46",
48
+ "sha": "a8dc531",
49
+ "date": "2026-08-23",
50
+ "subject": "release v46"
51
+ },
52
+ {
53
+ "version": "v45",
54
+ "sha": "0b2b501",
55
+ "date": "2026-08-23",
56
+ "subject": "release v45"
57
+ },
58
+ {
59
+ "version": "v44",
60
+ "sha": "a7877aa",
61
+ "date": "2026-08-23",
62
+ "subject": "Hotfix: allow renaming the active Map view"
63
+ },
64
+ {
65
+ "version": "v43",
66
+ "sha": "e2ba461",
67
+ "date": "2026-08-23",
68
+ "subject": "release v43"
69
+ },
70
+ {
71
+ "version": "v42",
72
+ "sha": "25b885e",
73
+ "date": "2026-08-23",
74
+ "subject": "release v42"
75
+ },
76
+ {
77
+ "version": "v41",
78
+ "sha": "1d6adcc",
79
+ "date": "2026-08-22",
80
+ "subject": "v41 vertical route navigation"
81
+ },
82
+ {
83
+ "version": "v40",
84
+ "sha": "1451837",
85
+ "date": "2026-08-22",
86
+ "subject": "release v40 route UI correction"
87
+ },
88
+ {
89
+ "version": "v39",
90
+ "sha": "a78bdf0",
91
+ "date": "2026-08-21",
92
+ "subject": "W39 clean stale migration targets"
93
+ },
94
+ {
95
+ "version": "v38",
96
+ "sha": "a556631",
97
+ "date": "2026-08-21",
98
+ "subject": "W39 reversible Live store cutover"
99
+ },
100
+ {
101
+ "version": "v37",
102
+ "sha": "1ddd8cf",
103
+ "date": "2026-08-21",
104
+ "subject": "W39: coalesce derived workspace reads"
105
+ },
106
+ {
107
+ "version": "v36",
108
+ "sha": "51db3b7",
109
+ "date": "2026-08-21",
110
+ "subject": "W39 share-field oid repair"
111
+ },
112
+ {
113
+ "version": "v35",
114
+ "sha": "0f64212",
115
+ "date": "2026-08-21",
116
+ "subject": "W39 visual QA fixture"
117
+ },
118
+ {
119
+ "version": "v34",
120
+ "sha": "cb4caa1",
121
+ "date": "2026-08-21",
122
+ "subject": "W39 Staging PostgreSQL egress controls"
123
+ },
124
+ {
125
+ "version": "v33",
126
+ "sha": "2b115e5",
127
+ "date": "2026-08-21",
128
+ "subject": "W39: stop nonproduction Postgres background egress"
129
+ },
130
+ {
131
+ "version": "v32",
132
+ "sha": "b47b27c",
133
+ "date": "2026-08-21",
134
+ "subject": "W39: repair Staging Postgres projection"
135
+ },
136
+ {
137
+ "version": "v31",
138
+ "sha": "8880a49",
139
+ "date": "2026-08-21",
140
+ "subject": "v31: Wave 39 staging candidate"
141
+ },
142
+ {
143
+ "version": "v30",
144
+ "sha": "71776ca",
145
+ "date": "2026-08-20",
146
+ "subject": "v30: W39-T29 Member-owned View first-share hotfix"
147
+ },
148
+ {
149
+ "version": "v29",
150
+ "sha": "55ced50",
151
+ "date": "2026-08-18",
152
+ "subject": "v29: the build staging has been running since 2026-08-18 (55ced50)."
153
+ },
154
+ {
155
+ "version": "v28",
156
+ "sha": "2793e4b",
157
+ "date": "2026-08-18",
158
+ "subject": "release v28"
159
+ },
160
+ {
161
+ "version": "v27",
162
+ "sha": "c05a794",
163
+ "date": "2026-08-18",
164
+ "subject": "release v27"
165
+ },
166
+ {
167
+ "version": "v25",
168
+ "sha": "bc24829",
169
+ "date": "2026-08-14",
170
+ "subject": "v25 - wave 32's LIVE build (deployed 2026-08-13, bc24829)"
171
+ },
172
+ {
173
+ "version": "v26",
174
+ "sha": "6a642e1",
175
+ "date": "2026-08-14",
176
+ "subject": "v26 - wave 33's build (deployed 2026-08-14, 6a642e1)"
177
+ },
178
+ {
179
+ "version": "v24",
180
+ "sha": "514d829",
181
+ "date": "2026-08-09",
182
+ "subject": "release v24"
183
+ },
184
+ {
185
+ "version": "v23",
186
+ "sha": "44cc283",
187
+ "date": "2026-08-09",
188
+ "subject": "release v23"
189
+ },
190
+ {
191
+ "version": "v22",
192
+ "sha": "f14e199",
193
+ "date": "2026-08-09",
194
+ "subject": "release v22"
195
+ },
196
+ {
197
+ "version": "v21",
198
+ "sha": "5fdffce",
199
+ "date": "2026-08-09",
200
+ "subject": "release v21"
201
+ },
202
+ {
203
+ "version": "v20",
204
+ "sha": "442e4a1",
205
+ "date": "2026-08-09",
206
+ "subject": "release v20"
207
+ },
208
+ {
209
+ "version": "v19",
210
+ "sha": "94cb4a4",
211
+ "date": "2026-08-09",
212
+ "subject": "release v19"
213
+ },
214
+ {
215
+ "version": "v18",
216
+ "sha": "0106ef9",
217
+ "date": "2026-08-09",
218
+ "subject": "release v18"
219
+ },
220
+ {
221
+ "version": "v17",
222
+ "sha": "308240c",
223
+ "date": "2026-08-09",
224
+ "subject": "release v17"
225
+ },
226
+ {
227
+ "version": "v16",
228
+ "sha": "a804df5",
229
+ "date": "2026-08-09",
230
+ "subject": "release v16"
231
+ },
232
+ {
233
+ "version": "v15",
234
+ "sha": "6b84d72",
235
+ "date": "2026-08-09",
236
+ "subject": "release v15"
237
+ },
238
+ {
239
+ "version": "v14",
240
+ "sha": "bee5eaf",
241
+ "date": "2026-08-07",
242
+ "subject": "release v14"
243
+ },
244
+ {
245
+ "version": "v13",
246
+ "sha": "314c0e3",
247
+ "date": "2026-08-06",
248
+ "subject": "release v13"
249
+ },
250
+ {
251
+ "version": "v12",
252
+ "sha": "4908f39",
253
+ "date": "2026-08-06",
254
+ "subject": "release v12"
255
+ },
256
+ {
257
+ "version": "v11",
258
+ "sha": "3126617",
259
+ "date": "2026-08-05",
260
+ "subject": "release v11"
261
+ },
262
+ {
263
+ "version": "v10",
264
+ "sha": "43f8042",
265
+ "date": "2026-08-05",
266
+ "subject": "release v10"
267
+ },
268
+ {
269
+ "version": "v9",
270
+ "sha": "2e26f57",
271
+ "date": "2026-08-05",
272
+ "subject": "release v9"
273
+ },
274
+ {
275
+ "version": "v8",
276
+ "sha": "8073b3e",
277
+ "date": "2026-08-05",
278
+ "subject": "release v8"
279
+ },
280
+ {
281
+ "version": "v7",
282
+ "sha": "b1c5a7e",
283
+ "date": "2026-08-05",
284
+ "subject": "release v7"
285
+ },
286
+ {
287
+ "version": "v6",
288
+ "sha": "a344020",
289
+ "date": "2026-08-05",
290
+ "subject": "release v6"
291
+ },
292
+ {
293
+ "version": "v5",
294
+ "sha": "c0b0fbb",
295
+ "date": "2026-08-05",
296
+ "subject": "release v5"
297
+ },
298
+ {
299
+ "version": "v4",
300
+ "sha": "de5037f",
301
+ "date": "2026-08-05",
302
+ "subject": "release v4"
303
+ },
304
+ {
305
+ "version": "v3",
306
+ "sha": "6b9fa62",
307
+ "date": "2026-08-05",
308
+ "subject": "release v3"
309
+ },
310
+ {
311
+ "version": "v2",
312
+ "sha": "fd05861",
313
+ "date": "2026-08-04",
314
+ "subject": "release v2"
315
+ },
316
+ {
317
+ "version": "v1",
318
+ "sha": "5b4e2c4",
319
+ "date": "2026-08-04",
320
+ "subject": "release v1"
321
+ }
322
+ ]
323
  }
VERSION CHANGED
@@ -1 +1 @@
1
- 4579959
 
1
+ 255773b
api/routes_customers.py CHANGED
@@ -21,6 +21,8 @@ SOURCE OF TRUTH; it does not make the two runtimes coherent. The fix is X4/Postg
21
  owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes — a TestClient
22
  proof is single-process and would report green on exactly the thing that is still broken.
23
  """
 
 
24
  import math
25
  import time
26
 
@@ -1600,6 +1602,44 @@ def _route_delivery_recipient_or_400(raw):
1600
  return recipient
1601
 
1602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1603
  @router.get("/customers/route-deliveries")
1604
  def route_delivery_list(session: Session = Depends(module_gate(MODULE))):
1605
  """Saved C2 route deliveries owned by the caller (or all for an administrator)."""
@@ -1630,13 +1670,13 @@ def route_delivery_create(body: dict = Body(default=None),
1630
  snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
1631
  # A route snapshot is allowed to freeze its *stops*, never to invent its View provenance.
1632
  # `map-current`/a route-plan fingerprint were a synthetic fallback that made an automation
1633
- # impossible to audit back to the actual filtered View. Resolve the submitted id against
1634
- # this reader's visible View contract and retain the supplied canonical config revision.
 
1635
  source_view_id = str(snapshot.get("sourceViewId") or "").strip()
1636
- source_revision = str(snapshot.get("sourceViewRevision") or "").strip()
1637
- if not source_view_id or source_view_id == "map-current" or not source_revision:
1638
  raise err(400, "invalid_route_source_view",
1639
- "choose an active saved View and include its frozen revision")
1640
  source_views = {str(view.get("id") or ""): view for view in
1641
  (grid_assembly(session, consume_corrections=False).get("views") or [])
1642
  if isinstance(view, dict) and view.get("id")}
@@ -1644,6 +1684,7 @@ def route_delivery_create(body: dict = Body(default=None),
1644
  if source_view is None:
1645
  raise err(400, "unknown_route_source_view",
1646
  "the route source View is no longer visible to this account")
 
1647
  raw = {
1648
  "name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
1649
  "kind": "route_delivery",
@@ -1654,9 +1695,6 @@ def route_delivery_create(body: dict = Body(default=None),
1654
  "recipient": recipient,
1655
  "routeSnapshot": {
1656
  **snapshot,
1657
- "sourceViewId": source_view_id,
1658
- "sourceViewRevision": source_revision,
1659
- "sourceViewName": str(source_view.get("name") or source_view_id),
1660
  "mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
1661
  },
1662
  },
 
21
  owner-blocked on B-3), and no test here may claim read-your-writes ACROSS runtimes — a TestClient
22
  proof is single-process and would report green on exactly the thing that is still broken.
23
  """
24
+ import hashlib
25
+ import json
26
  import math
27
  import time
28
 
 
1602
  return recipient
1603
 
1604
 
1605
+ def _route_view_revision(view_id, config):
1606
+ """Mint the route source's revision from the server-visible View config.
1607
+
1608
+ A browser may describe the saved View it used to construct a route, but it cannot choose the
1609
+ revision that becomes durable provenance. The complete config comparison below catches a
1610
+ stale browser before a route is scheduled; this digest then records the exact server View
1611
+ state the accepted route came from. It is an audit fingerprint, never a permission token.
1612
+ """
1613
+ canonical = json.dumps({"viewId": str(view_id), "config": config},
1614
+ sort_keys=True, separators=(",", ":"), ensure_ascii=False)
1615
+ return "view-config-v2-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
1616
+
1617
+
1618
+ def _route_snapshot_for_view_or_409(snapshot, source_view):
1619
+ """Accept a route only when the caller's View config is the View visible at this request.
1620
+
1621
+ Ordered stops remain the actual frozen work list. The source View is its separately auditable
1622
+ provenance, so a forged revision must never be stored beside an otherwise valid View id.
1623
+ Copy only canonical server facts forward; the submitted config is a compare-and-discard
1624
+ concurrency guard, not another persisted configuration copy.
1625
+ """
1626
+ source_view_id = str(source_view.get("id") or "").strip()
1627
+ source_config = source_view.get("config")
1628
+ submitted_config = snapshot.get("sourceViewConfig")
1629
+ if not source_view_id or not isinstance(source_config, dict):
1630
+ raise err(409, "stale_route_source_view",
1631
+ "the saved route View changed; refresh it before scheduling this route")
1632
+ if not isinstance(submitted_config, dict) or submitted_config != source_config:
1633
+ raise err(409, "stale_route_source_view",
1634
+ "the saved route View changed; refresh it before scheduling this route")
1635
+ return {
1636
+ **snapshot,
1637
+ "sourceViewId": source_view_id,
1638
+ "sourceViewRevision": _route_view_revision(source_view_id, source_config),
1639
+ "sourceViewName": str(source_view.get("name") or source_view_id),
1640
+ }
1641
+
1642
+
1643
  @router.get("/customers/route-deliveries")
1644
  def route_delivery_list(session: Session = Depends(module_gate(MODULE))):
1645
  """Saved C2 route deliveries owned by the caller (or all for an administrator)."""
 
1670
  snapshot = body.get("routeSnapshot") if isinstance(body.get("routeSnapshot"), dict) else {}
1671
  # A route snapshot is allowed to freeze its *stops*, never to invent its View provenance.
1672
  # `map-current`/a route-plan fingerprint were a synthetic fallback that made an automation
1673
+ # impossible to audit back to the actual filtered View. Resolve the submitted id against
1674
+ # this reader's visible View contract, compare the complete config, and mint its revision
1675
+ # server-side; a valid View id with a forged/stale revision is still not an honest snapshot.
1676
  source_view_id = str(snapshot.get("sourceViewId") or "").strip()
1677
+ if not source_view_id or source_view_id == "map-current":
 
1678
  raise err(400, "invalid_route_source_view",
1679
+ "choose an active saved View for this frozen route")
1680
  source_views = {str(view.get("id") or ""): view for view in
1681
  (grid_assembly(session, consume_corrections=False).get("views") or [])
1682
  if isinstance(view, dict) and view.get("id")}
 
1684
  if source_view is None:
1685
  raise err(400, "unknown_route_source_view",
1686
  "the route source View is no longer visible to this account")
1687
+ snapshot = _route_snapshot_for_view_or_409(snapshot, source_view)
1688
  raw = {
1689
  "name": " ".join(str(body.get("name") or snapshot.get("sourceViewName") or "Scheduled route").split())[:120],
1690
  "kind": "route_delivery",
 
1695
  "recipient": recipient,
1696
  "routeSnapshot": {
1697
  **snapshot,
 
 
 
1698
  "mapsPerLink": snapshot.get("mapsPerLink", body.get("mapsPerLink", 10)),
1699
  },
1700
  },
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -268,27 +268,9 @@ function frozenCountOf(config: ViewConfig): number {
268
  }
269
 
270
  /**
271
- * A View has a durable id but no server-side revision counter on this wire. A key-sorted,
272
- * deterministic configuration fingerprint is therefore the actual revision of the View state
273
- * that produced a frozen route snapshot. It is provenance, not a permission token.
274
  */
275
- function routeViewRevision(viewId: string, config: ViewConfig): string {
276
- const canonical = (value: unknown): unknown => {
277
- if (Array.isArray(value)) return value.map(canonical);
278
- if (value && typeof value === "object") {
279
- const out: Record<string, unknown> = {};
280
- for (const key of Object.keys(value as Record<string, unknown>).sort())
281
- out[key] = canonical((value as Record<string, unknown>)[key]);
282
- return out;
283
- }
284
- return value;
285
- };
286
- const text = JSON.stringify(canonical({ viewId, config }));
287
- let hash = 0x811c9dc5;
288
- for (let index = 0; index < text.length; index += 1)
289
- hash = Math.imul(hash ^ text.charCodeAt(index), 0x01000193);
290
- return `view-config-v1-${(hash >>> 0).toString(36)}`;
291
- }
292
  /* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` — the id because a second
293
  copy of a pinned literal is the drift class this repo gates against, the name because it is
294
  now topic-derived and the host mints the same string. */
@@ -7199,7 +7181,9 @@ function CustomerGridSurface({
7199
  ? {
7200
  id: activeView.id,
7201
  name: viewDisplayName(activeView),
7202
- revision: routeViewRevision(activeView.id, config),
 
 
7203
  }
7204
  : null;
7205
  // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
 
268
  }
269
 
270
  /**
271
+ * Route provenance is minted by the API from this saved config. The client only sends the
272
+ * current config as a compare guard, so a forged browser fingerprint cannot become durable.
 
273
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  /* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` — the id because a second
275
  copy of a pinned literal is the drift class this repo gates against, the name because it is
276
  now topic-derived and the host mints the same string. */
 
7181
  ? {
7182
  id: activeView.id,
7183
  name: viewDisplayName(activeView),
7184
+ // The API compares this complete saved config to its visible View and mints the durable
7185
+ // revision itself. A browser may carry provenance, never choose it.
7186
+ config: activeView.config,
7187
  }
7188
  : null;
7189
  // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover
web/src/customer-grid/MapView.tsx CHANGED
@@ -54,7 +54,7 @@
54
 
55
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
  import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
57
- import type { Field, Row } from "./types";
58
  import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
59
  import { formatDisplay } from "./cells";
60
  import { LAND_PATH, LAKE_PATHS } from "./mapGeometry";
@@ -381,7 +381,8 @@ interface RouteDelivery {
381
  export interface RouteSourceView {
382
  id: string;
383
  name: string;
384
- revision: string;
 
385
  }
386
 
387
  interface MapPoint {
@@ -1534,10 +1535,10 @@ export function MapView({
1534
  recipient: routeRecipient.trim(),
1535
  schedule: { cron: "0 8 * * *", enabled: true },
1536
  routeSnapshot: {
1537
- // This route's stop ids are frozen below. The View tuple is provenance, supplied by
1538
- // CustomerGrid from the actual active View and its canonical configuration revision.
1539
  sourceViewId: activeView.id,
1540
- sourceViewRevision: activeView.revision,
1541
  sourceViewName: activeView.name,
1542
  timezone,
1543
  routeField: saveTarget || openedRoute,
 
54
 
55
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
56
  import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
57
+ import type { Field, Row, ViewConfig } from "./types";
58
  import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
59
  import { formatDisplay } from "./cells";
60
  import { LAND_PATH, LAKE_PATHS } from "./mapGeometry";
 
381
  export interface RouteSourceView {
382
  id: string;
383
  name: string;
384
+ /** Exact saved config the API compares before minting immutable route provenance. */
385
+ config: ViewConfig;
386
  }
387
 
388
  interface MapPoint {
 
1535
  recipient: routeRecipient.trim(),
1536
  schedule: { cron: "0 8 * * *", enabled: true },
1537
  routeSnapshot: {
1538
+ // This route's stop ids are frozen below. The View config is only a compare guard:
1539
+ // the API resolves this saved View and mints the durable revision itself.
1540
  sourceViewId: activeView.id,
1541
+ sourceViewConfig: activeView.config,
1542
  sourceViewName: activeView.name,
1543
  timezone,
1544
  routeField: saveTarget || openedRoute,