---
type: plan
title: The Caddie Tool Contract (ct1)
slug: caddie-tool-contract
status: active
owner: rian
created: 2026-08-19
related:
  - /srv/apps/caddie/.logs/planning/01-vision-and-roadmap.md
  - /srv/apps/caddie/.logs/planning/03-punchlist-plan.md
  - /srv/apps/caddie/.logs/planning/04-interaction-standard.md
summary: >
  How any standalone BW app becomes a caddie tool: registration, provisioning,
  the signed launch handoff, IdP-rooted identity, scoped auto-provisioning,
  signed status reporting with participant convergence, the host bar,
  acked revocation, and the optional embed mode. The contract lives in the
  tools, not the hub — a rebuilt caddie re-implements its side and every tool
  keeps working. Draft ct1, adversarially reviewed 2026-08-19; hardens into a
  vendored drop-in during caddie M2.
---

# The Caddie Tool Contract — version ct1 (draft)

> **This is one of two standards a caddie app implements.** This doc moves a
> user *into* a tool and status back *out*. Its sibling,
> `04-interaction-standard.md`, carries the conversation — comments, @mentions,
> attachments, and the one cross-app notification bell. They are independent:
> adopt either alone, though a tool in a client-facing project wants both.

## 0. Principles

1. **A tool is a sovereign standalone BW app** on its own `*.bowden.works`
   subdomain, fully usable without caddie. The contract adds a face; it never
   replaces the app.
2. **Identity always comes from the IdP** (`auth.bowden.works`), via the tool's
   own normal BW SSO. Caddie is never an identity authority; the launch
   token's `sub` is an authorization hint, not authentication.
3. **Trust is a conjunction.** The signed token proves *caddie authorized this
   user for this assignment* (the hub owns project access); the IdP session
   proves *who the browser is* (the IdP owns identity). Privileged effects
   (auto-provisioning) require both to agree — and to still agree *now*, not
   only at mint time.
4. **The browser is never a dependency for truth.** All state the hub renders
   arrives server-to-server, signed; browser-side signals are sugar. The
   symmetric rule: access removal must CONVERGE server-to-server even if every
   individual webhook is lost.
5. **Handoff (full-page link-out) is the mandatory core. Embed (§9) is an
   optional per-tool mode**, earned by a tool that genuinely needs to render
   inside the project page — never a fleet requirement.
6. **The tool owns all domain data**, keyed by `assignment_id`. Caddie's
   `config` is write-once and echoed at provision; a tool that would lose
   state if caddie's row vanished is violating the contract.

## 1. Registration (once per tool; owner-run, in two halves)

There is deliberately no self-serve path: the kit admin API is session-authed
(no CLI credential exists) and caddie's Postgres publishes no port, so
registration is split between a host script and the browser:

**Half 1 — the secret (host script, run as rian):**
`tools/register-tool.sh <slug> <base_url>` generates a 32-byte shared secret
and writes it to BOTH sides — caddie's `.tool-secrets.env` (0660, group
`caddie-dev`) as `CADDIE_TOOL_SECRET_<SLUG>=...` and the tool's
`/srv/apps/<tool>/.caddie-tool.env` (0660, group `<tool>-dev`) as
`CADDIE_TOOL_SECRET=...` + `CADDIE_HUB_ORIGIN=https://caddie.bowden.works`.
Cross-project file writes are why this must run as rian on the host. The
secret is **never printed, never in argv, never in chat.** Both compose files
must already reference their env file (`env_file:`); the script finishes with
`srv-gw restart --project caddie` and `srv-gw restart --project <tool>` —
env-file changes do nothing until both processes restart, and the script says
so out loud.

