The Bell — Notification Substrate
Ce contenu n’est pas encore disponible dans votre langue.
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.
Where resolutions live: the orphan branch
Section titled “Where resolutions live: the orphan branch”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>.yamlWhy 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.
Three stores, joined at read time
Section titled “Three stores, joined at read time”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.
| Store | Holds | Mutable? | Travels with git? |
|---|---|---|---|
KLS _{tenant}.notifications | Rings the aggregator produced. Auto-cleared resolutions when a source stops emitting. AFS-baked human resolutions. | No — built by jin make | No |
SIS bell_resolutions | Durable human judgments: dismissed, acted_via, comment threads. | Yes — Inbox / CLI write | Indirectly — flushed to AFS at Phase 1d |
SIS bell_snoozes | Operational reminder schedule. “Remind me Tuesday.” | Yes — Inbox / CLI write | No — intentionally lost on SIS rebuild |
AFS afs/bell/<ring_id>.yaml | One file per ring with a committed resolution. | No — git-tracked | Yes |
A read merges three layers in priority order:
- AFS-baked KLS row (
resolution = 'dismissed' / 'acted_via') — the committed truth that travelled through git. - SIS
bell_resolutionsoverlay — newer mutations not yet flushed. - SIS
bell_snoozesoverlay — the operational layer, independent of resolutions.
Sources — five today, plugin-shaped
Section titled “Sources — five today, plugin-shaped”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.
| Source | Key tuple | Lifecycle | When it fires |
|---|---|---|---|
wisdom | (smebit_id, status) | persistent | A Subject Matter Level 1 check returns confirmed / violated. |
data_gap | (audit_id, tenant_id) | persistent | A tenant afs/audits/*.yaml trigger query returns rows. |
diagnostic | (code, text) | ephemeral | A make-time diagnostic with effective_severity = error or a whitelisted warning/note. |
action | (note_id, cell_id) | persistent | A notebook action cell is proposed or in_progress. |
validity | (silver_table, invalid_reason) | persistent | A 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. Clearsresolution = 'auto_cleared'(system’s “it stopped firing” assessment was stale). Leavesresolution = '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-stampingresolved_atevery build would mask when the source first went silent).
The stability contract
Section titled “The stability contract”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 fromproposedtoin_progressis one ring’s lifecycle, not two rings.diagnostic’s key includestext— the same code firing with different per-instance context is a distinct logical noticing.
The durability round-trip
Section titled “The durability round-trip”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 flushruns the publisher. ReadsSIS.bell_resolutions, writes one YAML per resolved ring toafs/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 pushpicks up the YAMLs and syncs them into thebellorphan branch via the existing_sync_artifacts_to_branchworktree pattern, then pushes. Same path asnotes,bookmarks, andrecents— 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.
Consumers
Section titled “Consumers”The Inbox is the singleton authoring surface. Every other consumer is a view on the same substrate.
| Consumer | Surface | Differs how |
|---|---|---|
| Inbox | /{tenant}/inbox — three tabs (Active / Snoozed / History), group-by-source, filter affordances | The home. Dismiss, snooze, acted_via, comments all happen here. |
| JinDesk status bell | Header icon, count badge for concern + alarm, dropdown of top rings | Awareness only. Footer link opens the Inbox. |
| Per-page banner | <BellBanner> Svelte component on entity pages | Inline 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 shell | Filtered to rings whose anchors point at authorable artefacts. A view, not a home. |
| CLI | jin bell list / dismiss / snooze / acted-via / clear | Headless / scripted workflows. Same substrate. |
Each ring optionally carries a suggested action — open_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.
Pass-aware (Sense 18)
Section titled “Pass-aware (Sense 18)”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: 30A 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.
What the Bell is not
Section titled “What the Bell is not”- 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.
Where it lives
Section titled “Where it lives”| Layer | Path | Role |
|---|---|---|
| Aggregator | jinflow/notifications/engine.py | Walks plugins, diffs against KLS, writes rings + auto_clear. |
| Source plugins | jinflow/notifications/sources/{wisdom,data_gap,diagnostic,action,validity}.py | One per source. Each exports produce(con, tenant_id) → list[Notification]. |
| ID contract | jinflow/notifications/sources/_id.py | stable_id() — single source of truth for the join key. |
| Config | jinflow/notifications/sources/_config.py | Reads bell: from the tenant’s config cascade. |
| KLS schema | jinflow/cli/commands/baking.py::bake_notifications_schema | _<tenant>.notifications table — CREATE TABLE IF NOT EXISTS. |
| AFS hydration | jinflow/cli/commands/baking.py::bake_bell_resolutions_from_afs | Reads afs/bell/*.yaml, stamps KLS rows. |
| SIS schema | explorer/src/lib/server/sisdb.ts | bell_resolutions + bell_snoozes DDL + v4→v5 migration. |
| SIS mutations | explorer/src/lib/server/queries/notifications.ts | dismissRing, snoozeRing, markActedVia, clearResolution, appendComment. |
| Phase 1d publisher | jinflow/cli/commands/sis_publish.py::publish_bell_resolutions | SIS → AFS YAML flush. |
| Inbox surface | explorer/src/routes/[tenant]/inbox/+page.{server.ts,svelte} | Singleton authoring page. |
| Header bell | explorer/src/lib/components/data/BellBar.svelte | Awareness icon. |
| Per-page banner | explorer/src/lib/components/data/BellBanner.svelte | Inline callout component. |
| Atelier projection | explorer/src/lib/components/data/AtelierRingPanel.svelte | Authoring-aside view. |
| CLI | jinflow/cli/commands/bell.py | jin 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.