Phase 8a Task 1. Adds the new intake_links collection (per-token
public entry point, owner-only rules), extends cards.created_by_role
with 'anonymous', and extends activity.action with
'card_submitted_anonymously'.
Note: getOrCreateCollection is a no-op on existing collections, so
the two existing-value extensions are applied by new/extended
ensure functions (ensureCardsCreatedByRoleValues and
ensureActivityActionValues respectively). Re-runs are idempotent.
Adds the three proposal fields (proposed_target_date, proposed_by,
proposed_at) to the cards collection with a partial index that backs
the 'pending proposals' query, plus two new ActivityAction values
(date_proposed, date_proposal_resolved) and column_changed for
return-to-scheduled.
New server actions under src/app/actions/:
- client-proposal.ts: client-manager drag creates a proposal (not
a target_date write); 404 (not 403) on cross-tenant failures and
Done-column cards, matching the rest of /api/client/*.
- owner-plan.ts: accept and dismiss clear the three proposal
fields together and emit the audit rows; returnToScheduledAction
moves a Done card back to the first working column.
Server-side Done-column guards added to:
- src/app/actions/reschedule.ts (was UI-only)
- src/app/api/client/cards/[id]/route.ts (was UI-only)
Both pass through the new writeCardWithAudit helper at
src/lib/cards/write-with-audit.ts — single source of truth for
'patch card + emit activity rows', replacing the inline copies in
the PATCH route and rescheduleCardAction.
Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping)
consumed by Pixel's puzzle UI; named SlotId API rather than the
plan's numeric indices for self-documenting callers.
CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per
the brief; proposals go through the dedicated action so they get
their own audit row type.
The Plan page replaces the read-only Bands tab with a drag-and-drop
puzzle surface. Both roles drag; owners write target_date directly via
the existing rescheduleCardAction, clients write proposed_target_date
via proposePlanMoveAction. Done-column cards are excluded; they live
on the Timeline tab with a new 'Return to scheduled' button.
UI:
- Tray (unscheduled) + 3 slot columns: This Month / Next / Month After.
Slot headers carry count + '+N more' chip beyond 12 cards. Tray
virtualises via scroll-window math at 50+ cards.
- HTML5 drag-and-drop matching the existing PendingTray pattern; the
PuzzleView keeps the optimistic-state pattern from the kanban Board
(no router.refresh until the server confirms).
- Keyboard parity: Tab focuses a card, Space opens a listbox (3 slot
options), 1/2/3 picks a slot, Escape closes, ArrowUp/Down reorders
within the slot (optimistic local display; no v1 server action for
in-slot position). aria-live='polite' announces each successful move.
- prefers-reduced-motion via Tailwind motion-safe: variants — the 180ms
settle animation is dropped for users who request it.
Proposal overlay:
- RoadmapCard shows a left-edge accent stripe + Accept/Dismiss chip
when proposedTargetDate is set and the viewer is the owner.
- The existing RoadmapCard primitive is reused — no new card component.
Return-to-scheduled button:
- New component on Done-card rows in TimelineView. Replaces the
drag-sideways motion that the new Done-column server guard blocks.
- Owner-only, calls returnToScheduledAction.
Toast system:
- New ui/toast.tsx: useToast() hook + top-right aria-live region, 4s
auto-dismiss. AppShell mounts the provider so any page can call it.
- roadmap-builder.tsx no longer console.errors on a failed drag.
Server action stubs:
- client-proposal.ts and owner-plan.ts: temporary no-op stubs that
return a non-ok response. Deleted when Lin's branch lands; the
dynamic imports in the UI call them lazily so a missing module
surfaces through the toast rather than crashing.
Type widening:
- src/lib/roadmap/load.ts: adds optional proposedTargetDate,
proposedBy, proposedAt, isDone fields to the RoadmapCard interface
so the Puzzle compiles against pre-migration data. Lin's branch
populates and tightens these.
Files (15 changed):
- New: slot-date.ts + tests (slot↔date mapping)
- New: puzzle-keyboard.ts + tests (pure listbox state machine)
- New: toast.tsx, proposal-overlay.tsx, return-to-scheduled-button.tsx
- Rewritten: band-view.tsx now exports PuzzleView (per task brief)
- Updated: roadmap-tabs, roadmap-card, roadmap-builder, timeline-view,
plan/page, app-shell, load.ts
Tests (38 new):
- 18 unit tests for slot-date.ts (month boundaries, year rollover,
past/future, mid-month symmetry)
- 20 unit tests for the keyboard listbox state machine
(nextListboxState + isCardShortcut)
UX decisions worth flagging:
- ArrowUp/Down reorders are optimistic only — the order resets to
canonical (priority desc, title asc) on the next page load. No v1
server action exists for in-slot position; v2 should add one.
- Done cards are excluded from the Puzzle (per spec §6.1); the
Return-to-scheduled button lives on the Timeline tab (per task brief).
- A drop on the tray itself is a no-op in v1 — there is no
'unschedule' action, and the spec lists only 1/2/3 for the keyboard
listbox.
getCurrentUser() only decoded the pb_auth cookie locally and trusted its
embedded role/client/active fields directly — the PocketBase JS SDK holds
no secret to check a JWT signature against, so a completely forged cookie
(garbage signature, role: "owner", any email) was accepted as a real
session. Verified exploitable end-to-end: a raw Cookie header against the
live app, no browser and no password, rendered the owner's home page.
This mattered most for actions writing through the superuser connection
(log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account
creation) — those never route the caller's token through PocketBase's own
rule engine at all, so the app-level role check was the ONLY gate, and it
was reading an unverified claim. Actions using the caller's own token
(cards.ts, projects.ts) still made the wrong authorization DECISION from
the forged role, even though PocketBase's rule engine would have rejected
the actual data write.
Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase
to confirm the token is genuine; a forged or tampered token is refused
there and treated as signed-out. Wrapped in React's cache() so the extra
round-trip happens once per request, not once per call site (several pages
call this more than once). Re-fetching the live record also means a
deactivated account (`active: false`) stops working on its very next
request instead of only being blocked at a new login.
Regression test builds the exact forged cookie used to verify this,
confirmed red against the vulnerable code and green against the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Extract prompt text into buildPolishPrompt() in polish-prompt.ts (no server-only),
matching the pattern used by batch.ts/buildBatchPrompt and narrative.ts/buildNarrativePrompt
so the prompt itself is unit testable.
- Add MAX_NOTE_LENGTH guard to truncate excessively long notes before polishing.
- Add early-return guard against empty input (non-retryable error).
- Run getProvider() and getSystemPrompt() concurrently via Promise.all().
- Document that polishNote() throws (foreground user-initiated action).
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Fix KPI row "Hours invested" caption to honestly show both session-derived and manually logged hours
- Add JSDoc warnings about manualMinutes being a discriminator (do not pass 0 for git commits)
- Extract isManual predicate to single source of truth, avoid triple derivation
- Add tests for mixed session+manual in same month and manual-only shape
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
MiniMax M2.x reasons inside <think> tags in the normal content field, and
that reasoning is billed against max_tokens. At 4096 the heavier batches
spent the entire budget thinking, were truncated before writing a single
answer, and stripThinking correctly removed the unclosed block — leaving an
empty response.
The signature was distinctive: batches returned 25/25 or 0/25, never
anything partial. 193 of 779 commits went unsummarised with no error
anywhere. At 16384 the same 193 completed 8/8 batches with zero failures.
The interview's question call had the same bug at 1024, where it would have
returned an empty question and silently ended the interview on the first
turn. Now 8192.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The integration project now aliases server-only to a tiny empty stub
so tests can import modules that carry 'import "server-only"' without
the throwing real implementation firing during prepare. Unit tests do
NOT get the alias — a pure module with a server import is exactly the
kind of leak the unit project's missing condition is meant to surface.
The plan's summariseSessions test expected 2 sessions for four commits
spanning two 3-hour gaps, but with the default 120-minute gap three hours
is too far and produces 3 sessions. The implementation matches the boundary
rule defined in the plan (strictly less than the gap continues a session);
the test was off. Adjusted to a 90-minute follow-up which keeps the same
session and verifies total + longest correctly.
- ingestProject now calls relinkProject after every store
- updateCard relinks when scopeScope rule is in the patch
- acceptCard, archiveCard, unlinkCommitArchiveCard relink after their change
- three owner Server Actions for the tray: relinkProjectAction, linkCommitAction, unlinkCommitAction