---
type: plan
title: Rebuilding easel as a caddie app — scope
slug: caddie-rebuild-scope
status: draft
owner: rian
created: 2026-09-01
related:
  - /srv/apps/caddie/.logs/planning/05-building-a-caddie-app.md
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
  - /srv/apps/easel/.logs/planning/inputs-2026-08-28.md
summary: >
  What it takes to rebuild Adi's WordPress `proto-preview` prototype as easel,
  a sovereign caddie app on the standard stack. Answers a scoping question —
  no decision is made here. The headline: two of the three standards easel
  would adopt do not exist yet, so the realistic path is seams-now,
  drop-ins-later, exactly as punchlist is doing.
---

# Rebuilding easel as a caddie app — scope

## 1. What "caddie app" actually commits us to

Per `05-building-a-caddie-app.md`, a caddie app is **a sovereign standalone BW
app on its own subdomain, fully usable without caddie**, that additionally
speaks two standards:

| | | Status |
|---|---|---|
| **Tool Contract (ct1)** | signed launch handoff in, signed status out, scoped auto-provisioning, acked revocation, host bar | drop-ins `caddie_tool.py` + `caddieHost.ts` **do not exist** — caddie M2 |
| **Interaction Standard** | comments, @mentions, attachments, one cross-app bell | kit `bw_interaction` **does not exist** — being extracted from punchlist |

Verified 2026-09-01: neither file exists anywhere under `/srv/apps` or
`/srv/system`. Punchlist — the first caddie tool — is running **seams-only**
and waiting on the same two.

Plus the non-negotiable stack (§3 of the runbook), which easel currently does
**not** meet:

| Requirement | easel today |
|---|---|
| React SPA + FastAPI, one process | ✅ already |
| **Postgres sidecar from day one** + SQLAlchemy 2.0 + Alembic | ❌ SQLite, no ORM, no migrations |
| Auth-kit store moved into that Postgres via `bw_store_sqlalchemy` managed mode (PACKS.md) | ❌ kit on SQLite |
| TS client generated from OpenAPI at image build; TanStack Query | ❌ hand-written `api.ts` |
| `client_party_id uuid NULL`, append-only event tables, capability booleans not level names | ❌ none |
| Version + newest-first CHANGELOG at `/api/meta` | ❌ no CHANGELOG |
| Phone-width floor on every client-reachable page | n/a yet |

So "rebuild as a caddie app" is not a coat of paint on the current scaffold —
it's the stack conversion plus the domain build.

## 2. What Adi actually built (the port map)

`/srv/apps/prototype/wp-content/plugins/proto-preview`, ~14,700 lines. It is
**further along than the checkpoint video shows** — in particular the guided
walkthrough already exists.

**Data model** — screens/options live as serialised post meta on a project
CPT; everything else is custom tables:

```
pp_projects · pp_pages · pp_layouts          (project → screen → option)
pp_comments          layout_id, user_id DEFAULT 0, author_name, author_email,
                     x_percent, y_percent, text, comment_type, title,
                     is_resolved, is_locked, resolved_by/at
pp_comment_replies   comment_id, user_id DEFAULT 0, author_name, author_email
pp_story_steps       layout_id, step_order, title, description, category,
                     target_type, target_selector, rect_data, action_type,
                     action_target, requires_approval
pp_story_approvals   step_id, client_name, client_email, status, comment
```

**Two findings worth pulling out:**

1. **The no-login client path is already built.** `user_id DEFAULT 0` +
   `author_name`/`author_email` on both comments and replies means an
   anonymous commenter identified by a name they type. Adi's "it should be
   super non-login user" isn't an aspiration — it's implemented.
2. **The walkthrough is already built, and goes further than the
   hartlingowners tour.** `pp_story_steps` is a tour engine
   (`tour-engine.js`, 868 lines) with per-step `requires_approval` and a
   matching approvals table — so a client can be walked through a concept and
   approve it beat by beat. Adi converged on rian's presentation idea
   independently and added sign-off to it.

