---
type: plan
title: Punchlist — a to-do list with a twist
slug: punchlist-plan
status: active
owner: rian
created: 2026-08-20
related:
  - /srv/apps/caddie/.logs/planning/01-vision-and-roadmap.md
  - /srv/apps/caddie/.logs/planning/02-tool-contract.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
summary: >
  Punchlist (punchlist.bowden.works) gets clients to DO things — grant Google
  access, send IDs, add admins — through items that look like a dead-simple
  checklist but carry owners, context-aware actions, alternatives, per-target
  checks, tutorials and turn-taking underneath. Four personas: clients check
  things off, the team confirms and responds, managers compose punchlists from
  templates plus quick one-offs, builders author reusable workflows — with AI
  (Claude now, a small local LLM later) as the first builder. Standalone-first
  BW app; caddie tool seams built in. This is also the app that teaches caddie
  what a "caddie app" needs, so learnings flow back into caddie's docs.
---

# Punchlist — plan

> Supersedes the earlier draft that lived at
> `/srv/apps/caddie/.logs/planning/03-punchlist-plan.md` (now a pointer here).
> Read `/srv/projects/standards/README.md` first like any code session; read
> the caddie tool contract + interaction standard (related links) before
> touching the caddie seams.

## 1. What it is — a to-do list with a twist

Getting clients to complete onboarding-style actions without being chased.
On the surface: a checklist. Underneath: each **item** runs a **workflow** —
steps with owners, typed inputs, alternatives and named jumps. Four ways
people use it:

**There are exactly TWO kinds of people: internal (agency side) and client.**
Team / manager / builder are not separate roles — they are HATS one internal
person wears in a day (rian, in the simplest case: builds workflows, composes a
punchlist onto a project, then responds as the client works it). Permissions
model the hats (`items.act_team`, `items.compose`, `workflows.author`), and the
seeded `internal` level carries all three; clients hold a per-punchlist grant
and nothing else.

| Hat | What it does | Surface |
|---|---|---|
| **Client** (the other side) | Work the list. See actions, mark them done. Everything else stays out of the way until needed. | The punchlist view (phone-first) |
| Internal: **responding** | Not a team task list — CONFIRM what clients did and respond so clients can proceed (verify access, answer trouble, advance multi-step flows). | The queue |
| Internal: **composing** | Instantiate templates/sets with variables, drop in quick one-off items, insert into a caddie project. | Compose view |
| Internal: **building** | Author the reusable workflow library — including conditional flows. **AI is the first builder** (Claude now, a small local LLM later); a builder UI comes after the backend proves out. | Files/CLI/API first, UI later |

"Send them a link" = the standard BW invite email (account + set-password,
lands on their punchlist). No magic links, no app-local auth, ever.

## 2. The item, anatomically (the client-side contract)

The name of the game is **a checklist: ONE LINE per item**, depth on demand:

```
[ Grant GA4 admin access to rian@… and heather@…   (Granted—rian@…) (Granted—heather@…) ··· v ]
```

1. **The LABEL carries the GOAL, and never changes with the step.** It is the
   workflow's title, goal-phrased, with {{variables}} rendered (never fields).
   Steps change the ACTION, not the item's identity: the label stays "Grant us
   GA4 admin access" while the button evolves from "Granted" to "Added at the
   property level". A step's own explainer is its `instruction`, and it lives
   behind the chevron — never on the default row.
2. **Primary action** — a compact button on the row whose label is
   context-aware, never a generic checkbox. **Button register (rian,
   2026-08-20): short verb-first phrases, 3-4 words max** — "Upload brand
   guide", "Submit ID", "Granted", "Added at the property level". Specific but
   never a first-person sentence; sentence-length wording belongs to MENU
   items and instructions, not buttons. **Steps with input fields keep the row clean**: the button
   opens a small act area with the inputs; submitting closes it. When the step declares
   **targets** (a list variable like `{{who_needs_access}}`), the primary
   action renders once per target and the step completes when every target is
   acted on — "grant to rian" and "grant to heather" are two satisfying
   clicks, one item.
