---
type: plan
title: easel rebuild — build plan (autonomous /goal session)
slug: rebuild-build-plan
status: active
owner: rian (executed autonomously by Claude)
created: 2026-09-01
related:
  - /srv/apps/easel/.claude/commands/goal.md
  - /srv/apps/easel/.logs/planning/caddie-rebuild-scope.md
summary: >
  The working plan for the /goal autonomous rebuild. Architecture decisions are
  recorded here as they are made; phases check off in place. The /goal command
  is the contract; this is the execution shape.
---

# easel rebuild — build plan

## Phases

- [x] **P0 Orient** — standards + kit read; research fan-out (punchlist
  precedent, Adi UX spec, league curation); this plan.
- [x] **P1 Stack conversion** — instances ON via bw-app.toml + scaffolder
  re-run; Postgres sidecar + secrets file; SQLAlchemy 2.0 + Alembic;
  `bw_store_sqlalchemy.managed()` swap; adi's admin row migrated; app_conftest
  fixture (`__all__`); conformance green on the sqlite path.
- [x] **P2 Domain schema + API** — tables below; routers; capability booleans;
  events; status rollup fn + tests.
- [x] **P3 Serving + isolation** — upload endpoints; bundle serving route with
  CSP `sandbox allow-scripts`, path-embedded signed token, Sec-Fetch-Dest
  check; bridge injection; attachment route.
- [x] **P4 Frontend** — overview grid, viewer (sandboxed iframe + pin overlay
  + drawer), play/comment modes, walkthrough player + author, selection,
  bell embryo, phone-width floor; OpenAPI TS client in image build.
- [x] **P5 Caddie seams** — `external_ref`, `caddie_links` table, rollup wired
  as the future status body; seam tests.
- [x] **P6 Seed league** — curated screens/options copied into easel data;
  walkthrough authored; darren member+grant; `id-user-grant` bookkeeping.
- [x] **P7 Deploy + verify** — `deploy --build`; conformance; own tests;
  security-audit; live header/behavior checks; View As of darren.
- [x] **P8 Feedback + checkpoint** — caddie 05 §5 appends; v8-feedback;
  ideas.md; CHANGELOG; handoff; owner-actions list.

## Architecture decisions (logged as made)

**D1 — Project == kit instance.** No separate project id. Easel-owned
`project_details` row (1:1 on instance id) carries `description`,
`client_party_id uuid NULL`, `external_ref text NULL` (caddie seam),
`sent_at timestamptz NULL` (drives in_progress→waiting_client).

**D2 — Domain tables** (SQLAlchemy 2.0, Alembic; kit tables join the same
metadata via managed mode):

```
project_details  instance_id PK · description · client_party_id uuid NULL ·
                 external_ref NULL · sent_at NULL · created_at
screens          id · instance_id · title · slug · position ·
                 selected_option_id FK NULL · created_at
                 UNIQUE(instance_id, slug)
options          id · screen_id FK · title · concept_tag · position ·
                 entry_path text · created_at
mockup_files     id(hex PK) · option_id FK · rel_path · stored_name ·
                 content_type · bytes · created_at   UNIQUE(option_id, rel_path)
pins             id · option_id FK · thread_id FK UNIQUE · x_percent ·
                 y_percent · locked · created_by · created_at
thread           (Interaction Standard §4 shape, byte-compatible)
comment          (§4 shape; soft delete)
attachment       (§4 shape)
notifications    (§5 event shape + read_at; dedupe_key UNIQUE; local-only bell)
walkthrough_steps id · option_id FK · step_order · title · body_md ·
                 target_selector · rect json NULL · requires_approval ·
                 created_by · created_at
step_approvals   id · step_id FK · username · status · comment NULL ·
                 created_at   UNIQUE(step_id, username)
events           id · instance_id · actor · kind · payload json · occurred_at
                 (append-only; INSERT-only in code)
caddie_links     assignment_id PK · instance_id · participants json ·
                 created_at   (seam — unused until M2)
```

