Repository navigation
feat(ai-chat-v2): Question forms, formatted replies, preview and issue in chat, full-height AI workspace - #11
Merged
Merged
Conversation
Second iteration of the AI chat on top of ai-assistant: draft-flow reads (sender profiles, products, look-alike customer search), Offered choices as buttons, full-height AI workspace, preview and issue from the chat, AI nav group, dashboard entry points, streamed-text NFR, privacy/export coverage. Adds the glossary term Offered choice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Arc42 SAD extending ai-assistant: offers as server-side rows built from resolver issues, presses as option references pinned in code, app-side look-alike fold, preview/issue from the chat with an expected version, workspace invoice-state store, dashboard prompt hand-off. 9 ADRs, critic pass applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
31 self-contained tasks (≤1 day each) with tasks.json, epic DAG and tracker. Decisions: AC-05b resolver hint plus eval fixture; Mastra pinned-turn spike first; staged migrations promoted and rehearsed on testcontainers only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
runTurn accepts pinned { request, resolution }: the resolution reaches the
model as a prior propose_draft tool step (approach A, spiked; recorded in
sad.md §11), a pinned PROPOSAL is the pending card set in code, a press is
never refused as off-topic, and the turn context carries the pins for T13.
SDD-Task: T01
SDD-AC: AC-10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(T02) ChoiceOffer (one OPEN per Conversation via partial unique index), ConversationMessage.origin with the TYPED backfill, Conversation.startedFrom and Invoice.issuedFromChat. Prisma schema edits, createChoiceOffer factory, startedFrom/origin/id on the conversation factory, issuedFromChat on the invoice factory, ChoiceOffer in APP_TABLES. The ai-assistant revert test now takes the later feature's migrations down first and restores them last. SDD-Task: T02 SDD-AC: AC-34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foldName now lives in lib/helpers/fold-name.ts with no server-only import; the resolver imports it from there. match-name.ts held only the fold and is removed with its test, whose cases moved into the shared look-alike fixture set (tests/support/fixtures/look-alike-names.ts) read by the fold unit test. SDD-Task: T03 SDD-AC: AC-03 SDD-AC: AC-17 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inds (T04) ActionErrorDetails gains CHOICE_UNAVAILABLE and ALREADY_ISSUED, with the one exported CHOICE_UNAVAILABLE_MESSAGE. Shared assistant types: ChoiceOfferKind, ChoiceOfferState, MessageOrigin, ConversationStartPoint, OfferView (optionId + label only). ConversationMessageView gains origin (a stored NULL on a USER row reads TYPED) and the data-offer part; the message list skips data-offer until T22. No exhaustive switch over details.kind exists, so none needed a new case. SDD-Task: T04 SDD-AC: AC-12b SDD-AC: AC-26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (T05) listSenderProfilesForAssistant returns the acting Freelancer's profiles (default first, by name, 20 per page) with bank accounts summarized by bank name, last four (accountLastFour), currency and default mark; the output schema has no field for a full number. Registered in READ_QUESTIONS, so MCP and the chat get it; wrapped in the metadata-only assistant-tools span. The stale MCP read-only comment now names the Draft-permission writer. Existing tool-list expectations updated (9 MCP tools; "send" matched as a name segment so list_sender_profiles is not taken for a sending tool). SDD-Task: T05 SDD-AC: AC-01 SDD-AC: AC-06 SDD-AC: AC-07 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
listProductsForAssistant lists the acting Freelancer's active catalogue, one
row per product and currency, by name then currency code then id, filtered by
a folded part of a name in the app (shared foldName, ADR-0004), at most 20 per
page with the total. Prices are the stored decimals as strings. A customerId
adds that Customer's Custom prices (same pick as the draft resolver); one that
is not the Freelancer's is answered exactly as an unknown id ("Customer not
found."). Registered after list_sender_profiles in READ_QUESTIONS (10 MCP
tools), wrapped in the metadata-only assistant-tools.products span.
SDD-Task: T06
SDD-AC: AC-02
SDD-AC: AC-05
SDD-AC: AC-06
SDD-AC: AC-07
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…contracts (T07) list_customers folds its name filter with the shared foldName over the current name and every name copied onto the Customer's invoices (in the app, no name index, ADR-0004), and pages at most 20 rows (pageSizeCapped above 20) with the total of all matches. Shapes unchanged; tool descriptions updated. A contract test validates the three read answers against the openapi schemas, walks their output shapes for full account number fields, checks the look-alike fixture set and the 10-tool listing; integration tests cover AC-03 "Оohn Doe", invoice-copied names, the cap, owner scoping, chat/MCP parity, no full account number on either channel, and the p95 <= 800 ms timing on the heavy fixture. SDD-Task: T07 SDD-AC: AC-03 SDD-AC: AC-05 SDD-AC: AC-06 SDD-AC: AC-07 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andidates (T08) DraftRequest gains senderProfileId (at most one of senderProfile and senderProfileId: "Give at most one of senderProfile and senderProfileId."), resolved owner-scoped; an unknown or foreign id is SENDER_PROFILE_NOT_FOUND with written = the id, worded as a name that matches nothing. Issue candidates carry what an Offered choice label needs (DraftIssueDelta): Customer email (or null), bank account bankName, lastFour and currency (label unchanged, never a full number), product unit, the rate the resolver would use and currency, and SENDER_PROFILE_NOT_FOUND gains profiles[] beside options. AC-04: a named profile's only account in the named currency is now marked as taken from the request (was DEFAULT when it was that profile's default). The ai-assistant DraftToolError example gains the additive email field. SDD-Task: T08 SDD-AC: AC-04 SDD-AC: AC-10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…int (T09) The chat instructions now name exactly the read questions in READ_QUESTIONS (including list_sender_profiles and list_products), say plainly when nothing matches and link to adding one, state the sender profile (the Freelancer's own business they invoice from) versus Customer (the one who pays) rule, and keep the AC-24 answer pointing to the draft's preview or editor. Decision D-1: CUSTOMER_NOT_FOUND gains an optional ownSenderProfile hint when the folded written name equals one of the acting Freelancer's sender profile names (owner-scoped; never on an id or a partial name). issuesText explains it in en and uk and propose_draft adds no add-Customer link for it; no card, no offer. openapi DraftIssueDelta (OwnSenderProfileHint) and the sync report record the additive change. SDD-Task: T09 SDD-AC: AC-05 SDD-AC: AC-05b SDD-AC: AC-24 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hoice (T10)
offerFromIssues maps the resolver's typed issues to at most one offer, the
first offer-able issue in the order Customer, sender profile, currency, bank
account, product; 1..8 options with 0-based optionIds, TOO_MANY with the count
above 8, NONE for kinds that ask for a value or a link. Labels per openapi
OfferOptionLabel: the saved Customer name, plus "(email)" or "(no email)" only
when two offered names fold equal; "<bank> ••<last four> · <currency>";
"<name> · <unit> · <price> <currency>"; the profile name; the currency code.
storedOfferOptionSchema validates { optionId, label, ref } with a one-key ref.
pinChoice writes the chosen record into the draft request (customerId,
senderProfileId, currency, bankAccount id, lines[i].productId), dropping the
written name. No model or AI SDK import.
SDD-Task: T10
SDD-AC: AC-08
SDD-AC: AC-08b
SDD-AC: AC-09
SDD-AC: AC-10
SDD-AC: AC-14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eopen returns offers (T11)
saveTurn creates a new Conversation with its startedFrom, closes the open offer
for a typed or dashboard message (never for a press), writes origin on the USER
row and the pre-generated id (no origin) on the reply, and stores the turn's
offer OPEN tied to that reply with its pre-generated id (so the data-offer part
can carry it). Options are validated with storedOfferOptionsSchema; an invalid
payload saves nothing. getConversation and loadConversationCards return
offers keyed by offerId as OfferView (labels and state only, no ref, no
request); the message-parts schema accepts data-offer. The route passes
TYPED, a fresh reply id and no offer until T13/T14.
Decision: the input keeps the parent's conversation { id, isNew } field (the
contract's conversationId: string | null is the unchanged parent field; the
code already implemented it this way); the offer input carries a pre-generated
id (T13 streams it before the save).
SDD-Task: T11
SDD-AC: AC-12
SDD-AC: AC-13
SDD-AC: AC-18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he allowance slot (T12)
checkChoice reads the actor's OPEN offer by id, requires the Conversation to
match and the option to exist, then re-reads the option's record owner-scoped:
Customer / sender profile exists; product exists, is active and in the
request's currency; bank account exists, belongs to the request's sender
profile and is in its currency; a currency always fits. Every cause answers
the same { ok: false }; it writes nothing and its chat.choice-check span
carries the outcome only. claimOffer moves OPEN to CHOSEN (false on a lost
race); releaseOffer moves CHOSEN back to OPEN. reserveAndClaim runs the slot
reservation and the claim in one transaction (reserveMessageSlot gains an
optional SlotClaim): no slot -> nothing claimed; lost claim -> no slot taken
(claimLost); release() gives the slot and the claim back together.
claimOffer / releaseOffer take the route's transaction, so the service-layer
inventory lists them as transaction steps, not actor-first business functions.
SDD-Task: T12
SDD-AC: AC-11
SDD-AC: AC-12b
SDD-AC: AC-13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…amed as data-offer (T13) On ISSUES the propose_draft adapter builds the offer with offerFromIssues (code only, never model text) and keeps the earliest call's offer of the turn as pending.offer (pre-generated id, the resolved request); more than 8 options give no offer and matchCount in the tool result (AC-14). In a pinned turn a re-call has the pinned customer, profile, currency, account and line products applied before resolving (AC-10). A pinned ISSUES resolution offers its next choice from the start (AC-08b). TurnResult.pendingOffer is dropped when the turn ends with a card. The route writes one data-offer part after the card and before data-outcome, assistantPartsOf saves it, and saveTurn stores the offer OPEN tied to the reply. PendingOffer moves to offers.ts as the one type. SDD-Task: T13 SDD-AC: AC-08 SDD-AC: AC-09 SDD-AC: AC-14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the press path in the route (T14)
chatRequestSchema accepts choice { offerId, optionId 0..7 }, origin (TYPED |
DASHBOARD) and startedFrom, with the openapi field combinations as 400s keyed
by field (a choice needs a saved Conversation - said in its own words; never
with origin; origin / startedFrom only on a first message; DASHBOARD only with
DASHBOARD_PROMPT). The route, after the owner check and before the allowance,
runs checkChoice and answers one 409 CHOICE_UNAVAILABLE body for every cause;
a valid press reserves the slot and claims the offer in one transaction (a lost
claim is the same 409), pins the option into the stored request, resolves it in
code, runs the turn from that pinned resolution and saves the stored label with
origin PRESSED. A system-side failure releases slot and claim together. Typed
messages save DASHBOARD only from a validated first-message origin, else
TYPED; startedFrom only on a new Conversation. checkChoice also requires the
option's ref to fit its offer kind (and a PRODUCT lineIndex to name a line), so
pinChoice can never throw.
Decisions: origin/startedFrom on a non-first message is 400 per openapi (the
test-plan's "ignored" is superseded by the contract). Test fixes: the T12
PRODUCT fixture now stores the line its lineIndex names; the T06 products-read
leak check drops the harness's timing field (a duration containing "77" made it
flaky in the full run).
SDD-Task: T14
SDD-AC: AC-08
SDD-AC: AC-08b
SDD-AC: AC-10
SDD-AC: AC-11
SDD-AC: AC-12b
SDD-AC: AC-29
SDD-AC: AC-36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oncurrent claims, release, spans (T15)
The injection fixture (tests/support/fixtures/offer-injection.ts, reused by
T29) stores a Customer note asking for an "Evil Corp" button and scripts reply
text naming an unfound "Evil Bank ••6666": every stored and streamed offer
equals offerFromIssues of the resolver's issues and no canary reaches a
ChoiceOffer row or a data-offer part. Every invalid-press cause (older reply,
other Freelancer, never existed, already chosen, account deleted, account
moved currency) returns a byte-identical 409 with no model call, no slot and
no state change; two concurrent presses process exactly once; a provider
failure after the claim releases slot and claim. A metadata-only span contract
covers chat.choice-check and the three read spans - which found that
list_customers had no span: its handler now runs in inOutcomeSpan
('assistant-tools.customers'), like products and sender profiles.
The DB-backed stream contract lives in tests/integration/ai-chat-v2-offer-stream.test.ts
(the contract config has no database); the span contract is its own file so
its Sentry/Prisma mocks do not change ai-spans-metadata-only.test.ts.
SDD-Task: T15
SDD-AC: AC-09
SDD-AC: AC-11
SDD-AC: AC-12b
SDD-AC: AC-13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on; issueInvoiceFromPreview marks issuedFromChat (T16)
On the locked row and before decideStatusChange, an expectedVersion gives
CONFLICT ALREADY_ISSUED (with the invoice's current LinkedInvoiceState) for a
non-draft and CONFLICT CHANGED_ELSEWHERE { currentVersion } for any saved
change; then the list/editor decision, draft rules and bounds run as before.
Without opts the row-menu path is unchanged. issueInvoiceFromPreview (new
service function, used by T17's action) issues to PENDING through the same
path and sets issuedFromChat in the same write; nothing else writes or copies
it. linkedInvoiceStateOf moves into card-view.ts and readInvoiceStates uses it.
Tests (tests/integration/services/invoices/ai-chat-v2-issue-status.test.ts,
under services/ for the service-layer inventory guard): issue success shape,
issued elsewhere, version mismatch (line and note-only), the currency rule
verbatim, five draft-rule fixtures equal to the list path, two concurrent
confirms issue once, row-menu unchanged, duplicate clears the flag, foreign
invoice NOT_FOUND.
SDD-Task: T16
SDD-AC: AC-25
SDD-AC: AC-26
SDD-AC: AC-26b
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons (T17)
getCardInvoicePreview (cards.ts) reads a CREATED Draft card's invoice
owner-scoped through getInvoice's serialization, with its version and link
state; a cleared invoiceId is UNAVAILABLE; a foreign, unknown or non-CREATED
card is NOT_FOUND "This invoice can't be found." in one set of words.
issueFromPreview validates { invoiceId, expectedVersion >= 0 int } with zod
(issueFromPreviewInputSchema), calls issueInvoiceFromPreview (now in span
invoices.issue-from-preview), maps NOT_FOUND to "This invoice is no longer
available." and FAILED to the contract words, passes CONFLICT/VALIDATION
through, and revalidates /invoices and /dashboard. Neither action touches the
allowance; both refuse without a session. A malformed preview card id reads
NOT_FOUND (same words).
SDD-Task: T17
SDD-AC: AC-20
SDD-AC: AC-21
SDD-AC: AC-22
SDD-AC: AC-23
SDD-AC: AC-27
SDD-AC: AC-36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he day groups and title search (T18) listConversationSummaries returns every owned Conversation newest last message first with the Freelancer time zone (no paging); listLatestConversations returns at most three for the dashboard. listConversations stays until T19 swaps the pages. lib/helpers/conversation-groups.ts (isomorphic): groupConversations groups by the last message's calendar day in the zone into Today, Yesterday, Previous 7 days (2-7) and Older (8+), omitting empty groups; filterByTitle folds query and titles with the shared foldName (blank = all). Unit tests cover the four groups, zone, local midnight, DST and the shared look-alike fixture set; integration under tests/integration/services for the service-layer inventory guard. SDD-Task: T18 SDD-AC: AC-17 SDD-AC: AC-30 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…app (T98) SDD-Task: T98 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s name the T94 pin, T97 and T98 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch - CHANGES REQUESTED Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds in code + rate limit) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… budget, capped chat tool answers (T99) SDD-Task: T99 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Freelancer (T100) SDD-Task: T100 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…agent in its defaults, name the AI Gateway in privacy §5 (T101) SDD-Task: T101 SDD-AC: AC-35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st check and owner checks after the security addendum (T102) SDD-Task: T102 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TED, follow-ups T103-T105 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll cap; cuts never split a character (T103) SDD-Task: T103 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mit store; refusals write no rows (T104) SDD-Task: T104 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eck (T105) SDD-Task: T105 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(low), follow-ups T106-T107 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… turn budget; draft answers and links are inside the byte cap (T106) SDD-Task: T106 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s named (T107) SDD-Task: T107 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…; docs name the pinned re-call (T108) SDD-Task: T108 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l switch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing locations, T109-T110 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and terms name the EU hosting and the US model transfer (T109) SDD-Task: T109 SDD-AC: AC-35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odel transfer (T110) SDD-Task: T110 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…without overclaiming; Sentry listed (T111) SDD-Task: T111 SDD-AC: AC-35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… disclose what Sentry receives (T112) SDD-Task: T112 SDD-AC: AC-35 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 tasks
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR is the second iteration of the in-app AI chat. It removes the friction around the first iteration's rules and keeps every rule: nothing is guessed, a draft exists only after Create, the Daily message allowance applies, and the AI never issues.
ask_usertool. The answers go as one counted message whose text the server builds. The server applies the answers exactly and runs the draft action itself. A form that cannot be applied is turned down before the model, is not counted, and always gets the same words.formreplaces the earlierchoicefield (ADR-0011).react-markdown: lists, tables, bold and code. Only links to the app's own pages stay links, and HTML shows as text (ADR-0012).search_invoicesfilters by the day created._review/consistency-2026-10-14.md.Spec:
docs/features/ai-chat-v2/spec.md. Changelog:_ship/changelog.md. Feature description:_ship/feature.md. Security reviews:_ship/security-review-2026-10-14.md(presentation rewrite and owner-feedback revision) and_ship/security-review-2026-10-13.md(first build).Owner decisions before merge
app/(public)/privacy/privacy-content.tsx§11 (it now names Question forms and their answers), and set the page's "Last updated" date. It still reads January 23, 2026 (constants/public-pages-content.ts). T28 stays inreviewuntil then.pnpm eval:assistantwith the gateway key on preview. It was not run here because of the free-tier cost. It includes the AC-05b, AC-14many-studiosand AC-24 rows and the new form rowsform-not-prose-uk/-en,single-profile-not-asked,yesterday-date,search-before-add,link-to-draftandlast-draft-january(AC-37, AC-45 – AC-49).issueFromPreviewcan flag the Freelancer's own non-chat draft as issued from the chat). See the 2026-10-14 security review.Acceptance criteria
search_invoicesfilters and sorts by the day created ✓ ("last draft created in January": eval row)Design
docs/features/ai-chat-v2/spec.md(+ux-flows.md,screens.md), including the owner-feedback revision F1–F13docs/features/ai-chat-v2/sad.mddocs/features/ai-chat-v2/adr/(ADR-0001 … ADR-0013). The ai-assistant ADRs stay in force; ADR-0012 supersedes ai-assistant AC-07's plain-text rendering for the in-app chat only.docs/features/ai-chat-v2/data-model.md(prisma/migrations/20261013100000_create_choice_offer…20261013107000_backfill_choice_offer_questions)docs/features/ai-chat-v2/contracts/openapi.yaml0.3.0 (POST /api/chatwithform,originandstartedFrom; the chat-onlyask_usertool; thesearch_invoicesadditions; MCPlist_sender_profiles,list_productsandlist_customers) andcontracts/server-actions.mddocs/features/ai-chat-v2/test-plan.mddocs/features/ai-chat-v2/_review/review-2026-10-13.md— PASS after 22 rounds (first build 1–15, presentation rewrite 16–19, owner-feedback revision 20–22); every finding was fixed, none deferredTasks (SDD-Task trailers)
The 77 tasks in
tasks.json, T01 – T77, are committed withSDD-Tasktrailers (git log --grep SDD-Task 9de5d62..HEAD, 85 commits). T28 waits for the owner's wording approval. They cover:formFromIssues/ask_user/composeForm,checkForm/claim/pin and the route's form path, the Question form UI, the Markdown renderer and link allowlist, the date and time-zone note, thesearch_invoicesadditions, the mounted Conversation list, the aligned header, the skeletons and avatars, the export v2.4, and the e2e and visual updates;The review fixes of rounds 1–13 are separate commits, listed by round in the review record.
Verification
The gate below ran at 0268b7b (after the 2026-10-14 consistency pass, T85–T89); the later commits only retake one Linux baseline. All runs are on macOS with Docker.
vitest.config.ts)tsc --noEmit)CI=1 pnpm test:e2e, caffeinate)mcr.microsoft.com/playwright:v1.63.0-noble,PW_SNAPSHOT_PLATFORM=linux)ai-v2-question-form-sentretaken on purpose (T88 removed the empty gap under a reply with a form)The feature ran for real. The e2e suite runs the app as a production build against a throwaway Postgres with all migrations, with real HTTP and a real Chromium, and with the scripted stub model in place of Gemini. The dev database was not touched. The checks of the revision that it observed:
Security review (spec §6.1, clean context, read only) of the presentation rewrite and the owner-feedback revision: no finding at MEDIUM or above. There is one informational finding (SEC-I-02), and two low findings carry over from the first review (SEC-L-01 unchanged, SEC-L-02 narrower). All of them are limited to the Freelancer's own data or analytics. See
_ship/security-review-2026-10-14.md.Deferred: replies from the real model (
pnpm eval:assistant, which also confirms the model-behaviour parts of AC-37 and AC-45 – AC-49) and the load scenarios. Both are owner items above.Operational notes
Migrations: eight additive, idempotent migrations run with
prisma migrate deploy. There are two data steps:20261013102000_backfill_conversation_message_originsetsorigin = 'TYPED'on existing Freelancer messages;20261013107000_backfill_choice_offer_questionsturns earlier offer rows into one-question forms. Production has no such rows.20261013105000_add_form_enum_valuesonly adds enum values, in a migration of its own. The down scripts are indocs/features/ai-chat-v2/migrations/; run them in reverse order (08 → 01). The dev database does not have the migrations yet.New dependencies:
react-markdown10.1.0 andremark-gfm4.0.1 (runtime, exact pins), andaxe-core4.13.0 (dev, exact pin).Settings, cron, services: none new. The ai-assistant settings still apply.
Rollback: redeploy the previous build. The new table and columns can stay. Down 06 deletes the forms and maps the
FORMmessages back toTYPED. Down 05 drops the "issued from the chat" KPI history. Down 01 drops all stored forms and Offered choices.🤖 Generated with Claude Code