**Front-end weight:** `comment-engine.js` 2,984 · `proto-preview-player.js`
3,231 · `tour-engine.js` 868. That is the real asset, and none of it survives
a port as code — it is a spec, not a library.

## 3. What easel would get from the standards vs. build itself

**From the Interaction Standard (once it exists):** threads, comments, soft
delete, server-side `@mention` parsing, attachments with the paranoid
treatment, the notification event shape, and the one cross-app bell. That is
most of Adi's comment layer, for free, and better — his is app-local with no
bell. The standard even uses *"a comment on a mockup"* as its worked example.

**What the standard does NOT cover, and easel must own:**

- **Pin anchoring.** The standard's model is one thread per subject
  (`UNIQUE(subject_type, subject_id)`). Easel needs *many* threads per option,
  each at an `(x%, y%)` on the design. This maps cleanly — mint a synthetic
  `subject_id` per pin and keep `x_percent`/`y_percent`/`is_locked` on an
  easel-owned `pin` table pointing at the thread — but it *is* an easel
  extension, and worth raising with the standard's author before diverging.
- **The screen/option model** and **option selection** (the "lock in a
  direction" act, which is easel's whole point).
- **Play mode / comment mode**, the viewer chrome, prev/next, device widths.
- **The walkthrough engine** + per-step approvals.
- **Prototype file hosting** — see §4, this is the hard one.
- **Status rollup** onto the contract's six states. Proposed mapping:
  no options uploaded → `not_started` · sent, no client action →
  `waiting_client` · open pins addressed to us → `waiting_agency` · client
  asked for something we can't read → `needs_attention` · an option selected
  and approved → `done`. Empty instance must report `not_started`, never
  `done`.

## 4. ⚠ The security problem the WordPress version has, and easel must not inherit

Adi's plugin serves the mockups from the **same origin as the app**
(`protopreviewer.local/wp-content/plugins/proto-preview/layouts/saas-v1.html`,
rendered in an iframe on the app's own domain).

**Why that is dangerous here.** The mockups are hand-authored HTML with
scripts, and they'd be uploaded by whoever is building a concept. On the same
origin, that file is not a document — it is code running as easel. It can read
`document.cookie`, call easel's own API as the signed-in user, and (once easel
is a caddie tool) reach any endpoint that user can, including the contract's
signed surfaces. One careless copy-pasted snippet in a mockup, or one
compromised source file, and it's a full session takeover of whoever views it
— including a client.

**What easel must do instead** (all three, not one):

1. **Serve prototypes from a different origin** than the app — a separate
   host, so the same-origin policy does the isolating. This is a DNS + Caddy
   decision and needs rian.
2. **`sandbox` the iframe** (`allow-scripts` without `allow-same-origin`,
   which is what actually severs the origin) and drive pin coordinates over
   `postMessage` with strict origin checks both ways — the same idiom the
   tool contract already mandates for embed mode.
3. **Strict CSP on the app's own routes**, plus `X-Content-Type-Options:
   nosniff` and a pinned `Content-Type` on the served files.

The uploaded files themselves get the Interaction Standard's attachment
treatment: sniffed type, size cap, random stored name, under the workspace
`data/` dir, never a static mount.

**This is the single most important thing to get right in the rebuild**, and
it is a reason to rebuild rather than to host Adi's plugin as-is.

## 5. The no-login collision (unresolved, and it's the fork from `brief.md`)

Three things currently disagree:

- **Adi's design:** anonymous commenters, name + email typed in.
- **Pattern B / easel today:** everything behind BW sign-in.
- **The Interaction Standard:** *"an app may only notify users who are members
  of that app"* — an anonymous commenter has no BW username, so cannot be a
  notification recipient, cannot be `@mentioned`, and cannot appear in the
  bell.
- **The Tool Contract:** identity always comes from the IdP; launch
  auto-provisions a *named* participant.

These are reconcilable, but only deliberately. The shape that fits everything:

> **Anonymous is a first-class *commenter*, never a *member*.** A client
> reaching easel through a caddie launch is a real BW account and gets the
> full suite (mentions, bell, approvals). A client sent a bare share link
> comments as a named guest — their comments are first-class and notify *us*,
> but they receive nothing back through the bell, only through the outbound
> delivery seam (§7 of the standard) once it exists.

That keeps the "costs the client zero time" property for the first send while
staying inside both standards. **It needs rian's sign-off before anything is
built on it** — it decides the schema.

## 6. Overlap worth settling first

Three apps now touch "client reacts to design work":

| App | Stage | Stack |
|---|---|---|
| **scout** | *before* design — crawls reference sites, gamified reactions → a direction brief | standard, built |
| **easel** | *during* concept — present mockups, pin feedback, pick an option | this doc |
| **review** | *after* build — page-level signoff on staging URLs (`ready for review` → `revision needed` → `approved`) | **Next.js + Supabase, off-standard**; the contract calls it "review (legacy)" |

Scout and easel are genuinely adjacent, not duplicative — scout answers "what
should it look like", easel answers "is *this* it". **Review is the real
question.** Its `pages.signoff` lifecycle is close to easel's option-approval
lifecycle, and it's the one app on a non-standard stack. Two plausible
readings, and rian should pick:

- **easel and review stay separate** — concepts vs. built pages are different
  enough, and review gets its own rebuild later.
- **easel eventually absorbs review** — one client-feedback tool covering
  concept → build, and review is retired. If this is the intent, easel's
  domain model should leave room for it *now* (a "source" that is either an
  uploaded mockup or a live staging URL), which costs almost nothing today and
  a lot later.

Review is also the visual reference the Interaction Standard points at (side
sheet, status dots, table-with-filters), so easel should look at it either way.

## 7. Phasing and rough effort

The runbook's own guidance is seams-now, drop-ins-later. Estimates are
working-session estimates for one experienced session, not calendar time, and
assume the open questions in §5 and §6 are answered first.

| Phase | Work | Rough |
|---|---|---|
| **0. Decisions** | §5 anonymous model · §6 review overlap · prototype-hosting origin | rian + Adi, no code |
| **1. Stack conversion** | Postgres sidecar, SQLAlchemy 2.0 + Alembic, kit store → managed mode (PACKS.md), OpenAPI-generated TS client, CHANGELOG + `/api/meta`, the `app_conftest` `__all__` fixture trap | 1–2 sessions |
| **2. Domain core** | project → screen → option, upload + isolated serving (§4), the viewer, play/comment modes | 2–3 sessions |
| **3. Feedback layer** | pins + threads as the interaction embryo, open/resolved, the side-drawer discussion panel, option selection | 2–3 sessions |
| **4. Walkthrough** | story steps + per-step approval, authored by us, replayed to the client | 1–2 sessions |
| **5. Caddie seams** | `external_ref` on the instance table, the six-state rollup fn, `caddie_links` shape, phone-width pass | ~0.5 session |
| **6. Drop-ins** | vendor `caddie_tool.py` + `caddieHost.ts`, swap the embryo for `bw_interaction`, run the §10 conformance checklist | **blocked on caddie M2** |

Phases 1–5 are unblocked today. Phase 6 cannot start until caddie M2 ships,
and the phase-3 embryo should be written expecting to be thrown away — keep
it behind a thin internal interface so the swap is a substitution, not a
rewrite.

**The honest headline:** this is a real rebuild, roughly 7–11 working sessions
before the caddie drop-ins land, and none of Adi's 14,700 lines port as code.
What ports is the design — and the design is good, which is most of the value.

## 8. Not decided here

Everything in §5 and §6, plus: whether easel keeps `has_instances = false`
(it almost certainly should flip to `instance_noun = "project"` to match the
contract's per-assignment grant model), and whether the prototype-serving
origin is a new subdomain or a path on an existing one.
