---
type: plan
title: Ticket — plan
slug: ticket-plan
status: draft
owner: rian
created: 2026-09-02
related:
  - /srv/apps/ticket/.logs/planning/02-caddie-feedback.md
  - /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/caddie-ui-standard-proposal.md
  - /srv/apps/with/docs/SATELLITE-CONTRACT.md
  - /srv/system/id-auth/app-auth/QUICKSTART-BW-APP.md
summary: >
  Ticket is an email-first ticketing system for client work: tickets, replies,
  status, and time — where most tickets are born from emails rian forwards in,
  an AI decides which project they belong to and whether they continue an
  existing ticket, and the reading/writing effort of every email is credited
  to the ticket as time. Built as a caddie drop-in on the standard stack,
  seams-now/drop-ins-later like punchlist and easel, with a running feedback
  log to caddie. Idea-phase plan: decisions are proposals until rian confirms.
---

# Ticket — plan

> **Status:** idea phase. This plan turns rian's brief (2026-09-02) into a
> domain model, an email pipeline, a time model, a caddie fit, and milestones
> with gates. Every decision in §10 is a **proposal** until rian says so; §13
> lists what only he can answer. Nothing in `main/` beyond the scaffold exists.

## 1. What it is

A ticketing system at `ticket.bowden.works` for agency ↔ client work: create
tickets, reply, track status, log time. Its distinguishing property is that
**most tickets never get created by a client** — clients email rian, so the
system is built around **email forwarded in**, with AI doing the filing:

| Work enters by | When | How it is filed |
|---|---|---|
| **Email forwarded to the app** | v1 (the main path) | AI triage: which project, new ticket vs. progress on an existing one, then a time credit for reading it |
| **The UI** | v1 | rian/staff create and reply directly; clients later, as external BW accounts |
| **Client replies to app-sent email** | M3 | a per-ticket reply-to token routes the reply straight onto the ticket — no forwarding needed once a thread has been through the app once |
| **A Gmail add-on** | later | attach a Gmail thread to a ticket from inside Gmail, replacing forwarding |

It is a **caddie drop-in**: a sovereign standalone BW app that speaks the Tool
Contract and the Interaction Standard, and it will eventually **port its time
data into `with`** (nothing to do there now beyond keeping the identifiers
right — §8).

## 2. People and surfaces

- **rian / agency staff** — app-wide levels (`admin`, `member`), seeded with
  `all_instances=True` so staff see every project. They work the **queue**
  (all open tickets across projects), the **project page** (its tickets and
  time), the **ticket** (one conversation), and the **triage inbox** (AI
  decisions that need a human).
- **Clients** — external BW accounts granted per project (caddie D7). Not in
  v1's first two milestones; they arrive in M3 with the reply loop. When they
  do, they see their project's tickets, can open one and reply, and see time
  only if rian turns that on per project.
- **The AI** — a triage actor, not a user. It never holds a session; it
  produces one structured decision per inbound email, every decision is logged
  and reversible, and it never has side effects outside the triage schema.
- **External email participants** — people on the email who have no BW
  account (the usual client). They appear as authors of messages, resolved
  through the project's contacts, and get nothing back from the app until M3.

## 3. Domain model (Postgres, SQLAlchemy 2.0, Alembic)

Conventions from caddie D5: no ticket FK ever points at a kit table; the kit
instance id is a plain string; actor columns are username strings with
display-name snapshots; state changes land in append-only event tables.

