@ondeinference / onde / commits / 096f461

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)