3. **Alternatives** — the **"or" chip**: a soft pill attached after the
   action button (subtle-but-legible beat the faint "..." it replaced). It
   opens a menu headed "Can't do this right now?" with the builder-defined
   alternatives for this step — "I'll do it later", "Having trouble", "I don't
   have an option to add admins" — plus always **Other…** (free text). Three
   kinds:
   - `jump` — moves to another step: this is how **conditional flows unfold**
     ("no admin option" → the check-property-access step → failing that, a
     further step). A conditional is a human picking a button; the engine
     stays linear-with-named-jumps.
   - `flag` — stays on the step, records a message, tone `attention` (Having
     trouble → team queue, needs_attention) or `ack` (I'll do it later →
     acknowledged, sorted down, no alarm).
   - `other` — free text, treated as an `attention` flag.
4. **Expand** — the `v` opens the step's `instruction` + any extra detail.
   **Inside** the expansion sits "Detailed instructions" — the tutorial
   (screenshots via the bw-guide-shot pipeline). 90% never open any of it; the
   10% find it exactly where they'd look. The team's History also hides in the
   "..." menu — useful, never default-visible.
5. **Comments** — the standard comment interface, linked to the item
   (Interaction Standard; M1 ships the embryo, §7).

**States as the CLIENT perceives them** (derived, never stored as such):

| Client sees | Underneath |
|---|---|
| **To do** | current step is theirs |
| **Flagged** ("I'll do it later" chip) | ack flag on a client step |
| **Waiting on us** (struck through, quiet) | client acted; current step is the team's (verify/respond) — the client's work LOOKS done the moment they act |
| **Confirmed** (pops out with a small satisfying animation into the completed bucket — the "hole") | workflow reached `$done` since their last visit |

That third row is load-bearing: a team verification step must never read as
"still your problem" to the client.

## 3. The team surface

A queue, not a project board: **things a team member must confirm or answer.**