```
project        id · kit_instance_id text (the kit "project", unique)
               name_hint text · client_party_id uuid NULL · with_project_id uuid NULL
               email_domains text[] (hint for routing) · kind ('engagement'|'support')
               external_ref text NULL (caddie assignment seam) · created_at

contact        id · project_id FK · email citext · display_name · source ('manual'|'learned'|'confirmed')
               confidence real · first_seen · last_seen · UNIQUE(project_id, email)
               -- the sender roster the router consults; corrections write here

ticket         id · number int (TKT-123, per app, monotonic) · project_id FK
               title · status ('new'|'open'|'waiting_client'|'waiting_us'|'resolved'|'closed')
               priority ('low'|'normal'|'high') · source ('email'|'ui'|'api')
               opened_by text (actor: username or 'email:<addr>') · assignee text NULL
               thread_id (the Interaction Standard thread for this ticket, 1:1)
               reply_token text UNIQUE (M3: the secret in tickets+<token>@)
               last_message_at · closed_at NULL · created_at

ticket_event   id · ticket_id · kind ('status'|'assign'|'priority'|'ai_filed'|'ai_corrected'|'merged')
               actor · payload jsonb · occurred_at            -- append-only

-- the conversation: the Interaction Standard's thread/comment/attachment tables
-- (vendored from caddie's services/interaction.py), one thread per ticket
-- (subject_type='ticket', subject_id=ticket.id). An email becomes a comment.

message_meta   comment_id PK · direction ('in'|'out'|'note')
               author_email · author_name · from_contact_id NULL
               message_id text · in_reply_to text · references text[]
               sent_at · received_at · forwarded_by text NULL
               raw_ref text (data/mail/<uuid>.eml) · fingerprint text UNIQUE
               parse jsonb (what the parser extracted, for re-parse later)
               triage_id NULL

triage         id · inbound_id · decision jsonb (the schema in §4.5) · model · confidence
               route ('token'|'thread'|'contact'|'ai') · outcome ('applied'|'queued'|'corrected'|'rejected')
               corrected_by · corrected_to jsonb · latency_ms · tokens_in/out · created_at

inbound        id · received_at · envelope jsonb · auth jsonb (spf/dkim/dmarc results)
               forwarder text · status ('accepted'|'quarantined'|'rejected') · reason
               raw_ref text · UNIQUE on a raw-content hash   -- every hit on the webhook, good or bad

time_entry     id · ticket_id · project_id (denormalised for reports) · actor
               minutes int · kind ('manual'|'timer'|'email_read'|'email_write')
               source_comment_id NULL · estimate jsonb NULL (inputs + constants version)
               note · started_at NULL · ended_at NULL · billable bool · created_at
               edited_from_minutes int NULL   -- calibration signal (§5.4)
```

`inbound` and `ticket_event` and `triage` are append-only. `time_entry` edits
keep the prior value in `edited_from_minutes` rather than rewriting history.

## 4. Email in — the pipeline

```
mail → receiving layer → POST /api/inbound/email (HMAC) → inbound row + .eml on disk
     → auth checks (forwarder allow-list, SPF/DKIM) → parse (split forwards, strip
       quotes, attachments) → fingerprint/dedupe → route (§4.4) → ticket + comment
       + message_meta → time credit (§5) → bell/turn notification
```

### 4.1 Getting mail to the app

`bowden.works` has **no MX records** (checked 2026-09-02; `demoing.info` and
`rian.ca` are Google Workspace). So an inbound address on `bowden.works`
conflicts with nothing. Two candidate receiving layers:

| Option | How | Pros | Cons |
|---|---|---|---|
| **A. Cloudflare Email Routing → Email Worker → HTTPS** (recommended) | `tickets@bowden.works` routed to a ~40-line Worker that POSTs the raw MIME to the app with an HMAC header | free; zone already on Cloudflare; the mail touches no third vendor; Cloudflare verifies SPF/DKIM/DMARC on receipt | one small Worker to own; needs the Email Routing + Workers scopes rian sets up in the dashboard |
| **B. Resend inbound** | MX on `in.bowden.works` (or the apex) → Resend receives → webhook `email.received` → app fetches the message | same vendor the gateway already sends through; no code outside the app | inbound mail stored at a third party; webhook + fetch is two hops |

Either is a DNS + network change → **security review and rian's explicit
approval before configuring** (server rule). The app side is identical for
both: one webhook, one secret, raw MIME in.

**Rejected for v1: reading rian's Gmail mailbox** (Gmail API with a stored
OAuth token). It would make forwarding unnecessary and preserve every header,
but it puts a read-everything credential to rian's mail on a box that has been
breached once. Forwarding exposes only what rian chooses to forward. The Gmail
**add-on** (later) gets the same benefit without the standing credential: it
runs in Gmail and pushes one thread to the app's API on rian's click.

### 4.2 Trust — who may feed the pipeline

Anyone who learns the address could inject tickets and time. Four checks, all
required, each logged on the `inbound` row:

1. **HMAC on the webhook** (shared secret in `.app.env`, timestamp + nonce like
   the Tool Contract's S2S headers) — proves the receiving layer sent it.
2. **Forwarder allow-list** — v1 accepts only mail whose authenticated `From`
   is one of rian's addresses (config). M3 adds the per-ticket reply-token path
   for clients.
3. **SPF/DKIM/DMARC pass** on the incoming message — from the receiving
   layer's `Authentication-Results`, re-checked in-app on the raw MIME where
   possible. The `From` header alone is never trusted.
4. **A secret alias** — the address is `tickets+<12 random chars>@bowden.works`
   (plus-addressing), so the bare `tickets@` is a dead letter. Rotating the
   alias is a config change.

A message failing any check is **quarantined** (kept as raw, visible to admin,
never parsed into a ticket, never triaged by the AI).

### 4.3 Parsing a forwarded email

Forwarding mangles mail; the parser expects it:

- **Inline forward** (Gmail default): the original's `Message-ID` is gone; the
  body carries a `---------- Forwarded message ---------` block with
  `From / Date / Subject / To / Cc` lines. Parse that block to recover the
  original sender and date; the forwarder becomes `forwarded_by`.
- **Forward as attachment** (Gmail: ⋮ → *Forward as attachment*): the original
  `.eml` arrives intact with all headers. **Preferred** — ask rian to use it
  when convenient; both forms work.
- **Whole-thread forwards**: split on the forwarded-message separators; each
  original becomes its own message on the ticket, in date order, deduped.
- **Quote stripping**: only the **new content** of each message counts (for
  reading and for the AI); quoted history and signatures are stripped with a
  reply-parser library plus the split above. The full text stays in the `.eml`.
- **Attachments**: the Interaction Standard addendum's hostile-surface rules —
  allow-listed types (images, PDF, office docs; no scripts/HTML/SVG), sniffed
  type, size cap, server-generated names under `data/uploads/`, served only
  through a route that re-checks ticket visibility, `nosniff`.
- **Fingerprint**: `sha256(normalised(from, date, subject, first 400 chars of
  new content))` → a message forwarded twice, or present both as a forward and
  as quoted history, is stored and credited **once**.
- **Raw kept**: every accepted email is saved as `data/mail/<uuid>.eml` so the
  parser can be improved and re-run later (`parse` jsonb records what version
  extracted what).

### 4.4 Routing — deterministic first, AI second

Cheap, exact signals win before the model is asked:

1. **Ticket token in the subject** (`[TKT-123]`, present once the app has sent
   anything about the ticket) → that ticket. Exact.
2. **Threading headers** (`In-Reply-To` / `References` matching a stored
   `message_id`) → that ticket. Exact; only available on attachment-forwards
   and M3 direct replies.
3. **Known contact** — every original sender/cc resolved against `contact`;
   if all resolve to **one** project, that project is fixed (the AI still
   decides new-vs-existing ticket within it).
4. **The AI** (§4.5) decides whatever is still open: the project (from the
   roster + email domains + content), new ticket vs. progress on an existing
   one, a title, a summary, and a complexity rating.
5. **Confidence gate**: below the threshold (config, start 0.8), or when the
   AI says `needs_review`, the email lands in the **triage inbox** with the
   AI's suggestion pre-filled — one click to accept, a picker to correct.

### 4.5 The AI triage call

- **Model:** `claude-opus-5` with adaptive thinking (the default for anything
  judgement-shaped; measured later against a cheaper setting if volume
  warrants — rian's call, not a silent downgrade). Server-side refusal
  fallbacks enabled per the API guidance.
- **Shape:** `client.messages.parse(...)` with a Pydantic output model — the
  decision is **schema-constrained**, never free text:

  ```
  TriageDecision:
    project_id: str | None          # from the candidate list only
    action: 'new_ticket' | 'append_to_ticket' | 'needs_review'
    ticket_id: str | None           # from the candidate list only
    confidence: float               # 0..1
    title: str                      # for a new ticket
    summary: str                    # one paragraph, what is being asked
    comprehension_complexity: 1..5  # feeds the time estimator (§5.3)
    reasoning: str                  # shown in the triage inbox, logged
  ```
- **Context given:** the system prompt (stable, cached); the project roster
  (name hints, email domains, known contacts — volatile, after the cache
  breakpoint); the candidate open tickets for the candidate projects (number,
  title, last-message summary, last activity); then the email's **new
  content**, headers, and attachment names, wrapped as data.
- **Prompt-injection stance:** the email is untrusted input to the model. It
  is presented as data inside delimiters, the schema is the only channel out,
  there are **no tools** in this call, and the app validates every id in the
  decision against the candidate lists before acting. An email that says
  "close all tickets" can at most produce a wrong `action`, which the
  confidence gate and the review inbox catch, and the log makes reversible.
- **Cost (Opus 5, list price):** roughly 4–10K input tokens and ~400 output
  tokens per email → about $0.03–0.06 per email; at 20 forwarded emails a day
  ≈ $1/day. Prompt caching on the system prompt + roster cuts the input side.
  Usage is recorded per triage (`tokens_in/out`) so this stays measured.
- **Key storage:** `ANTHROPIC_API_KEY` in `.app.env` (660, group-readable by
  the gateway), staged as a `REPLACE_WITH_` placeholder that rian fills; never
  in chat, compose, or logs. Ticket is the first app on this box calling
  Claude — logged as a substrate note in `.logs/v8-feedback.md`.

### 4.6 The review inbox and the learning loop

Every AI decision is a `triage` row with `outcome`. The **triage inbox** lists
`queued` ones (and, for the first weeks, a "recently auto-filed" tab so rian
can spot-check). A correction:

- moves the comment(s) to the right ticket/project (`ticket_event: ai_corrected`
  on both; the Interaction kit's move-thread pattern from easel v0.42);
- writes/upgrades `contact` rows (`source='confirmed'`) for the senders → the
  deterministic router wins next time;
- records `corrected_to` on the triage row, which is the accuracy dataset.

**Accuracy is a first-class number** shown on the admin page: auto-filed vs.
corrected, per route. The gate for trusting auto-file without the spot-check
tab is measured, not felt.

## 5. Time

### 5.1 Entries

A `time_entry` is minutes against a ticket with a `kind`. Manual entries and a
simple start/stop timer are the UI path. `email_read` and `email_write`
entries are created by the pipeline, one per credited message, linked to the
comment (`source_comment_id`) so the ticket shows *why* the time exists.
Estimates are always **labelled "est."** in the UI and exports.

### 5.2 What counts

- **Inbound messages** (author ≠ rian/staff): reading + comprehension time.
- **rian's own replies** inside a forwarded thread (author ∈ staff addresses):
  writing time. rian said forwarded emails count; his replies are the other
  half of that effort. Config flag, on by default.
- **Never twice:** the fingerprint rule (§4.3) means a message seen again is
  not re-credited.

### 5.3 The reading-time estimator (all constants are config, versioned)

```
minutes_read  = overhead_read                                   # 1.5 — open, orient, decide
              + words_new / (base_wpm × complexity_factor)      # base_wpm 200: work email is read for comprehension, not skimmed
              + attachments × attachment_minutes                # 2.0 per document, 1.0 per image
              + links × link_minutes                            # 0.25
              clamped to [1, 30], rounded up to the minute

complexity_factor = grade_factor(FK grade: ≤8 → 1.0 · 9–12 → 0.85 · ≥13 → 0.7)
                  × density_factor(URLs/code/numbers/paths per 100 words: 0 → 1.0 … heavy → 0.6)
                  × ai_factor(comprehension_complexity 1..5 → 1.0, 0.9, 0.8, 0.7, 0.6)

minutes_write = overhead_write + words_new / compose_wpm       # 2.0 + words/25, clamped [1, 45]
```

`words_new` is the stripped new content only. The AI's
`comprehension_complexity` is a free input (it already read the email); the
deterministic factors keep working when the AI is skipped (§4.4 routes 1–3
skip the model — a cheap follow-up call for complexity only is an option, or
grade + density alone).

### 5.4 Calibration

Every estimate stores its inputs and the constants version in
`estimate` jsonb. When rian edits an estimated entry, the original stays in
`edited_from_minutes`. After a few weeks, `tools/calibrate.py` (M2 optional)
reports actual/estimated ratios by factor and proposes new constants. The
model is honest about what it is: an estimate rian can defend, tuned on his
own corrections.

## 6. Closing the loop (M3)

- **Reply from the app, by email.** A reply typed in the ticket goes out via
  the gateway's Resend path (the same key that sends auth email; a
  per-app policy like `ID_APP_INVITE_DAILY_CAP`), `From: Bowden Works
  <tickets@bowden.works>`, `Reply-To: tickets+<reply_token>@bowden.works`,
  subject carrying `[TKT-123]`. The client replies normally; the reply arrives
  through §4 with the token as an exact route — **no forwarding for any thread
  the app has touched once**. Requires the sending domain verified in Resend
  (SPF/DKIM) — likely already true for `auth@bowden.works`; confirm.
- **Clients in the UI.** External BW accounts granted per project (kit invites,
  caddie D7); a client sees their tickets, replies in the thread, gets the bell.
  Time visibility per project is a switch, default off.
- **Outbound notifications** stay the Interaction Standard's seam: bell now,
  digest email later, through whatever the inbox service becomes.

## 7. Caddie fit

Per the runbook (`05 §1`): a caddie app is a sovereign standalone BW app that
speaks two standards. Both drop-ins are still unbuilt (verified 2026-09-02:
no `caddie_tool.py`, `caddieHost.ts`, or `bw_interaction.py` anywhere under
`/srv/apps` or `/srv/system`). So, like punchlist and easel: **seams now,
drop-ins later**.

### 7.1 Adopted from day one

- **Stack (05 §3):** Postgres 17 sidecar on an internal network with no
  published port, SQLAlchemy 2.0 + Alembic, the kit store in
  `bw_store_sqlalchemy` managed mode (PACKS.md), sync `def` CRUD, OpenAPI-
  generated TS client + TanStack Query, `/api/meta` + newest-first CHANGELOG,
  phone-width floor. `app_conftest.py` with the `__all__` fixture trap handled.
- **The `caddie-ui` pack** (canonical copy in easel until caddie stamps it):
  two-row shell, the one side panel, the Bell, `ThreadCard/ThreadView/
  Composer`, `Body` render rule — vendored byte-for-byte, never hand-edited.
  A ticket page is: app bar (ticket switcher, status, assignee) + the
  conversation in the panel's Discussion tab + a Time tab.
- **The Interaction embryo:** caddie's `services/interaction.py` +
  `attachments.py` copied as-is (the extraction source the standard names),
  behind a thin internal interface so the swap to `bw_interaction` is a
  substitution. Threads: one per ticket; mentions server-side; `turn`
  notifications when a ticket flips to `waiting_us`/`waiting_client`.
- **Seams:** `project.external_ref`, the six-state rollup function (§7.2), a
  `caddie_links`-shaped table, `client_party_id`/`with_project_id` columns.

### 7.2 Status rollup (contract §5, mapped on paper before adoption)

| Ticket project state | Contract state |
|---|---|
| no tickets ever | `not_started` |
| any open ticket `waiting_client` and none `waiting_us`/`new` | `waiting_client` |
| any ticket `new` or `waiting_us` | `waiting_agency` |
| any ticket in the triage inbox older than 24h, or a quarantined inbound | `needs_attention` |
| open tickets, all `open` and assigned | `in_progress` |
| ≥1 ticket ever and none open | `done` |

`summary` = "3 open · 1 waiting on you"; `counts` = `{client_open, agency_open}`.
An ongoing support project reads as `done` between tickets — see feedback
entry F4; the summary line carries the truth.

### 7.3 What waits for caddie M2

Vendor `caddie_tool.py` + `caddieHost.ts`, mount the host bar into
`GlobalBar`'s locator slot (easel's proposal §3.3), call `post_status()` at
the ticket mutation points, register the tool, run the §10 conformance
checklist. Budget: half a day of glue, as the contract promises.

### 7.4 Tracking caddie's evolution

Caddie is a moving target and rian wants the friction recorded. Two
mechanisms, both in this workspace:

- **`tools/caddie-watch.sh`** — hashes the caddie standards (the four planning
  docs, the runbook, easel's proposal, the `caddie-ui` pack, the interaction
  embryo, the BW-app standard docs) against
  `.logs/planning/caddie-snapshot.sha256` and prints what changed. **Run it at
  the start of every session**; read the diffs; update the status table in the
  feedback log; `--update` after.
- **`.logs/planning/02-caddie-feedback.md`** — the running list: every place
  ticket's needs stress a caddie standard, what we did about it, and the
  proposal back to caddie. Seeded from this review; append as hit. It is the
  input to the next caddie session, the way easel's proposal was.

## 8. `with`, and the future port

Nothing to build now. What must be right from day one so the port is a
mapping, not an archaeology dig:

- `project.client_party_id` (party = person or organisation; never a role;
  the name only as a refreshable hint) and `project.with_project_id` nullable
  — exactly the SATELLITE-CONTRACT rule. Resolving via the registry API is
  M3+; storing the column is M0.
- `time_entry` carries `project_id`, `actor`, `kind`, `billable`, and the
  estimate provenance. `with` owns rates and invoicing; ticket never stores a
  rate. The export shape (`GET /api/projects/{id}/time?from&to`, CSV + JSON)
  is the port's input.
- If tickets themselves move into `with` later, they carry their thread and
  message_meta; the raw `.eml` files are the forensic backup either way.

## 9. Security posture

- **Host is Pattern B — never gated.** Hide pre-launch with
  `srv-gw id-site-set --host ticket.bowden.works --lan-only true`.
- **Inbound email is hostile input** at every layer: the webhook (HMAC +
  timestamp + nonce), the sender (allow-list + SPF/DKIM), the parser (size
  caps, bounded recursion on nested forwards, attachments per the addendum),
  the renderer (the pack's `Body`, own-URL images only, never innerHTML), and
  the model (data-in-delimiters, schema-out, no tools, ids validated).
- **Secrets** in `.app.env` (660): `APP_SESSION_SECRET`, `POSTGRES_PASSWORD`,
  `ANTHROPIC_API_KEY`, `INBOUND_WEBHOOK_SECRET`, `INBOUND_ALIAS`. Placeholders
  staged; rian fills; shape-checked with `grep -c REPLACE_WITH_`.
- **Database** unreachable outside the compose project (internal network, no
  port). Raw mail and uploads under the workspace `data/`, never in the image,
  never a static mount.
- **Default-deny** middleware from the kit; 404-never-403 on invisible tickets;
  clients see only their project's tickets; time hidden from clients unless
  switched on.
- **Container runs as root** — an inherited scaffold gap already logged by
  caddie; matters more here because the app accepts email + attachments.
  Tracked in `v8-feedback.md`; not fixable from this workspace.
- `srv-gw security-audit` after every milestone that touches the network path.

## 10. Decisions (proposals until rian confirms)

- **D1 — Instance noun is `project`**, and a project may be an engagement or an
  ongoing support relationship (`kind`). Most support email maps to a *client*
  more reliably than to a bounded project, so a client's support line is just
  a project of kind `support` with the same `client_party_id`. (Caddie D12
  keeps projects bounded; ticket needs both — feedback F6.)
- **D2 — One conversation per ticket, and it is the Interaction Standard's
  thread.** Emails are comments; external senders are namespaced actors
  (`email:<addr>`) with the human details in `contact`/`message_meta`. The
  bell, mentions, attachments, and the panel come for free; the kit's
  notifier only targets real usernames, so an external author never receives
  a bell (correct — they get email in M3). Rejected: a separate `messages`
  table (two conversation surfaces on one ticket). Proposal to caddie: F1.
- **D3 — Deterministic-first, AI-second routing; the AI is schema-bound.**
  No tool use in triage; every decision logged and correctable; corrections
  become contacts. Confidence threshold and spot-check tab until accuracy is
  measured.
- **D4 — Time credits are estimates, labelled, from new content only, inputs
  logged, constants versioned, corrections kept for calibration.** A message
  is credited once (fingerprint).
- **D5 — Forwarding is the v1 input; the reply-to token (M3) makes it
  unnecessary for threads the app has touched; the Gmail add-on replaces it
  later. Mailbox-level Gmail access is rejected** (credential blast radius).
- **D6 — Raw `.eml` kept for every accepted email**; parsers get better,
  history gets re-parsed, forensics stay possible.
- **D7 — Postgres from day one, managed kit store, Alembic-only on Postgres**
  (caddie D5 / runbook §3), including the `app_conftest` fixture.
- **D8 — Ticket numbers are app-global (`TKT-123`)**, not per project —
  they go in email subjects where a per-project counter would collide.
- **D9 — Clients arrive in M3, not M1.** rian-only until the email loop is
  proven; clients are external BW accounts per project when they come.
- **D10 — `with` gets identifiers, not integration.** Columns now; registry
  calls and the port later.

## 11. Milestones (working sessions, not calendar; each gate is a demo)

| # | Milestone | Gate |
|---|---|---|
| **M0** | **Stack + seams.** Postgres sidecar, SQLAlchemy/Alembic, managed store, `app_conftest`, CHANGELOG + `/api/meta`, `project` table replacing the sample instances, `caddie-ui` + interaction embryo vendored, `client_party_id`/`with_project_id`/`external_ref` columns, `tools/caddie-watch.sh` in use | conformance pack green (`new-bw-app.sh --check`); a project created + a member granted; the two-row shell and bell render; version visible at `/api/meta` |
| **M1** | **Tickets in the UI.** Queue, project page, ticket page with the panel conversation, status/priority/assignee, manual time + timer, time report per project/period (CSV+JSON) | rian works a real ticket end-to-end on a phone: create, reply with a screenshot, `@mention` lights the bell with a deep link, log time, see it on the project report |
| **M2** | **Email in.** Receiving layer chosen + secured (§4.1–4.2), webhook, `.eml` store, parser (inline + attachment + whole-thread forwards, quote stripping, dedupe), deterministic routes, AI triage with structured output, triage inbox, learning loop, reading/writing credits, accuracy + cost on the admin page | **20 real forwarded emails** from rian's inbox: measured filing accuracy (target ≥ 90% auto-filed correctly after the first ten corrections), zero double-credits, time estimates reviewed by rian and within reason, quarantine proven with a spoofed sender |
| **M3** | **Close the loop.** Reply-by-email via the gateway's Resend path with reply-to tokens and `[TKT-n]` subjects, direct client replies routed by token, client accounts per project, status rollup + `caddie_links` shape, `turn` notifications | one client thread runs a full round with **no forwarding**; a client signs in and replies in the UI; `srv-gw security-audit` clean |
| **M4** | **Caddie drop-ins** (blocked on caddie M2) | contract §10 conformance checklist green; launched from a caddie stage, host bar in the locator slot |
| later | Gmail add-on (Apps Script → app API with a per-user token); calibration tool; `with` registry resolution + the time port; digest delivery | — |

## 12. Sharpest risks and their early tests

- **R1 — The AI misfiles.** Test: M2's 20-email gate with a hand-scored
  answer key; the confidence gate + spot-check tab bound the damage; accuracy
  is a visible number before auto-file is trusted.
- **R2 — Forwarded-mail parsing is brittle** (client mail apps differ). Test:
  seed the parser fixtures from real forwards (Gmail inline, Gmail attachment,
  Outlook, Apple Mail); keep raw `.eml`s so a parser fix re-parses history;
  M3's reply-to token removes forwarding from the steady state.
- **R3 — Time estimates are challenged.** They are labelled, explainable
  (inputs shown on the entry), and correctable; calibration data accrues from
  day one.
- **R4 — Inbound address abuse.** Four checks (§4.2); a spoofed-sender test in
  the M2 gate; quarantine visible to admin.
- **R5 — Standards move under us.** `caddie-watch.sh` at session start; the
  interaction embryo and pack behind thin interfaces; nothing hand-edited in
  vendored files.

## 13. Open questions for rian

1. **Receiving layer:** Cloudflare Email Routing + Worker (recommended) or
   Resend inbound? Both need a dashboard step only you can do.
2. **Trusted forwarders:** which of your addresses forward mail in?
3. **Do your own replies count as time** (writing estimate), on by default?
4. **Project vs client:** is a per-client "support" project the right home for
   ongoing email, alongside bounded engagement projects (D1)?
5. **Clients in the UI at all in v1**, or is ticket rian-only until the `with`
   port (which would simplify M3 to reply-by-email only)?
6. **Time visible to clients** ever, or never (affects the M3 switch)?
7. **Model cost stance:** Opus 5 for every email (≈ $1/day at 20 emails), or
   measure a cheaper setting once accuracy is known?

## 14. Not doing (v1)

Invoicing, rates, budgets (`with`) · SLA/escalation engine · IMAP or Gmail
mailbox polling · a public no-login ticket portal · per-ticket mailboxes
beyond the reply-to token · embed mode · Telegram/Slack/WhatsApp delivery ·
AI-written replies (the AI files and summarises; it never speaks to a client)
· merging the queue with `with`'s comments before the port is designed.
