Skip to content

feat(notifications): versioned notification contract and a delivery outbox - #25

Merged
arg1998 merged 7 commits into
mainfrom
feat/notifications-n0-contract-outbox
Sep 29, 2026
Merged

arg1998 merged 7 commits into
mainfrom
feat/notifications-n0-contract-outbox

Conversation

@arg1998

@arg1998 arg1998 commented Sep 27, 2026

Copy link
Copy Markdown
Owner

First of the five notification PRs (N0 of the plan). It lays the foundations that Telegram, Discord and ntfy build on:

  • a versioned message contract;
  • producers that emit it;
  • a delivery outbox in SQLite with retries and a circuit breaker;
  • schema v5.

No external channel can be configured yet. With none configured, the only change you can see is that in-app notifications follow what they announce.

Before / after

Surface Before After
An attention request or vault confirmation you settle (or that times out, or is cancelled) The notification kept reading "agent blocked, lease frozen" forever Same row, same place in the list, with an outcome pill: resolved (green), expired, closed (cancelled). A toast still on screen closes.
A recovered degradation Nothing Its system.degraded notification moves to resolved
GET /api/v1/notifications, WS notifications 14 fields +kind, category, severity, state, revision, thread. Additive; old fields and in-app behaviour unchanged.
Text copied into a notification (the agent's attention reason, page URL, error codes) Stored verbatim Goes through the Redactor (registered secrets and credential patterns); URLs lose their query strings. A real leak path is closed.
Delivery seam NotificationChannel.send(dto), fire-and-forget, never called Widened port (capabilities, send/edit/delete returning platform refs), the in-app inbox on it, a transactional outbox for external channels
Database schema v4 schema v5 (0005-notification-outbox, compatible: true). Notifications gain 7 columns, backfilled from facts only; message_json stays NULL for old rows. New tables: notification_channels, notification_deliveries, notification_channel_messages.
browserhive purge on a database from an older schema — Skips the tables it does not have. It would otherwise have failed once v5 tables were listed.

How it works

  • Contract (@browserhive/contracts/notifications, D-32):

    • NotificationMessage with schema: 1 and full-state revisions;
    • blocks built from a tiny inline AST, never markdown;
    • act and open actions;
    • entities and privacy;
    • snake_case on the wire (D-05).

    The JSON Schema is generated into docs/reference/notification-message.schema.json and checked by gen:docs --check.

  • Producers (pure, table-driven):

    • Each draft carries kind, severity, state, thread and its message content.
    • revisionFor turns attention.resolved, vault.confirm.resolved and system.recovered into revisions of the thread's notification.
    • A growing tool-error group is a silent revision.
    • Nothing is ever authored by an agent.
  • Pure steps:

    • scrubMessage (redaction, and clipping to the contract limits);
    • restrictContent (counts < titles < full);
    • degrade(message, capabilities): tables → lists, images dropped or linked, act → open fallback, button cap, plain text, truncation with "… Open in BrowserHive";
    • routing: categories, minimum severity, session globs, harness, quiet hours in an IANA zone (DST-safe), TTL.
  • Outbox (D-34): a notification change and its notification_deliveries rows commit in one transaction. NotificationOutbox drains them:

    • Coalescing: a job renders the current state, and older revisions become superseded.
    • Edits are spaced at least 3 s apart.
    • Retries use exponential backoff with jitter and honour retry_after. A job is dead after 8 attempts or 24 h.
    • Jobs left sending are recovered at start.
    • The circuit breaker opens after 5 consecutive failures. It sets the channel to broken and produces an in-app-only channel.broken notification. There is no degradation: the loop is cut by kind, and a test proves it.
    • Backlog collapse: more than 20 pending info sends become the newest one, with a "you missed N" note.
    • A TTL sweeper over expires_at.
    • The worker runs, and arms its timer, only while a channel exists.
  • Registry: ChannelRegistry merges dashboard rows with startup channels. Startup channels are projected read-only, and a name clash is CONFIG_INVALID. It builds adapters through per-kind factories, and none are registered yet. Secrets are env var names only.

  • Startup catch-up: a request settled while nothing listened (orphan recovery at start, or a shutdown) revises its notification after the reconcile. I found this gap during real verification.

  • OTel:

    • browserhive.notifications.deliveries{channel_kind,status};
    • a notification.deliver span per platform call.

Spec (committed first)

  • D-16 and D-25 are rewritten.
  • New decisions D-32 to D-39, each with an Implementation line (N0/N1/N2):
    • contract ownership and versioning; agents never author notifications;
    • no hosted infrastructure, bring your own credentials, secrets as env names only;
    • outbox semantics;
    • TTL performed by BrowserHive, with the Telegram 48 h note;
    • screenshots opt-in, vault screenshots before the fill;
    • publicUrl;
    • Discord webhook or bot;
    • startup channels CLI-only and read-only.
  • Spec 03: §4.8/§6.6, §7 (v5 DDL, backfill, retention classes, ports), §9 rewritten (producer table, contract, channels and registry, outbox). The reserved-table list no longer names notification_channels.
  • Other specs:
    • 01: seams;
    • 02: no notify tool;
    • 04: outcome pill, toast close;
    • 05: layer placement;
    • 08 §5.7: the --notificationChannel flag-only grammar, specified for N1;
    • 09: tests;
    • 10: span, metric, redaction, no degradation for channels.

Verification

  • Local gate:
    • bun run check: exit 0. 2763 server tests and 344 dashboard tests; openapi, docs and db-types are fresh.
    • test:goldens, build and package:check: exit 0.
    • test:integration: 67 pass.
    • e2e, replicated as in CI: 9 passed, 1 skipped.
  • New suites:
    • message/producers;
    • degrade + content levels;
    • routing (quiet hours across DST);
    • outbox (19 cases, including the loop test);
    • registry;
    • service revisions and the startup catch-up;
    • redaction property: a seeded generator, 1 000 cases. It checks that a registered sentinel never reaches message_json, the in-app row or payload, the channel delivery or the delivery log. It found a real bug: an oversized field made the contract parse throw and would have lost the notification. That is fixed by clipping, plus a safe fallback.
    • v4→v5 migration backfill, compatibility-window reads and CHECKs;
    • a conformance suite run against both the SQLite repositories and the in-memory doubles;
    • the full path on real SQLite;
    • retention of outbox history.
  • Real run (scratch data dir, port 9951):
    1. origin/main (0.1.3, schema v4), built in a second worktree, produced real notifications over MCP: two tool-error groups, attention requests resolved or rejected from the REST API, and one cancelled.
    2. The v5 build on the same dir wrote the v4 backup and migrated 4 → 5.
    3. The backfill, read back read-only:
      • resolved/rejected requests → resolved;
      • the cancelled one → final;
      • tool groups → open, with thread tool-errors:<session>;
      • message_json NULL.
    4. The dashboard renders the old rows with pills.
    5. New traffic on v5 (every row read back from the DB):
      • a tool-error group ends at revision 3, with a silent message rev 3;
      • an attention request is resolved → rev 2, resolved, "Resolved by admin after 2 s", no actions;
      • 2 lease reaps (--sessionLease 1m) → session.reaped;
      • no notification_deliveries rows while no channel exists.
    6. I killed the daemon with an attention request pending and restarted it. The orphan recovery rejected the request, then notifications caught up revised=1 moved the notification to rev 2, resolved, "Rejected after 55 s".
    7. I added two channel rows to the stopped scratch DB (a telegram channel with no adapter yet, and a paused discord channel) and generated errors. Every change wrote outbox rows in the same transaction: send/edit jobs, suppressed with no_adapter or channel_paused.
    8. purge --dryRun of the v4 backup with the v5 binary lists its tables without error.
  • Screenshots: the Notifications page and the bell at 1440 and 768 px, light and dark, after the migration and after the new traffic. I reviewed them all: outcome pills, closed for the cancelled request, nothing for crashes or open rows.

Known and not done

Follow-ups for N1

  • Adapters: Telegram, Discord webhook, ntfy, webhook. Register one factory per kind in buildOps({ channelFactories }).
  • publicUrl through the LinkBuilder port.
  • The --notificationChannel parser, handing StartupNotificationChannel[] to ChannelRegistry.load().
  • The channel API and the dashboard page.
  • Screenshots.
  • notify-live.yml.

The handoff document lists every seam.

Contract change: schema v5 (migration 0005-notification-outbox, compatible: true, so min_reader_version stays 1); fixture v5.db, golden schema-v5.json; v1–v4.db still upgrade to head and match a fresh install. OpenAPI: Notification gains 6 required response fields (additive). New public JSON Schema docs/reference/notification-message.schema.json (NotificationMessage schema 1), and a new contracts subpath @browserhive/contracts/notifications. WS payloads: the same 6 fields on notification.*. The MCP tool surface is unchanged.

@arg1998
arg1998 marked this pull request as ready for review September 27, 2026 16:18
@arg1998
arg1998 merged commit 9fce259 into main Sep 29, 2026
21 of 23 checks passed
@arg1998
arg1998 deleted the feat/notifications-n0-contract-outbox branch September 29, 2026 00:04
arg1998 added a commit that referenced this pull request Sep 29, 2026
…th publicUrl, screenshots and self-destruct (#27)

## Notifications on your phone (N1)

N1 of the notification channels program (plan
`scratchpad/notification-channels-design.md`, owner-approved). It builds
on N0 (#25): the message contract and the delivery outbox.

### What it does

- **Four channels:** a Telegram bot, a Discord webhook, an ntfy topic
and a generic webhook. Each is a pure renderer (contract → platform
request) plus a transport, behind N0's outbox (retries, `Retry-After`,
breaker, coalescing).
- **Edits in place, silently.** Resolving an attention request edits the
message and removes its buttons. A growing tool-error group edits its
count.
- **Self-destruct** per channel × category (default never; Telegram
capped at 47 h because bots may delete for 48 h only). **Delete when
resolved** is available, off by default. Deletes that fall due while
BrowserHive is off happen at the next start.
- **Screenshots**, opt-in per channel × category: an attention request
(CAPTCHAs included), a vault confirmation (taken before the fill; never
during one or in a secret window), a crash's last frame. Masked or
unmasked, per channel. Never with `recordToolResults=none` or below
content level `full`.
- **`publicUrl`** (`--publicUrl` / `BROWSERHIVE_PUBLIC_URL` / JSON). All
links use it. Its host joins the Host allow-list, and its origin passes
the CSRF guard, which fixes proxies that rewrite `Host`. `doctor`, the
System page and `GET /system/public-url` check `<publicUrl>/health`
against an `instance_id` minted at each start: ok / elsewhere / behind a
login / unreachable. Without it, links are labelled "Open on this
computer".
- **Startup channels:** a flag-only `--notificationChannel
"telegram:name=phone,token=env:BH_TG_TOKEN,chat=…"`, repeatable. Secrets
go only as `env:NAME`; an inline secret exits 64 without echoing it, and
so does a name clash with a dashboard channel. These channels show
read-only in the dashboard.
- **API:** `/api/v1/channels` CRUD, pause/resume, test send
(rate-limited), a pure preview (the same renderer as a send, secrets
shown as variable names), the delivery log with filters and cursor, an
env-var set/missing check (never the value), and a Telegram one-tap
connect (a `t.me/<bot>?start=<code>` link, then a 2-minute setup-only
long poll). Scopes are `channels:read|write`, and a `channels` WS topic
carries changes live.
- **CLI:** `browserhive channels list | test <name> | preview <name>
[--sample]` against a running server. `doctor` checks every channel's
variables and `publicUrl`. The banner gains a `Notify` line.
- **Dashboard:** Notifications → Channels, with cards (status, last
delivery, 24 h counts, pause/resume/edit/duplicate/delete/test) and a
5-step wizard:
1. Platform, with Discord's "What's the difference?" panel drawing
webhook vs bot live.
2. Credentials, with launch-specific lines and live set/missing; the
draft survives a restart.
  3. Connect: Telegram one-tap and QR, or ntfy server/topic and QR.
  4. What to send: presets plus advanced options.
5. Preview: near-exact Telegram / Discord / ntfy mocks drawn from the
real renderer output, then Save and Send test.
  
The Delivery log is live, with filters and "why wasn't this sent?", and
the System page gains a Public address card.

### Setup in short

1. **Telegram:** open @Botfather, send `/newbot`, run `export
BH_TELEGRAM_TOKEN=…`, and restart. Then use the wizard's one-tap link.
2. **Discord:** in Channel settings, go to Integrations → Webhooks → New
→ Copy URL. Run `export BH_DISCORD_WEBHOOK=…` and restart.
3. **ntfy:** install the app and scan the wizard's QR code. On ntfy.sh
the topic is the password; for screenshots, prefer a self-hosted server.
4. **Links on the phone:** use `--publicUrl https://…`, for example with
Tailscale `serve` or your reverse proxy. The test message's **Open
dashboard** is the proof.

The full walkthroughs are in `docs/guide/notifications.md`.

### Spikes

| Spike | Result |
|---|---|
| Telegram Rich Messages (Bot API 10.1–10.3) | `sendRichMessage` with
`html` (h3, table, footer) plus an inline keyboard was accepted, and so
was `editMessageText(rich_message)`. Rendering on phones was not
compared. **D-40:** classic HTML for now; two spike messages are left in
the test group for you to compare. |
| Telegram button URLs | Refused: `localhost`, dotless hosts, IPv6
literals. Accepted: domains and IPv4. A `publicUrl` of
`http://localhost:…` failed every send, so it is fixed: links go into
the text. |
| Discord Components V2 vs embeds | The docs say V2 webhook messages may
not carry `files[n]`/embeds, so a webhook V2 message cannot upload a
screenshot. We use embeds plus a link-button action row
(`with_components=true`). Not verified live; there is no webhook yet. |
| ntfy | Checked against the real server `binwiederhier/ntfy:v2.28.0`:
sequence-id replace, `DELETE /<topic>/<seq>`, `PUT` upload (only the
path form or the header carries the sequence id; the query form is
ignored), and a maximum of 3 actions. A server without an attachment
cache refuses uploads, so we fall back to text. |

### Verification

- **Gates (local):** `bun run check` passes (lint, typecheck, depcruise,
unit ≈3 000 server tests plus 384 dashboard tests, and the
openapi/docs/db-types checks). `test:goldens`, `build`, `package:check`
and the website build pass. `test:integration` gives 67 passed / 2
skipped (the real-ntfy test needs `BHDEV_NTFY_URL`). E2e, run the CI way
against the built daemon, gives **11 passed / 1 skipped**: the 9
existing tests plus the new channels journey on desktop and phone.
- **Built daemon, against fakes and local ntfy containers:**
  - A startup webhook channel via the flag delivers HMAC-signed POSTs.
- A tool-error group sends, then edits its count, and the intermediate
edit is `covered`.
- Attention requests take masked and unmasked screenshots; I checked
visually that the inputs are blacked out.
- An ntfy server without an attachment cache refuses uploads; we fall
back to the text alone.
- TTL deletes work on ntfy (the `message_delete` event arrives).
Resolving after a TTL delete gives `superseded: message_deleted`.
  - The `publicUrl` check reports `ok`.
- An inline-secret flag exits 64 with the exact message and never echoes
the value. `BROWSERHIVE_NOTIFICATION_CHANNEL` exits 64 with a hint. A
name clash between a startup and a dashboard channel exits 64.
- **Real platforms** (your local test bot and group, plus an ntfy.sh
topic; Discord only against fakes; I recorded outcomes only):
  - Telegram and ntfy.sh test sends: OK.
- Attention request: Telegram `sendPhoto` with the masked screenshot,
and an ntfy.sh upload with the attachment and view actions.
  - Resolve: both edited in place.
- After a 4-minute TTL: Telegram `deleteMessage` and ntfy.sh `DELETE`
both succeeded.
- `scripts/notify-live.ts`: Telegram ✅, ntfy.sh ✅ (after adding cache
polling), Discord skipped.
- This caught two bugs, now fixed: Telegram refuses buttons with a
localhost `publicUrl`, and ntfy.sh read-back lags a publish.
- **Dashboard visual review:** 74 screenshots at 1440 and 768, light and
dark, all reviewed: the channels list, every wizard step for every
platform, the preview mocks, "What's the difference?", the delivery log
and its detail sheet, and the System Public address card in each state.
Not attached here because they come from a local run.

### Contract change

- **OpenAPI:**
- new `listChannels`, `createChannel`, `getChannel`, `updateChannel`,
`deleteChannel`, `pauseChannel`, `resumeChannel`, `testChannel`,
`previewChannel`, `listDeliveries`, `getDelivery`, `checkChannelEnv`,
`startTelegramConnect`, `getTelegramConnect` and `getPublicUrlStatus`;
  - `HealthResponse.instance_id` (optional);
- error codes `CHANNEL_NOT_FOUND`, `CHANNEL_NAME_TAKEN`,
`CHANNEL_READ_ONLY`, `CHANNEL_NOT_READY`, `CHANNEL_KIND_UNAVAILABLE`,
`CHANNEL_PLATFORM_ERROR` and `DELIVERY_NOT_FOUND`.
- **Config:** a new key `publicUrl`, plus the flag-only
`--notificationChannel`. `BROWSERHIVE_NOTIFICATION_CHANNEL` and a
`notificationChannel` file key fail with a hint.
- **WS:** topic `channels` (`channel.changed`, `channel.removed`,
`delivery.updated`); the scopes `channels:read` and `channels:write`.
- **Contract (`NotificationChannelRules`):** gains `mask_images`
(additive; `schema` stays 1).
- **Database:** no schema change (N0's v5 tables).
- **Goldens:** the WS protocol, help, exports snapshot and OpenAPI
goldens are re-blessed after reading the diffs, and there are new
renderer goldens under `packages/core/test/goldens/notifications/`.

### Please check on your phone

1. The two spike messages in the Telegram test group, **classic HTML**
vs **Rich Message**: which reads better?
2. Telegram: add a channel through the wizard's one-tap link, then
**Send test**. Tap **Open dashboard**; it opens only if `publicUrl` is
reachable from the phone.
3. Trigger an attention request with screenshots on. Check the photo,
the masked fields, and the edit to "Resolved" after you resolve it.
After the TTL, check that the message disappears.
4. ntfy: scan the wizard's QR code and check the notification, the tags,
the priority and the view actions.
5. Discord (when you have a webhook): an embed with the screenshot, the
edit that keeps the image, and the delete.
6. Optionally add the `live-notify` label to this PR to run
`notify-live.yml` (it needs your approval of the `notify-live`
environment).

### Also in this PR

- **The sidebar** now has a Notifications entry. It had none before; the
page was reachable only through the bell.
- **A new dashboard dependency:** `uqr` (MIT) for QR codes.

### Known and not chased

- `integration (macos-latest)` and `integration (windows-latest)` are
known flaky/failing (#23, #2).

### Follow-ups for N2 (handoff `scratchpad/notifications-handoff-N1.md`)

- Command tokens plus `notification_actions`; mint tokens and pass them
to `RenderContext.actToken`.
- The Telegram callback long-poll loop, one per bot token and not
concurrent with the connect flow.
- Discord bot mode: the renderer already draws it; it needs the gateway,
bot sends and the picker.
- ntfy topic B, after the `http`-action spike.
- Allow-lists: the Telegram connect already captures the connecting
user's id.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant