Zum Inhalt springen

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.md in 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).


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 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:

PropertyWhat it carries
scopeper-tenant (numetrix.inspire, hrcentral.vai)
generationmonotonically 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 localStorage per-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.

SenseWhat flowsDirectionMechanism
30 — Wiretyped protocolinstance ↔ worldHTTP + signed JSON
31 — Inletbytes from sourcesworld → enginescripts + DLZ
33 — Bellnoticings in the dataKLS → user (badge, inbox)DuckDB table + UI
34 — BreezeUI events between tabsbrowser ↔ browser (same origin)BroadcastChannel
35 — Driftsystem events about the dataserver → browserGET /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).


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 the DbWatcher component already polls every 15 s.
  • <DbWatcher /> — already mounted in [tenant]/+layout.svelte, already does invalidateAll() + a brief bottom-right toast on change. It just (a) wasn’t being shown the per-tenant drift signal and (b) only reacted to file mtime, which is null in cloud KLS-store mode.

So the implementation extends rather than duplicates:

  • /api/db-status/[tenant] now also returns { driftGeneration, driftAt, driftSizeMb } (from getDriftState, only meaningful in KLS-store mode; 0 elsewhere). (The route name /api/drift was already taken by the unrelated AFS-commit-vs-GitHub-HEAD detector — another reason reuse beat a new route.)
  • DbWatcher now tracks two signals: file mtime (local/desktop rebuild) and driftGeneration (cloud per-tenant swap). Whichever is live drives the toast. A swap of another tenant does not bump this tenant’s driftGeneration, so no false toast.

DbWatcher (existing component, extended):

  1. Poll cadence: every 15 s (its existing interval — kept; it’s already cheap, a stat + map read).

  2. Baselines: first poll per signal establishes the baseline (mtime and/or driftGeneration). Subsequent polls detect a genuine change against the baseline.

  3. 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 from driftGeneration, so the user is only told when their tenant moved.

  4. Suppression: handled by the baseline/notified bookkeeping in DbWatcher — a given mtime/generation toasts at most once.

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.


  • “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 logs as 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.

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.

Status: shipped.


  1. 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 in swapEntry() (comparing pre- and post-swap row counts on a fixed list of tables). Possible Sense 35 Phase 2.
  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.
  3. 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.
  4. 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.

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

jazzisnow jinflow is a jazzisnow product
v0.64.7 · built 2026-09-20 19:48 UTC