---
type: plan
title: Punchlist ↔ caddie — what each session must not get wrong
slug: punchlist-coordination
status: active
owner: rian
created: 2026-08-24
related:
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
summary: >
  Written for the OTHER session (punchlist), which is standing up a real client
  board now while caddie builds M1. Four cheap-now/expensive-later asks, plus
  the one thing the contract does not yet cover: importing a board that already
  exists.
---

# Punchlist ↔ caddie coordination

Two sessions are running: this one builds caddie M1, the other is creating a
real client punchlist. Neither blocks the other — punchlist is sovereign and
the client can start today. This doc exists so that **nothing built this week
has to be undone when the two are joined.**

## The two things rian asked about, and why "shared" is the wrong word for both

### Members — NOT a shared table

Identity comes from the IdP (`auth.bowden.works`), and it already is shared:
the same BW username signs into caddie and into punchlist. There is no shared
member table and there must never be one — each app keeps its own kit store,
and the *same username* holds a grant in each.

> **Ask 1: punchlist must not grow a user directory of its own.** Whoever is
> invited to the board is a BW account; caddie will later grant the same
> username on the same board via the contract's auto-provisioning (§4).

This is also why a caddie rebuild cannot cost punchlist its users: they were
never caddie's.

### Comments — NOT shared either. Notifications ARE.

The standard's one architectural decision (04 §2): **comments live in the app
that owns the subject; notifications aggregate into one inbox.** A comment on
a punchlist item stays in punchlist forever. What is shared is the *bell* —
one inbox, one event shape, identical in every app. N bells is worse than none.

Today both apps run the bell **local-only**; the shared inbox service stands up
at the joint gate and both repoint. Two asks make that repoint a config change
instead of a migration:

> **Ask 2: emit the §5 event shape NOW**, even while the inbox is local —
> `recipient, actor, app, kind, category, context_label, body, url,
> source, project_id, dedupe_key, occurred_at`. In particular the **`url` must
> be ABSOLUTE** (`https://punchlist.bowden.works/...`), because the inbox is
> cross-app and a relative path is meaningless in another app's bell.
>
> **Ask 3: record `project_id` on every thread now** (nullable until a board is
> attached to a caddie project). It is what lets caddie later render a
> project-wide discussion by asking each tool, without owning the messages —
> and backfilling it across a live board is far more annoying than writing it
> from the start.

## The gap: this board will already EXIST

The contract as written (§2) only describes provisioning that **creates**: caddie
posts to the tool, the tool makes a new board, returns `external_ref`. Rian is
about to create a real board *first* and wants to import it into caddie later.
That is the reverse direction and it is not yet specified.

What it needs on each side:

- **caddie (M2):** "attach existing" alongside "attach new" — an assignment that
  binds to a known `external_ref` instead of asking the tool to create one.
- **punchlist:** `POST /caddie/api/assignments` accepts an optional
  `external_ref`; when present it **binds the existing board** to the
  `assignment_id` rather than creating a second one. Idempotent on
  `assignment_id`, as the contract already requires.

> **Ask 4: leave `punchlists.external_ref` free.** It already exists on the
> model and is the seam this import lands on. Do not repurpose it for anything
> else.

### The trap inside the import: reconcile must not evict the humans

The contract's revocation convergence (§2, §5) says a tool drops grants for
users no longer in caddie's `participants`. On a board that existed *before*
caddie, that would evict people rian invited by hand — the client, himself —
the moment the first reconcile runs.

The contract already has the right answer and it must be honoured on import:
**only LAUNCH-PROVISIONED grants are ever dropped.** So:

> **Ask 5: mark grant provenance on the board** — was this grant made by a
> human invite, or auto-provisioned by a caddie launch? Manual grants are
> permanent and survive every reconcile. This is one boolean, it is nearly free
> today, and without it the first reconcile after import silently removes the
> real client.

## Summary — five asks for the punchlist session

1. No user directory of its own; BW usernames are the identity.
2. Emit the §5 notification event shape now, with an **absolute** url.
3. Store `project_id` on threads now (nullable).
4. Leave `punchlists.external_ref` free for the caddie assignment id.
5. Mark grant provenance (manual vs launch-provisioned) before any grant exists
   that we would be sad to lose.

Nothing here asks punchlist to speak the contract yet — that is its M3. These
are only the decisions that are cheap this week and expensive later.

---

## Addendum (2026-08-24, caddie v0.3.1) — one more, from building attachments

**6. The attachment URL shape differs between the two apps, and a shared
renderer has to know both.** Punchlist emits
`![name](/api/items/{item_id}/attachments/{file})`; caddie emits
`![name](/api/attachments/{stage_id}/{file})`. Both satisfy the 04 addendum's
actual rule — *renderers only load images from the app's OWN relative
attachment URLs* — because each app's markdown renderer whitelists its own
prefix and nothing else. That is fine while comments render inside the app
that owns them, which is the standard's whole point.

It stops being fine the moment anything renders a comment body from the OTHER
app. Nothing does today, and by design nothing should: the bell carries a
`body` string and a link, never markdown to re-render. **So the ask is only
this: don't let a comment body cross an app boundary as markdown.** If a
future surface ever needs it, the fix is a shared prefix
(`/api/attachments/{subject_id}/{file}`) plus one renderer, and it is much
cheaper to agree on that now than to migrate stored bodies later.

Caddie generalised punchlist's `attachments.py` on the way in — the owning
thing is a `subject_id`, not an `item_id` — so the service itself is already
portable; only the route prefix differs.

---

## Addendum (2026-09-02, caddie v0.4.0) — the chrome is a pack now

Three apps (caddie, punchlist, easel) had each hand-rolled the bell and the
comment surface the standard says are shared. The `caddie-ui` pack ends that:
canonical at `/srv/apps/caddie/packs/caddie-ui/`, stamped byte-for-byte and
verified with `--check` (04 §6, 05 §3a). Four more asks, cheap now:

**7. Stamp the pack and make `pl-bell` / `CommentsDrawer` pack consumers.**
```bash
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/punchlist
/srv/apps/caddie/packs/stamp-caddie-ui.sh /srv/apps/punchlist --check
```
The adapter shape is caddie's `main/frontend/src/Bell.tsx` (~40 lines over
the pack's `Bell`) and `Discussion.tsx` (the app maps its rows into
`ThreadRow`s and wires its API to the pack's callbacks; the pack owns the
look). Board rows keep only a count chip; the words live in the one side
panel.

**8. `decision` is a notification kind** (04 §5) — for a step approved or a
choice made; it is news, never "needs you". Punchlist emits these as `status`
today.

**9. `context_label` is a breadcrumb in punchlist's nouns** — `{board} ›
{item}` — never a type word (04 §6).

**10. The client reads both error shapes** (05 §3a): the kit's middleware
answers a top-level `{error_code, summary, details}` that a `detail`-only
parser turns into "Request failed (403)".

And when punchlist reaches them: the project-level conversation, one
discussion read per board, resolve / edit / soft-delete / move — all in 04
§4.1, all in caddie's `app/services/interaction.py` as the reference shape.