**Half 2 — the registry row (caddie admin UI, owner's browser):** slug,
base_url, `secret_ref` (the env-var NAME — no secret material ever enters the
DB), `capabilities` (`{"embed": false}` baseline), `contract_version`. The row
is inert until the env vars from half 1 exist.

**Rotation (no hard cut):** env files hold `CADDIE_TOOL_SECRET` and optional
`CADDIE_TOOL_SECRET_PREV`. Every S2S call carries `X-Caddie-Key: <kid>`
(kid = first 8 hex of SHA-256(secret)); receivers accept current or prev.
Re-running the script writes new-as-current, demotes old to prev, restarts
both; a later `--finish-rotation` run clears prev. No signature-verification
gap, so no session is ever tempted to "temporarily skip verification."

**Direction separation:** the drop-in derives two keys from the shared secret
— `HKDF(secret, info="caddie->tool")` and `HKDF(secret, info="tool->caddie")`
— so a component that only ever verifies one direction cannot forge the other.

**Stated risk:** 0660 group-readable means every developer granted on a tool's
project can read that tool's contract key and forge its reports. Acceptable
while rian is the only developer on tools; revisit before adding one.

## 2. Provisioning (assignment time, not launch time)

When the agency attaches a tool to a stage, caddie calls (signed per §5
headers):

```
POST {base_url}/caddie/api/assignments
{ "assignment_id": "<uuid>", "project_id": "<slug>", "project_name": "…",
  "config": { …frozen stage config… },
  "participants": ["clientuser", …],
  "contract": "ct1" }
```

The tool creates its own domain object (e.g. a punchlist board), records the
mapping in its app-owned `caddie_links` table (`assignment_id PK →
tool_instance_id`, plus the current `participants`), and returns
`{ "status": "ready", "external_ref": "<tool's own id>" }`. Idempotent on
`assignment_id`.

**`participants` is load-bearing**: it is caddie's authoritative list of who
may hold a launch-provisioned grant on this assignment. It gates
auto-provisioning (§4) and drives revocation convergence (§5, §7). Caddie
re-sends it on every reconcile; the tool diffs and drops launch-provisioned
grants for de-listed users.

A freshly provisioned, still-empty tool instance reports `not_started` —
never `done` (an empty board must not read as a finished stage).

**The project's journey rides along (added 2026-09-02).** Caddie also sends the
project's client-visible stage rail — `"stages": [{key, title, status,
position, started_at, closed_at}]` — on provision AND on every reconcile
(§5), so a tool can draw the project's journey (01 §4: a tool's landing page
is the project's journey, not the tool's inventory) without a live read of the
hub. The tool holds it as a **read-only mirror** in caddie's own shape and
never edits it; caddie seeds a new project's roadmap (01 §5.5 templates). Used
standalone, a tool seeds its own default roadmap in the same shape and drops
the seeding the day it is launched from caddie. Decided over a live read
because a page view that depends on the hub answering violates §0.6.

## 3. Launch

Caddie renders every tool stage CTA as a plain anchor:

```
GET https://caddie.bowden.works/go/{assignment_id}
```

Session-authed + project-read-authorized (default-deny middleware). It
instantly paints a minimal "Opening {tool} for {project}…" page (something on
screen within ~100ms — the SSO chain behind it can take seconds on a phone)
which redirects to `{base_url}/launch?lt=<token>`. `/go/` responses send
`Referrer-Policy: no-referrer`. Bookmarks and middle-click work forever
(fresh token each hit); a forwarded tool URL dies in 120 seconds.

**Launch token** — JWT, HS256 with the caddie→tool key:

| Claim | Value | Notes |
|---|---|---|
| `iss` / `aud` | `caddie` / tool slug | tool rejects a wrong `aud` |
| `ver` | `ct1` | contract version |
| `sub` | BW username of the clicking user | *intended* user — advisory |
| `jti` | UUID | single-use (burn rule below) |
| `iat` / `exp` | now / now + 120s | ±30s skew tolerated |
| `prj` / `asg` | project slug / assignment UUID | `asg` is the stable join key |
| `pnm` / `stg` | project display name / stage label | host bar renders before any API call |
| `ret` | `https://caddie.bowden.works/projects/{slug}?stage={id}` | validation rule below |

`ret` validation: **parse the URL; require scheme `https` AND host equal to
`caddie.bowden.works` exactly.** Never a `startswith` prefix check
(`caddie.bowden.works.evil.com` is the classic bypass). Bulky config is NOT in
the token — the tool holds it from §2, or re-fetches
`GET caddie /api/tool/assignments/{asg}` (signed).

**Token exposure, honestly:** `?lt=` transits the shared Caddy front door and
the tool's access log, and lands in browser history. The mitigations are
mandatory, not optional:
1. `/launch` **burns the `jti` first, before any other work** — burn = `INSERT
   INTO consumed_jti` under a unique constraint; violation ⇒ 401. No
   check-then-insert race, and a redeem that fails later still leaves the
   token dead.
2. `/launch` **never renders content on the `lt` URL** — it immediately 303s
   to a token-free URL (`…?cctx={ctx_id}`), so the token never appears in a
   Referer and a token harvested from history or logs is worthless the moment
   the 303 lands.
3. The 120s TTL bounds the live window for a real-time log reader; scrub `lt`
   from access-log query strings where the logging layer allows it.

(The embed mode carries its token in the URL fragment, which never reaches a
server log; handoff keeps the query string because a fragment needs a JS
bounce page on every tool — burn-first + 303 buys equivalent protection for
one request-log line.)

**Tool side** (`GET /launch`, all in the drop-in): verify sig/`aud`/`exp`/
`ver` → burn `jti` (rule above) → create a server-side `launch_ctx` row
(`ctx_id` = 128-bit random; asg, prj, pnm, stg, ret, sub; TTL 24h) → 303 →
identity (§4) → land on the linked instance `?cctx={ctx_id}`.
**`launch_ctx` is display context only** (it feeds the host bar). It carries
no authorization: provisioning eligibility is bounded by the token's own
`exp`, never by ctx lifetime (§4).

## 4. Identity and membership

Identity resolution on the tool, top-level navigation (the friendliest case
the web has — SameSite=Lax cookies always sent, no framing, no partitioning
heuristics):

1. Tool session cookie present → done.
2. Anonymous → standard auth-code + PKCE with `prompt=none`; `cctx` rides in
   OAuth `state`. Master IdP session present → silent sign-in, zero UI.
3. `login_required` (master session expired — even while the caddie session
   lives) → interactive IdP login as a normal top-level page; the user lands
   back mid-flow with ctx intact. One login screen, no dead ends.
4. `bw_optout` set (user once logged out) → skip `prompt=none`, go straight
   interactive: an explicit click is not a silent probe.

**Auto-provisioning** fires only when ALL hold:
- the launch token itself is still within `exp` (the redeem flow, not a later
  visit through a stale ctx — a sign-in hours later never provisions);
- `authenticated_username == sub`;
- `sub` is in the assignment's **current** `participants` — if the tool's
  local copy is older than 15 minutes, confirm first with one signed
  `GET caddie /api/tool/assignments/{asg}`. This closes the window where
  caddie revoked someone after minting a token.

**Mechanism** (the kit has no system-actor API — every mutation takes an
actor and enforces permissions): the drop-in calls the vendored kit's
functions with **`actor` = the app's configured owner** (`bw-app.toml
owner` — the kit's owner short-circuit is the de-facto system path):
add member at the tool's configured default level (e.g. `client`) + grant on
**exactly the linked instance**, marked as launch-provisioned in
`caddie_links`. Nothing app-wide, no other instances, idempotent. The kit's
audit line will show the owner as actor, so the drop-in writes its own audit
row carrying the launch `jti` alongside. (A first-class kit system actor is a
candidate substrate ask; until it exists, this is the documented mechanism —
never call the kit's private store directly.)

**Mismatch** (signed-in user ≠ `sub`): NO auto-provisioning. If the actual
user independently holds a grant on the linked instance, proceed as
themselves; else render **"This link was prepared for a different user."** —
it names nobody (naming `sub` would leak a username to whoever holds a
forwarded link) — with switch-account + Back to Caddie.

## 5. Status reporting (tool → caddie)

`POST https://caddie.bowden.works/api/tool/report`, with these headers on
**every S2S call in both directions** (provision, report, reconcile, revoke):

| Header | Value |
|---|---|
| `X-Caddie-Tool` | tool slug |
| `X-Caddie-Contract` | `ct1` |
| `X-Caddie-Key` | key id (rotation, §1) |
| `X-Caddie-Timestamp` | unix seconds; reject skew > 300s |
| `X-Caddie-Nonce` | UUID; replay cache **in the DB** (survives restarts), pruned past the skew window |
| `X-Caddie-Signature` | hex HMAC-SHA256(direction key, `ts + "\n" + nonce + "\n" + raw_body`) |

Body:

```json
{ "assignment_id": "<uuid>", "state": "waiting_client",
  "counts": { "client_open": 2, "agency_open": 1 },
  "summary": "3 of 12 items left", "actors_waiting": ["clientuser"],
  "seq": 17, "occurred_at": "2026-08-19T20:14:00Z" }
```

**The state enum — frozen at six values:**
`not_started | in_progress | waiting_client | waiting_agency | needs_attention | done`

Mapped on paper before freezing (the contract's riskiest guess, so it was
tested against every real lifecycle we have):

| Tool | Its lifecycle → contract state |
|---|---|
| punchlist | zero flows → `not_started`; any flow waiting_on_client → `waiting_client`; any needs_help → `needs_attention`; all flows done (≥1 flow) → `done`; else `waiting_agency` |
| scout | review round open for client raters → `waiting_client`; direction chosen → `done`; importing/preparing → `in_progress` |
| review (legacy) | "ready for review" → `waiting_client`; "revision needed" → `waiting_agency`; "approved" → `done` |
| easel | nothing ready to show → `not_started`; presented with nothing on it → `needs_attention`; ready but not yet presented → `in_progress`; presented and any open client thread, OR the client pressed "Feedback complete" → `waiting_agency`; every decidable screen chosen and every required point approved → `done`; else → `waiting_client` |

Rules:
- `seq` is per-assignment monotonic — caddie ignores stale seq, so delivery
  order never matters; the endpoint is idempotent.
- **The first push for a client-visible mutation is awaited synchronously
  inside the action request** (short timeout, then fall back to the retry
  queue). By the time the browser can navigate back to caddie, caddie already
  knows. The retry queue with backoff covers everything else.
- `summary` is one opaque human line; caddie never parses it. **Anything
  richer than this body stays behind the tool's own URL** — the moment caddie
  grows a per-tool column, the contract has failed.
- **A client-owned round marker stays in the tool** (decided 2026-09-02,
  against adding `client_closed_round_at` to this body). "Feedback complete"
  is the client saying the ball is with the agency: the tool records it, and
  reports `waiting_agency` with `actors_waiting` = the agency; presenting again
  reports `waiting_client` (a new round). A tool never infers "they are done"
  from silence. The body stays frozen — the six states already say whose turn
  it is, which is all the hub renders (D9, D14).

**Reconcile (self-healing + convergence):** caddie pulls
`GET {base_url}/caddie/api/assignments/{asg}/status` (signed) hourly, on
project-page view when the last push is older than 15 minutes, and **always
when the project page is loaded via a return URL (`?stage=` present)** — the
belt-and-suspenders against the "I just did that" race. The reconcile
exchange also carries caddie's current `participants`; the tool diffs and
drops launch-provisioned grants for anyone de-listed. A dropped webhook costs
minutes, never truth — for status AND for access.

## 6. The host bar (`caddieHost.ts`)

A vendored ~150-line framework-free TS module (mounts a div; works in React
and Next.js alike):

1. On boot: read `cctx` from the URL → `sessionStorage` → strip via
   `history.replaceState`.
