docs(phase-9): spec for per-client intake links + project picker

Phase 9 design doc. Same format as Phase 8. Headline:

- intake_links.client replaces intake_links.project for new rows;
  old shape stays live
- projects.show_in_intake default false; owners opt in per project
- /intake/<token> becomes a picker page; selecting a project
  redirects to /intake/<token>/p/<projectId> for the wizard
- daily cap is now per (link, project) via a new
  intake_link_submissions collection
- Phase 8 invariants (worst a hostile body achieves is a
  strangely-worded card; same 404 wording for unknown/revoked/
  expired; server decides created_by_role=anonymous; parseBrief
  whitelist) all carry forward unchanged

Three plans in 9a/9b/9c. ~3 working days.
This commit is contained in:
Dave 2026-09-21 12:42:13 +02:00
parent b02ebb7cf6
commit 917736ebb0

View file

@ -0,0 +1,499 @@
# Phase 9 — Per-Client Intake Links + Project Picker
**Date:** 2026-09-21
**Status:** Draft for review
**Phase:** 9
**Depends on:** Phase 8 (public anonymous intake, PWA shell, per-project markdown context)
---
## 1. Purpose
Phase 8 made the public route work end-to-end. The next thing the
developer asked for after playing with it for an hour is the link shape
itself: **one URL per client to share, with a project picker in front of
the wizard**, instead of one URL per project. The reasoning is in the
thread above this spec; the short version is that human clients do not
think in projects, they think in companies, and "send us a request —
`rjb.example.com/intake`" beats "send us a request for the SE-Sprint-2
migration — `/intake/hkXHJPUS…`" by a mile.
Three things change, and nothing else moves:
1. **The link is bound to a client, not a project.** Each client has a
small set of links (typically one) that open a picker page listing the
client's active projects.
2. **The picker page is the new entry.** It is a real, anonymous,
authenticated-by-token page that lists projects the client has and
asks the user to pick one before the wizard runs.
3. **The wizard itself does not change.** It still takes a `projectId`
and still asks questions and still writes a card. It just gets that
`projectId` from the URL of the picker-selected project rather than
from the link row.
The security property Phase 8 set up carries forward unchanged: **the
worst a hostile body achieves is a strangely-worded card in the Inbox**.
The picker is a UI change to how the link and the wizard connect — it
does not add a way for a hostile body to escape the parseBrief whitelist
or the parse-and-server-decide-fields rule.
Out of scope for this spec:
- **Re-introducing per-project links.** The new shape is additive; the
old per-project shape stays live. Existing Phase 8 tokens keep
working as-is and continue to point at their original project.
- **Multi-client links.** One token, one client, easy to rotate. A
token cannot span clients, today or later.
- **Reordering intake by client-defined workflow.** Each project still
gets its own card; the picker just routes which project's wizard runs.
- **Renaming projects in the picker to be friendlier than the internal
`slug`.** A later phase can add a `client_display_name`; for now the
picker shows the project's actual name.
- **Branch-by-tenant lookup of intake links, analytics, etc.** One
PocketBase superuser; one rule set. Per-tenant isolation was deliberately
out from Phase 1 onwards.
## 2. What is already settled
Carried forward unchanged from the Phase 8 spec and its ancestors:
| Decision | Where from |
|---|---|
| The link is a capability, not a session. Rotation is the threat model. | Phase 8 §3 |
| The brief is re-parsed through `parseBrief`, which is a whitelist | Phase 4b |
| Column, priority, `accepted`, `created_by_role` are set by the server, never the body | Phase 1, Phase 4b |
| Submitted text is data, never instructions | Phase 1 + 3 preamble |
| The wizard is stateless — the transcript travels in the prompt | Phase 4 |
| Today's signed-in wizard does not change | Phase 8 / 8 / 8c |
| Phase 8's "minimal intake layout with no app shell" is the picker page's layout too | Phase 8 Task 9 |
| The PWA manifest, service worker, and iOS meta already cover any `/intake/*` URL | Phase 8 Tasks 3–5 |
## 3. Threat model for the picker
The Phase 8 threat model is the floor. Three Phase-9-specific concerns
sit on top of it; I'll name each and what we do about it.
**Concern 1 — Listing too many projects.**
A picker that dumps every active project a client has leaks work the
client didn't know about. A picker with too few projects is useless.
Resolution: each project carries a `show_in_intake: bool` (default
`false`, owner opts in). The picker queries `project.active AND
project.show_in_intake` — never the wider set. A confidential project
("Acme Rebrand — pre-announce") simply isn't in the list until the
owner says so.
**Concern 2 — Cross-client leakage through the picker.**
The token resolves to one client. The picker lists *that client's*
projects. Cross-client is structurally impossible because the picker
reads projects through the link's `client`, not by enumerating all
projects.
**Concern 3 — Enumeration via the picker.**
There is no "is this token valid?" probe because the picker is the
response to an unknown token: same 404 wording as the wizard in
Phase 8. The picker cannot be used to test whether a project name
exists on the platform.
The Phase 8 controls carry forward unchanged:
- rate limit per `(token, ip)`
- daily cap (now per `(link, project)` — see §5)
- token shape check before any DB read
- 404 not 403 on bad tokens (same wording for unknown/revoked/expired)
- server-decides-fields (the body never sets `column`, `priority`,
`created_by_role`, etc.)
- `parseBrief` whitelist on the model's output
The token is unchanged: 24 bytes CSPRNG → base64url → 32 chars.
## 4. Data model
Two schema changes, both additive. The Phase 8 `intake_links.project`
shape stays live alongside the new shape; the two are distinguished by
which field is populated on the row.
### 4.1 `intake_links.client` (new required field on intake_links)
Replace `intake_links.project` (Phase 8) with `intake_links.client` for
new rows. The schema definition in `scripts/setup-remote.ts` flips:
```ts
// scripts/setup-remote.ts — intake_links collection declaration
fields: [
{
name: "client",
type: "relation",
required: true,
collectionId: (await pb.collections.getOne("clients")).id,
cascadeDelete: true,
minSelect: 0,
maxSelect: 1,
},
// project field is gone for new rows. Migration §10 keeps
// existing rows intact via a partial-index trick.
...
],
indexes: [
"CREATE UNIQUE INDEX idx_intake_links_token ON intake_links (token)",
"CREATE INDEX idx_intake_links_client ON intake_links (client, active)",
],
```
`project` becomes an *optional* field, kept on the collection for the
migration window (§10). New rows leave it empty. The TypeScript
`IntakeLinkRecord` becomes:
```ts
export interface IntakeLinkRecord extends BaseRecord {
client: string;
/** Migration window only: projects this link binds to directly (Phase 8 shape).
* Empty on Phase 9 rows. The wizard reads projects via the client. */
project: string;
/** The owner's name for this link, e.g. "Acme — till phone". */
label: string;
token: string;
active: boolean;
expires_at: string;
max_submissions_per_day: number;
submission_count: number;
// submission_count_today and submission_count_today_date become
// per-project for Phase 9 — see §5.
...
}
```
### 4.2 `projects.show_in_intake` (new optional bool)
```ts
{ name: "show_in_intake", type: "bool", required: false }
```
Default `false`. Owner opts a project in by toggling it on the existing
project admin page (one new field on `BackgroundMdEditor`'s sibling
panel — same shape).
### 4.3 API rules (intake_links)
Same as Phase 8 — owner-only at the rule level. The wizard reads
through the superuser connection; the rule exists so a signed-in client
manager cannot enumerate links, same as before.
### 4.4 Daily cap, per `(link, project)`
Phase 8 tracked today's submissions on the link row itself:
`submission_count_today`, `submission_count_today_date`. With the new
per-client shape, that counter now means "across all projects under
this link" — which is wrong. One busy project could lock the others
out at the cap.
Phase 9 splits it: the daily counter moves onto a *new* collection,
`intake_link_submissions`, one row per (link, project, date):
```ts
{
link: string; // intake_links.id
project: string; // project id
date: string; // YYYY-MM-DD
count: number; // submitted today
}
unique index: (link, project, date)
```
The wizard's daily-cap check (`isLinkUsable`) becomes:
```
cap = max_submissions_per_day || 100
for the chosen project: count = today's row, or 0
return count < cap
```
Existing per-project Phase 8 links keep using `submission_count_today`
on the link row for that one project — the field stays, the new
collection is additive for the new shape only.
### 4.5 What the owner must opt
Owner-side:
1. On each project: flip `show_in_intake` to `true` for the projects
they want in the picker. Default off, so a freshly-created project
never appears in the picker until explicitly opted in.
2. On each client: mint at least one intake link from
`/admin/clients/[clientId]` (a new page) or via a "New link for this
client" button on an existing client-detail page.
The first link minted under the new shape is effectively the default
"send a request" link. Owners can mint more if they want segmented
URLs (e.g. one per support channel), but most clients need one.
## 5. Public route
Two URLs, two steps, both unauthenticated and the same minimal
layout Phase 8 Task 9 wrote.
### 5.1 `/intake/[token]` — the picker
Server component. Resolves the link server-side (same `resolveIntakeLink`
as Phase 8 — it does not care whether the row carries `project` or
`client`). On unknown/revoked/expired, **404 with the same wording as
the wizard** so a hostile body cannot probe.
On success, renders a project picker:
- Reads every project on the link's client with
`active = true AND show_in_intake = true`.
- If zero or one project qualifies, the page skips the picker and
behaves like a per-project link:
- One project → server-side redirect to `/intake/[token]/p/[projectId]`.
- Zero projects → static "no projects enabled" page with the
developer's email or phone (also owner-controlled).
- Two or more → renders a small list (`<ul>` of project names).
Each item is a `<Link>` to `/intake/[token]/p/[projectId]`.
The picker is the only place a project is exposed to the public. No
list of clients, no list of all projects, no alphabetical sort key
that would let a hostile body infer project names by timing.
### 5.2 `/intake/[token]/p/[projectId]` — the wizard
Server component. Same as Phase 8's `/intake/[token]` page, except:
- The token resolves to a link (same as Phase 8).
- The URL's `projectId` is verified to belong to the link's client
AND have `show_in_intake = true`. Mismatch → 404.
- The wizard runs with that `projectId`.
`IntakeWizard` and `IntakePublic` already accept `projectId` as a
prop (Phase 4 / Phase 8). Nothing in the wizard itself changes.
### 5.3 Caching and PWA
The Phase 8 service worker already intercepts `/intake/*` HTML
network-first with an offline fallback. The picker and wizard URLs
both fall under that pattern. No SW changes needed; the cache key
naturally covers both paths.
The manifest's `start_url` is unchanged (`/intake/`). A returning PWA
user lands on the picker first; selecting a project re-uses the
cached wizard HTML for that project if available.
## 6. Server Action & submission route
Both change shape to carry `projectId`. The change is *additive* to
the action's inputs — older fields stay for backwards compat with the
existing Phase 8 wizard code path which never sets `projectId` (the
link's `project` carries it).
### 6.1 `intakeStepPublicAction`
```ts
export async function intakeStepPublicAction(input: {
token: string;
projectId: string; // NEW: required on the new shape. Empty on the
// old shape, where the link's project fills in.
turns: Turn[];
finishNow: boolean;
}): Promise<InterviewStep>;
```
Internally:
1. Resolve `link` from token.
2. If `projectId` is provided, verify it belongs to `link.client` and
has `show_in_intake = true AND active = true`.
3. If `projectId` is empty, fall back to `link.project` (Phase 8 path).
4. Rate limit + daily cap (now per `(link, project)`) + token shape —
same shape check, same reasons.
5. `nextStep({ projectId, turns, finishNow })`.
The `clientIpFromHeaders(next/headers())` call from the recent fix
(`b02ebb7`) carries over unchanged.
### 6.2 `POST /api/public/intake`
```ts
export async function POST(request: Request): Promise<Response> {
// Same body whitelisting + token-shape check + 404 handling as Phase 8.
// NEW: read `projectId` from the body; verify it against the link.
// The card-create call uses the verified projectId.
}
```
The integration tests from Phase 8 (`intake-submit-public.test.ts`)
exercise the Phase 8 path with `projectId` left empty. New Phase 9
tests (file `intake-submit-picker.test.ts`) exercise the picker path
with both tokens and project ids on the wire.
## 7. Owner UI
### 7.1 Client admin page (new)
`/admin/clients/[clientId]` currently exists? (No — Phase 8 only added
links at the project level.) Phase 9 adds:
- A new route `/admin/clients/[clientId]` (server component,
owner-only via the existing admin layout).
- Reads the client plus the active projects on it (the owner's view of
what's deployable for intake purposes).
- Renders three panels stacked:
1. **Public intake links** — the same `<IntakeLinksPanel>` from
Phase 8, but now `link.project` is always empty on new rows.
Mint / rotate / revoke / per-link daily cap.
2. **Projects** — a list of the client's projects with a small
`<ShowInIntakeToggle>` next to each (one toggle calls one new
Server Action per project). Disabled + active styled. Toggling
`show_in_intake` is two clicks, not three.
3. *(Future)* a "client URL" badge — a copyable URL of the form
`https://<host>/intake/<token>` — landed on the page when at
least one active link and at least one opted-in project exist.
The admin layout already enforces owner-only access; no extra guard
needed here.
### 7.2 Project admin page (modify)
`/admin/projects/[projectId]` already exists. One small addition:
- A new `<ShowInIntakeToggle projectId={...} current={...} />` rendered
in the existing admin page, just above the
`<BackgroundMdEditor>`. It calls a new Server Action
`setProjectShowInIntakeAction({ projectId, show })`. Owner-only.
The Public-intake-links section on the project page stays (Phase 8
shape: a per-project link can still be minted). It just becomes the
"legacy / experimental" path; the new client-level link is the
default.
### 7.3 No new Server Actions beyond what is listed above
- `setProjectShowInIntakeAction` (owner-only)
- The picker page itself needs no Server Action — it's a server
component that reads the database and renders. Submission still
goes through `intakeStepPublicAction` and `POST /api/public/intake`.
## 8. Migration of existing Phase 8 tokens
Phase 8 left any number of per-project tokens live in PocketBase.
Most owners will want to consolidate to a single per-client link,
but doing that automatically could change the URL a client already has.
**Phase 9 deliberately does not auto-rotate Phase 8 tokens.** They keep
working with the Phase 8 behaviour: the old URL still renders the
wizard, the wizard still runs against the link's `project`, the
existing rate limit + daily cap continue to apply on the link row.
Owners can:
- Leave them as-is indefinitely (they're still secure).
- Manually rotate them from the project admin page (which now reads
as "this is a Phase 8 shape; consider minting a Phase 9 link
from `/admin/clients/[clientId]` instead" but does not force it).
- The owner UI surfaces a per-link `created_via` field (added to
`intake_links`):
`"phase-8-project-link" | "phase-9-client-link"` set at insert
time.
The choice not to auto-rotate is on purpose — a leaked Phase 8 link
must remain revokable through the same mechanism it was minted
through.
## 9. Daily cap, per `(link, project)`
Section 4.4 named the shape. Two operational consequences:
1. **Existing `submission_count_today` on the link row stays.** It's
the canonical counter for *Phase 8* links. Phase 9 links use the
new `intake_link_submissions` collection for *their* cap.
2. **The UI shows per-project counts.** Where Phase 8 says `1 total`,
Phase 9 says `quotes=12, billing=3` — at most one per project the
client has, summed across the lifetime of the link. The picker
itself does NOT show today's counter to the public; the counter is
owner-side only.
## 10. Plan split
Three plans in the same format as Phase 4b / 8a / 8b / 8c.
| Plan | Scope | Day-estimate |
|---|---|---|
| **9a — schema + per-project opt-in + owner toggle** | Add `intake_links.client` (optional, additive), add `projects.show_in_intake`, add `intake_link_submissions`, add migration seed; new `<ShowInIntakeToggle>`; `setProjectShowInIntakeAction`. Daily-cap unit tests for the new per-(link, project) shape. | 1 day |
| **9b — picker page + route split + new owner page** | `/admin/clients/[clientId]` route + panel; `/intake/[token]` picker page; `/intake/[token]/p/[projectId]` wizard page; `intakeStepPublicAction` accepts `projectId`; `POST /api/public/intake` verifies it; `<IntakePublic>` takes a `projectId` from URL; client URL badge in owner UI. | 1 day |
| **9c — integration tests + verification** | `intake-submit-picker.test.ts` mirroring Phase 8 Task 17 shape; happy path, hostile project-id, revoked project, mid-cap; full migration smoke; updated old-test signatures if needed. | 1 day |
Total: 3 working days. The Phase 8 architecture carries the load; the
new work is narrow.
## 11. Acceptance criteria
1. **Single-URL UX.** A client with one Phase 9 link and three projects
gets a picker page listing those three; selecting any one runs the
wizard against it.
2. **Opt-in is the default.** A newly-added project is NOT in the
picker until the owner flips `show_in_intate` to `true`.
3. **One URL per client.** `/admin/clients/[clientId]` shows every
link minted for that client, with mint / rotate / revoke / per-link
daily cap, same `IntakeLinksPanel` UI Phase 8 shipped.
4. **Worst a hostile body achieves** on the picker path matches the
Phase 8 invariant: a strangely-worded card in the Inbox of the
chosen project. `parseBrief` whitelists the output; server decides
`column`, `priority`, `accepted`, `created_by_role="anonymous"`,
`created_by=""`.
5. **Token shape check fires first.** A 32-char base64url for `token`,
a real project id for `projectId`. Anything else → 404, same wording.
6. **Daily cap is per `(link, project)`.** Two projects under the same
link each get their own 100/day budget; one doesn't lock the other.
7. **Phase 8 tokens keep working.** A pre-existing per-project link
(with `link.project` set) still resolves the Phase 8 way; the test
suite doesn't regress.
8. **The picker page is itself a static, public, signed-out route.**
No AppShell, no theme provider, the minimal layout Phase 8 wrote.
9. **The PWA service worker continues to cache `/intake/*`.** No
service-worker change; the picker is part of the same cache
allowlist.
10. **The signed-in wizard / signed-in route / signed-in owner UI
is unchanged.** Existing tests still pass; signed-in behaviour
does not regress.
## 12. What this spec deliberately doesn't do
- **Cross-client links.** A token still binds to exactly one client.
Multi-tenant intake is a different problem.
- **Recategorising links automatically.** No system process that
detects a Phase 8 link and "upgrades" it. The owner does the work
explicitly if they want; otherwise the old shape lives forever.
- **Inline client-name overrides.** The picker shows the project's
actual `name`. A future Phase 10 can add `client_display_name` to
`projects` for client-branded naming if it ever matters.
- **Renaming the old URL.** `/intake/[token]` with `project` on the
link still resolves to the project; the picker is only added to
client-shaped rows. There are no redirects to introduce.
- **Search / tag filtering in the picker.** Picker is alphabetical
by project name. If a client has 30 projects the picker is a long
list — that's their problem to solve (their team should be a smaller
list), not ours.
- **Branch-by-tenant analytics on intake.** One PocketBase, one
collection, one schema. Reporting on intake still happens through
the same dashboard phase 2 shipped.
---
## 13. Definition of done
1. `npm run test:all` passes: ~459 unit, ~75 integration (existing
Phase 8 set + the new picker path).
2. `npm run build` succeeds; colour check empty (modulo the spec-known
PWA themeColor metadata exception).
3. The full picker flow worked end-to-end in the developer's browser
before merge.
4. Phase 8 manual walkthrough steps 19.3–19.8 still pass.
5. Owner can opt a project in/out from `/admin/projects/[id]`.
6. A wiped-and-rebuilt dev PocketBase from this spec produces a
working picker with one seeded link and three opted-in projects.
---
## 14. Deliberately not in scope for this spec
The owner-side UX work — drag-and-drop reorder of links, link
notes/description editor, copy-URL-to-clipboard button on the client
admin page — is a Phase 9.5 task list. This spec closes on the
minimum surface that makes the picker flow work; everything else is
polish on top.