Drop a session transcript and ignore .idea
The Pulse geography transcript was committed by accident. It is working notes, not documentation, and spec 0008 in onde-web records the decisions it led to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Sep 25, 2026 at 07:45 UTC
096f4614aa227be76183bcab6cfdaf938c93af83
2 files changed
+3
-1988
.gitignore
+3
@@ -33,5 +33,8 @@ sdk/python/.pytest_cache/
33
# Zed settings
34
.zed
35
36
+# JetBrains settings
37
+.idea/
38
+
39
# SDK credentials (build-time secrets)
40
.env
Pulse globe geography decoupling.md
deleted
-1988
@@ -1,1988 +0,0 @@
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)