… update patch (#2361)
manage_quotes exposed the full quote write surface with no read tools —
unlike invoices (list_invoices/get_invoice) and contracts
(list_contracts/get_contract). The model could not discover a quote or
see its blocks/lines; the only workaround was an empty-patch update,
a write used as a read that bumped updatedAt.
- list_quotes: org/status filters, newest first, mirroring
list_contracts; validates filters with the shared
listQuotesQuerySchema the GET /quotes route uses.
- get_quote: full view (header with derived totals/deposit breakdown +
blocks + lines) reusing the same getQuote service the web UI reads —
no new SQL.
- Registered in every sibling list: aiTools registry
(registerQuoteTools), toolInputSchemas (aiToolSchemas.ts), and
TOOL_PERMISSIONS (aiGuardrails.ts, quotes:read). TOOL_TIERS
(aiAgentSdkTools.ts) intentionally NOT touched — the billing-domain
siblings are absent from it by design (it only curates the in-app SDK
agent's offered toolset).
- manage_quotes {action:"update", patch:{}} now returns a structured
VALIDATION_ERROR pointing at get_quote instead of running a no-field
UPDATE that dirtied updatedAt.
Tests: list_quotes filter/limit forwarding + invalid-status
VALIDATION_ERROR, get_quote full-view + missing-quoteId +
QUOTE_NOT_FOUND mapping, empty-patch rejection (asserts updateQuote is
never called). Registry-parity, deviceArgs-coverage, site-scope and
aiGuardrails contract suites green.
Closes #2361
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Stacked on #2370 (base branch =
fix/2362-manage-quotes-validation) so the diff shows only the #2361 work. Merge #2370 first, then retarget/merge this one.manage_quotesexposed the full quote write surface (header, blocks, lines, deposits, send/decline/pay-link) with no read tools — inconsistent with siblings (invoices:list_invoices/get_invoice; contracts:list_contracts/get_contract). The model could not discover a quote or see its blocks/lines, and the only "read" workaround was{action:"update", patch:{}}— a write used as a read that bumpedupdatedAt.Changes
list_quotes— org/status filters, newest first, copying thelist_contractspattern. Filters are validated with the sharedlistQuotesQuerySchema(the same schema theGET /quotesroute uses), so a bad filter returns a structuredVALIDATION_ERROR. Returns{ quotes, showing }.get_quote— full view: header (with deriveddueOnAcceptanceTotal/depositDueTotal/categoryBreakdown), blocks, and lines, reusing the existinggetQuoteservice the web UI reads. No new SQL.registerQuoteTools(aiToolsQuotes.ts), tier 2 /deviceArgs: []likeget_contract;toolInputSchemas(aiToolSchemas.ts);TOOL_PERMISSIONS(aiGuardrails.ts) →quotes:read.TOOL_TIERS(aiAgentSdkTools.ts) is deliberately not touched: the billing-domain siblings (list_contracts,get_contract,get_invoice,list_invoices,manage_quotesitself) are all absent from it — that map only curates the in-app SDK agent's offered toolset; the MCP server path reads the registry tier directly.{action:"update", patch:{}}now returns{"error":"patch is empty — nothing to update. Use get_quote to read a quote.","code":"VALIDATION_ERROR"}before any UPDATE runs, so a no-op can no longer dirtyupdatedAt. I chose the reject-with-VALIDATION_ERROR option (over silent short-circuit) because the empty patch was only ever used as a read workaround — rejecting steers the model to the newget_quote, and a silent success would hide caller bugs.Tests
aiToolsQuotes.test.ts:list_quotesdefault limit + filter forwarding + invalid-statusVALIDATION_ERROR;get_quotefull view + missingquoteId+QUOTE_NOT_FOUNDmapping; empty-patch rejection assertingupdateQuoteis never called.aiToolsRegistryParity(schema + RBAC parity for every registered tool),aiTools.deviceArgsCoverage.contract,aiTools.deviceAccessSiteScope.contract,aiGuardrails(97 tests).tsc --noEmit --project apps/api/tsconfig.jsonclean.Closes #2361
🤖 Generated with Claude Code