---
type: plan
title: The BW Interaction Standard — comments, mentions, alerts, notifications
slug: bw-interaction-standard
status: active
owner: rian
created: 2026-08-19
related:
  - /srv/apps/caddie/.logs/planning/01-vision-and-roadmap.md
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
summary: >
  The second thing every caddie app shares, alongside the launch/status
  contract: one conversation and alert layer that follows a person across the
  hub and every tool. Comments, @mentions and screenshot attachments live in
  the app that owns the subject; notifications aggregate into ONE inbox with
  one bell, wherever the user happens to be. Modelled on the review app's
  system, corrected for its three known weaknesses. Delivery (email, Telegram,
  Slack, WhatsApp) is a spec'd seam, deliberately unbuilt.
---

# The BW Interaction Standard

## 1. Why this is a standard and not a caddie feature

A client moves hub → tool → hub through one project. If each app keeps its own
comments and its own bell, the client has to visit every app to find out
anything — which is the review app's known flaw multiplied by the number of
tools. The conversation has to follow the person, not the app.

So there are **two** things every caddie app implements, not one:

| Layer | What it does | Doc |
|---|---|---|
| **Tool Contract (ct1)** | launch handoff, identity, provisioning, status reporting | `02-tool-contract.md` |
| **Interaction Standard (this)** | comments, @mentions, attachments, alerts, the bell | here |

They are independent: a tool can adopt the interaction layer while used purely
standalone, and caddie itself is just another consumer of it.

## 2. The split — the one architectural decision

**Comments live locally in the app that owns the subject. Notifications
aggregate centrally.**

- A comment on a mockup belongs next to the mockup, in the tool that owns it.
  Local storage keeps tools sovereign, keeps them working standalone, and
  keeps conversation history alive across a caddie rebuild.
- A notification is "you personally need to know this" — and it only works if
  there is exactly **one** inbox and **one** bell, identical in caddie and in
  every tool. N bells is worse than no bells.

Every thread also records the `project_id` it belongs to, so caddie can render
a project-wide discussion view by asking each attached tool — the conversation
"rides along" with the project without caddie owning the messages.

## 3. What review got right (carry it) and wrong (fix it)

Review's `notifications` table evolved 0014 → 0017, and what got *added* is
the interesting part — the standard ships all of it from day one:

**Carry:**
- A **deep link to the exact place** on every notification (0014). Not "there's
  a comment" — a link that opens the thread, scrolled to it.
- **Server-side mention parsing** via triggers, "so it can't be bypassed and
  needs no client changes." Never trust the client to declare who it notified.
- A **body preview**: image markdown stripped, whitespace collapsed, 160 chars.
- **Never notify the author of their own action.**
- **`category`** (0016) and **`context_label`** (0017) — "so the bell can show,
  at a glance, WHAT each notification is about." The bell is useless without
  the human label; it was added last and should have been first.
- **`resolved`** (0016) with propagation — a notification about a thing that
  got resolved stops nagging.
- **Thread-participation notifications** (0016) — you hear about replies in
  threads you took part in, not only when @mentioned.

**Fix:**
1. **Mentions keyed on email.** In BW-land identity is the **BW username**;
   mentions are `@username`, resolved through the id-auth directory
   (`search_users`), which already scopes to the app's own population.
2. **Supabase Realtime for the bell.** Not available on this stack. The bell
   polls (30s, backing off when the tab is hidden); SSE only if polling
   measurably isn't enough.
3. **Per-app, login-required.** The whole point of this doc — one cross-app
   inbox (§6), plus an outbound delivery seam (§7) so a client learns
   something is waiting without logging in to find out.

## 4. Threads, comments, attachments (local to each app)

Vendored kit tables, identical in every app that adopts the standard:

