Skip to content

The Bell — Notification Substrate

A singleton notification primitive across the engine. Many consumers share one substrate; many sources feed one primitive; many machines agree on one durability contract.

The Bell (Sense 33) is jinflow’s notification primitive. A user sees “things the system noticed that may want their attention” in one canonical place — the Inbox at /{tenant}/inbox — and the same ring data drives JinDesk’s header bell, an inline banner above the entity a ring is about, a panel inside the Atelier, and the jin bell CLI.

The design constraint that makes the rest fall into place: one table, one writer, many readers, one durability story.


Bell resolutions are A-tier artifacts in the jinflow architecture — operational tenant state, not branch-bound analytical knowledge. That means they live on a dedicated orphan branch of the tenant AFS, alongside notes, bookmarks, and recents:

tenant AFS git repo
├── main ← analytical truth (smebits, signals, ...)
├── scenario/lower-thresholds ← "what if we tightened revenue leakage?"
├── scenario/zeta-onboarding ← "does the pipeline work for the new tenant?"
├── notes ← orphan branch (no shared history) — notebook/*.yaml
├── bookmarks ← orphan branch — bookmark/*.yaml
├── recents ← orphan branch — recents/recents_<user>.yaml
└── bell ← orphan branch — bell/<ring_id>.yaml

Why orphan branches: because they share no history with main, A-tier artifacts survive any experiment on the analytical branches. A dismissal lands on the bell branch regardless of whether you were on main or scenario/tighter-thresholds when you dismissed it — and every scenario reads the same resolutions. The Bell branch sits outside the scenario DAG.

This is registered in jinflow/cli/commands/afs.py::ARTIFACT_BRANCHES. Push (jinflow afs push) syncs the working tree’s afs/bell/ into the bell orphan branch via a temporary worktree. Pull (Phase 1c of jinflow make) copies the bell branch back into the working tree before the bake reads it.

Beyond the orphan branch (which is the durable AFS layer), the Bell also crosses operational stores. Each tier lives in the store it belongs to, and the read path joins them in the consumer.

StoreHoldsMutable?Travels with git?
KLS _{tenant}.notificationsRings the aggregator produced. Auto-cleared resolutions when a source stops emitting. AFS-baked human resolutions.No — built by jin makeNo
SIS bell_resolutionsDurable human judgments: dismissed, acted_via, comment threads.Yes — Inbox / CLI writeIndirectly — flushed to AFS at Phase 1d
SIS bell_snoozesOperational reminder schedule. “Remind me Tuesday.”Yes — Inbox / CLI writeNo — intentionally lost on SIS rebuild
AFS afs/bell/<ring_id>.yamlOne file per ring with a committed resolution.No — git-trackedYes

A read merges three layers in priority order:

  1. AFS-baked KLS row (resolution = 'dismissed' / 'acted_via') — the committed truth that travelled through git.
  2. SIS bell_resolutions overlay — newer mutations not yet flushed.
  3. SIS bell_snoozes overlay — the operational layer, independent of resolutions.

The aggregator (jinflow/notifications/engine.py) walks jinflow/notifications/sources/ and runs every produce(con, tenant_id) function it finds. Each plugin returns a list of Notification records. Adding a new source is a plugin addition, not an engine fork.

SourceKey tupleLifecycleWhen it fires
wisdom(smebit_id, status)persistentA Subject Matter Level 1 check returns confirmed / violated.
data_gap(audit_id, tenant_id)persistentA tenant afs/audits/*.yaml trigger query returns rows.
diagnostic(code, text)ephemeralA make-time diagnostic with effective_severity = error or a whitelisted warning/note.
action(note_id, cell_id)persistentA notebook action cell is proposed or in_progress.
validity(silver_table, invalid_reason)persistentA Silver model with is_valid + invalid_reason has more invalid rows than the configured threshold.

The aggregator diffs each plugin’s output against the existing rings in the KLS:

  • New ring → INSERT with created_at = last_seen_at = now.
  • Re-fire of an existing ring → UPDATE last_seen_at. Clears resolution = 'auto_cleared' (system’s “it stopped firing” assessment was stale). Leaves resolution = 'dismissed' / 'acted_via' untouched — a human’s judgment stands until they un-dismiss themselves.
  • Ring no longer produced → UPDATE resolution = 'auto_cleared' on the active subset only. Already-cleared rings stay where they are (re-stamping resolved_at every build would mask when the source first went silent).

A ring’s id is the join key everything else depends on. It must stay identical across builds — otherwise the diff logic doesn’t recognise re-fires, and resolutions don’t follow their rings.

id = "<source>_<sha256(source|key_1|key_2|…)[:16]>"

Centralised in jinflow/notifications/sources/_id.py:

def stable_id(source: str, *parts: str | None) -> str:
safe = ["" if p is None else str(p) for p in parts]
raw = "|".join([source, *safe])
return f"{source}_{hashlib.sha256(raw.encode()).hexdigest()[:16]}"

The per-source key tuples encode what makes a ring the same ring:

  • wisdom’s key is (smebit_id, status) — the same wisdom firing the same way is the same ring, even if its prose was retitled.
  • validity’s key omits row count — the same cluster across builds keeps its id as the count fluctuates.
  • action’s key omits status — moving from proposed to in_progress is one ring’s lifecycle, not two rings.
  • diagnostic’s key includes text — the same code firing with different per-instance context is a distinct logical noticing.

A user’s dismissal on machine A reaches machine B’s KLS by passing through git as YAML.

Inbox (machine A)
→ SIS.bell_resolutions
→ jin bell flush → publish_bell_resolutions
→ afs/bell/<ring_id>.yaml (working tree)
→ jin afs push → _sync_artifacts_to_branch → `bell` orphan branch
→ git push (remote)
→ git pull (machine B)
→ next jin make: Phase 1c pulls `bell` branch into working tree
Phase 0 bake_bell_resolutions_from_afs stamps KLS
→ Inbox on B sees the dismissal — without machine A's SIS.

Two CLI moments matter:

  • jin bell flush runs the publisher. Reads SIS.bell_resolutions, writes one YAML per resolved ring to afs/bell/<ring_id>.yaml. Per-file layout so each ring’s git history is independent (mirroring the bookmark and note pattern). Comment threads serialize as a structured YAML list of {body, actor, at}, not a stringified JSON blob, so diffs on individual comments stay readable.

  • jin afs push picks up the YAMLs and syncs them into the bell orphan branch via the existing _sync_artifacts_to_branch worktree pattern, then pushes. Same path as notes, bookmarks, and recents — Bell joined that infrastructure on 2026-05-16.

Snoozes are intentionally not published. A “remind me Tuesday” is calendar UX with a soft contract; an unflushed snooze from a vanished SIS isn’t worth recovering through git.

The bake (jinflow/cli/commands/baking.py::bake_bell_resolutions_from_afs) runs at every jin make, between the schema bake and the aggregator, so the diff logic sees the hydrated resolutions and the “don’t wipe human resolutions on re-fire” invariant works consistently.


The Inbox is the singleton authoring surface. Every other consumer is a view on the same substrate.

ConsumerSurfaceDiffers how
Inbox/{tenant}/inbox — three tabs (Active / Snoozed / History), group-by-source, filter affordancesThe home. Dismiss, snooze, acted_via, comments all happen here.
JinDesk status bellHeader icon, count badge for concern + alarm, dropdown of top ringsAwareness only. Footer link opens the Inbox.
Per-page banner<BellBanner> Svelte component on entity pagesInline callout when rings are anchored at the specific entity the page is showing. Renders nothing when there are none.
Atelier projection<AtelierRingPanel> inside the authoring shellFiltered to rings whose anchors point at authorable artefacts. A view, not a home.
CLIjin bell list / dismiss / snooze / acted-via / clearHeadless / scripted workflows. Same substrate.

Each ring optionally carries a suggested actionopen_wisdom, open_note, … — that resolves to a primary CTA button on the ring card. Anchor ({surface, target_id}) is what the ring is about; suggested action is what to do next. The Inbox launches; it does not author.


The Inbox respects the role/pass capability model:

  • view:bell — read rings, leave comments. Resolution buttons hidden.
  • act:bell — dismiss, snooze, acted_via, restore.

Comments are deliberately gated by view:bell, not act:bell — Sense 33 makes the thread a collaboration primitive so a read-mode user can leave context for the act-mode user who will eventually resolve.


Configurability — no hardcoded thresholds

Section titled “Configurability — no hardcoded thresholds”

All Bell behaviour reads through the config engine’s 4-level cascade (engine defaults → pack config.yml → tenant config.yml → entity override). The bell-specific subtree:

bell:
sources:
diagnostic:
enabled: true
bell_codes: # promote warnings / notes into the Inbox
- PRICING_NEG
validity:
enabled: true
threshold: 50 # minimum invalid-row count to fire
wisdom:
enabled: true
data_gap:
enabled: true
action:
enabled: true
snooze:
default_days: 7
per_source:
action: 14
validity: 30

A tenant can silence a noisy source (bell.sources.validity.enabled: false), lower a threshold to catch a smaller cluster (bell.sources.validity.threshold: 10), or override snooze defaults. No engine change needed.


  • Not an alerting system. No emails, no pager, no 3am calls. The Bell is for noticings, not for incidents that require synchronous response.
  • Not a logging system. Build logs continue to live where they live. The Bell only carries items that are meaningfully resolved by a human action.
  • Not an exception catcher. Runtime errors belong to operational logs, not the Bell.
  • Not an authoring assistant. The Bell rings on existing noticings; it does not invent new wisdoms or signals.

LayerPathRole
Aggregatorjinflow/notifications/engine.pyWalks plugins, diffs against KLS, writes rings + auto_clear.
Source pluginsjinflow/notifications/sources/{wisdom,data_gap,diagnostic,action,validity}.pyOne per source. Each exports produce(con, tenant_id) → list[Notification].
ID contractjinflow/notifications/sources/_id.pystable_id() — single source of truth for the join key.
Configjinflow/notifications/sources/_config.pyReads bell: from the tenant’s config cascade.
KLS schemajinflow/cli/commands/baking.py::bake_notifications_schema_<tenant>.notifications table — CREATE TABLE IF NOT EXISTS.
AFS hydrationjinflow/cli/commands/baking.py::bake_bell_resolutions_from_afsReads afs/bell/*.yaml, stamps KLS rows.
SIS schemaexplorer/src/lib/server/sisdb.tsbell_resolutions + bell_snoozes DDL + v4→v5 migration.
SIS mutationsexplorer/src/lib/server/queries/notifications.tsdismissRing, snoozeRing, markActedVia, clearResolution, appendComment.
Phase 1d publisherjinflow/cli/commands/sis_publish.py::publish_bell_resolutionsSIS → AFS YAML flush.
Inbox surfaceexplorer/src/routes/[tenant]/inbox/+page.{server.ts,svelte}Singleton authoring page.
Header bellexplorer/src/lib/components/data/BellBar.svelteAwareness icon.
Per-page bannerexplorer/src/lib/components/data/BellBanner.svelteInline callout component.
Atelier projectionexplorer/src/lib/components/data/AtelierRingPanel.svelteAuthoring-aside view.
CLIjinflow/cli/commands/bell.pyjin bell subcommands.

For the user-facing how-to, see the Bell guide. For the original design rationale, the canonical spec is docs/design/sense_33_the_bell.md in the engine repo.

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