Phase 9b Task 6. intakeStepPublicAction takes optional projectId;
branches on link.client — Phase 9 path runs isLinkProjectUsable
(per-(link, project) cap) and resolveProjectForClient; Phase 8 path
falls through to validateIntakeLinkForStep unchanged. The wizard
component threads projectId from the URL into the action.
POST /api/public/intake verifies the URL's projectId belongs to the
link's client and is opted in; 404 if not, 429 if the
per-(link, project) cap is reached, 201 on success. On success the
bump goes to bumpDailyCapForProject (Phase 9) or the legacy
bumpSubmissionCount (Phase 8).
Phase 8 backwards compat: a per-project link (link.client empty)
goes through the unchanged Phase 8 path; body without projectId
still works on the Phase 8 route.
Phase 9b Task 5. Verifies the URL's projectId belongs to the link's
client AND is opted in AND is active. Phase 8 URLs (with link.project
set, link.client empty) ignore the URL projectId and use the link's
own — so the Phase 8 wizard URL pattern still works.
Phase 9b Task 4. The /intake/[token] route is now the picker.
Single-project links redirect straight to the wizard (no extra
click). Zero-projects links show a static 'nothing here yet' page
with no info leakage. Phase 8 backwards compat: a Phase 8 link
(with link.project set, link.client empty) redirects into the
wizard URL using that projectId, so the Phase 8 UX is unchanged.
Phase 9b Task 3. listPickerProjects reads the public-eligible
projects on a client; resolveProjectForClient checks that a given
projectId belongs to the client and is opted in. Both fail closed
on any DB read error.
Phase 9b Task 2. Panel takes a mode prop ('project' keeps Phase 8
behaviour; 'client' is the new shape). The Server Action's input
takes an IntakeLinkScope union ({type:'project', projectId} |
{type:'client', clientId}) so revalidatePath hits the right admin
page after every change.
The Phase 8 site ('/admin/projects/[id]') passes mode='project' and
projectId; the Phase 9 site ('/admin/clients/[id]') passes
mode='client' and clientId. Both render the same UI; only the scope
forwarded to the actions differs.
Phase 9b Task 1. /admin/clients/[clientId] shows the client's active
projects with a 'List on request page' toggle next to each. The
existing /admin/clients list now links each client name into the new
page.
Request-links panel placeholder; Task 2 wires the real
<IntakeLinksPanel mode='client'> here.
Phase 9a Task 6. Three integration tests:
- schema lands: row inserts with link/project/date/count fields,
composite unique index is the only constraint (different
project same link + date is allowed)
- duplicate (link, project, date) is rejected by the unique index
- the public route returns a sensible status, never 500, on a
Phase 9 link (the route will be wired in 9b; 9a only proves
it does not crash today)
Two bridges from the plan:
- PocketBase's date field defaults to unique=true; my schema
fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
The relation and number fields appeared to share the same
default but getOne()'s response omits the key, so the
explicit unique=false on those doesn't stick — yet rows with
the same link and different projects work in practice (the
composite index does its job). Worth a follow-up if a future
PocketBase release tightens the default.
- bumpDailyCapForProject wraps the read-then-create-or-update
in a retry on the unique constraint to handle PocketBase's
read-after-write latency window. The cap-isolation property
is verified end-to-end in 9c.
Phase 9a Task 5. Single switch, owner-only via the Server Action
wired in Task 4. Saves immediately on click; reverts on error so
a transient PB failure can't leave the UI lying. Default off, per
the Phase 9 spec §4.5.
Phase 9a Task 4. Owner-only. Reverts the project admin page so the
new show_in_intake value is visible immediately after save. The
toggle component that calls this lands in Task 5.
Phase 9a Task 3. isLinkProjectUsable reads today's counter (or
treats no row as 0). bumpDailyCapForProject upserts via the unique
(link, project, date) index. The Phase 8 helpers stay live for the
old shape; Phase 9b's wizard picks which one to call based on
whether the link is per-project (Phase 8) or per-client (Phase 9).
Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached,
nextCount, computeDateForCounter. 7 unit tests cover the caps
boundary (cap=0 means 'no submissions today'; todayCount === cap
is reached), the count increment, and the local-date format.
Note: project convention puts unit tests next to source
(src/**/*.test.ts), not under tests/unit/. Adjusted from the plan.
Phase 9a Task 1. Additive only — the Phase 8 shape stays live.
intake_links.project becomes optional so Phase 9 rows can leave it
empty. intake_links.client is the new owner of the link, read by
the picker page in 9b. projects.show_in_intake defaults to false
and is the knob that hides a project from the picker. The new
intake_link_submissions collection tracks the daily counter per
(link, project, date) so the cap applies per project, not per link.
Type additions:
- ProjectRecord.show_in_intake: boolean
- IntakeLinkRecord.client: string (Phase 9)
- new IntakeLinkSubmissionRecord
Eight tasks in the same format as 8b:
1. /admin/clients/[clientId] page (link the existing /admin/clients
list into it)
2. <IntakeLinksPanel> mode='client' variant; Server Action takes
a scope union for revalidation
3. listPickerProjects + resolveProjectForClient helpers
4. /intake/[token] picker page (single-project redirect, zero-
projects fallback, multi-project list)
5. /intake/[token]/p/[projectId] wizard page
6. intakeStepPublicAction + POST /api/public/intake accept and
verify projectId; cap check branches on link.client empty vs
set
7. confirm thanks page links to picker (no change)
8. full verification
Backwards compat: Phase 8 URLs (link.project set, link.client empty)
still work — the wizard URL is the same shape as before, the action
falls through to the Phase 8 path.
Seven tasks in the same format as 8a:
1. schema (intake_links.client, projects.show_in_intake,
intake_link_submissions)
2. pure helpers + unit tests
3. PocketBase read/write helpers
4. setProjectShowInIntakeAction
5. ShowInIntakeToggle component on /admin/projects/[id]
6. integration test (permissive on route status until 9b wires
intakeStepPublicAction to honour projectId)
7. full verification
What 9a does NOT own (deliberately deferred to 9b): the picker
page, the two-step URL, intakeStepPublicAction changes, the new
/admin/clients/[id] route.
Spec changes from feedback:
1. §5.1 — the picker description was written as if the user could
see the rendered HTML. Replaced with an ASCII sketch and a
stronger 'no internal concepts reach the rendered surface' line.
2. §7 — added a binding label-mapping table for every owner-facing
term. The internal names stay (in the DB, in code, in the spec);
only rendered text changes. Soft-language variants:
intake_links -> 'Request link'
Mint a link -> 'New request link'
Per-link daily cap -> 'Daily cap'
Revoke -> 'Pause'
Re-activate -> 'Resume'
show_in_intake -> 'List on request page'
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.
Phase 8a Task 7 shipped the headers argument as part of the
Server Action signature. Server Actions serialise their arguments
through Next's RSC protocol, and a Headers instance does not survive
that — it ends up as something with no .get() and the action
explodes with 'headers.get is not a function' the first time the
wizard runs.
Fix: drop the headers parameter from the action and read the
inbound request's headers via next/headers inside the action. The
client component no longer needs to construct a Headers instance
either.
This was caught the first time a real browser hit the wizard — the
integration tests in Task 17 mock the request and never serialised
a Headers through RSC.
Phase 8c Task 5. Server Action caps at 40000 chars on write, owner-
only via requireOwner(). Client component: one textarea, live
counter, explicit Save button (no auto-save), repo URL rendered as
a 'see also' link rather than a tool call.
Phase 8c Task 4. Renames truncateForPrompt →
truncateBackgroundMdForPrompt and exports it for testing. Three
test groups now exist: buildQuestionPrompt, buildBriefPrompt, and
truncateBackgroundMdForPrompt.
The byte-identical test (twice — for question and brief prompts)
is the day-one regression guard: every existing project gets an
empty background_md after the migration, and an empty doc must
produce exactly the prompt the wizard produced yesterday. A diff
here breaks every in-flight interview on the day 8c ships.
Phase 8c Tasks 2 + 3. IntakeContext gains backgroundMd; intakeContext
in interview.ts reads project.background_md, trims it to the
30000-char prompt cap with a marker. Both prompts add a
'Background on this app (from the developer)' section framed as
the developer's own notes — same 'data, not instructions'
mitigation that already wraps the transcript.
Phase 8c Task 1. Optional text field on projects, max 40000 chars.
The prompt module caps at 30000 with a truncation marker. Empty
documents produce byte-identical prompts to today — the test in
Task 4 locks that in.
Phase 8b Task 5. Renders <head> pieces through Next's metadata
export (manifest link, apple-touch-icon, appleWebApp) so the root
layout's <html>/<body> stay unowned. The only inline JSX is the
service-worker registration script. Verified: manifest, icon,
sw.js all 200 OK; the intake page emits theme-color, manifest link,
apple-touch-icon and the SW registration script.
Phase 8b Task 4. Cache-first for the static asset allowlist (icons,
manifest, root HTML). Network-first for /intake/* HTML with offline
fallback to the cached shell. Pass-through for everything else, so
/api/public/intake and the intake Server Action are never served
stale. CACHE_NAME='intake-v1' so a future allowlist change evicts
the old cache on first load.
Phase 8b Task 3. start_url is /intake/ (trailing slash) so the
browser falls back to whatever token was last visited, or to the
install-time URL on first open. The intake form is per-token anyway;
the developer will issue a fresh link to a client who closes and
re-opens the PWA after revocation.
Phase 8b Task 2. @resvg/resvg-js renders public/icons/source.svg
into the four PNG sizes the manifest + iOS expect: 192, 512,
maskable-512 (Android safe-zone), and apple-touch-icon-180.
Build is one-shot — outputs are committed, runtime doesn't ship
the dep.
Phase 8b Task 1. Rounded-square brand tile with a single white
exclamation mark. Colour matches src/lib/brand-default.ts. Tasks 2
or later rasterise this into the four PNGs the manifest + iOS
expect.
Phase 8a Task 18. Server Actions are exposed via Next's RSC
boundary, not as plain HTTP endpoints; reaching into them from
vitest is not worth the complexity for a single new action. These
tests assert the admin project page renders for the owner so the
absence of unit-level action coverage is explicit.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Deviation from plan: the second project needs an explicit Inbox
column created in beforeAll — the schema does not auto-create one
for new projects.
The schema the plan specified had three places where a PocketBase
quirk blocked normal use of intake_links / activity rows. None of
them surfaced in Task 1 because the unit-level test there only
checked rule application, not inserts.
1. intake_links.submission_count and submission_count_today were
required: true with min: 0. PocketBase treats 0 as blank for
required number fields, so every insert 400'd. Made optional.
2. intake_links.active was required: true, which rejects false as
blank (the same quirk). The owner can mint an already-revoked
link (the test exercises this); made optional.
3. activity.actor was a required relation. The public route writes
a card_submitted_anonymously row for a submitter who has no user
account; empty string is not a valid relation. Made optional and
dropped actor: '' from the route.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Phase 8a Task 16. The admin layout already enforces owner-only
access. Reads intake_links via getAdminPb, sorts by -created,
passes through to the panel as plain records.
Phase 8a Task 15. Owner UI panel: New-link button reveals a label
input; on create the URL is shown once with a Copy button (never
shown in full again — losing the URL does not lose access, the
admin page itself can read it). Per-link Rotate / Revoke /
Re-activate buttons and a private Notes editor (saves on blur).
Phase 8a Task 14. Server Actions are owner-only via requireOwner().
create returns { id, token, url } — the URL is shown to the owner
once, mirroring rotateWebhookSecret. rotate issues a fresh token and
invalidates the old URL immediately. setIntakeLinkActiveAction
covers revoke (active=false). Notes are capped at 1000 chars on
both write paths.
Phase 8a Task 13. CardSubmittedBy renders nothing for 'owner',
'Requested' for 'client', and 'Submitted via public link' for
'anonymous'. Renders nothing once the card is accepted. Replaces the
inline client-only check on the card detail page.
Phase 8a Task 12. Static page; no card read, no brief shown, no
link to the signed-in board. Resolves the token only to confirm it
is still alive — a revoked token landing here still shows 'Thanks'
so the submission cannot be probed.
Phase 8a Task 11. Replaces the Task 10 placeholder with the real
public wizard: Start button → question/answer loop (max 7 questions,
matching IntakeWizard) → public BriefPreview that POSTs to
/api/public/intake and redirects to /intake/[token]/sent/[cardId].
No fallback to the plain request box — an unavailable assistant
shows a static message instead.
Phase 8a Task 10. Server component 404s on unknown/revoked/expired
tokens with the same wording so the response cannot probe token
existence. Only the link's label and the project name cross into
the rendered HTML; counts, notes and the token itself stay
owner-only.
The IntakePublic component is a placeholder — Task 11 replaces it
with the real wizard. Keeping the placeholder lets the build stay
green between the two commits.
Phase 8a Task 9. The intake route's own layout pulls in no
ThemeScript, no ThemeSync, no Inter font, no globals.css — so the
public route ships without the signed-in app shell.
Deviation from plan: this layout does NOT redefine <html>/<body>.
Next.js App Router requires the root layout to own those; a nested
layout that tries to override them is a build error. The intent
(public route is its own thing, no app shell) is preserved.
Phase 8a Task 8. POST /api/public/intake is the sibling of
/api/client/intake, not a branch: same parseBrief whitelist, same
card-create code path, with created_by_role='anonymous' set in code
(not from the body). 404 for unknown/revoked/expired tokens — same
wording for all three, so the response cannot probe token existence.
Counter bumps are best-effort so a card is never lost to a
counter-update failure.
Deviation from plan: dropped the dead 'tokenHeader' line that the
plan's own comment admits is vestigial.
Phase 8a Task 7. token-shape check → rate-limit → daily-cap →
resolve. Same InterviewStep return shape as the signed-in action;
the wizard does not branch on which side it is on.