- **Confirm loop:** client clicks "Granted" → item strikes through for them →
  team verifies ("Confirmed" → `$done`, and the client's next visit pops it
  into the hole) or bounces ("Not linked — back to client", a named jump that
  resets the client's action, with a comment explaining why).
- **Respond loop:** attention flags and Other messages surface here with the
  client's text; the reply lands on the item; resolving clears the flag so the
  client can act again.
- **Advance loop:** multi-step flows where the team acts in the middle ("we
  requested Google Ads access") — completing their step flips the item to the
  next client step ("Accept PlusROI's manager request", action "Accepted").
- Sorted: attention flags first, then oldest-in-state. Age chips (amber >3d,
  red >7d). Manager override "set current step" exists (audited) for the
  cases builders didn't foresee.

## 4. Managers — composing punchlists

- **From the library:** pick a template set ("Google marketing onboarding"),
  fill its variables once (`who_needs_access`), get the items. Attachable to a
  caddie project stage (via the tool contract) or standalone.
- **Quick one-off items:** a small form — headline, primary-action label, a
  couple of off-the-top alternatives, optional detail — creating an item with
  an **inline spec** (`spec_inline`), never entering the library. This is the
  "this client also runs Shopify" case: a manager becomes a builder for
  thirty seconds without ceremony.
- **Promotion:** a one-off that turns out to be worth keeping gets published
  into the library as a v1 workflow (the manager crossing into builder). The
  original item keeps its inline spec (pinning rule); future items use the
  template.

## 5. Builders — and why the backend is AI-first

Builders author **workflows**: the JSON spec in §6. The deliberate call:

- **v1 authoring is Claude + the validating API/CLI/seed files — not a UI.**
  Rian builds the first workflows in-session; the builder UI comes once the
  spec has survived real use.
- **Design for a future small local LLM builder:** one small schema, ONE
  canonical spec document (§6 — kept in the punchlist repo and always current),
  validation errors that say exactly what to fix ("step 'agency_verify'
  transitions to unknown step 'client_accpet' — did you mean
  'client_accept'?"), and a dry-run validate endpoint. A modest model with
  exact context beats a big model with none; where it fails, that gap is the
  next tool rian+Claude build.
- The eventual builder UI is assembled from the same API — the UI is never the
  only author.

## 6. The workflow spec (one jsonb document, Pydantic `WorkflowSpec`)

```jsonc
{
  "key": "google_ads_access",
  "title": "Google Ads access",
  "variables": {
    "who_needs_access": { "type": "email_list", "label": "Team emails needing access", "required": true }
  },
  "start": "submit_customer_id",
  "steps": [
    {
      "id": "submit_customer_id",
      "owner": "client",                        // "client" | "team"
      "headline": "Send us your Google Ads customer ID",
      "detail": "Your customer ID lets us link your account…",   // the expand; optional
      "tutorial": "asset://ads-customer-id.png",                 // inside the expand; optional
      "fields": [
        { "key": "customer_id", "type": "text", "label": "Customer ID",
          "required": true, "pattern": "^\\d{3}-\\d{3}-\\d{4}$" }
      ],
      "primary": { "label": "Submit ID", "to": "team_request" }, // fields must validate
      "alternatives": [
        { "key": "later",   "label": "I'll do it later",  "kind": "flag", "tone": "ack" },
        { "key": "trouble", "label": "Having trouble",    "kind": "flag", "tone": "attention" }
      ]                                                          // "Other…" is implicit on every client step
    },
    {
      "id": "grant_admins",                      // (illustrating targets — really from the GA4 seed)
      "owner": "client",
      "headline": "Grant GA4 admin access to {{who_needs_access}}",
      "targets": "who_needs_access",             // primary renders per entry; step completes when all acted
      "primary": { "label": "Granted", "to": "team_verify" },
      "alternatives": [
        { "key": "no_admin_option", "label": "I don't have an option to add admins",
          "kind": "jump", "to": "check_property_access" }        // conditional unfolding
      ]
    }
  ]
}
```

Engine invariants (rules, not suggestions):
- One current-step pointer per item; transitions are `to: "<step_id>" | "$done"`.
  Linear with named jumps — jumps go forward OR back; no parallel branches.
- The degenerate workflow — one step, no fields, primary `{"label": "Done",
  "to": "$done"}` — IS a checkbox, and is what manager quick-adds generate.
- Validation rejects: unknown `to`, undeclared `{{var}}` or `targets`,
  unreachable steps, no path to `$done`, a `targets` variable that is not an
  email_list/list type. Errors name the step and suggest near-matches.
- Published specs are immutable (code guard + DB trigger); editing = new
  version; items pin the version they started on, forever.

## 7. Runtime model (Postgres + Alembic; kit store in managed mode per PACKS.md)

```
workflow_templates  id PK · key · version int · title · spec jsonb
                    status enum(draft,published,retired) · created_by · created_at
                    UNIQUE(key,version); published rows never UPDATEd (trigger-guarded)

template_sets       id PK · key · version int · title · variables jsonb
                    items jsonb [{template_key, pin_version|null}]
                    status enum(draft,published,retired) · UNIQUE(key,version)

punchlists          id PK · title · client_label text · client_party_id uuid NULL
                    external_ref text NULL       -- caddie assignment_id when attached
                    state enum(active,archived) · created_by · created_at
                    -- the kit INSTANCE (noun "punchlist"); grants live in kit tables

set_runs            id PK · punchlist_id FK · set_template_id FK · variables jsonb · created_by · created_at

items               id PK · punchlist_id FK · position int
                    template_id FK NULL XOR spec_inline jsonb NULL   -- library vs one-off (CHECK constraint)
                    set_run_id FK NULL · title text · variables jsonb
                    current_step_id text NULL     -- NULL + done ⇔ "$done"
                    status enum(waiting_on_client,waiting_on_team,needs_attention,done)  -- derived cache
                    flag text NULL ("later"|"trouble"|"other"|…) · created_at · updated_at

item_events         id PK · item_id FK · seq int UNIQUE(item_id,seq) · created_at
                    actor_username · actor_kind enum(client,team,system)
                    action_key · step_id · from_step · to_step NULL
                    target text NULL              -- which per-target entry this act was for
                    field_values jsonb NULL · message text NULL   -- flag/Other/reply text (M1 embryo, see below)
                    -- APPEND-ONLY; item.status/current_step are caches rebuildable by replay

punchlist_seen      (punchlist_id, username) PK · last_seen_at   -- drives the confirmed pop-out
```

- **Per-target completion:** with `targets`, each primary act records its
  `target`; the step's transition fires when every resolved target has one.
- **Status derivation:** `done` at `$done`; `needs_attention` while an
  attention flag is open; else `waiting_on_{owner of current step}`. The
  client-perception mapping in §2 renders on top of this.
- **Interaction Standard note (sequencing-driven):** punchlist builds BEFORE
  caddie M1, so there is no interaction kit to vendor yet. M1 therefore ships
  the embryo — flag/reply text on `item_events.message` and a lightweight
  unread cue — and **M2 replaces it with the real vendored kit** (threads,
  @mentions, the bell), which gets EXTRACTED from what we build here into
  caddie's standard. That extraction is one of this build's deliverables.

## 8. Seeds (ship exactly these, in §6 format)

- **`google_ads_access`** — submit customer ID (fields) → team requests from
  MCC → client accepts ("Accept PlusROI's manager request", action
  "Accepted") → team verifies (jump-back "Not linked"). Alternatives: later /
  trouble.
- **`ga4_admin_access`** — grant admins (**targets** = `who_needs_access`,
  per-email "Granted" buttons) → team verifies (jump-back "Someone's
  missing"). Alternatives: later / trouble / **"I don't have an option to add
  admins"** → jump to `check_property_access` (property-level fallback step) —
  the conditional-unfolding proof case.
- Set: `google_marketing_onboarding` = [ga4, ads, search_console,
  google_business, gtm] sharing `who_needs_access` (last three written in M2,
  same shapes).

Seeds live in `main/app/seeds/workflows/*.json`, loaded idempotently
(insert-if-absent by key+version). Tutorial screenshots come from the
bw-guide-shot capture pipeline.

## 9. Caddie seams (handoff mode; contract §related)

Same as the reviewed contract: `punchlists.external_ref` = assignment id;
provision creates an empty punchlist (reports `not_started` — never `done`);
the drop-ins (`caddie_tool.py`, `caddieHost.ts`) arrive from caddie M2;
`post_status()` fires synchronously on client-visible mutations from the same
hook that recomputes item status. Rollup mapping: zero items → `not_started`;
any waiting_on_client → `waiting_client`; any needs_attention →
`needs_attention`; all done (≥1) → `done`; else `waiting_agency`. Counts:
`client_open` / `agency_open` (team + attention).

## 10. Milestones

- **M1 — the engine runs real items.** Scaffold (done) + Postgres sidecar +
  managed kit store + schema §7 + `WorkflowSpec` validation with pointed
  errors + event-sourced action endpoint + both §8 seeds + punchlist create +
  single-item instantiation + the client view (item anatomy §2 complete:
  per-target buttons, alternatives incl. Other, expand + tutorial, struck-
  through waiting-on-us, confirmed pop-out) + the team queue (§3 confirm /
  respond / advance). **Gate:** rian + a test client walk BOTH seeds on
  phones — including one per-target GA4 grant, one "Having trouble" round
  trip, one "I don't have an option to add admins" conditional unfold, and a
  team jump-back — with the event timeline showing every hop.
- **M2 — managers + the library loop.** Sets + fill-once instantiation; kit
  invites (`notify_added` for existing accounts); quick one-off items
  (inline specs) + promotion to library; the remaining three Google seeds;
  the validate/authoring API (dry-run endpoint + CLI) documented for AI
  builders; interaction kit extraction (threads/@mentions/bell replace the
  M1 embryo). **Gate (standalone):** fresh throwaway email → invite → land on
  their punchlist → complete a per-target step; a manager spins up the
  5-workflow set with one variable fill AND drops in a one-off Shopify item
  in under a minute.
- **M3 — the caddie contract** (needs caddie M2's drop-ins): vendor, wire
  provision/report/revoke + participant convergence, register, joint
  bootstrap dry run through a caddie project stage; silent-SSO measurement on
  iOS Safari + Android Chrome. Conformance checklist green.

## 10a. Organization tools (design intent — simplify, never clutter)

The team Queue's eventual shape is **review's table idiom** (sortable columns,
column filters, stat chips) — rian's named reference. The client list never
becomes a table; the queue may, once real volume justifies it.


Rian's rule: filters, search, tags, priorities are all candidates, but every
addition must SIMPLIFY a real list. Shipped now: **sections** (an optional
string per item, set-supplied, rendered as quiet uppercase headers that group
without re-sorting — the email's Billing/Design/Decisions/Access shape).
Designed but deliberately unbuilt until a real list hurts without them:
tags (+filter chips), search (only once lists exceed a screen), priority
(ordering already carries it; an explicit field only if cross-list views need
it). Each lands behind the same test: does it remove clutter from the Keith
list, or add some?

## 11. Non-goals (v1)

Parallel steps · due dates/scheduling · outbound delivery (bell ships with the
kit in M2; email/chat is suite-wide later) · builder UI (AI + API first) ·
client/org registry (`client_party_id` only; `with` is system-of-record;
future datapoint↔with linking noted in caddie's plan) · template
upgrade-in-place · embed mode.
