Skip to content

fix(quote_generation): refuse Generate Quote before qualification approval instead of leaving an orphan quote - #2034

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-2032-quote-before-qualification
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-2032-quote-before-qualification

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2032
Clause-②: no

Ruling executed: hotcrm-R74b item 2, B. Before 立项 is approved, Generate Quote refuses and creates nothing (REQ-0006 step 11, with steps 15 and 18). With the gate off, the default, nothing changes and the #2020 keep-stage rule still holds.

Measured before the fix (the public door)

On base 08cfa209 (@objectstack/* 17.7.0), I armed the gate the way the flow header says an install does: crm_opportunity.qualification_approval_status defaultValue not_required → pending, trap-restored afterwards (blob proven equal to HEAD). Then a sales_rep created a 50,000 deal at qualification, so the deal was born pending, and drove quote_generation through the verify handle's flow doors (flows.run + flows.resume):

  • The resume answered FLOW_FAILED 400: Node 'update_opportunity' failed: update_record(crm_opportunity) failed: This deal needs qualification approval first: tick Request Qualification Approval. Stage can change once it is approved. The run summary showed create_quote acted 1 and update_opportunity failed (stranded).
  • 1 crm_quote was left behind (Q-REPRO, draft, 45,000).
  • The deal stayed at qualification, verdict pending.

The fix, and why it is the minimal one

Only src/revenue/flows/quote-generation.flow.ts changes behaviour:

  • get_held (get_record) right after start, ahead of the form and of every writer. Its filter is the gate's reading: { id: '{recordId}', qualification_approval_status: { $in: ['pending', 'rejected'] } }. That is the same two values opportunity_lifecycle refuses on, with the same fail-open on every other value (not_required, approved, no verdict). held therefore binds the deal only while it is held, and null otherwise.
  • e9 / e10: complementary conditionals off that data node (vars.held != null / vars.held == null). This is the shape billing-handoff-contract-activated.flow.ts records. There is no has() guard because the read is get_record-dominated (the case test/flow-variable-conditions.test.ts says needs none, like lead_conversion's e5 / e6).
  • refuse_held: an end node with outcome: 'refused'. The run ends refused: never failed, never parked, nothing written. The console shows the message with Close only, with no Submit and no "completed" toast.
  • e1 (start → screen_1) is retired and left vacant. New edges take e7, e9, e10.

Why here and only here:

  • The flow is the one choke point every door reaches: the record-header button, the list-row button and POST /automation/quote_generation/trigger. The hook already refuses the stage write. What was missing was refusing before create_quote.
  • generate_quote keeps its visibility. Hiding it would leave the rep a missing button with no reason, because an action's visible / disabled are bare predicates with no message. The ruling asks for a refusal the user can see.
  • get_opportunity stays after the screen, so pricing and the quote_generation leaves its quote behind when the deal is approval-locked: a rep's run fails RECORD_LOCKED at update_opportunity and the created quote stays #2015 approval branch (e4a / e4b, untouched) still read the deal as it is at submit. The gate's own read happens at click time. The verdict cannot drift toward held while the form is open, because not_required and approved are never written back to pending.
  • No hook, action, object or translation file changes.

Token ratchet. The first cut cost ~393 tokens against the 178 left under the src/revenue authored-total ceiling (19,000). The ratchet is paid by compressing and the ceiling stays where it is, so the gate was rewritten as the single filtered read above. src/revenue now reads ~18,994 / 19,000. Comments are free, so the rationale stays in the file.

Measured after the fix (same door, same armed install)

  • flows.run returned { success: true, status: 'refused', refusalMessage: 'Qualification approval comes first: tick Request Qualification Approval. Nothing was created.' }, with no runId and no screen. The summary had acted 0 and failed 0; only start and get_held ran.
  • 0 crm_quote rows. The deal is still qualification / pending. The sys_automation_run row has status: refused, error: null and refusal_message set.

The test that pins the business fact

test/flow-quote.test.ts › quote_generation flow — the 立项 gate (#2032). Each deal carries its verdict from the moment it is written, which is the row an armed install produces:

  • pending / rejected: refused before the form and before any write. The run is refused / success: true, has no runId and no screen, and its summary shows acted 0 and failed 0. 0 quotes, and stage and verdict are unchanged.
  • approved: the quote is drafted (45,000 at 10%) and the stage advances to proposal.
  • Gate off (not_required, and a deal with no verdict): unchanged. The quote is drafted and the stage advances.
  • One reading of the gate. For each of pending, rejected, approved, not_required and null, the flow refuses exactly when opportunity_lifecycle refuses the same rep's stage move (RECORD_LOCKED / 409).

Reverse verification, from the committed f57bf734. Each leg was trap-restored and proven by blob hash with git diff HEAD at 0 bytes:

  • Leg 1, the flow reverted to the base blob 866add41: 4 failed | 14 passed. The pending and rejected refusals failed, and so did the pending and rejected agreement cases.
  • Leg 2, the filter mutated to $in: ['pending'] (original count 0, injected count 1): 2 failed | 16 passed. Both failures were the rejected cases. Both legs turned red in the predicted direction.

Deviation from the dispatch's suggested route: the refusal sentence ships in English only

The dispatch asked for the sentence to be translated in every locale. At 17.7.0 the flows translation group carries only a flow's label and its screens' title and field copy. It has no key for an end node's message. Measured with TranslationDataSchema.safeParse:

  • flows.quote_generation.screens.X.title is accepted, but translateFlow overlays only type: 'screen' nodes, so it would translate nothing.
  • screens.X.description is refused ("description — a screen's body text — is not part of the flows translation surface").
  • screens.X.message, flows.X.refusals and flows.X.message are all refused as unrecognized keys.

The only translatable alternative is a message-only screen with a translated title. That is the shape the platform's outcome: 'refused' replaced, because it renders Submit and then toasts Flow "…" completed at a user who was just refused (spec 17.4.0 changelog). Its run also stays parked when the dialog is closed, since the console never cancels a run. So the refusal uses the platform's first-class construct. The dialog is titled with the action's translated label, the docs state the rule in all three locales, and the missing translation key is reported as a platform gap for upstream filing. The existing opportunity_lifecycle refusal of the same gate is English-only too.

Docs and changeset

  • Quotes › Generating a quote (en, zh-Hans, zh-Hant): the rule, stated only for organisations that arm 立项.
  • Administration › State machines (3 locales): the line saying quoting "can happen at any open stage" now names the qualification exception.
  • .changeset/2032-quote-before-qualification.md ('hotcrm': patch).

Acceptance notes

  • content/docs/sales/opportunity-qualification*.mdx › Qualification approval lists what waits for 立项 ("moving its stage and the won/lost call") and does not mention Generate Quote, which now waits too. That page is outside this PR's claimed file surface, so it is not edited here. It is reported for a docs follow-up: one clause per locale.
  • The gate holds Generate Quote, as ruled. A rep still holds crm_quote allowCreate: true (src/sales/profiles/sales-rep.profile.ts), so a quote created by hand on an unqualified deal is not held. Whether step 11 also covers that door is a business question. It was not measured here.
  • lead_conversion's three refusals (refuse_unapproved, refuse_confirmed_duplicate, refuse_no_match_key) are still message-only screens. Noted, not filed.

Gates

  • pnpm verify @ ee54bc50 (origin/main f071b36c merged in): os-verify-lock: VERDICT command-exit 0. Steps: ✓ Validation passed, tsc --noEmit clean, lint --strict 18 suggestion(s) and 0 errors, ✓ i18n lint gate: 0, ✓ source hygiene clean, ✓ source token ratchet clean (src/revenue authored total ~18,994, ceiling ~19,000), ✓ Build complete, Test Files 172 passed (172), Tests 3587 passed | 1 skipped (3588).
  • pnpm changeset status --since=origin/main: exit 0, hotcrm patch.
  • Local scope: pnpm verify is the repo's whole local chain. test:coverage thresholds (hooks only, untouched), the docs app, link-check and Playwright e2e are declared to CI.

Patch round 1 (appended by the repo:hotcrm seat)

Head 2688dacd, on the seat's review of ee54bc50 (amended claim 6077845274): content/docs/sales/opportunity-qualification*.mdx § Qualification approval now says Generate Quote waits for 立项 too, and its Approved row opens it; the new zh paragraphs on the Quotes pages name the button by its zh-CN label, 生成报价单 / 生成報價單. Docs only, 5 files, +8 / −8. The refusal sentence stays English: flows translations have no key for an end node's message, filed as objectstack-ai/objectstack#22450.


Generated by Claude Code

claude added 4 commits October 9, 2026 08:19
…roval instead of leaving an orphan quote

With the REQ-0006 qualification gate armed, a deal whose
qualification_approval_status is pending or rejected had its quote
created and then the stage write refused by opportunity_lifecycle, so
the run failed with FLOW_FAILED 400 and the draft quote stayed behind.

The flow now reads the verdict at click time, ahead of the form and of
every writer, and a partitioning decision routes pending/rejected to an
end node with outcome 'refused': the run ends refused, nothing is
written, and the rep is told qualification approval comes first. Every
other verdict, including the shipped not_required default, runs exactly
as before.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
…he gate is armed

The Generating a quote section of the Quotes page states the rule in all
three locales, and the state-machines note that quoting can happen at any
open stage names the qualification exception. Adds the patch changeset.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
…ed fetch to stay inside the revenue token ceiling

The first cut cost ~393 tokens against the 178 left under the src/revenue
authored-total ceiling (19,000), and the ratchet is paid by compressing,
never by raising it. The gate is now one get_record whose filter is the
gate's reading (pending or rejected), two complementary conditional edges
off it (no decision node, no has() guard on a get_record-dominated read),
and the same end node with outcome 'refused'. Behaviour is unchanged:
src/revenue reads ~18,994 tokens.

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 9, 2026 9:05am UTC

Request Review

…on approval too

The Qualification approval section now says quoting waits while the deal
is Pending or Rejected, and the Approved row opens Generate Quote with the
stage and the won/lost call, in all three locales. The new Quotes-page
paragraphs name the button by its UI label (生成报价单 / 生成報價單).

Claude-Session: https://claude.ai/code/session_012zh91QzFgePbkmuHnugLN3
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation

Projects

None yet

2 participants