**D3 — Mockup bundles, path-embedded token.** An option's mockup is a file
bundle (rel_path preserved). Serving: `/m/{token}/{rel_path}` where token =
signed `{option_id, exp}` (itsdangerous, ~15 min TTL), minted by an authed API
per viewer-open. Relative subresources resolve under the same `/m/{token}/`
prefix, so every request carries auth without cookies — sidesteps the
cookies-into-opaque-origin ambiguity entirely (the goal's §3.3 fallback, made
primary). Response headers: `Content-Security-Policy: sandbox allow-scripts`,
`X-Content-Type-Options: nosniff`, pinned Content-Type from the stored
allow-listed type, `Referrer-Policy: no-referrer`. `Sec-Fetch-Dest: document`
(present) → 403 friendly page; absent header allowed (CSP is the boundary).
Public-path handling: `/m/` is session-exempt in middleware **because the
token IS the auth** — documented as such, exact-prefix, not `/api/*`.

**D4 — Bridge.** Server-side injected `<script>` before `</body>` of entry
HTML at serve time (never stored into the file): reports `{type:'easel',
nonce, event:'height', px}` on load/resize via ResizeObserver, and handles
`scroll-to {y_percent}`. Parent: accepts only `event.source ===
iframe.contentWindow` + nonce match, expects `origin === "null"`; sends with
targetOrigin `"*"`. Nonce minted per token and embedded in both the injected
script and the mint response.

**D5 — Pins on the parent, not in the frame.** The overlay (parent DOM,
positioned over the full-height iframe) captures clicks in comment mode and
converts to x/y percent of the document box; pins render as absolutely-placed
elements on the overlay. No click capture inside the frame — the bridge stays
minimal (height + scroll only).

**D6 — Rollup mapping** (pure fn, §6 of the goal): no options →
`not_started`; options + `sent_at` NULL → `in_progress`; sent + any OPEN
client-authored pin thread → `waiting_agency`; sent + selection complete on
every screen + all requires_approval steps approved → `done`; sent + open
client pins AND selection complete → `needs_attention` (conflicting signals);
otherwise → `waiting_client`. "Client-authored" = author's effective level is
`member` (not admin/owner) at evaluation time.

**D7 — Levels.** Kit defaults kept: `admin` = manager side, `member` = client
side. Permission vocabulary extended with easel domain permissions seeded onto
admin: `projects.manage` (screens/options/uploads/walkthrough author/resolve/
send), plus kit's existing accounts.* / instances.*. Member gets none
app-wide; commenting/approval/selection are session+grant-gated abilities on
granted instances, exposed to the UI as capability booleans from a per-project
`/api/projects/{id}/capabilities` (or folded into the project GET).

**D8 — Notifications embryo.** Table matches §5 event fields; emitted on:
comment on a thread you participate in, @mention (server-parsed against app
members), resolve, selection, approval, "sent". Bell = polling GET
/api/notifications (30s, backoff hidden tab). No outbound delivery. Behind
`app/services/interaction.py` so `bw_interaction` replaces the internals.

**D9 — Frontend structure.** `views/` (ProjectsList, ProjectOverview, Viewer,
WalkthroughAuthor), `components/` (extracted primitives), generated
`src/api/schema.d.ts` via openapi-typescript in the node build stage, TanStack
Query for server state. Vendored primitives: keep the scaffold's bw-admin pack
styling idiom; add minimal shared Button/Dialog/Drawer primitives (vendored,
not a dependency).

**D10 — Seeding mechanism.** Curated league files copied to
`data/seed/league/` on the host; idempotent `python -m app.seed_league` run
via `docker exec easel-app` (owner session can docker; the seed calls kit +
domain services with actor `rian`). Seed marks the project description
"seeded by Claude — curation pending Adi's review".

## Deviations from the goal text (logged, with why)

- §3.3 said "If session cookies don't ride into the sandboxed load, auth the
  file fetch with a short-lived signed per-file token in the iframe src."
  Decision: use the token **unconditionally** (D3) — cookie behavior into
  opaque-origin subresource fetches is browser-murky, and per-bundle
  path-tokens make subresources work with zero cookie dependence. Strictly
  stronger than the conditional form.
