@ondeinference / onde / commits / 72cd0ea

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)