wip
Seto Elkahfi committed
Sep 5, 2026 at 09:45 UTC
72cd0ea7ebddeff577fe2eb063529b733e06ade9
1 file changed
+1988
Pulse globe geography decoupling.md
new
+1988
@@ -0,0 +1,1988 @@
1
+## User
2
+
3
+Work on onde repo feature/decouple-gresiq-from-onde-inference branch. Use /gh-stack continue working on **M4 geography**: the public `/pulse` globe still reads IP-geolocated regions from the platform. That's the largest Onde-owned piece left and spec 0007 deliberately excludes it pending a separate geography spec, mostly because it's a privacy and retention decision before it's an engineering one.
4
+
5
+## Assistant
6
+
7
+I'll start by getting oriented in the onde repo and loading the gh-stack skill.
8
+
9
+**Tool Call: cd /Users/setoelkahfi/Repositories/onde && git status --short | head -30 && echo "--- BRANCH ---" && git branch --show-current && echo "--- LOG ---" && git log --oneline -15**
10
+Status: Completed
11
+
12
+Terminal:
13
+```
14
+--- BRANCH ---
15
+feature/deps
16
+--- LOG ---
17
+019cd894 v1.2.3
18
+a9f41644 Merge pull request #11 from ondeinference/feature/sdk-dart-android-v2
19
+cb342599 Fix NDK version comparison and native lib task ordering
20
+ebf77159 Build the Dart SDK's Android libs with cargo-ndk
21
+84c6e528 Merge branch 'development' of gh-keypair34:ondeinference/onde into development
22
+5cc7b9e9 Merge pull request #10 from ondeinference/feature/add-UQFF-support
23
+6051fd1a Fix sysctl-rs deps
24
+14a1d113 Fix ci
25
+5a6615af Add UQFF model format
26
+c8972c02 Merge branch 'development' into feature/add-UQFF-support
27
+5d0f032c Merge branch 'feature/deps' into development
28
+3d7eca58 Update deps
29
+a495aab0 Merge branch 'development' into feature/add-UQFF-support
30
+30272293 Merge branch 'feature/release-improvement' into development
31
+2ae59c59 Fix dart example
32
+```
33
+
34
+
35
+**Tool Call: Load skill: gh-stack**
36
+Status: Completed
37
+
38
+Launching skill: gh-stack
39
+
40
+**Tool Call: git branch -a | head -50 && echo "=== REMOTES ===" && git remote -v && echo "=== SPECS ===" && ls -d specs* .specify 2>/dev/null; find . -maxdepth 3 -type d -name "*spec*" -not -path "./target/*" -not -path "./node_modules/*" 2>/dev/null | head**
41
+Status: Completed
42
+
43
+Terminal:
44
+```
45
+ development
46
++ feature/decouple-gresiq-from-onde-inference
47
+* feature/deps
48
+ feature/diffusion-vision
49
+ feature/v1.0.0-chat-transcribe-demucs-rs
50
+ feature/v1.0.0-chat-transcribe-demucs-rs-DeepSeek-Coder-V2-Lite-gguf
51
+ main
52
+ remotes/gh-keypair34/HEAD -> gh-keypair34/main
53
+ remotes/gh-keypair34/claude/zen-feynman-0u78dk
54
+ remotes/gh-keypair34/copilot/assess-inference-engine-complexity
55
+ remotes/gh-keypair34/copilot/fix-onde-npm-sdk-v1-1-2-release
56
+ remotes/gh-keypair34/development
57
+ remotes/gh-keypair34/feature/ci
58
+ remotes/gh-keypair34/feature/deps
59
+ remotes/gh-keypair34/feature/diffusion-vision
60
+ remotes/gh-keypair34/feature/release-v0.1.8
61
+ remotes/gh-keypair34/feature/release-v1
62
+ remotes/gh-keypair34/feature/sdk-dart
63
+ remotes/gh-keypair34/feature/sdk-dart-android-v2
64
+ remotes/gh-keypair34/feature/sdk-kotlin
65
+ remotes/gh-keypair34/feature/sdk-kotlin-kmm-ios
66
+ remotes/gh-keypair34/feature/sdk-rust
67
+ remotes/gh-keypair34/feature/sdk-swift
68
+ remotes/gh-keypair34/feature/swift-release-1.1.1-recovery
69
+ remotes/gh-keypair34/main
70
+ remotes/gh-setoelkahfi/claude/qwen-3-support-d1sxiw
71
+ remotes/gh-setoelkahfi/claude/zen-feynman-0u78dk
72
+ remotes/gh-setoelkahfi/copilot/assess-inference-engine-complexity
73
+ remotes/gh-setoelkahfi/copilot/fix-onde-npm-sdk-v1-1-2-release
74
+ remotes/gh-setoelkahfi/development
75
+ remotes/gh-setoelkahfi/feature/add-UQFF-support
76
+ remotes/gh-setoelkahfi/feature/ci
77
+ remotes/gh-setoelkahfi/feature/decouple-gresiq-from-onde-inference
78
+ remotes/gh-setoelkahfi/feature/deps
79
+ remotes/gh-setoelkahfi/feature/diffusion-vision
80
+ remotes/gh-setoelkahfi/feature/release-v0.1.8
81
+ remotes/gh-setoelkahfi/feature/release-v1
82
+ remotes/gh-setoelkahfi/feature/sdk-dart
83
+ remotes/gh-setoelkahfi/feature/sdk-dart-android-v2
84
+ remotes/gh-setoelkahfi/feature/sdk-kotlin
85
+ remotes/gh-setoelkahfi/feature/sdk-kotlin-kmm-ios
86
+ remotes/gh-setoelkahfi/feature/sdk-rust
87
+ remotes/gh-setoelkahfi/feature/sdk-swift
88
+ remotes/gh-setoelkahfi/feature/swift-release-1.1.1-recovery
89
+ remotes/gh-setoelkahfi/fix/visionos-onde-tqdm
90
+ remotes/gh-setoelkahfi/main
91
+=== REMOTES ===
92
+gh-keypair34 gh-keypair34:ondeinference/onde.git (fetch)
93
+gh-keypair34 gh-keypair34:ondeinference/onde.git (push)
94
+gh-setoelkahfi git@github.com:ondeinference/onde.git (fetch)
95
+gh-setoelkahfi git@github.com:ondeinference/onde.git (push)
96
+=== SPECS ===
97
+(eval):1: no matches found: specs*
98
+```
99
+
100
+
101
+**Tool Call: git log --oneline development..feature/decouple-gresiq-from-onde-inference | head -30; echo "=== diff stat ==="; git diff --stat development...feature/decouple-gresiq-from-onde-inference | tail -30**
102
+Status: Completed
103
+
104
+Terminal:
105
+```
106
+69f2e767 feat(pulse): measure TTFT and stamp edge liveness in dual-write
107
+952dbde0 fix(pulse): harden GresIQ document dual-write rollout
108
+f59e9fc8 feat(pulse): dual-write telemetry to GresIQ document gateway (ADR-0003 M2)
109
+=== diff stat ===
110
+ Cargo.lock | 12 +--
111
+ Cargo.toml | 4 +-
112
+ src/inference/engine.rs | 66 +++++++++++-
113
+ src/pulse/client.rs | 260 +++++++++++++++++++++++++++++++++++++++++++++++-
114
+ src/pulse/events.rs | 69 +++++++++++++
115
+ 5 files changed, 398 insertions(+), 13 deletions(-)
116
+```
117
+
118
+
119
+**Tool Call: for r in onde-cloud onde-web onde-klepon onde-swift; do echo "=== $r ==="; ls /Users/setoelkahfi/Repositories/$r/specs 2>/dev/null | head -20; done**
120
+Status: Completed
121
+
122
+Terminal:
123
+```
124
+=== onde-cloud ===
125
+=== onde-web ===
126
+=== onde-klepon ===
127
+=== onde-swift ===
128
+
129
+
130
+```
131
+
132
+
133
+**Tool Call: cd /Users/setoelkahfi/Repositories && for r in onde onde-cloud onde-web onde-klepon; do echo "=== $r ==="; find $r -iname "*0007*" -o -iname "*ADR-0003*" -o -iname "*adr*0003*" 2>/dev/null | grep -v node_modules | grep -v "/target/" | head; done**
134
+Status: Completed
135
+
136
+Terminal:
137
+```
138
+=== onde ===
139
+=== onde-cloud ===
140
+=== onde-web ===
141
+onde-web/.agents/specs/0007-pulse-document-read-migration.md
142
+=== onde-klepon ===
143
+
144
+Shell cwd was reset to /Users/setoelkahfi/Repositories/onde-swift
145
+```
146
+
147
+
148
+**Tool Call: ls /Users/setoelkahfi/Repositories/onde-web/.agents/specs/ /Users/setoelkahfi/Repositories/onde-web/.agents/ 2>/dev/null; echo "=== git ==="; git -C /Users/setoelkahfi/Repositories/onde-web log --oneline -5; git -C /Users/setoelkahfi/Repositories/onde-web branch --show-current; git -C /Users/setoelkahfi/Repositories/onde-web status --short | head**
149
+Status: Completed
150
+
151
+Terminal:
152
+```
153
+/Users/setoelkahfi/Repositories/onde-web/.agents/:
154
+AGENTS.md
155
+memory
156
+skills
157
+specs
158
+
159
+/Users/setoelkahfi/Repositories/onde-web/.agents/specs/:
160
+0001-onde-cloud-platform.md
161
+0002-onde-app-id-pulse-events.md
162
+0003-offline-first-on-device-model-loading.md
163
+0004-sdk-example-apple-signing-strategy.md
164
+0005-session-expired-on-root-models.md
165
+0006-cloud-model-availability-controls.md
166
+0007-pulse-document-read-migration.md
167
+README.md
168
+TEMPLATE.md
169
+=== git ===
170
+0097920 feat(pulse): page document reads by cursor
171
+076aac6 feat(pulse): derive edge liveness from recency, not a stale status
172
+68bbd05 feat(pulse): read app telemetry from GresIQ documents
173
+10512e7 docs(pulse): specify document read migration
174
+960d465 Merge branch 'development' into feature/pulse-migration-gresiq-document
175
+feature/pulse-migration-gresiq-document
176
+```
177
+
178
+
179
+**Tool Call: cat /Users/setoelkahfi/Repositories/onde-web/.agents/specs/0007-pulse-document-read-migration.md**
180
+Status: Completed
181
+
182
+Terminal:
183
+```
184
+# Onde Web Pulse Document Read Migration
185
+
186
+| Field | Value |
187
+| --- | --- |
188
+| Spec | 0007 |
189
+| Status | Read boundary implemented; document mode not enabled |
190
+| Owner | Onde Web / Forward-deployed engineering |
191
+| Created | 2026-09-04 |
192
+| Updated | 2026-09-04 |
193
+| Repos | onde-web, onde, smbcloud-api |
194
+| Related | [[0002-onde-app-id-pulse-events]], smbcloud-api `docs/adr-0003.md` (M1–M5) |
195
+
196
+## Summary
197
+
198
+Move Onde product-specific Pulse reads and aggregation into Onde Web while
199
+keeping GresIQ a tenant-agnostic document service. The first cutover is the
200
+authenticated, per-app dashboard summary; the public geographic Pulse view is
201
+not part of this migration until Onde owns a location-enrichment design.
202
+
203
+## Background / Problem
204
+
205
+The legacy dashboard reads Onde-specific `pulse_*` relational resources from
206
+GresIQ. That makes a shared smbCloud service understand Onde's telemetry
207
+schema, query model, and product identity.
208
+
209
+ADR-0003 M1 supplies a generic document gateway, and the SDK M2 branch can
210
+mirror the following opaque documents when both `ONDE_PULSE_DUAL_WRITE` is
211
+explicitly enabled and an Onde app ID is supplied:
212
+
213
+| Collection | Key | Required product fields |
214
+| --- | --- | --- |
215
+| `pulse_edges` | `<onde_app_id>:<edge_id>` | `onde_app_id`, `edge_id`, `status` |
216
+| `pulse_models` | `<onde_app_id>:<model_id>` | `onde_app_id`, `model_id`, `model_name` |
217
+| `pulse_deployments` | `<onde_app_id>:<edge_id>:<model_id>` | app, edge, model, load duration, residency state |
218
+| `pulse_inference_events` | `<onde_app_id>:<request_id>` | app, edge, model, request ID, duration, status |
219
+
220
+The documents are intentionally opaque to GresIQ. Onde Web must therefore
221
+authenticate the user, scope reads to the selected Onde app, decode the
222
+documents, and construct the existing UI data shape. The existing
223
+`normalizePulseData` boundary in `app/pulse/pulse-data.ts` remains the only
224
+camel-case rendering boundary.
225
+
226
+## Goals
227
+
228
+- Add a server-only, app-scoped document reader for the authenticated Onde app
229
+ dashboard.
230
+- Introduce shadow-read parity checks before a user-visible source switch.
231
+- Make the source selection reversible and fail closed to legacy Pulse data.
232
+- Keep GresIQ generic: no Onde-specific routes, tables, document interpretation,
233
+ or browser-exposed GresIQ credentials.
234
+
235
+## Non-Goals
236
+
237
+- Replacing the public `/pulse` globe, country map, or regional rollups.
238
+- Deriving geography from IP addresses in GresIQ or storing raw IP addresses in
239
+ the generic document gateway.
240
+- Deleting legacy `pulse_*` resources, backfilling un-attributed events, or M5
241
+ retirement.
242
+- Changing cloud-model availability controls or billing behavior.
243
+
244
+## Requirements
245
+
246
+- Onde Web must query document collections only from server code using the
247
+ selected app's GresIQ data-plane credentials; those credentials must never be
248
+ returned to a browser.
249
+- Every query and every assembled record must be constrained by an exact
250
+ `onde_app_id`. A document missing that field is ineligible, even if its key
251
+ happens to share an app-ID prefix.
252
+- The reader must preserve the existing rendered `PulseData` contract through
253
+ `normalizePulseData` or a tested adapter immediately before it.
254
+- `ONDE_PULSE_DOCUMENT_READ` must be server-only, disabled by default, and
255
+ select document data only after the app passes parity gates. A read failure,
256
+ malformed document, missing credentials, or incomplete document set must use
257
+ legacy data rather than render invented zeroes.
258
+- The reader must page the generic list API by cursor until it is exhausted,
259
+ and must treat a walk that stops early — at its own safety cap — as an
260
+ incomplete read rather than a whole window.
261
+- Shadow comparison telemetry must contain counts and bounded latency/error
262
+ categories only—never data-plane secrets, request text, raw IPs, or event
263
+ payloads.
264
+
265
+## Design / Approach
266
+
267
+### Read boundary
268
+
269
+Add a server-side product reader, for example
270
+`app/pulse/document-pulse-data.ts`, and an app-scoped route such as
271
+`app/api/gresiq/apps/[appId]/pulse/data/route.ts`. The route authorizes access
272
+to `appId` using the same app-dashboard policy as existing protected endpoints,
273
+then passes the resolved GresIQ application credentials to the reader. It must
274
+not reuse a global unscoped `/api/gresiq/pulse/data` response for an app page.
275
+
276
+The reader lists each collection with an exact top-level document filter for
277
+`onde_app_id`, validates collection-specific fields, and converts valid
278
+documents into an Onde-owned intermediate model. A final adapter produces the
279
+legacy `PulseData` shape and calls `normalizePulseData` before rendering.
280
+
281
+### Source modes
282
+
283
+| Mode | User response | Secondary work |
284
+| --- | --- | --- |
285
+| `legacy` (default) | Existing relational response | None |
286
+| `shadow` | Existing relational response | Fetch/assemble document data; emit parity metrics |
287
+| `document` | Document response for an allowlisted app | Fall back to legacy on any completeness/error gate |
288
+
289
+Feature configuration must be evaluated only on the server. `document` is
290
+per-app allowlisted, not a global flip, so a bad tenant dataset has a contained
291
+rollback.
292
+
293
+### Parity and completeness
294
+
295
+For a fixed app and measurement window, compare legacy and document results:
296
+
297
+- distinct edges and models;
298
+- deployments by `(edge_id, model_id)` and residency state;
299
+- inference-event counts by `(model_id, status)`;
300
+- oldest/newest source timestamps and the number of rejected documents.
301
+
302
+The expected M2 document set has no geographic fields. Geographic dimensions
303
+are excluded from comparison and block only the public Pulse migration, not the
304
+per-app summary cutover.
305
+
306
+TTFT is excluded for a different reason: the relational route stores a
307
+hardcoded `ttft_ms: 0`, so legacy latency figures are aggregates over zeros
308
+rather than measurements. The SDK now measures TTFT on the streaming path only
309
+and omits the field elsewhere, so the two sources are not comparable on latency
310
+and the document side is the more truthful of the two.
311
+
312
+Missing app attribution, a truncated list, or a document decode failure makes
313
+the comparison inconclusive—not equal.
314
+
315
+### Cross-repository dependency
316
+
317
+Before enabling the first app, deploy the SDK M2 dual-write for that app with a
318
+stable per-installation edge ID and `ChatEngine::with_app_id` (or `ONDE_APP_ID`).
319
+The gateway provides cursor pagination as of the ADR-0003 M1 extension, so the
320
+reader can walk an entire window. It stayed a generic platform capability: no
321
+Onde-specific interpretation entered GresIQ.
322
+
323
+## Implementation Plan
324
+
325
+- [ ] Confirm one non-production Onde app emits all four M2 collections with
326
+ explicit app IDs and stable edge IDs. Blocked on shipping, not on code: the
327
+ dual-write lives on `onde`'s `feature/decouple-gresiq-from-onde-inference`
328
+ and no released SDK carries it yet.
329
+- [x] Add typed document-envelope and collection decoders in Onde Web, including
330
+ rejection accounting for malformed or unscoped documents.
331
+ (`types/pulse-documents.ts`, `app/pulse/pulse-documents.ts`)
332
+- [x] Implement the authorized app-scoped server reader and adapter to the
333
+ existing `PulseData` rendering contract.
334
+ (`app/pulse/document-pulse-data.ts`,
335
+ `app/api/gresiq/apps/[appId]/pulse/data/route.ts`; the app dashboard now reads
336
+ that route instead of the global proxy)
337
+- [x] Add a server-only `legacy` / `shadow` / per-app `document` source policy;
338
+ retain the legacy endpoint as the fallback. (`app/pulse/pulse-source.ts`,
339
+ `ONDE_PULSE_DOCUMENT_READ` + `ONDE_PULSE_DOCUMENT_APPS`)
340
+- [x] Add cursor pagination or a versioned product-owned aggregate capability
341
+ before asserting full-window parity or enabling `document` in production.
342
+ The gateway now returns `next_cursor` (keyset on `(order column, id)`) and the
343
+ reader pages until it is null. A per-collection safety cap still reports
344
+ `result_truncated` so an unbounded walk degrades to legacy instead of hanging
345
+ on a request path.
346
+- [ ] Run shadow mode for a bounded pilot, record parity/error/latency metrics,
347
+ and define an app-specific pass threshold.
348
+- [ ] Enable document reads for the pilot app, then expand only after the same
349
+ gates pass for each app.
350
+
351
+## Acceptance Criteria
352
+
353
+- [x] Browser network traffic contains no GresIQ data-plane credential.
354
+- [x] An authenticated app dashboard cannot receive documents belonging to a
355
+ different `onde_app_id`. Enforced twice: the gateway query filters on
356
+ `onde_app_id`, and every decoder re-checks the field per document.
357
+- [x] Decoder and adapter tests cover valid documents, app/key mismatch,
358
+ malformed payloads, duplicate inference requests, and legacy fallback.
359
+ (`pnpm test`, vitest)
360
+- [x] Shadow mode cannot change the user-visible response.
361
+- [x] A document read error or incomplete result returns the current legacy
362
+ response and emits a bounded diagnostic category. A walk that stops before
363
+ the gateway runs out of rows counts as incomplete.
364
+- [ ] Production document mode is enabled only for app allowlists that satisfy
365
+ the agreed completeness and parity thresholds.
366
+- [ ] `/pulse` geographic views still use their current source until a separate
367
+ product-owned geography spec is accepted.
368
+
369
+## Open Questions
370
+
371
+- ~~Which component should expose cursor pagination: the generic document
372
+ gateway or an Onde-owned aggregate publisher?~~ **Resolved: the gateway.**
373
+ Paging is a storage concern, not a product one, and every future tenant needs
374
+ it; an Onde-owned aggregate publisher would have put a product-shaped
375
+ workaround on top of a generic gap.
376
+- What historical window and numerical tolerance define parity for a pilot?
377
+- Where should Onde retain coarse geographic aggregation, and what privacy and
378
+ retention policy applies before migrating the public globe?
379
+- Should document-mode configuration live in deployment environment variables,
380
+ an authenticated admin setting, or both?
381
+
382
+## Out of Scope
383
+
384
+M5 legacy retirement and all destructive cleanup are deferred until document
385
+mode has operated successfully through a full retention period and a backfill
386
+decision exists.
387
+
388
+## References
389
+
390
+- `smbcloud/services/smbcloud-api/docs/adr-0003.md`
391
+- `onde/src/pulse/client.rs` on `feature/decouple-gresiq-from-onde-inference`
392
+- `onde-web/app/pulse/pulse-data.ts`
393
+- `onde-web/app/api/gresiq/pulse/data/route.ts`
394
+```
395
+
396
+
397
+**Tool Call: sed -n '1,120p' /Users/setoelkahfi/Repositories/smbcloud/services/smbcloud-api/docs/adr-0003.md**
398
+Status: Completed
399
+
400
+Terminal:
401
+```
402
+# ADR-0003: Make GresIQ an Application-Agnostic Managed-Data Service
403
+
404
+## Status
405
+
406
+**M0 + M1 shipped, M1 extended with cursor pagination. M2 implemented in the
407
+product tier (unreleased). M3–M5 parked.**
408
+
409
+Direction accepted; M0 resolved (Project-owned). **M1 document core is now
410
+SHIPPED and live** (schema version `2026_06_28_130000`): `gresiq_documents` table
411
+migrated, `GresiqDocument` model, and the generic gateway
412
+`Gresiq::V1::DocumentsController` (CRUD + containment filter + aggregate) wired at
413
+`/gresiq/v1/collections/:collection`, authenticated by the Project app's
414
+api_key/secret (`GresiqGatewayAuthenticatable`). Fully additive — it coexists with
415
+the live `gresiq_ondeinference_*` pulse tables, which still serve Pulse until the
416
+M5 cut-over. The FK cascade gap from the initial migration was corrected in
417
+`20260628130000_add_cascade_to_gresiq_documents_app_fk`.
418
+
419
+The document list API now returns `next_cursor` and accepts `cursor`, using
420
+keyset pagination on `(<order column>, id)`. Offset paging was not an option: a
421
+product reading a whole window while documents are still being written would
422
+have rows shift under it between pages. A `nil` cursor is the platform's
423
+statement that the caller has seen every matching document, which is what lets
424
+a product distinguish a complete read from a truncated one.
425
+
426
+**M2 is implemented but unreleased:** `onde` dual-writes the four `pulse_*`
427
+collections behind `ONDE_PULSE_DUAL_WRITE` on
428
+`feature/decouple-gresiq-from-onde-inference`, and `onde-web` has an app-scoped
429
+document reader behind `ONDE_PULSE_DOCUMENT_READ`. Neither is enabled for any
430
+app, and no SDK release carries the dual-write yet.
431
+
432
+**Still parked (M3–M5):** promotion + soft-ref validation, externalizing Onde's
433
+rollups/dashboards, and the backfill/cut-over/retire of the
434
+`gresiq_ondeinference_*` tables. The remainder is a multi-quarter, 4-repo effort
435
+(see Complexity assessment).
436
+
437
+> **Note:** the *product* layers on top of this document core — realtime, RLS +
438
+> BYO-issuer identity, client SDKs, vector/RAG — are tracked separately in
439
+> **ADR-0005** (BaaS surface, modularity) and **ADR-0006** (pgvector), with the
440
+> auth seam specced in `docs/gresiq-identity-contract.md`. None of those are
441
+> started yet.
442
+
443
+Why parked, not dropped: it is the agreed top-priority direction for making GresIQ
444
+application-agnostic; the cost/sequencing just makes it a planned program, not an
445
+in-flight change.
446
+
447
+**Resolved (M0, principal model):** A GresIQ app is owned by a **Project**. It may
448
+*store* `AuthApp` / `AuthUser` rows as application data; because GresIQ and Auth
449
+live in the same service and database, those references are **real foreign keys**,
450
+not opaque external IDs. There is therefore exactly one app-ownership concept
451
+(`gresiq_apps` → `Project`); the former Onde end-user registry
452
+(`gresiq_ondeinference_apps`) collapses into app-defined *data* inside the
453
+Project's GresIQ schema. See "Resolved principal model" below.
454
+
455
+## Context
456
+
457
+GresIQ is described (in the Rust `smbcloud-gresiq-sdk` crate) as:
458
+
459
+> a managed-database layer inside smbCloud. It sits in front of a PostgreSQL
460
+> database, adds API-key auth, and exposes a simple REST interface for
461
+> inserting and querying rows. **Schema knowledge (which tables exist, what the
462
+> rows look like) lives in the caller.**
463
+
464
+That is the intended contract: GresIQ is the *platform data layer*; downstream
465
+products (Onde Inference, FatApp, siGit) are *tenants* of it and own their own
466
+schemas. The smbCloud → Onde Inference → siGit stack is vertically integrated,
467
+but the layers are supposed to be **modular**: GresIQ must not know what an
468
+"edge", a "model", or a "TTFT" is.
469
+
470
+The current Rails implementation violates that contract. Onde Inference's entire
471
+telemetry domain — schema, ingest semantics, geolocation, rollups, and dashboard
472
+aggregation — is baked into the platform. This ADR records the deep-research
473
+findings and assesses the complexity of making GresIQ genuinely agnostic.
474
+
475
+## Findings: the current GresIQ subsystem
476
+
477
+GresIQ today is **three subsystems wearing one name**, and Onde is welded into
478
+all three.
479
+
480
+### Subsystem 1 — Generic gateway registry (`gresiq_apps`) — *partly agnostic*
481
+
482
+`gresiq_apps` (`tenant_id`, `project_id`, `name`, `table_prefix`, `api_key`,
483
+`api_secret_digest`, `status`) is the one genuinely generic piece. It models
484
+"an application that stores data in GresIQ", scoped to a Project, authenticated
485
+by `X-Gresiq-Api-Key` + `X-Gresiq-Api-Secret` (BCrypt). `GresiqApp#table_name_for`
486
+composes physical names as `gresiq_<table_prefix>_<short_name>`, and the
487
+controllers reach those tables through anonymous `Class.new(ApplicationRecord)`
488
+subclasses. **This is the right primitive** and should survive.
489
+
490
+Two gaps:
491
+
492
+- **No provisioning.** Nothing creates the per-app physical tables. The design
493
+ *implies* dynamic per-app schemas, but in practice the Onde tables were
494
+ hand-written in a migration (`20260601000001_create_ondeinference_pulse_tables.rb`).
495
+ The generic prefix mechanism is half-built — routing/auth is prefix-aware, DDL
496
+ is not.
497
+- **It is bypassed by Subsystem 2.** The user-facing dashboard and the SDK do
498
+ *not* use `gresiq_apps` as the app identity; they use a second, parallel
499
+ registry (below). The telemetry data even foreign-keys to that second one.
500
+
501
+### Subsystem 2 — Onde-specific app registry (`gresiq_ondeinference_apps`) — *fully coupled*
502
+
503
+A second "app" concept, scoped to a **different principal**:
504
+
505
+| | `gresiq_apps` | `gresiq_ondeinference_apps` |
506
+|---|---|---|
507
+| Owner principal | Tenant + Project (platform operator) | AuthUser + AuthApp (Onde end user) |
508
+| Identity | `api_key` | UUID PK = `app_id` |
509
+| Secret | BCrypt digest | UUID PK of `gresiq_ondeinference_app_secrets` row |
510
+| Consumed by | ingest gateway, M2M validate | dashboard, SDK `model_config` |
511
+
512
+So "a GresIQ application" is modelled **twice**, incompatibly, and the name
513
+`ondeinference` is hardcoded into the table, model, controller, and route names
514
+(`GresiqOndeinferenceApp`, `/v1/client/gresiq/...`, `OndeSdkController`,
515
+`OndeinferenceValidateAppController`).
516
+
517
+### Subsystem 3 — Onde's telemetry domain, living in the platform — *fully coupled*
518
+
519
+The actual data tables are Onde's application schema, physically named
520
+`gresiq_ondeinference_*` and created by platform migrations:
521
+```
522
+
523
+
524
+**Tool Call: cd /Users/setoelkahfi/Repositories/onde-web && ls app/pulse/ && echo "=== api gresiq ===" && find app/api/gresiq -type f | head -20 && echo "=== geo grep ===" && grep -rniE "country|region|latitude|longitude|geo|ip_address|city" --include=*.ts --include=*.tsx -l app types lib 2>/dev/null | head -30**
525
+Status: Completed
526
+
527
+Terminal:
528
+```
529
+document-pulse-data.test.ts
530
+document-pulse-data.ts
531
+globe-view.tsx
532
+page.tsx
533
+pulse-data.ts
534
+pulse-documents.test.ts
535
+pulse-documents.ts
536
+pulse-scene.tsx
537
+pulse-source.test.ts
538
+pulse-source.ts
539
+span-selector.tsx
540
+=== api gresiq ===
541
+app/api/gresiq/models/route.ts
542
+app/api/gresiq/apps/route.ts
543
+app/api/gresiq/pulse/data/route.ts
544
+app/api/gresiq/apps/[appId]/route.ts
545
+app/api/gresiq/apps/[appId]/cloud-models/route.ts
546
+app/api/gresiq/apps/[appId]/model/route.ts
547
+app/api/gresiq/apps/[appId]/pulse/data/route.ts
548
+=== geo grep ===
549
+(eval):1: no matches found: --include=*.ts
550
+```
551
+
552
+
553
+**Tool Call: grep -rniE "country|region|latitude|longitude|geo|city" -l app types lib 2>/dev/null | head -30; echo "=== pulse-data.ts ==="; wc -l app/pulse/*.ts app/pulse/*.tsx app/api/gresiq/pulse/data/route.ts**
554
+Status: Completed
555
+
556
+Terminal:
557
+```
558
+app/page.tsx
559
+app/enterprise/page.tsx
560
+app/pulse/pulse-data.ts
561
+app/pulse/pulse-scene.tsx
562
+app/pulse/pulse-source.ts
563
+app/pulse/document-pulse-data.ts
564
+app/about/page.tsx
565
+app/pulse/globe-view.tsx
566
+app/pulse/page.tsx
567
+app/cloud/page.tsx
568
+app/root/models/[modelId]/page.tsx
569
+app/root/models/page.tsx
570
+app/pulse/document-pulse-data.test.ts
571
+app/globals.css
572
+app/root/apps/[appId]/page.tsx
573
+app/pulse-demo/page.tsx
574
+app/billing/page.tsx
575
+types/pulse.ts
576
+=== pulse-data.ts ===
577
+ 307 app/pulse/document-pulse-data.test.ts
578
+ 361 app/pulse/document-pulse-data.ts
579
+ 398 app/pulse/pulse-data.ts
580
+ 140 app/pulse/pulse-documents.test.ts
581
+ 172 app/pulse/pulse-documents.ts
582
+ 55 app/pulse/pulse-source.test.ts
583
+ 107 app/pulse/pulse-source.ts
584
+ 294 app/pulse/globe-view.tsx
585
+ 267 app/pulse/page.tsx
586
+ 219 app/pulse/pulse-scene.tsx
587
+ 39 app/pulse/span-selector.tsx
588
+ 59 app/api/gresiq/pulse/data/route.ts
589
+ 2418 total
590
+```
591
+
592
+
593
+**Tool Call: cat app/pulse/pulse-data.ts**
594
+Status: Completed
595
+
596
+Terminal:
597
+```
598
+import type {
599
+ PulseData,
600
+ PulseModel,
601
+ PulseRegion,
602
+ PulseSummary,
603
+ RawPulseData,
604
+ RawPulseRecord,
605
+} from "@/types/pulse";
606
+
607
+export const placeholderPulseData: PulseData = {
608
+ updatedAt: "2026-03-29T15:42:00.000Z",
609
+ summary: {
610
+ liveRegions: 12,
611
+ activeEdges: 384,
612
+ inferencesPerMinute: 18240,
613
+ tokensPerSecond: 92844,
614
+ medianTtftMs: 118,
615
+ p95TtftMs: 204,
616
+ },
617
+ regions: [
618
+ {
619
+ id: "arn1",
620
+ label: "Stockholm",
621
+ code: "ARN1",
622
+ lat: 59.3293,
623
+ lon: 18.0686,
624
+ activeEdges: 28,
625
+ activationsPerMinute: 1180,
626
+ inferencesPerMinute: 1760,
627
+ ttftMs: 93,
628
+ dominantModel: "Qwen 2.5 7B",
629
+ },
630
+ {
631
+ id: "fra1",
632
+ label: "Frankfurt",
633
+ code: "FRA1",
634
+ lat: 50.1109,
635
+ lon: 8.6821,
636
+ activeEdges: 34,
637
+ activationsPerMinute: 1490,
638
+ inferencesPerMinute: 2240,
639
+ ttftMs: 102,
640
+ dominantModel: "Llama 3.1 8B",
641
+ },
642
+ {
643
+ id: "yyz1",
644
+ label: "Toronto",
645
+ code: "YYZ1",
646
+ lat: 43.6532,
647
+ lon: -79.3832,
648
+ activeEdges: 26,
649
+ activationsPerMinute: 1075,
650
+ inferencesPerMinute: 1615,
651
+ ttftMs: 108,
652
+ dominantModel: "DeepSeek R1 Distill",
653
+ },
654
+ {
655
+ id: "iad1",
656
+ label: "Virginia",
657
+ code: "IAD1",
658
+ lat: 38.9072,
659
+ lon: -77.0369,
660
+ activeEdges: 48,
661
+ activationsPerMinute: 1960,
662
+ inferencesPerMinute: 2920,
663
+ ttftMs: 121,
664
+ dominantModel: "Llama 3.1 8B",
665
+ },
666
+ {
667
+ id: "sjc1",
668
+ label: "San Jose",
669
+ code: "SJC1",
670
+ lat: 37.3382,
671
+ lon: -121.8863,
672
+ activeEdges: 42,
673
+ activationsPerMinute: 1820,
674
+ inferencesPerMinute: 2680,
675
+ ttftMs: 115,
676
+ dominantModel: "Qwen 2.5 7B",
677
+ },
678
+ {
679
+ id: "hnd1",
680
+ label: "Tokyo",
681
+ code: "HND1",
682
+ lat: 35.6762,
683
+ lon: 139.6503,
684
+ activeEdges: 39,
685
+ activationsPerMinute: 1715,
686
+ inferencesPerMinute: 2540,
687
+ ttftMs: 98,
688
+ dominantModel: "Llama 3.2 3B",
689
+ },
690
+ {
691
+ id: "mex1",
692
+ label: "Mexico City",
693
+ code: "MEX1",
694
+ lat: 19.4326,
695
+ lon: -99.1332,
696
+ activeEdges: 24,
697
+ activationsPerMinute: 955,
698
+ inferencesPerMinute: 1420,
699
+ ttftMs: 129,
700
+ dominantModel: "Phi 4 Mini",
701
+ },
702
+ {
703
+ id: "cgk1",
704
+ label: "Jakarta",
705
+ code: "CGK1",
706
+ lat: -6.2088,
707
+ lon: 106.8456,
708
+ activeEdges: 33,
709
+ activationsPerMinute: 1425,
710
+ inferencesPerMinute: 2140,
711
+ ttftMs: 113,
712
+ dominantModel: "Qwen 2.5 7B",
713
+ },
714
+ {
715
+ id: "jog1",
716
+ label: "Yogyakarta",
717
+ code: "JOG1",
718
+ lat: -7.7971,
719
+ lon: 110.3708,
720
+ activeEdges: 22,
721
+ activationsPerMinute: 930,
722
+ inferencesPerMinute: 1410,
723
+ ttftMs: 124,
724
+ dominantModel: "DeepSeek R1 Distill",
725
+ },
726
+ {
727
+ id: "ikn1",
728
+ label: "Nusantara",
729
+ code: "IKN1",
730
+ lat: -0.9667,
731
+ lon: 116.7089,
732
+ activeEdges: 18,
733
+ activationsPerMinute: 755,
734
+ inferencesPerMinute: 1155,
735
+ ttftMs: 139,
736
+ dominantModel: "Phi 4 Mini",
737
+ },
738
+ {
739
+ id: "kno1",
740
+ label: "Medan",
741
+ code: "KNO1",
742
+ lat: 3.5952,
743
+ lon: 98.6722,
744
+ activeEdges: 17,
745
+ activationsPerMinute: 690,
746
+ inferencesPerMinute: 1025,
747
+ ttftMs: 136,
748
+ dominantModel: "DeepSeek R1 Distill",
749
+ },
750
+ {
751
+ id: "sub1",
752
+ label: "Surabaya",
753
+ code: "SUB1",
754
+ lat: -7.2575,
755
+ lon: 112.7521,
756
+ activeEdges: 20,
757
+ activationsPerMinute: 812,
758
+ inferencesPerMinute: 1225,
759
+ ttftMs: 132,
760
+ dominantModel: "Phi 4 Mini",
761
+ },
762
+ ],
763
+ models: [
764
+ {
765
+ id: "qwen-2_5-7b",
766
+ name: "Qwen 2.5 7B",
767
+ modality: "Text",
768
+ activeRegions: 8,
769
+ activationsPerMinute: 5810,
770
+ inferencesPerMinute: 8910,
771
+ share: 0.31,
772
+ ttftMs: 96,
773
+ },
774
+ {
775
+ id: "llama-3_1-8b",
776
+ name: "Llama 3.1 8B",
777
+ modality: "Text",
778
+ activeRegions: 6,
779
+ activationsPerMinute: 4204,
780
+ inferencesPerMinute: 6402,
781
+ share: 0.24,
782
+ ttftMs: 118,
783
+ },
784
+ {
785
+ id: "deepseek-r1-distill",
786
+ name: "DeepSeek R1 Distill",
787
+ modality: "Reasoning",
788
+ activeRegions: 5,
789
+ activationsPerMinute: 3014,
790
+ inferencesPerMinute: 4115,
791
+ share: 0.18,
792
+ ttftMs: 141,
793
+ },
794
+ {
795
+ id: "phi-4-mini",
796
+ name: "Phi 4 Mini",
797
+ modality: "Compact",
798
+ activeRegions: 4,
799
+ activationsPerMinute: 2190,
800
+ inferencesPerMinute: 3350,
801
+ share: 0.13,
802
+ ttftMs: 88,
803
+ },
804
+ {
805
+ id: "llama-3_2-3b",
806
+ name: "Llama 3.2 3B",
807
+ modality: "Text",
808
+ activeRegions: 3,
809
+ activationsPerMinute: 1540,
810
+ inferencesPerMinute: 2425,
811
+ share: 0.09,
812
+ ttftMs: 81,
813
+ },
814
+ ],
815
+ timeseries: [
816
+ { minute: "00", activations: 11210, inferences: 17022, ttftMs: 127 },
817
+ { minute: "05", activations: 11884, inferences: 17620, ttftMs: 124 },
818
+ { minute: "10", activations: 12490, inferences: 18344, ttftMs: 121 },
819
+ { minute: "15", activations: 12920, inferences: 18810, ttftMs: 119 },
820
+ { minute: "20", activations: 13110, inferences: 19042, ttftMs: 118 },
821
+ { minute: "25", activations: 13380, inferences: 19324, ttftMs: 115 },
822
+ { minute: "30", activations: 13620, inferences: 19750, ttftMs: 111 },
823
+ { minute: "35", activations: 13888, inferences: 20142, ttftMs: 110 },
824
+ { minute: "40", activations: 14100, inferences: 20494, ttftMs: 114 },
825
+ { minute: "45", activations: 13955, inferences: 20180, ttftMs: 120 },
826
+ { minute: "50", activations: 13714, inferences: 19860, ttftMs: 123 },
827
+ { minute: "55", activations: 13402, inferences: 19484, ttftMs: 118 },
828
+ ],
829
+ events: [
830
+ { time: "15:41:58", region: "Tokyo", model: "Llama 3.2 3B", message: "burst activation wave detected", tone: "hot" },
831
+ { time: "15:41:36", region: "Virginia", model: "Llama 3.1 8B", message: "p95 ttft recovered under 220ms", tone: "stable" },
832
+ { time: "15:41:22", region: "Frankfurt", model: "DeepSeek R1 Distill", message: "new edge pool attached", tone: "cool" },
833
+ { time: "15:40:59", region: "Singapore", model: "Qwen 2.5 7B", message: "inference throughput peaked at 2.8k/min", tone: "hot" },
834
+ ],
835
+};
836
+
837
+export const pulseQuerySketch = {
838
+ overview: `
839
+select
840
+ count(distinct region_id) as live_regions,
841
+ count(*) filter (where status = 'online') as active_edges,
842
+ coalesce(sum(inferences_per_minute), 0) as inferences_per_minute,
843
+ coalesce(sum(tokens_per_second), 0) as tokens_per_second,
844
+ percentile_cont(0.5) within group (order by ttft_ms) as median_ttft_ms,
845
+ percentile_cont(0.95) within group (order by ttft_ms) as p95_ttft_ms
846
+from pulse_region_minute
847
+where bucket_at = date_trunc('minute', now());
848
+`,
849
+ regions: `
850
+select
851
+ r.id,
852
+ r.code,
853
+ r.city_label as label,
854
+ r.latitude as lat,
855
+ r.longitude as lon,
856
+ m.active_edges,
857
+ m.activations_per_minute,
858
+ m.inferences_per_minute,
859
+ m.ttft_ms,
860
+ dm.name as dominant_model
861
+from pulse_regions r
862
+join pulse_region_minute m on m.region_id = r.id
863
+left join pulse_models dm on dm.id = m.dominant_model_id
864
+where m.bucket_at = date_trunc('minute', now())
865
+order by m.inferences_per_minute desc;
866
+`,
867
+ models: `
868
+select
869
+ pm.id,
870
+ pm.slug,
871
+ pm.name,
872
+ pm.modality,
873
+ count(distinct rmm.region_id) as active_regions,
874
+ sum(rmm.model_activations_per_minute) as activations_per_minute,
875
+ sum(rmm.model_inferences_per_minute) as inferences_per_minute,
876
+ avg(rmm.ttft_ms) as ttft_ms
877
+from pulse_models pm
878
+join pulse_region_model_minute rmm on rmm.model_id = pm.id
879
+where rmm.bucket_at >= now() - interval '1 minute'
880
+group by pm.id, pm.slug, pm.name, pm.modality
881
+order by inferences_per_minute desc;
882
+`,
883
+};
884
+
885
+// The smbCloud GresIQ backend is Rails, which serializes JSON in snake_case by
886
+// default (active_edges, inferences_per_minute, ttft_ms, ...). Every consumer in
887
+// this app reads camelCase. Normalize once, right after the fetch, so the render
888
+// path is correct regardless of what casing the backend sends. camelCase input
889
+// passes through unchanged because each lookup prefers the camelCase key first.
890
+
891
+function pick<T = unknown>(
892
+ obj: RawPulseRecord | null | undefined,
893
+ camel: string,
894
+ snake: string,
895
+): T | undefined {
896
+ if (obj == null) return undefined;
897
+ return (obj[camel] ?? obj[snake]) as T | undefined;
898
+}
899
+
900
+function normalizeSummary(
901
+ summary: RawPulseRecord | null | undefined,
902
+): PulseSummary | undefined {
903
+ if (!summary) return undefined;
904
+ return {
905
+ liveRegions: pick(summary, "liveRegions", "live_regions"),
906
+ activeEdges: pick(summary, "activeEdges", "active_edges"),
907
+ inferencesPerMinute: pick(
908
+ summary,
909
+ "inferencesPerMinute",
910
+ "inferences_per_minute",
911
+ ),
912
+ tokensPerSecond: pick(summary, "tokensPerSecond", "tokens_per_second"),
913
+ medianTtftMs: pick(summary, "medianTtftMs", "median_ttft_ms"),
914
+ p95TtftMs: pick(summary, "p95TtftMs", "p95_ttft_ms"),
915
+ totalInferences: pick(summary, "totalInferences", "total_inferences"),
916
+ };
917
+}
918
+
919
+function normalizeRegion(
920
+ region: RawPulseRecord | null | undefined,
921
+): PulseRegion | null | undefined {
922
+ if (!region) return region;
923
+ return {
924
+ id: region.id as string | number | undefined,
925
+ code: region.code as string | undefined,
926
+ label: pick(region, "label", "city_label"),
927
+ lat: pick(region, "lat", "latitude"),
928
+ lon: pick(region, "lon", "longitude"),
929
+ activeEdges: pick(region, "activeEdges", "active_edges"),
930
+ activationsPerMinute: pick(
931
+ region,
932
+ "activationsPerMinute",
933
+ "activations_per_minute",
934
+ ),
935
+ inferencesPerMinute: pick(
936
+ region,
937
+ "inferencesPerMinute",
938
+ "inferences_per_minute",
939
+ ),
940
+ ttftMs: pick(region, "ttftMs", "ttft_ms"),
941
+ dominantModel: pick(region, "dominantModel", "dominant_model"),
942
+ };
943
+}
944
+
945
+function normalizeModel(
946
+ model: RawPulseRecord | null | undefined,
947
+): PulseModel | null | undefined {
948
+ if (!model) return model;
949
+ return {
950
+ id: model.id as string | number | undefined,
951
+ slug: model.slug as string | undefined,
952
+ name: model.name as string | undefined,
953
+ modality: model.modality as string | undefined,
954
+ activeRegions: pick(model, "activeRegions", "active_regions"),
955
+ activationsPerMinute: pick(
956
+ model,
957
+ "activationsPerMinute",
958
+ "activations_per_minute",
959
+ ),
960
+ inferencesPerMinute: pick(
961
+ model,
962
+ "inferencesPerMinute",
963
+ "inferences_per_minute",
964
+ ),
965
+ ttftMs: pick(model, "ttftMs", "ttft_ms"),
966
+ share: model.share as number | undefined,
967
+ };
968
+}
969
+
970
+export function normalizePulseData(
971
+ data: RawPulseData | null | undefined,
972
+): PulseData | null | undefined {
973
+ if (!data || typeof data !== "object") return data;
974
+ return {
975
+ ...data,
976
+ updatedAt: pick(data, "updatedAt", "updated_at"),
977
+ summary: normalizeSummary(data.summary),
978
+ regions: Array.isArray(data.regions)
979
+ ? (data.regions.map(normalizeRegion).filter(Boolean) as PulseRegion[])
980
+ : undefined,
981
+ models: Array.isArray(data.models)
982
+ ? (data.models.map(normalizeModel).filter(Boolean) as PulseModel[])
983
+ : undefined,
984
+ };
985
+}
986
+
987
+export function formatPulseTimestamp(timestamp: string | number | Date): string {
988
+ return new Intl.DateTimeFormat("en", {
989
+ hour: "2-digit",
990
+ minute: "2-digit",
991
+ second: "2-digit",
992
+ hour12: false,
993
+ timeZone: "UTC",
994
+ }).format(new Date(timestamp));
995
+}
996
+```
997
+
998
+
999
+**Tool Call: cd /Users/setoelkahfi/Repositories/smbcloud/services/smbcloud-api && grep -rniE "geocoder|maxmind|geoip|geolocat|remote_ip|latitude" --include="*.rb" app lib config db/schema.rb 2>/dev/null | grep -v spec | head -40**
1000
+Status: Completed
1001
+
1002
+Terminal:
1003
+```
1004
+app/controllers/gresiq/v1/pulse_controller.rb:209: lat: region.latitude,
1005
+app/controllers/gresiq/v1/pulse_controller.rb:270: lat: region.latitude,
1006
+app/controllers/gresiq/v1/pulse_controller.rb:348: # ── IP geolocation helpers ───────────────────────────────────────────────
1007
+app/controllers/gresiq/v1/pulse_controller.rb:356: ip = request.remote_ip
1008
+app/controllers/gresiq/v1/pulse_controller.rb:359: result = Geocoder.search(ip).first
1009
+app/controllers/gresiq/v1/pulse_controller.rb:370: latitude: result.latitude.to_f,
1010
+app/controllers/gresiq/v1/pulse_controller.rb:380: def find_or_create_region(id:, code:, city_label:, latitude:, longitude:)
1011
+app/controllers/gresiq/v1/pulse_controller.rb:385: r.latitude = latitude
1012
+config/initializers/geocoder.rb:3:# IP geolocation for pulse edge region detection.
1013
+config/initializers/geocoder.rb:6:Geocoder.configure(
1014
+db/schema.rb:526: t.float "latitude", null: false
1015
+```
1016
+
1017
+
1018
+**Tool Call: sed -n '330,400p' app/controllers/gresiq/v1/pulse_controller.rb && echo "=== geocoder init ===" && cat config/initializers/geocoder.rb && echo "=== schema regions ===" && sed -n '515,545p' db/schema.rb**
1019
+Status: Completed
1020
+
1021
+Terminal:
1022
+```
1023
+ # owned by the GresIQ app's prefixed schema.
1024
+ def ar_class_raw(table_name)
1025
+ @ar_classes_raw ||= {}
1026
+ @ar_classes_raw[table_name] ||= Class.new(ApplicationRecord) do
1027
+ self.table_name = table_name
1028
+ self.primary_key = 'id'
1029
+ end
1030
+ end
1031
+
1032
+ # Compute a percentile from a pre-sorted array of integers.
1033
+ # Returns 0 for empty input.
1034
+ def percentile_value(sorted_values, p)
1035
+ return 0 if sorted_values.empty?
1036
+
1037
+ idx = [ (p * (sorted_values.length - 1)).round, sorted_values.length - 1 ].min
1038
+ sorted_values[idx]
1039
+ end
1040
+
1041
+ # ── IP geolocation helpers ───────────────────────────────────────────────
1042
+
1043
+ # Resolve the pulse_region for the current request IP at city level.
1044
+ # The first device from a city creates the region row; subsequent devices
1045
+ # from the same city reuse it.
1046
+ # Region ID is a stable slug e.g. "stockholm-se", "jakarta-id".
1047
+ # Returns "unknown" for private/loopback IPs or when geocoding fails.
1048
+ def resolve_region
1049
+ ip = request.remote_ip
1050
+ return 'unknown' if private_ip?(ip)
1051
+
1052
+ result = Geocoder.search(ip).first
1053
+ return 'unknown' unless result&.city.present? && result&.country_code.present?
1054
+
1055
+ city = result.city
1056
+ country_code = result.country_code.upcase
1057
+ region_id = "#{city.downcase.gsub(/[^a-z0-9]+/, '-')}-#{country_code.downcase}"
1058
+
1059
+ find_or_create_region(
1060
+ id: region_id,
1061
+ code: "#{country_code}-#{city.first(3).upcase}",
1062
+ city_label: "#{city}, #{country_code}",
1063
+ latitude: result.latitude.to_f,
1064
+ longitude: result.longitude.to_f,
1065
+ )
1066
+ rescue StandardError => error
1067
+ Rails.logger.warn("pulse: geocode failed for #{ip.inspect}: #{error.message}")
1068
+ 'unknown'
1069
+ end
1070
+
1071
+ # Find or auto-create a pulse_regions row for this city.
1072
+ # Returns the region id on success, "unknown" on any DB error.
1073
+ def find_or_create_region(id:, code:, city_label:, latitude:, longitude:)
1074
+ region_class = ar_class('pulse_regions')
1075
+ region_class.find_or_create_by!(id: id) do |r|
1076
+ r.code = code
1077
+ r.city_label = city_label
1078
+ r.latitude = latitude
1079
+ r.longitude = longitude
1080
+ end
1081
+ id
1082
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => error
1083
+ Rails.logger.warn("pulse: region upsert failed for #{id.inspect}: #{error.message}")
1084
+ 'unknown'
1085
+ end
1086
+
1087
+ # True for loopback (127.x, ::1) and RFC-1918 private addresses.
1088
+ def private_ip?(ip)
1089
+ addr = IPAddr.new(ip.to_s)
1090
+ addr.loopback? || addr.private?
1091
+ rescue IPAddr::InvalidAddressError
1092
+ true
1093
+ end
1094
+=== geocoder init ===
1095
+# frozen_string_literal: true
1096
+
1097
+# IP geolocation for pulse edge region detection.
1098
+# Uses ipinfo.io (free tier — 50k req/month, no API key required).
1099
+# Caches results in Rails.cache to avoid redundant lookups for the same IP.
1100
+Geocoder.configure(
1101
+ # Use ipinfo.io for IP → lat/lon lookups.
1102
+ ip_lookup: :ipinfo_io,
1103
+
1104
+ # Fail fast — pulse telemetry is non-critical.
1105
+ timeout: 3,
1106
+
1107
+ # Cache geocode results so repeated model-loads from the same device
1108
+ # don't hammer the lookup service.
1109
+ cache: Rails.cache,
1110
+
1111
+ # Suppress exceptions — errors are logged, not raised.
1112
+ always_raise: [],
1113
+)
1114
+=== schema regions ===
1115
+ t.string "region_id", null: false
1116
+ t.uuid "model_id", null: false
1117
+ t.integer "model_activations_per_minute", null: false
1118
+ t.integer "model_inferences_per_minute", null: false
1119
+ t.integer "ttft_ms", null: false
1120
+ t.index ["bucket_at"], name: "gresiq_ondeinference_pulse_rmm_bucket_idx", order: :desc
1121
+ end
1122
+
1123
+ create_table "gresiq_ondeinference_pulse_regions", id: :string, force: :cascade do |t|
1124
+ t.string "code", null: false
1125
+ t.string "city_label", null: false
1126
+ t.float "latitude", null: false
1127
+ t.float "longitude", null: false
1128
+ t.string "provider"
1129
+ t.datetime "created_at", default: -> { "now()" }, null: false
1130
+ t.index ["code"], name: "index_gresiq_ondeinference_pulse_regions_on_code", unique: true
1131
+ end
1132
+
1133
+ create_table "jwt_denylists", force: :cascade do |t|
1134
+ t.string "jti", null: false
1135
+ t.datetime "exp", null: false
1136
+ t.index ["jti"], name: "index_jwt_denylists_on_jti"
1137
+ end
1138
+
1139
+ create_table "mail_api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
1140
+ t.uuid "mail_app_id", null: false
1141
+ t.bigint "tenant_id", null: false
1142
+ t.bigint "created_by_id", null: false
1143
+ t.string "name", null: false
1144
+ t.string "token_digest", null: false
1145
+ t.string "token_prefix", null: false
1146
+```
1147
+
1148
+
1149
+**Tool Call: cd /Users/setoelkahfi/Repositories/onde && git show feature/decouple-gresiq-from-onde-inference:src/pulse/events.rs | head -120**
1150
+Status: Completed
1151
+
1152
+Terminal:
1153
+```
1154
+use serde::Serialize;
1155
+
1156
+/// What gets sent to GresIQ when a model finishes loading.
1157
+#[derive(Debug, Clone, Serialize)]
1158
+pub struct ModelLoadedEvent {
1159
+ /// Which machine this is. Keep it stable across restarts — change it
1160
+ /// and you'll get a duplicate edge row in the dashboard.
1161
+ pub edge_id: String,
1162
+ /// The HuggingFace repo ID, e.g. `bartowski/Qwen2.5-3B-Instruct-GGUF`.
1163
+ pub model_id: String,
1164
+ /// What shows up in the UI, e.g. `Qwen 2.5 3B`.
1165
+ pub model_name: String,
1166
+ /// How long the load took. This is `elapsed.as_millis() as u64`,
1167
+ /// where `elapsed` is what `load_gguf_model` returns.
1168
+ pub load_duration_ms: u64,
1169
+ /// The Onde app that loaded this model (UUID string from the
1170
+ /// ondeinference.com dashboard). `None` for SDK builds without app
1171
+ /// credentials or for direct Rust consumers. In the document store this
1172
+ /// is a plain field on the `pulse_models` doc, not a foreign key.
1173
+ #[serde(skip_serializing_if = "Option::is_none")]
1174
+ pub onde_app_id: Option<String>,
1175
+}
1176
+
1177
+/// What gets sent to GresIQ after each `send_message` or `generate`.
1178
+#[derive(Debug, Clone, Serialize)]
1179
+pub struct InferenceEvent {
1180
+ /// Same edge as in `ModelLoadedEvent`.
1181
+ pub edge_id: String,
1182
+ /// Same model as in `ModelLoadedEvent`.
1183
+ pub model_id: String,
1184
+ /// Auto-generated per request. Looks like `onde-1720000000000-42`.
1185
+ pub request_id: String,
1186
+ /// Wall-clock time for the whole response, in milliseconds.
1187
+ pub duration_ms: u64,
1188
+ /// Time to first token, in milliseconds. Only the streaming path can
1189
+ /// observe this; a blocking `send_message` gets the whole response at
1190
+ /// once and reports `None` rather than a stand-in value.
1191
+ #[serde(skip_serializing_if = "Option::is_none")]
1192
+ pub ttft_ms: Option<u64>,
1193
+ /// `"success"`, `"cancelled"`, or `"error"`. Currently always
1194
+ /// `"success"` here — inference errors throw before we reach this point.
1195
+ pub status: String,
1196
+ /// Same Onde app as the corresponding `ModelLoadedEvent`. `None` for SDK
1197
+ /// builds without app credentials or direct Rust consumers.
1198
+ #[serde(skip_serializing_if = "Option::is_none")]
1199
+ pub onde_app_id: Option<String>,
1200
+}
1201
+
1202
+#[cfg(test)]
1203
+mod tests {
1204
+ use super::*;
1205
+
1206
+ #[test]
1207
+ fn model_loaded_serializes_onde_app_id_when_present() {
1208
+ let event = ModelLoadedEvent {
1209
+ edge_id: "edge-1".into(),
1210
+ model_id: "org/repo".into(),
1211
+ model_name: "Repo 3B".into(),
1212
+ load_duration_ms: 1234,
1213
+ onde_app_id: Some("app-uuid".into()),
1214
+ };
1215
+ let value = serde_json::to_value(&event).expect("serialize");
1216
+ assert_eq!(value["onde_app_id"], "app-uuid");
1217
+ assert_eq!(value["load_duration_ms"], 1234);
1218
+ }
1219
+
1220
+ #[test]
1221
+ fn onde_app_id_is_omitted_when_absent() {
1222
+ let event = InferenceEvent {
1223
+ edge_id: "edge-1".into(),
1224
+ model_id: "org/repo".into(),
1225
+ request_id: "onde-1-0".into(),
1226
+ duration_ms: 42,
1227
+ ttft_ms: None,
1228
+ status: "success".into(),
1229
+ onde_app_id: None,
1230
+ };
1231
+ let value = serde_json::to_value(&event).expect("serialize");
1232
+ assert!(value.get("onde_app_id").is_none());
1233
+ }
1234
+
1235
+ #[test]
1236
+ fn ttft_is_omitted_when_the_path_cannot_measure_it() {
1237
+ let mut event = InferenceEvent {
1238
+ edge_id: "edge-1".into(),
1239
+ model_id: "org/repo".into(),
1240
+ request_id: "onde-1-0".into(),
1241
+ duration_ms: 42,
1242
+ ttft_ms: None,
1243
+ status: "success".into(),
1244
+ onde_app_id: None,
1245
+ };
1246
+ assert!(serde_json::to_value(&event)
1247
+ .expect("serialize")
1248
+ .get("ttft_ms")
1249
+ .is_none());
1250
+
1251
+ event.ttft_ms = Some(17);
1252
+ assert_eq!(serde_json::to_value(&event).expect("serialize")["ttft_ms"], 17);
1253
+ }
1254
+}
1255
+```
1256
+
1257
+
1258
+**Tool Call: cd /Users/setoelkahfi/Repositories/onde-web && cat app/api/gresiq/pulse/data/route.ts && echo "=== pulse-source.ts ===" && cat app/pulse/pulse-source.ts**
1259
+Status: Completed
1260
+
1261
+Terminal:
1262
+```
1263
+import { NextResponse } from "next/server";
1264
+import {
1265
+ resolvedEnv,
1266
+ smbcloudApiUrl,
1267
+ gresiqCredentials,
1268
+} from "../../../_lib/credentials";
1269
+import { normalizePulseData } from "../../../../pulse/pulse-data";
1270
+
1271
+// GET /api/gresiq/pulse/data?env=development|production
1272
+//
1273
+// Proxy the smbCloud GresIQ pulse endpoint.
1274
+// GresIQ credentials come from server-side env vars and never reach the browser.
1275
+export async function GET(request: Request) {
1276
+ const { searchParams } = new URL(request.url);
1277
+ const env = resolvedEnv(searchParams);
1278
+ const { apiKey, apiSecret } = gresiqCredentials(env);
1279
+
1280
+ if (!apiKey || !apiSecret) {
1281
+ return NextResponse.json(
1282
+ { error: "GresIQ pulse credentials not configured." },
1283
+ { status: 503 },
1284
+ );
1285
+ }
1286
+
1287
+ const span = searchParams.get("span");
1288
+ const windowMinutes = searchParams.get("window_minutes");
1289
+
1290
+ let queryStr = "";
1291
+ if (span === "alltime") {
1292
+ queryStr = "?span=alltime";
1293
+ } else if (windowMinutes) {
1294
+ queryStr = `?window_minutes=${windowMinutes}`;
1295
+ }
1296
+
1297
+ const url = `${smbcloudApiUrl(env)}/gresiq/v1/pulse/data${queryStr}`;
1298
+
1299
+ try {
1300
+ const upstream = await fetch(url, {
1301
+ headers: {
1302
+ "Content-Type": "application/json",
1303
+ "X-Gresiq-Api-Key": apiKey,
1304
+ "X-Gresiq-Api-Secret": apiSecret,
1305
+ },
1306
+ // Revalidate every 60 seconds. The backend aggregates this data per minute.
1307
+ next: { revalidate: 60 },
1308
+ });
1309
+
1310
+ const body = await upstream.json().catch(() => ({}));
1311
+ // Normalize snake_case (Rails) → camelCase so the per-app dashboard, which
1312
+ // reads camelCase fields, renders correctly regardless of upstream casing.
1313
+ const normalized = upstream.ok ? normalizePulseData(body) : body;
1314
+ return NextResponse.json(normalized, { status: upstream.status });
1315
+ } catch (error) {
1316
+ return NextResponse.json(
1317
+ { error: (error as Error).message },
1318
+ { status: 502 },
1319
+ );
1320
+ }
1321
+}
1322
+=== pulse-source.ts ===
1323
+// Server-only source policy for per-app pulse reads.
1324
+//
1325
+// `document` is per-app allowlisted rather than a global flip so one bad
1326
+// tenant dataset rolls back on its own. Nothing here is NEXT_PUBLIC_-prefixed:
1327
+// the browser never learns or chooses which source served a response.
1328
+
1329
+import type { PulseData } from "@/types/pulse";
1330
+import type {
1331
+ PulseDocumentDiagnostics,
1332
+ PulseDocumentIncompleteReason,
1333
+} from "@/types/pulse-documents";
1334
+
1335
+export type PulseSourceMode = "legacy" | "shadow" | "document";
1336
+
1337
+type EnvRecord = Record<string, string | undefined>;
1338
+
1339
+export function pulseSourceMode(
1340
+ appId: string,
1341
+ env: EnvRecord = process.env,
1342
+): PulseSourceMode {
1343
+ const configured = (env.ONDE_PULSE_DOCUMENT_READ ?? "").trim().toLowerCase();
1344
+ if (configured === "shadow") return "shadow";
1345
+ if (configured !== "document") return "legacy";
1346
+
1347
+ const allowlist = (env.ONDE_PULSE_DOCUMENT_APPS ?? "")
1348
+ .split(",")
1349
+ .map((entry) => entry.trim())
1350
+ .filter(Boolean);
1351
+
1352
+ return allowlist.includes(appId) ? "document" : "legacy";
1353
+}
1354
+
1355
+export interface PulseParityReport {
1356
+ modelCount: { legacy: number; document: number };
1357
+ activeEdges: { legacy?: number; document?: number };
1358
+ totalInferences: { legacy?: number; document?: number };
1359
+ /** Model ids present in one source only. */
1360
+ modelIdMismatches: number;
1361
+ /** True only when both sides are comparable and every counted dimension agrees. */
1362
+ equal: boolean;
1363
+}
1364
+
1365
+function modelIds(data: PulseData | null | undefined): Set<string> {
1366
+ return new Set(
1367
+ (data?.models ?? [])
1368
+ .map((model) => (model.id ?? model.slug)?.toString())
1369
+ .filter((id): id is string => Boolean(id)),
1370
+ );
1371
+}
1372
+
1373
+/**
1374
+ * Compare a legacy response against a document response for one app and
1375
+ * window. Geographic dimensions are excluded: the M2 document set carries no
1376
+ * geography, and that gap gates the public Pulse view, not this summary.
1377
+ */
1378
+export function comparePulse(
1379
+ legacy: PulseData | null | undefined,
1380
+ document: PulseData | null | undefined,
1381
+): PulseParityReport {
1382
+ const legacyIds = modelIds(legacy);
1383
+ const documentIds = modelIds(document);
1384
+ let mismatches = 0;
1385
+ legacyIds.forEach((id) => {
1386
+ if (!documentIds.has(id)) mismatches += 1;
1387
+ });
1388
+ documentIds.forEach((id) => {
1389
+ if (!legacyIds.has(id)) mismatches += 1;
1390
+ });
1391
+
1392
+ const report: PulseParityReport = {
1393
+ modelCount: { legacy: legacyIds.size, document: documentIds.size },
1394
+ activeEdges: {
1395
+ legacy: legacy?.summary?.activeEdges,
1396
+ document: document?.summary?.activeEdges,
1397
+ },
1398
+ totalInferences: {
1399
+ legacy: legacy?.summary?.totalInferences,
1400
+ document: document?.summary?.totalInferences,
1401
+ },
1402
+ modelIdMismatches: mismatches,
1403
+ equal: false,
1404
+ };
1405
+
1406
+ report.equal =
1407
+ mismatches === 0 &&
1408
+ report.activeEdges.legacy === report.activeEdges.document &&
1409
+ report.totalInferences.legacy === report.totalInferences.document;
1410
+
1411
+ return report;
1412
+}
1413
+
1414
+export interface PulseSourceDiagnostic {
1415
+ appId: string;
1416
+ mode: PulseSourceMode;
1417
+ served: "legacy" | "document";
1418
+ incompleteReason?: PulseDocumentIncompleteReason;
1419
+ documents?: PulseDocumentDiagnostics;
1420
+ parity?: PulseParityReport;
1421
+}
1422
+
1423
+/**
1424
+ * Emit counts and bounded categories only. Never event payloads, request
1425
+ * text, raw IPs, or any data-plane credential.
1426
+ */
1427
+export function emitPulseSourceDiagnostic(diagnostic: PulseSourceDiagnostic) {
1428
+ console.info("[pulse.source]", JSON.stringify(diagnostic));
1429
+}
1430
+```
1431
+
1432
+
1433
+**Tool Call: cd /Users/setoelkahfi/Repositories/onde && ls -a | head -30 && echo "=== docs ===" && ls docs .agents 2>/dev/null | head -30 && echo "=== pulse dir ===" && ls src/pulse/**
1434
+Status: Completed
1435
+
1436
+Terminal:
1437
+```
1438
+.
1439
+..
1440
+.DS_Store
1441
+.agents
1442
+.cargo
1443
+.claude
1444
+.env
1445
+.git
1446
+.github
1447
+.gitignore
1448
+.zed
1449
+AGENTS.md
1450
+CHANGELOG.md
1451
+CLAUDE.md
1452
+Cargo.lock
1453
+Cargo.toml
1454
+LICENSE-APACHE
1455
+LICENSE-MIT
1456
+Makefile
1457
+README.md
1458
+assets
1459
+build.rs
1460
+dist
1461
+generated
1462
+rust-toolchain.toml
1463
+scripts
1464
+sdk
1465
+src
1466
+target
1467
+uniffi-bindgen
1468
+=== docs ===
1469
+.agents:
1470
+AGENTS.md
1471
+skills
1472
+=== pulse dir ===
1473
+client.rs
1474
+events.rs
1475
+mod.rs
1476
+```
1477
+
1478
+
1479
+**Tool Call: git show feature/decouple-gresiq-from-onde-inference:src/pulse/client.rs | sed -n '1,140p'**
1480
+Status: Completed
1481
+
1482
+Terminal:
1483
+```
1484
+use smbcloud_gresiq_sdk::{Environment, GresiqClient, GresiqCredentials};
1485
+
1486
+use super::events::{InferenceEvent, ModelLoadedEvent};
1487
+
1488
+/// GresIQ credentials embedded at SDK build time — one pair per environment.
1489
+/// Consumer apps never set these — they're Onde Inference's own credentials.
1490
+const EMBEDDED_API_KEY_DEV: Option<&str> = option_env!("GRESIQ_API_KEY_DEV");
1491
+const EMBEDDED_API_SECRET_DEV: Option<&str> = option_env!("GRESIQ_API_SECRET_DEV");
1492
+const EMBEDDED_API_KEY_PRODUCTION: Option<&str> = option_env!("GRESIQ_API_KEY_PRODUCTION");
1493
+const EMBEDDED_API_SECRET_SECRET_PRODUCTION: Option<&str> =
1494
+ option_env!("GRESIQ_API_SECRET_PRODUCTION");
1495
+
1496
+/// Onde telemetry client. Wraps GresiqClient so pulse events land in the
1497
+/// right GresIQ-managed tables without consumer apps knowing anything about
1498
+/// the GresIQ auth layer underneath.
1499
+///
1500
+/// GresIQ credentials are embedded in the SDK at build time.
1501
+/// Consumer apps only provide an `edge_id` (stable device identifier).
1502
+///
1503
+/// Cheap to clone: the inner GresiqClient holds an Arc-backed reqwest::Client,
1504
+/// so cloning is a pointer bump, not a new TCP connection.
1505
+#[derive(Debug, Clone)]
1506
+pub struct PulseClient {
1507
+ inner: GresiqClient,
1508
+ edge_id: String,
1509
+ onde_app_id: Option<String>,
1510
+}
1511
+
1512
+impl PulseClient {
1513
+ /// Returns true when pulse telemetry is disabled explicitly by the host app.
1514
+ pub fn disabled_by_env() -> bool {
1515
+ matches!(
1516
+ std::env::var("ONDE_DISABLE_PULSE")
1517
+ .ok()
1518
+ .as_deref()
1519
+ .map(str::trim)
1520
+ .map(str::to_ascii_lowercase)
1521
+ .as_deref(),
1522
+ Some("1") | Some("true") | Some("yes") | Some("on")
1523
+ )
1524
+ }
1525
+
1526
+ /// ADR-0003 M2 dual-write toggle. The mirror is deliberately opt-in while
1527
+ /// document parity is being proven. When enabled, every attributed pulse
1528
+ /// event is *also* written to the GresIQ document gateway
1529
+ /// (`/gresiq/v1/collections/:collection`) in addition to the semantic
1530
+ /// `pulse/*` routes, which still serve reads until the M5 cut-over. Set
1531
+ /// `ONDE_PULSE_DUAL_WRITE=1` (or `true`/`yes`/`on`) to enable it.
1532
+ fn dual_write_enabled() -> bool {
1533
+ dual_write_enabled_value(std::env::var("ONDE_PULSE_DUAL_WRITE").ok().as_deref())
1534
+ }
1535
+
1536
+ /// Build a pulse client using the GresIQ credentials embedded in the SDK.
1537
+ ///
1538
+ /// Returns `None` if the SDK was compiled without `GRESIQ_API_KEY` /
1539
+ /// `GRESIQ_API_SECRET` (e.g. a local dev build of onde without `.env`).
1540
+ /// In that case telemetry is silently disabled — no panic, no partial state.
1541
+ ///
1542
+ /// `edge_id` is a stable device identifier (installation UUID).
1543
+ /// Pass an empty string to default to `"onde-unknown"`.
1544
+ ///
1545
+ /// `onde_app_id` is the Onde app UUID (from the ondeinference.com
1546
+ /// dashboard) that owns this telemetry. `None` is fine — events then carry
1547
+ /// no app association (open-source/direct Rust consumers).
1548
+ pub fn new(
1549
+ environment: Environment,
1550
+ edge_id: String,
1551
+ onde_app_id: Option<String>,
1552
+ ) -> Option<Self> {
1553
+ if Self::disabled_by_env() {
1554
+ return None;
1555
+ }
1556
+
1557
+ let (api_key, api_secret) = match environment {
1558
+ Environment::Dev => (EMBEDDED_API_KEY_DEV?, EMBEDDED_API_SECRET_DEV?),
1559
+ Environment::Production => (
1560
+ EMBEDDED_API_KEY_PRODUCTION?,
1561
+ EMBEDDED_API_SECRET_SECRET_PRODUCTION?,
1562
+ ),
1563
+ };
1564
+
1565
+ let edge_id = if edge_id.is_empty() {
1566
+ "onde-unknown".to_string()
1567
+ } else {
1568
+ edge_id
1569
+ };
1570
+
1571
+ // reqwest 0.12.x requires a live Tokio runtime (with I/O reactor)
1572
+ // to construct a Client. GresiqClient::from_credentials calls
1573
+ // reqwest::Client::new() internally. Guard against panics when
1574
+ // called from a thread/context that lacks a Tokio reactor.
1575
+ if tokio::runtime::Handle::try_current().is_err() {
1576
+ log::warn!(
1577
+ "pulse: no Tokio runtime available — \
1578
+ deferring PulseClient creation"
1579
+ );
1580
+ return None;
1581
+ }
1582
+
1583
+ let credentials = GresiqCredentials {
1584
+ api_key,
1585
+ api_secret,
1586
+ };
1587
+
1588
+ let inner = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1589
+ GresiqClient::from_credentials(environment, credentials)
1590
+ })) {
1591
+ Ok(client) => client,
1592
+ Err(_) => {
1593
+ log::warn!(
1594
+ "pulse: GresiqClient::from_credentials panicked \
1595
+ (likely missing Tokio reactor) — telemetry disabled"
1596
+ );
1597
+ return None;
1598
+ }
1599
+ };
1600
+
1601
+ Some(PulseClient {
1602
+ inner,
1603
+ edge_id,
1604
+ onde_app_id,
1605
+ })
1606
+ }
1607
+
1608
+ /// Writes the model-load event to the pulse/model_loaded table and
1609
+ /// **awaits** the result before returning.
1610
+ ///
1611
+ /// This must complete before any `record_inference` call for the same
1612
+ /// edge + model is made, because the API enforces a foreign-key
1613
+ /// constraint: an `inference_event` row is rejected with HTTP 404
1614
+ /// ("Call model_loaded first") if no matching `model_loaded` row exists.
1615
+ ///
1616
+ /// Keeping this synchronous with respect to the model-load path means
1617
+ /// the row is always present by the time the first inference event fires.
1618
+ /// A failed write emits a warn! log line — no retry, no queue, and no
1619
+ /// effect on the caller.
1620
+ pub async fn record_model_loaded(
1621
+ &self,
1622
+ model_id: String,
1623
+ model_name: String,
1624
+```
1625
+
1626
+
1627
+**Tool Call: git show feature/decouple-gresiq-from-onde-inference:src/pulse/client.rs | sed -n '140,330p'**
1628
+Status: Completed
1629
+
1630
+Terminal:
1631
+```
1632
+ model_name: String,
1633
+ load_duration_ms: u64,
1634
+ ) {
1635
+ let client = self.clone();
1636
+ let send_event = async move {
1637
+ let event = ModelLoadedEvent {
1638
+ edge_id: client.edge_id.clone(),
1639
+ model_id,
1640
+ model_name,
1641
+ load_duration_ms,
1642
+ onde_app_id: client.onde_app_id.clone(),
1643
+ };
1644
+ if let Err(error) = client.inner.insert("pulse/model_loaded", &event).await {
1645
+ log::warn!("pulse: model_loaded failed: {}", error);
1646
+ }
1647
+ client.spawn_model_loaded_dual_write(event);
1648
+ };
1649
+
1650
+ if tokio::runtime::Handle::try_current().is_ok() {
1651
+ send_event.await;
1652
+ return;
1653
+ }
1654
+
1655
+ let join = std::thread::spawn(move || {
1656
+ let runtime = tokio::runtime::Builder::new_current_thread()
1657
+ .enable_all()
1658
+ .build();
1659
+
1660
+ match runtime {
1661
+ Ok(runtime) => {
1662
+ runtime.block_on(send_event);
1663
+ }
1664
+ Err(error) => {
1665
+ log::warn!(
1666
+ "pulse: could not create fallback runtime for model_loaded: {}",
1667
+ error
1668
+ );
1669
+ }
1670
+ }
1671
+ });
1672
+
1673
+ if join.join().is_err() {
1674
+ log::warn!("pulse: fallback model_loaded thread panicked");
1675
+ }
1676
+ }
1677
+
1678
+ /// Same fire-and-forget pattern as record_model_loaded but for inference
1679
+ /// completions. Writes to pulse/inference_event. Logs on failure, no retry.
1680
+ ///
1681
+ /// Swift / UniFFI consumers may call into the SDK from contexts where
1682
+ /// `tokio::spawn` does not have a current runtime handle even though the
1683
+ /// outer API is async. To avoid panicking on Apple platforms, we prefer
1684
+ /// the current Tokio runtime when available and otherwise fall back to a
1685
+ /// short-lived current-thread runtime on a native background thread.
1686
+ pub fn record_inference(
1687
+ &self,
1688
+ model_id: String,
1689
+ request_id: String,
1690
+ duration_ms: u64,
1691
+ ttft_ms: Option<u64>,
1692
+ status: String,
1693
+ ) {
1694
+ let client = self.clone();
1695
+ let send_event = async move {
1696
+ let event = InferenceEvent {
1697
+ edge_id: client.edge_id.clone(),
1698
+ model_id,
1699
+ request_id,
1700
+ duration_ms,
1701
+ ttft_ms,
1702
+ status,
1703
+ onde_app_id: client.onde_app_id.clone(),
1704
+ };
1705
+ if let Err(error) = client.inner.insert("pulse/inference_event", &event).await {
1706
+ log::warn!("pulse: inference_event failed: {}", error);
1707
+ }
1708
+ client.dual_write_inference(&event).await;
1709
+ };
1710
+
1711
+ if let Ok(handle) = tokio::runtime::Handle::try_current() {
1712
+ handle.spawn(send_event);
1713
+ return;
1714
+ }
1715
+
1716
+ std::thread::spawn(move || {
1717
+ let runtime = tokio::runtime::Builder::new_current_thread()
1718
+ .enable_all()
1719
+ .build();
1720
+
1721
+ match runtime {
1722
+ Ok(runtime) => {
1723
+ runtime.block_on(send_event);
1724
+ }
1725
+ Err(error) => {
1726
+ log::warn!(
1727
+ "pulse: could not create fallback runtime for inference_event: {}",
1728
+ error
1729
+ );
1730
+ }
1731
+ }
1732
+ });
1733
+ }
1734
+
1735
+ // ── ADR-0003 M2 dual-write to the document gateway ───────────────────────
1736
+ //
1737
+ // These mirror each semantic pulse event into JSONB collections under the
1738
+ // Onde gresiq_app. The relational `pulse/*` routes still own reads until the
1739
+ // M5 cut-over, so failures are logged and never affect inference.
1740
+
1741
+ /// Move the three model-load mirror requests off the model-loading critical
1742
+ /// path. A dedicated thread is acceptable here because this runs once per
1743
+ /// model activation, not once per inference, and also works for callers
1744
+ /// whose original async runtime is short-lived.
1745
+ fn spawn_model_loaded_dual_write(&self, event: ModelLoadedEvent) {
1746
+ if !Self::dual_write_enabled() || event.onde_app_id.is_none() {
1747
+ return;
1748
+ }
1749
+
1750
+ let client = self.clone();
1751
+ std::thread::spawn(move || {
1752
+ let runtime = tokio::runtime::Builder::new_current_thread()
1753
+ .enable_all()
1754
+ .build();
1755
+ match runtime {
1756
+ Ok(runtime) => runtime.block_on(client.dual_write_model_loaded(&event)),
1757
+ Err(error) => log::warn!(
1758
+ "pulse: could not create runtime for model-loaded dual-write: {}",
1759
+ error
1760
+ ),
1761
+ }
1762
+ });
1763
+ }
1764
+
1765
+ /// Mirror a model-load into `pulse_edges`, `pulse_models`, and
1766
+ /// `pulse_deployments`. Each collection is a separate upsert (the
1767
+ /// FK-ordered chain of the semantic route becomes client-orchestrated
1768
+ /// multi-insert; the document store has no relationships to order).
1769
+ async fn dual_write_model_loaded(&self, event: &ModelLoadedEvent) {
1770
+ let Some(onde_app_id) = event.onde_app_id.as_deref() else {
1771
+ return;
1772
+ };
1773
+
1774
+ let edge_key = scoped_document_key(onde_app_id, &[&event.edge_id]);
1775
+
1776
+ let model_key = scoped_document_key(onde_app_id, &[&event.model_id]);
1777
+ let deployment_key = scoped_document_key(onde_app_id, &[&event.edge_id, &event.model_id]);
1778
+ let edge_document = edge_document(event, onde_app_id);
1779
+ let model_document = serde_json::json!({
1780
+ "slug": event.model_id,
1781
+ "model_id": event.model_id,
1782
+ "model_name": event.model_name,
1783
+ "onde_app_id": event.onde_app_id,
1784
+ });
1785
+ let deployment_document = serde_json::json!({
1786
+ "edge_id": event.edge_id,
1787
+ "model_id": event.model_id,
1788
+ "load_duration_ms": event.load_duration_ms,
1789
+ "onde_app_id": event.onde_app_id,
1790
+ "residency_state": "loaded",
1791
+ });
1792
+
1793
+ tokio::join!(
1794
+ self.write_doc("pulse_edges", Some(&edge_key), &edge_document),
1795
+ self.write_doc("pulse_models", Some(&model_key), &model_document),
1796
+ self.write_doc(
1797
+ "pulse_deployments",
1798
+ Some(&deployment_key),
1799
+ &deployment_document,
1800
+ ),
1801
+ );
1802
+ }
1803
+
1804
+ /// Mirror an inference event idempotently. Request IDs are already unique,
1805
+ /// so using an app-scoped natural key prevents a retry from double-counting
1806
+ /// the same inference during cutover validation.
1807
+ async fn dual_write_inference(&self, event: &InferenceEvent) {
1808
+ if !Self::dual_write_enabled() {
1809
+ return;
1810
+ }
1811
+ let Some(onde_app_id) = event.onde_app_id.as_deref() else {
1812
+ return;
1813
+ };
1814
+ let event_key = scoped_document_key(onde_app_id, &[&event.request_id]);
1815
+ let document = inference_document(event);
1816
+
1817
+ self.write_doc("pulse_inference_events", Some(&event_key), &document)
1818
+ .await;
1819
+ }
1820
+
1821
+ /// Upsert (or append, when `key` is `None`) one document, logging failures.
1822
+ async fn write_doc(&self, collection: &str, key: Option<&str>, doc: &serde_json::Value) {
1823
+```
1824
+
1825
+
1826
+**Tool Call: git show feature/decouple-gresiq-from-onde-inference:src/pulse/client.rs | sed -n '330,420p'**
1827
+Status: Completed
1828
+
1829
+Terminal:
1830
+```
1831
+ async fn write_doc(&self, collection: &str, key: Option<&str>, doc: &serde_json::Value) {
1832
+ if let Err(error) = self.inner.upsert_document(collection, key, doc).await {
1833
+ log::warn!("pulse: dual-write to {} failed: {}", collection, error);
1834
+ }
1835
+ }
1836
+}
1837
+
1838
+fn dual_write_enabled_value(value: Option<&str>) -> bool {
1839
+ matches!(
1840
+ value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
1841
+ Some("1") | Some("true") | Some("yes") | Some("on")
1842
+ )
1843
+}
1844
+
1845
+/// Keys are scoped inside Onde's shared GresIQ app so identical installation
1846
+/// ids belonging to different Onde customer apps cannot overwrite each other.
1847
+fn scoped_document_key(onde_app_id: &str, parts: &[&str]) -> String {
1848
+ std::iter::once(onde_app_id)
1849
+ .chain(parts.iter().copied())
1850
+ .collect::<Vec<_>>()
1851
+ .join(":")
1852
+}
1853
+
1854
+/// Build the inference document. `ttft_ms` is omitted entirely when the path
1855
+/// could not measure it, so a reader can tell "not measured" from a real zero
1856
+/// — the relational route stores a hardcoded `0` and cannot make that
1857
+/// distinction.
1858
+fn inference_document(event: &InferenceEvent) -> serde_json::Value {
1859
+ let mut document = serde_json::json!({
1860
+ "edge_id": event.edge_id,
1861
+ "model_id": event.model_id,
1862
+ "request_id": event.request_id,
1863
+ "duration_ms": event.duration_ms,
1864
+ "status": event.status,
1865
+ "onde_app_id": event.onde_app_id,
1866
+ });
1867
+
1868
+ if let (Some(map), Some(ttft_ms)) = (document.as_object_mut(), event.ttft_ms) {
1869
+ map.insert("ttft_ms".to_string(), serde_json::json!(ttft_ms));
1870
+ }
1871
+
1872
+ document
1873
+}
1874
+
1875
+/// `status` is always `"online"`: this document is only written when the edge
1876
+/// just loaded a model, and nothing reports a shutdown. `last_seen_ms` is what
1877
+/// makes that honest — a reader counts an edge as active by recency, the way
1878
+/// the relational dashboard windows on `last_seen_at`, rather than trusting a
1879
+/// status that is never revoked.
1880
+fn edge_document(event: &ModelLoadedEvent, onde_app_id: &str) -> serde_json::Value {
1881
+ serde_json::json!({
1882
+ "edge_id": event.edge_id,
1883
+ "onde_app_id": onde_app_id,
1884
+ "status": "online",
1885
+ "last_seen_ms": epoch_millis(),
1886
+ })
1887
+}
1888
+
1889
+/// Milliseconds since the Unix epoch. The crate has no date dependency and
1890
+/// this is only ever read as a recency window, so an integer beats pulling in
1891
+/// a formatting library for an RFC 3339 string.
1892
+fn epoch_millis() -> u64 {
1893
+ std::time::SystemTime::now()
1894
+ .duration_since(std::time::UNIX_EPOCH)
1895
+ .map(|elapsed| elapsed.as_millis() as u64)
1896
+ .unwrap_or(0)
1897
+}
1898
+
1899
+#[cfg(test)]
1900
+mod tests {
1901
+ use super::*;
1902
+
1903
+ #[test]
1904
+ fn dual_write_requires_an_explicit_positive_flag() {
1905
+ for disabled in [
1906
+ None,
1907
+ Some(""),
1908
+ Some("0"),
1909
+ Some("false"),
1910
+ Some("no"),
1911
+ Some("off"),
1912
+ ] {
1913
+ assert!(!dual_write_enabled_value(disabled));
1914
+ }
1915
+ for enabled in [Some("1"), Some("true"), Some("YES"), Some(" on ")] {
1916
+ assert!(dual_write_enabled_value(enabled));
1917
+ }
1918
+ }
1919
+
1920
+ #[test]
1921
+ fn document_keys_include_the_onde_app_boundary() {
1922
+```
1923
+
1924
+
1925
+## Assistant
1926
+
1927
+I've got the full picture. Here's what M4 geography actually is today:
1928
+
1929
+**smbcloud-api** (`gresiq/v1/pulse_controller.rb:348-395`) geolocates `request.remote_ip` through **ipinfo.io** at **city** level on every model-load, and auto-creates a permanent `gresiq_ondeinference_pulse_regions` row (`stockholm-se`, lat/lon, no retention, no k-anonymity — "the first device from a city creates the region row"). The public `/pulse` globe renders those rows. That's three ADR-0003 violations at once: product geography in the generic platform, device IPs going to a third party, and city-level pinpointing on a public page.
1930
+
1931
+The M2 document set (`pulse_edges`) carries no geography at all, which is exactly why spec 0007 excluded the globe from parity.
1932
+
1933
+Before I write spec 0008 and the stack, these are your calls, not mine:
1934
+
1935
+**Tool Call: Asking for your input**
1936
+Status: Completed
1937
+
1938
+Your questions have been answered: "Where should the public globe's geography come from once GresIQ stops geolocating IPs?"="Device-declared, from OS locale/timezone (Recommended)", "How coarse should a region be before it can appear on the public /pulse globe?"="Country, with a k-anonymity floor (Recommended)", "What retention applies to geography-bearing Pulse telemetry?"="Raw docs 30d, aggregates indefinite (Recommended)". You can now continue with these answers in mind.
1939
+
1940
+## Input Requested
1941
+
1942
+Please answer the following questions.
1943
+
1944
+## Assistant
1945
+
1946
+Recorded. Now checking tooling prerequisites before I start committing — this repo has two account remotes and no `origin`, which both gh-stack and commit signing care about.
1947
+
1948
+**Tool Call: gh extension list 2>&1 | head; echo "--- config ---"; git config --get remote.pushDefault; git config --get rerere.enabled; git config --get user.name; git config --get user.email; git config --get user.signingkey; git config --get commit.gpgsign; echo "--- gh auth ---"; gh auth status 2>&1 | head -20**
1949
+Status: Completed
1950
+
1951
+Terminal:
1952
+```
1953
+gh stack github/gh-stack v0.1.0
1954
+--- config ---
1955
+Seto Elkahfi
1956
+1797197+setoelkahfi@users.noreply.github.com
1957
+B58A4C0C7E61728364A29FC82B76DF0F04014E36
1958
+true
1959
+--- gh auth ---
1960
+github.com
1961
+ ✓ Logged in to github.com account setoelkahfi (keyring)
1962
+ - Active account: true
1963
+ - Git operations protocol: ssh
1964
+ - Token: gho_************************************
1965
+ - Token scopes: 'gist', 'read:org', 'repo'
1966
+
1967
+ ✓ Logged in to github.com account keypair34 (keyring)
1968
+ - Active account: false
1969
+ - Git operations protocol: ssh
1970
+ - Token: gho_************************************
1971
+ - Token scopes: 'gist', 'read:org', 'repo'
1972
+
1973
+ ✓ Logged in to github.com account paydii (keyring)
1974
+ - Active account: false
1975
+ - Git operations protocol: ssh
1976
+ - Token: gho_************************************
1977
+ - Token scopes: 'gist', 'read:org', 'repo'
1978
+```
1979
+
1980
+
1981
+**Tool Call: Load skill: git-commit-signing**
1982
+Status: Completed
1983
+
1984
+Launching skill: git-commit-signing
1985
+
1986
+## Assistant
1987
+
1988
+You've hit your session limit · resets 11:10pm (Europe/Stockholm)