```
thread        id PK · project_id text NULL   -- the caddie project this rides with
              subject_type text · subject_id text   -- app-defined ("stage","mockup","flow_step")
              title text · resolved bool · created_by · created_at
              UNIQUE(subject_type, subject_id)

comment       id PK · thread_id FK · body_md text · author_username text
              created_at · edited_at NULL · deleted_at NULL  -- soft delete; never hard-delete a conversation

attachment    id PK · comment_id FK · stored_name text (random) · original_name text
              content_type text · bytes int · created_at
```

**Attachments are untrusted client input** and get the paranoid treatment:
images (+PDF) only by sniffed type, hard size cap, random stored filename,
EXIF stripped, written under the **workspace `data/` dir** (never the instance,
never a static mount), served only through an authenticated route that checks
thread access, with `Content-Type` pinned and `X-Content-Type-Options:
nosniff`. A screenshot uploaded by a client must never be reachable by URL
guessing and must never be executable.

**Mention parsing is server-side, on write**: parse `@username` out of
`body_md`, resolve against the directory, drop unknown and self, and emit one
notification per distinct mentioned user (§5). The client's payload never
decides who gets notified.

### 4.1 Subjects are plural — everything a person can look at is one (amended 2026-09-02)

The `subject_type` seam was written with one subject per app in mind; real use
made it plural, and the UNIQUE constraint held without a change. The rules
that came out of it:

- **Anything a person can look at is a subject** — a spot or an area on a
  design, a page, a version, a point in a walkthrough, a stage, and the WORK
  SURFACE ITSELF (caddie's project page is a subject of caddie; a tool's board
  is a subject of the tool). One thread per subject, created on the first
  comment, counted the same way, notified with a deep link to the subject *in
  its own view*. **Put the composer where the subject is shown** — never make
  a person leave the thing to talk about it.
- **One discussion read per work surface.** A page fetches every subject's
  conversation in one call (caddie: `GET /api/projects/{id}/discussion`);
  writes stay on their own subject routes; every mutation invalidates the one
  read. The kit offers `discussion_of(subjects…)`.
- **A subject nobody has spoken about is still listed — as a starter.**
  Opening it is an inline composer, so the first word on a subject is as easy
  as the tenth and "where do I say this?" has one answer.
- **The item in a sequence is the ROOT of its conversation** (a walkthrough
  point, a punchlist item, a review issue): its own words first, everything
  said about it as replies; listed among the conversations only once someone
  has replied.
- **A conversation can be re-filed** under another subject of the same
  project (`POST /threads/{id}/move {subject_type, subject_id}`): the comments
  keep their authors and times; onto an empty subject the thread changes
  address, onto one with a conversation the comments join it and the empty
  shell goes. The opener or the team; a target the caller cannot see is 404.
  Kit note: re-parent through the relationship, never by deleting. Old deep
  links still land — the client resolves `#c-{id}` by comment id across the
  whole read.
- **Edit and delete.** The author edits their own words (`edited_at`; a newly
  named person is mentioned, nobody else hears an edit). The author or the
  team removes — SOFT (`deleted_at`): reads omit it, the row stays. Never
  hard-delete a conversation.
- **Resolved propagates.** Resolving a thread flags every notification it
  raised (`resolved`): they leave the unread count and the default bell list
  and stay behind "Show resolved"; the people in the thread hear it as a
  `resolved` event. Reopening un-flags. The opener or the team.
- **Anchored feedback (spatial tools): point or area, drafted on press,
  movable after.** A note is a point (x/y) or an area (x/y/w/h). Pressing
  drops a pulsing draft marker at once, dragging draws an area, and nothing is
  saved until the words are posted; afterwards the author or the team can
  move a marker or resize an area. A click on a marker opens its
  conversation; the card goes to the marker. Anchors live on an app-owned
  table beside the standard tables.

## 5. The notification event (the shared contract)

One shape, emitted by caddie and every tool:

```json
{ "recipient": "clientuser",
  "actor": "rian",
  "app": "punchlist",
  "kind": "mention",
  "category": "onboarding",
  "context_label": "Google Ads access",
  "body": "can you grab the customer ID from…",
  "url": "https://punchlist.bowden.works/boards/7?flow=12#c-88",
  "source": { "type": "comment", "id": "88" },
  "project_id": "brentwood-rebuild",
  "dedupe_key": "comment:88:clientuser",
  "occurred_at": "2026-08-19T20:14:00Z" }
```

Notes that matter:
- **`url` is absolute**, not an app-relative path — the inbox is cross-app.
- **`app` + `context_label` + `category`** are what let one bell show items
  from four apps and still read clearly ("Punchlist · Google Ads access").
- **`dedupe_key`** makes emission idempotent (retries, replays, double-saves).
- **`kind`** is a small fixed vocabulary:
  `mention · reply · turn · status · resolved · decision`, where **`turn`** is
  the cross-app "the ball is now in your court" event — the same turn-flip
  caddie already records in its activity log, and punchlist's
  waiting-on-client — and **`decision`** (added 2026-09-02) is *something was
  decided*: an approval given, a direction picked, a walkthrough point
  approved. A decision closes a loop, so the bell reads it as news — never
  "needs you". Only `mention` and `turn` are "needs you".
- **`resolved`** is emitted when a thread is resolved, to the people in it,
  and the notifications that thread raised are flagged `resolved` at the same
  time (they leave the count; "Show resolved" still finds them) — so resolved
  work stops nagging.
- **The round-closer rings twice.** A tool whose client owns a round marker
  ("Feedback complete") emits `turn` to the manager side AND, once the
  delivery seam (§7) exists, sends it outbound — moment two of two, the first
  being "presented". A tool must never rely on someone happening to look at
  the bell for the message they are waiting for.

**Recipient authority:** an app may only notify users who are members of that
app — same scoping rule the auth kit already uses for password resets. The
inbox enforces it; a compromised app cannot spam the suite.

## 6. The inbox and the bell

**The inbox is its own small service** — substrate, like id-auth, NOT a caddie
feature. That is deliberate: caddie is the app most likely to be rebuilt, and
a bell that dies with it defeats the purpose. Every app (caddie included)
publishes events to it over the same signed S2S pattern the tool contract
already uses, and reads the signed-in user's inbox from it.

- **Until a second app exists, caddie M1 runs the kit in local-only mode** —
  same kit API, same components, events land in a local table. Standing the
  service up is a config change plus a one-time backfill, so nothing is
  rewritten. (Name TBD — `marshal.bowden.works` is a candidate.)
- **The chrome is a vendored pack — `caddie-ui`** (since 2026-09-02; canonical
  copy `/srv/apps/caddie/packs/caddie-ui/`, stamped into an app by
  `packs/stamp-caddie-ui.sh <workspace>`, verified byte-for-byte with
  `--check`). **An app mounts the pack's components and never re-implements
  them; a needed change goes to the pack.** Three apps hand-rolled three
  bells and three comment surfaces within three weeks of this doc naming them
  as shared — that drift is what the pack ends. The pieces, by name: `Bell`
  (the bell) · `SidePanel` + `ThreadCard` / `ThreadReplies` / `ThreadStarter`
  / `Composer` (the conversation surface) · `Body` / `Prose` (the two
  renderers) · `Lightbox` · `GlobalBar` / `AppBar` (the two-row shell) ·
  `Menu` · `Timeline` · `Stages` · `PanelSection`. The pack README is the
  standard text for each.