2. `GET /caddie/api/context/{ctx}` — **requires the tool's own authenticated
   session** (never public: ctx ids transit URLs and logs) and returns exactly
   `{project_name, stage_label, return_url}`, nothing more.
3. Render **"Part of {project_name} on Caddie · ← Back to project"** — INTO
   the global row's locator slot when the tool wears the `caddie-ui` shell
   (`GlobalBar locator={…}`; amended 2026-09-02, it is what the slot is for),
   and only in a framework-free host as a fixed 40px bar of its own. Two bars
   at the top of a launched tool cost 40px on every screen for a line of text
   the global row already has room for.
4. **Durability:** after ctx expiry — or on a fresh direct visit — the bar
   falls back to the `caddie_links` table: if the current tool instance is
   linked to an assignment, derive "Part of {project}" from the link. The bar
   never depends on the launch surviving.
5. `caddieHost.returnToProject()` for the tool's own "Done" button →
   `location.assign(return_url)`. Because the completing push was synchronous
   (§5) and caddie force-reconciles on `?stage=` arrival, the project page
   renders the completed state on load — two independent mechanisms, no race.

Same-tab is the default (it's a flow; Back and the host bar both return);
middle-click/new-tab works because `/go/` mints per-request.

**A guided introduction crosses the same seam (added 2026-09-02).** A first
visit's walkthrough starts on the project page, hands into the tool, and
closes on the project page — so its opening ("welcome, where we are, what
this round is for") and its close ("your preference, when you are done") are
caddie's, and the tool draws only its own points. The hand-off is a URL
contract: `?tour=1` into the tool (`?intro=1` carried across the tool's own
versions), `?tour=done` back to `ret`. A tool never draws the project page's
points; caddie never draws the tool's. No popup gate on either side: a first
visit opens the introduction by itself, once, with Skip one press away.

## 7. Revocation — pending until acked, convergent regardless

When a user loses project access in caddie (grant removed, project archived):

1. Caddie records a **pending revocation** and calls
   `POST {base_url}/caddie/api/revoke {username, assignment_id}` (signed).
   The tool drops the launch-provisioned grant, kills that user's tool
   sessions, and acks. **Caddie retries with backoff until acked** — a revoke
   that fires during a tool deploy is delivered, not lost.
2. Independently, the `participants` diff on every reconcile (§5) converges
   the same result even across total webhook loss. A revoked client's access
   outlives the revocation by minutes at worst, never indefinitely.

Kit-level account deactivation is NOT assumed to propagate instantly into a
tool under the managed store (there is no per-request central recheck); it
propagates through this same channel — which is why the channel must converge.

## 8. Failure modes

| Failure | Behavior |
|---|---|
| Token expired / replayed (stale tab, forwarded URL) | Tool renders "Link expired" + one button → caddie `/go/{asg}` → fresh token. One tap. |
| Tool down | `/go/` health-checks `{base_url}/api/meta` (60s cache); if dead, caddie serves its OWN "temporarily unavailable" page — the client never sees a naked 502. Project page shows staleness from `last_report_at`. |
| Breaking tool deploy | `ver` / `X-Caddie-Contract` mismatch → tool 409s launches; caddie flags "needs re-registration" to the owner only; clients get the unavailable page. The vendored conformance tests pin `/launch` + `/caddie/api/*` across rebuilds. |
| User denied / deactivated / revoked | Tool's default-deny 403 → drop-in page "You no longer have access" + Back to Caddie. Revocation converges per §7. |
| Browser privacy settings | Top-level first-party navigation — the most permissive case there is. Total cookie blocking breaks all BW SSO equally; not a contract property. |

## 9. Embed mode (optional extension — earned per-tool, never required)

For a tool that proves it needs to render inside the project page. Adds, on
top of everything above (which stays authoritative):

- **Launch:** iframe `src = {base_url}/embed#lt=<token>` — the token rides the
  URL **fragment** (never hits server logs or Referer); embed JS reads it and
  redeems via `POST /api/embed/redeem` (same claims, same burn-first rule).
- **Identity in-frame:** the same-site fact (one registrable domain,
  SameSite=Lax) means the tool's session cookie works inside the frame and the
  silent probe can complete framed. `login_required` in-frame → a one-button
  card that opens interactive login in a **popup** (the kit's established
  `window.open` + origin-checked postMessage idiom) — never password entry
  inside an iframe.
- **postMessage bus** `{type:'caddie-embed', v:1, event, ...}` — strict origin
  checks both ways (tool accepts only `CADDIE_HUB_ORIGIN`; hub accepts only the
  registered tool origin AND `event.source === iframe.contentWindow`). Events:
  tool→hub `ready {height}`, `resize {height}`, `navigate {path}`,
  `status-changed`, `needs-auth`, `relaunch` (expired → hub mints fresh token,
  swaps fragment silently), `title {text}`; hub→tool `init {viewport, theme}`,
  `restore {path}`. The hub treats `navigate.path` as **opaque state to echo
  back in `restore`** — never as a hub navigation target (a compromised frame
  must not steer the hub).
- **CSP:** embed routes set `frame-ancestors 'self'
  https://caddie.bowden.works`; every other route `frame-ancestors 'self'`.
  Ships before the mode is enabled — audit-checked.
- **One scrollbar:** `scrolling=no`, height driven by `resize`; the hub page
  scrolls. Embed routes render chrome-less; `position:fixed` is banned inside
  the frame.
- **The cost, stated:** an embed-capable tool must be tested twice —
  standalone AND framed at 375px — forever. That is why the mode is earned.

## 10. Deliverables and conformance

- **`caddie_tool.py`** (~300 lines, vendored into each tool like the auth kit;
  canonical copy lives in the caddie workspace, stamped/refreshed by tooling,
  never hand-edited in a tool): `/launch` (burn-first + 303),
  `/caddie/api/context/{ctx}` (session-authed), `/caddie/api/assignments`
  (provision + status GET with participants), `/caddie/api/revoke` (+ack),
  `post_status()` (synchronous-first + queue), HKDF/HMAC/JWT helpers, one
  Alembic migration (`launch_ctx`, `consumed_jti`, `caddie_nonces`,
  `caddie_links`), provisioning glue into the vendored auth kit (owner-actor
  mechanism, §4). Ships with a **stub tool** — a minimal in-repo consumer the
  conformance suite (and caddie M2's gate) exercises end-to-end without a real
  tool. Config: `caddie-tool.toml` (`slug`, `default_level`).
- **`caddieHost.ts`** — §6: reads the ctx, fetches the context, and renders
  into the `caddie-ui` global row's locator (or its own bar in a
  framework-free host); `returnToProject()`; the `?tour=` hand-off.
- **Tool-side conformance checklist** (a tool is caddie-ready when): launch →
  SSO → landing works for a granted user; the `lt` URL never renders content
  (burn-first 303 verified); jti replay 401s; the mismatch page names nobody
  and provisions nothing; provisioning refuses a non-participant and a
  post-`exp` redeem; provision is idempotent; wipe-and-reprovision with the
  same `assignment_id` loses nothing (the tool owns its data); an empty
  instance reports `not_started`; the first push after a client action is
  synchronous; status pushes carry monotonic seq; reconcile GET answers and a
  participants diff drops grants; revoke acks, drops the grant, kills
  sessions; host bar mounts and falls back to `caddie_links`; every
  client-reachable page works at phone width.
- The tool's own work: call `post_status()` at its mutation points, mount the
  host-bar div, map its lifecycle onto the six states. Target: **half a day of
  glue for a new tool**; the scaffolder can stamp it later.

## 11. Versioning

`ct1` is pinned in the registry row, every token (`ver`), and every S2S call
(`X-Caddie-Contract`). A breaking change mints `ct2`; tools declare what they
speak; caddie refuses mismatches loudly (owner-facing), degrades gracefully
(client-facing). The contract graduates out of this planning dir into a
standards home once two tools speak it in production.
