Sense 35: The Drift — Server-to-Browser Ambient System Events
Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.
Sense 35 · Steady · Last touched 2026-08-20
- last_verified: 2026-07-27
Synced from
docs/design/sense_35_the_drift.mdin the engine repo — that’s the source; this page is a build-time mirror.
The data has moved. Whoever was looking decides what to do.
A small yes, something happened from the server to the rooms that might want to know. The room itself decides whether to show anything.
Status: proposed Date: 2026-05-14 Author: the owner + Claude (after the per-tenant DETACH/ATTACH work) Family: completes the four-direction picture — 30 Wire (outward), 31 Inlet (inward), 33 Bell (in-data noticings), 34 Breeze (tab ↔ tab), 35 Drift (server → browser, ambient).
What pulled us in
Section titled “What pulled us in”When something happens at the system layer that browser windows might want to know about — a KLS got re-attached, a tenant config changed, a deploy completed — today’s JinDesk has no way to tell them. Each open window sees stale data on the current page until the user takes an action; the right action might be “reload, the underlying data just changed” but nothing on screen suggests so.
The Bell (Sense 33) is the wrong channel:
- Bell items live in the KLS as data observations (
wisdom,diagnostic,data_gap, …). A re-attach event is not a finding in the data; it’s a fact about the pipeline. - Bell items require human resolution — accepting a wisdom, dismissing a duplicate. “Data refreshed” is purely informational; there’s nothing to do about it.
- Bell items are persistent, source-anchored. A drift event is transient; once you’ve been told, the event is over.
The Breeze (Sense 34) is the wrong channel:
- Breeze is browser-tab ↔ browser-tab via
BroadcastChannel, bounded to one origin. - There is no way for the server to push into a Breeze.
The missing piece: a way for the server to say “the data has moved” to whichever browser windows happen to be open on that tenant, without persisting anything in the KLS and without commanding the browser to do anything specific.
The Principle
Section titled “The Principle”The Drift is a tap on the shoulder, not a pull on the sleeve.
A drift event is ambient observation. The server announces: “this tenant’s data has moved; here’s when, here’s roughly how much.” It does not tell anyone what to do. Each browser window decides:
- Some windows show a toast; some ignore it (off-screen tabs)
- The user can reload, dismiss, or do nothing
- Once seen and dismissed, the same drift doesn’t re-toast
Three properties define every drift event:
| Property | What it carries |
|---|---|
| scope | per-tenant (numetrix.inspire, hrcentral.vai) |
| generation | monotonically increasing integer per tenant; bumps on each successful KLS swap |
| payload | { last_at: ISO timestamp, last_size_mb: number } — context for the toast text |
And four things it deliberately is not:
- Not an alert. Drift events are informational; the user is free to ignore them. If the system needs the user to act (security, billing), that’s not the Drift’s job.
- Not push. Drift is polled, not streamed. Slightly less real-time, much simpler. No SSE, no WebSocket. A future SSE variant is possible; the contract works either way.
- Not persistent storage. Generations live in server memory.
A process restart resets the counter to 0; that’s fine — the
browser also resets (its
localStorageper-tenant cursor stays, but the first poll after restart will see gen=0 and skip the toast). - Not a data layer. Drift carries system events about the data, not observations from the data itself. The two flows are orthogonal: a tenant’s KLS can be re-attached (drift) without any new notifications in its inbox (bell), and vice versa.
Where It Sits
Section titled “Where It Sits”| Sense | What flows | Direction | Mechanism |
|---|---|---|---|
| 30 — Wire | typed protocol | instance ↔ world | HTTP + signed JSON |
| 31 — Inlet | bytes from sources | world → engine | scripts + DLZ |
| 33 — Bell | noticings in the data | KLS → user (badge, inbox) | DuckDB table + UI |
| 34 — Breeze | UI events between tabs | browser ↔ browser (same origin) | BroadcastChannel |
| 35 — Drift | system events about the data | server → browser | GET /api/drift (polled) |
The four together enumerate the surface across which signal can travel in the system: outward, inward, sideways, downward. Each one names exactly one direction and trusts the others to handle theirs.
The closest sibling is the Bell — both end up at the same actor (the user’s eyes), but they carry different kinds of fact and have different lifetimes. A Bell ring says “there is a noticing about the data.” A Drift toast says “the data underneath this page has just been replaced.” The user can act on a Bell ring (visit the inbox, mark it resolved); they can only acknowledge a Drift event (reload or dismiss).
Concrete Mechanics
Section titled “Concrete Mechanics”Server side
Section titled “Server side”Per tenant, in klsStore.ts:
interface DriftState { generation: number; // 0, 1, 2, ... bumps on every successful swap last_at: string; // ISO timestamp of last bump last_size_mb: number; // size of the newly-attached KLS}
const driftByTenant = new Map<string, DriftState>();
function bumpDriftFor(entry: KlsEntry): void { const prev = driftByTenant.get(entry.compositeId); driftByTenant.set(entry.compositeId, { generation: (prev?.generation || 0) + 1, last_at: new Date().toISOString(), last_size_mb: entry.sizeMb, });}bumpDriftFor() is called from swapEntry() (Sense 34’s
companion implementation) immediately after the new ATTACH
succeeds and metadata is re-read. getDriftState(compositeId)
returns { generation: 0, last_at: null, last_size_mb: 0 }
when no swap has happened in this process lifetime.
Implementation note (reuse, not a new endpoint). The design
originally proposed a dedicated GET /[tenant]/api/drift route +
a new <DriftToast /> component. During implementation we found
JinDesk already has the exact mechanism:
GET /api/db-status/[tenant]— the lightweight stat endpoint theDbWatchercomponent already polls every 15 s.<DbWatcher />— already mounted in[tenant]/+layout.svelte, already doesinvalidateAll()+ a brief bottom-right toast on change. It just (a) wasn’t being shown the per-tenant drift signal and (b) only reacted to filemtime, which isnullin cloud KLS-store mode.
So the implementation extends rather than duplicates:
/api/db-status/[tenant]now also returns{ driftGeneration, driftAt, driftSizeMb }(fromgetDriftState, only meaningful in KLS-store mode; 0 elsewhere). (The route name/api/driftwas already taken by the unrelated AFS-commit-vs-GitHub-HEAD detector — another reason reuse beat a new route.)DbWatchernow tracks two signals: filemtime(local/desktop rebuild) anddriftGeneration(cloud per-tenant swap). Whichever is live drives the toast. A swap of another tenant does not bump this tenant’sdriftGeneration, so no false toast.
Browser side
Section titled “Browser side”DbWatcher (existing component, extended):
-
Poll cadence: every 15 s (its existing interval — kept; it’s already cheap, a stat + map read).
-
Baselines: first poll per signal establishes the baseline (mtime and/or driftGeneration). Subsequent polls detect a genuine change against the baseline.
-
On change:
invalidateAll()(SvelteKit client-cache invalidation → next render reads the new KLS) followed by a quiet 4 s bottom-right toast:New data arrived and attached (HH:MM, 418 MB)No buttons. The reload is automatic via
invalidateAll(); the toast is pure acknowledgement — the “tap on the shoulder, not pull on the sleeve” of the Stance. Per-tenant scoping comes fromdriftGeneration, so the user is only told when their tenant moved. -
Suppression: handled by the baseline/notified bookkeeping in
DbWatcher— a given mtime/generation toasts at most once.
What this does NOT do
Section titled “What this does NOT do”The Drift announces after the swap completes. The 21s window during DETACH → ATTACH where the catalog is unavailable belongs to the per-tenant swap (Sense 34 companion impl) and remains unaddressed here. A request that lands mid-window still gets a 5xx; the Drift toast appears once the catalog is back.
If we ever want mid-window UX (queue requests during the swap,
return 503 Retry-After), that’s a future refinement on the
swap mechanism — not a change to the Drift contract.
What This Lets Fall Away
Section titled “What This Lets Fall Away”- “Why are my numbers different now?” between two clicks becomes explicable: the toast names the moment the data moved.
- Manual reload after
jin ship <tenant>is no longer necessary for browser windows that are already open. fly logsas the only signal that a refresh happened — users can see it directly.- The temptation to push refresh events through the Bell — resolved by saying “those events live in the Drift, not the Bell.” Each Sense stays clean.
How this manifests in jinflow
Section titled “How this manifests in jinflow”The Drift is the server-to-browser ambient channel: when the data has moved (a new make completed, a new Bell ring landed, a new commit landed in the AFS) the server signals “yes, something happened” and lets the looking browser decide what to do — refresh silently, raise a toast, drop a pending notification.
- Pairs with: Sense 33 — The Bell (what the something is) · Sense 34 — The Breeze (browser-to-browser; the Drift is server-to-browser)
Status: shipped.
Open Questions
Section titled “Open Questions”- Should the toast also offer “Show what changed”? A diff
summary (X more findings, Y more verdicts) would be richer
than
418 MB. Deferred — needs a separate “what changed” computation inswapEntry()(comparing pre- and post-swap row counts on a fixed list of tables). Possible Sense 35 Phase 2. - Should multi-tab toasting coordinate via the Breeze? Two
tabs open on inspire → both auto-reload and both toast
independently. Mostly fine (each tab needs its own
invalidateAll()anyway), but the double-toast is slightly noisy. The Breeze (Sense 34) is the natural channel for “tab A already handled drift gen N” if it ever bothers anyone. Small refinement, not load-bearing. Should the toast auto-fade?Resolved in implementation: the toast auto-fades after 4 s and has no buttons — the reload is automatic (invalidateAll()), so the toast is acknowledgement only. The “I missed it” case is acceptable because the data already refreshed underneath them; missing the toast just means they don’t know why the numbers moved, not that they’re looking at stale data.- SSE variant later? Polling (DbWatcher’s existing 15 s interval) is the right tradeoff today; the data doesn’t change that fast. If real-time ever matters (live collaboration?), the contract trivially lifts to SSE: same payload, push instead of pull.
Stance
Section titled “Stance”The Drift is quiet. It taps the user on the shoulder; it does not pull on their sleeve. The user has the last word.
A drift event is not an interruption. It’s a moment of awareness: “the world moved while you were looking at it; you may want to reload, or you may want to keep reading.” Either is correct. The Drift’s job is to make that choice available — never to make it for the user.
Numerical neighbors: ← Sense 34: The Breeze — Lateral Signalling Through the Jinflow Void · Sense 36: The Lineage — A Tenant Remembers Where It Came From →