- **The conversation surface is the ONE SIDE PANEL, not inline rows** (rian,
  2026-08-21, from review's notes-sheet): `SidePanel`, docked on the right
  and pushing the work aside rather than covering it; full-width on phones;
  rows on the page carry only a small comment-count chip that opens the panel
  at that subject. **Review's design language is the kit's visual reference**
  — the side sheet, status-dot categories, and its table-with-filters idiom —
  translated simpler per app, never copied wholesale. Rules the panel obeys:
  - **One panel, content by STATE.** The Discussion, a walkthrough, a tool
    introduction are contents of the one panel, switched by what the person
    is doing; tabs exist for apps that need them. One frame, one Escape, one
    right edge.
  - **The panel being open IS the mode.** An app with anchored feedback shows
    its anchors and takes a click as "drop one" while the panel is open;
    closed is the work, plain. No separate comment/play toggle.
  - **The list stays a list.** A conversation opens INLINE under its card
    (replies, then a composer) when **Reply** is pressed — the count sits on
    the button. The card itself goes TO the subject (the stage on the page,
    the spot on the canvas); a subject with nowhere to go does nothing but
    Reply. A drill-in on every click made the list a corridor.
  - **Starters** for subjects nobody has spoken about (§4.1).
  - **Labels are breadcrumbs in the app's own nouns** — `Brentwood › Mockups`,
    `Page › Homepage`, `Option 1 › Point 4` — never a type word.
  - **A picture opens in a lightbox, never a new tab.** One `Lightbox` at the
    app root; `Body` opens it.
  - **A highlight is a box AND a dark, BLURRED surround with the box cut out**
    (a full-surface overlay with `backdrop-filter: blur` and an even-odd
    `clip-path` hole — a `box-shadow` can darken but never blur). No
    transition on the box: it jumps, because a transition lags the words.
  - **Escape crosses a frame boundary.** A tool hosting foreign content in a
    sandboxed frame forwards Escape (only Escape) and replays it on its
    document, so the panel, a tour and a menu close the same way either side.
  - **The panel pins to the global row's MEASURED bottom**, not its nominal
    height (`GlobalBar` publishes `--cu-shell-bottom`): a strip above the row
    — a dev banner, the host bar — pushes the row down, and a panel pinned to
    the nominal height hid its own close and back controls under it.
- **The bell** is the pack's `Bell`, mounted in the global row by every app,
  with a ~30-line transport adapter (poll, mark read, navigate) — never a
  re-implementation. States: **idle** (nothing unread) · **unread** (count
  badge) · **needs you** (accent state when any unread item is a `mention` or
  a `turn` — something is actually asked of you, versus something merely
  happened). Rows read as sentences ("dana *mentioned you* on **Brentwood ›
  Mockups**"), with the category chip, a one-line preview and the time;
  clicking marks read and follows `url`; "Show resolved" finds what a
  resolved thread hid.
- **The panel is the same everywhere.** A client who sees the bell in caddie
  sees the identical bell, with the identical items, inside punchlist.

## 7. Delivery — the seam, deliberately unbuilt

Notifications a person only sees after logging in are the review app's
weakness. The fix is outbound delivery, but the channel set is explicitly
**not** email-only: Telegram, Slack, and WhatsApp are all wanted eventually.

So the standard specs the seam and builds none of it yet:

- Every inbox row carries `delivered_at NULL` per channel — a future delivery
  worker selects undelivered rows, fans out, and stamps. No schema change when
  it lands, and history backfills correctly.
- **Channels are adapters** behind one interface (`send(user, channel, items)`).
  Email goes first because the gateway already sends via Resend with per-app
  and per-account caps; a chat adapter is the same interface with a different
  transport and a per-user channel address.
- **Digest, not firehose.** Delivery batches per user per app over a window
  (start: ~30 minutes) so a five-flow onboarding set produces one "3 things are
  waiting on you" message, not five. The batching window is the single knob
  most likely to need tuning against real client behaviour — expect to change
  it, and keep it a config value.
- **Per-user channel preferences** live with the inbox, not in any app.

Build it after the first real client has run through, when we know what people
actually miss. Nothing in caddie or punchlist blocks on it.

## 8. What a tool must implement

Same target as the tool contract: cheap enough to vibe-code. The vendored
backend kit (`bw_interaction.py` — extraction source: caddie's
`app/services/interaction.py`, which carries the whole of §3–4) holds the
tables, the migration, mention parsing, attachment handling, resolve / edit /
soft-delete / move, the discussion read and the publisher; the vendored
front-end pack (`caddie-ui`, §6) holds the components. A tool's own work:

1. Stamp the pack; mount `GlobalBar` with the pack's `Bell` behind a
   ~30-line transport adapter.
2. Decide what its subjects ARE (§4.1) and map them onto `ThreadRow`s in one
   file — the pack owns the look, the app owns what a subject is; render the
   one `SidePanel` with cards, starters and inline composers, passing the
   caddie `project_id` when it has one.
3. Emit a `turn` notification when its own status flips to waiting-on-someone
   (one call, next to the `post_status()` it already makes), and `decision`
   when something is decided.

Conformance: bell renders and polls; a mention notifies exactly the mentioned
user and never the author; an unknown @name is dropped silently; attachments
reject a non-image and a too-large file; every notification's `url` deep-links
to the exact comment; resolving a thread clears its notifications; all of it
works at phone width.

## 9. Phasing

| When | What |
|---|---|
| **punchlist 0.6.0 (done 2026-08-20)** | The standard's FIRST implementation landed in punchlist, review-modelled: one thread per item (workflow messages + freeform comments as one stream), server-side mentions, turn notifications per side, dedupe, the bell (idle/unread/needs-you, 30s poll, deep links). Extraction source: `punchlist main/app/services/interaction.py` + `frontend/src/Bell.tsx`. |
| **caddie M1 (done 2026-08-24)** | Vendored the kit EXTRACTED from punchlist's implementation; comments + mentions + bell on stages. |
| **easel v0.32–0.45 (2026-09-02)** | The pack's origin: three apps had hand-rolled the shared chrome, so easel built `caddie-ui` once and proposed it; fourteen versions of real client use produced §4.1 and the §6 shell rules. |
| **caddie 0.4.0 (2026-09-02)** | The pack made canonical in caddie's workspace with stamp + `--check` tooling; caddie a full consumer of its own standard (two-row shell, the one panel, the pack's bell); §3–4 complete on caddie's backend — the project as a subject, the discussion read, resolve/edit/delete/move, `decision`, breadcrumb labels. |
| **punchlist M1–M2** | Same kit, local-only, on flow steps. Two apps independently exercising it. |
| **caddie M2 / punchlist M3** | Stand up the inbox service; both apps repoint (config + backfill). **Gate: one bell in caddie shows a punchlist mention, and the same bell inside punchlist shows a caddie one.** |
| **After the first real client** | Delivery: email adapter + digest window, then a chat channel. |

## Addendum (2026-08-22): attachments in comments

The reference implementation (punchlist v0.9.0) added files-in-comments, and
the pattern is standard-worthy for every caddie-family app:

- **One action, files ride along.** Paste, drop or the paperclip uploads
  immediately; **the composer HOLDS the file** (a thumbnail strip under the
  words, each removable) **and the markdown is appended on Send** — the
  markdown is the wire format, not something a person should read while
  writing (the first person who pasted was confused by it; wording amended
  2026-09-02, the pack's `Composer` does it this way). Send posts a normal
  comment; a comment may be a file alone. There is no separate "attachment"
  object in the conversation model — the comment body carries
  `![name](/api/…/attachments/{file})`.
- **Storage is DB-free and app-local** (`data/uploads/{subject_id}/{uuid}.{ext}`
  on the mounted volume). Access control is NOT per-file: every read
  re-checks visibility of the OWNING subject, so permissions can never drift
  from the app's own model. 404-never-403 discipline applies.
- **The upload surface is hostile territory**: extension allow-list with no
  script-capable types (no SVG, no HTML), server-generated filenames (the
  strict name regex doubles as the traversal guard), magic-byte agreement,
  size + per-subject caps, `X-Content-Type-Options: nosniff`, content-type
  from the app's table never the upload.
- **Renderers only load images from the app's own relative attachment URLs**
  — never arbitrary external URLs from user text (tracking-pixel guard).

When the interaction kit is extracted for caddie, this ships as part of it
(composer paste handler + the two endpoints + the render rule).
