feat(opportunity): the 立项 (qualification) approval gate, off by default (REQ-0006 step 11) - #1996
Merged
objectstack-fleet[bot] merged 4 commits intoOct 3, 2026
Conversation
…lt (REQ-0006 step 11) REQ-0006 step 11: 「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、 投标、赢丢单操作。」 Built on the step-14 status-change gate's pattern and not a fork of it: - `qualification_approval_status` (readonly verdict; `defaultValue: 'not_required'` IS the switch, so the gate ships OFF) and `qualification_requested` (the rep's request, a boolean with `defaultValue: false` so the off-to-on transition is visible on every driver), both in the `qualification` group. - `opportunity_qualification_approval`: a record-change flow that opens one approval when the request is NEW (the transition, has()-guarded, fail-closed on an invisible prior row) while the verdict is pending or rejected. Approve stamps `approved`; reject stamps `rejected` and unticks the request, so ticking it again opens a fresh approval. `runAs: 'system'`, `onEmptyApprovers: 'admin_rescue'`, approvers on `sales_manager`. `lockRecord: false`, taken from the lead gate on step 11's own words (「新增商机可跟进」): the deal keeps being worked while 立项 is reviewed. - `opportunity_lifecycle`: while the verdict reads pending or rejected (input-first), a user write that changes `stage`, records `will_bid` or raises a new `requested_status` is refused RECORD_LOCKED / 409. It sits above the step-14 block, so with both gates armed 立项 comes first. The two new columns join APPROVAL_FIELDS so the closed-deal freeze cannot refuse the flow's own verdict stamp. Skeleton measured before docs: src/sales business semantics 55,279 -> 56,505 (ceiling 59,000), authored total 98,761 -> 99,987 (ceiling 107,000). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT
…cks, docs, counts, changeset - test/opportunity-qualification-approval-gate.test.ts (new): the shipped-OFF default, the start condition over every OFF and ARMED shape (transition, fail-closed, total), both approval branches, the three held acts and the open edits on the write path, APPROVAL_FIELDS on a closed deal, the user-less run with the runAs counter-proof, and the interaction matrix: off/off, 立项 pending and rejected, 立项 approved, status only, both on before and after 立项 — which gate answers each of stage move, bid decision, won/lost request and direct close. - Registered in runtime-coverage; refusal-envelope's hand count 21 -> 22. - The four locale packs carry both fields (label, help, option labels). - Docs: a Qualification approval section on the qualification page (3 locales), the new automation row, 32 flows and the Approvals paragraph (3 locales), the two fields in the opportunities field-group table, and README / docs/STATUS.md recomputed from `pnpm validate` (363 Fields, 32 Flows). - .changeset/1995-qualification-approval-gate.md (minor), naming REQ-0006 step 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT
- The flow's transition term: measured on a boot with the gate armed and the term deleted, one re-entry per request caught only by the engine's self-trigger guard; with it, none (two boots, 0 re-entries). - One pending approval per record: a deal raised past the large-deal line while its 立项 request waited failed `opportunity_approval`'s run with DUPLICATE_REQUEST, and the amount approval opened on the next write, the 立项 verdict stamp itself. Recorded in the flow docstring and as an admin tip on the qualification page (3 locales). - The verdict field's note now cites the measurement on its own column: a user update carrying `qualification_approval_status: 'approved'` reaches the hooks without it and is refused 409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT
…on-approval-gate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This was referenced Oct 3, 2026
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.
Fixes #1995
Clause-②: no. This is app metadata on
crm_opportunityand adds one flow; it touches no published schema and no accept set.REQ-0006 step 11 on
crm_opportunity: the 立项 (qualification) approval gate. It ships switched off. It is built on the step-14 status-change gate from PR #1950 and does not fork it. One changeset names REQ-0006 step 11 (.changeset/1995-qualification-approval-gate.md, minor).The customer's step 11, verbatim: 「销售立项需走审批流程;新增商机可跟进,立项通过后方可更新阶段、投标、赢丢单操作。」
What is on the branch
qualification_approval_status) is the readonly verdict. ItsdefaultValueis the switch, shipped asnot_required.qualification_requested) is the rep's request. It is a boolean withdefaultValue: false, so every new deal stores the key and the off-to-on transition is visible on every driver.opportunity_qualification_approval.approved. Reject stampsrejectedand unticks the request, so ticking it again asks again.opportunity_lifecycle, placed above the step-14 block.RECORD_LOCKED/ 409: changingstage, recordingwill_bid, and raising a newrequested_status.opportunity-approval.flow.ts, theopportunity_account_capabilityhook, and bothvalidations[].The #1950 pattern, term by term
defaultValue, not the flow'sstatusqualification_approval_status, shippednot_required.approved. Measured on 17.6.0 with a real ObjectQL: a user update carrying{ stage, qualification_approval_status: 'approved' }reached the hooks as{ id, stage }and was refused 409.rejectedrefuses aspendingdoeshas(), fail-closedqualification_requested.runAs: 'system',onEmptyApprovers: 'admin_rescue', approvers onsales_managerRECORD_LOCKED/ 409APPROVAL_FIELDSlockRecord: truefalse, taken from the lead gate. Step 11 says 「新增商机可跟进」: a deal awaiting 立项 keeps being worked. The hook refuses the three held acts whether or not a request is open, so the platform lock would only add a freeze.Why both columns join
APPROVAL_FIELDS. The flow stamps its verdict under the triggering user, because elevation is not anonymity. On a rejection it also clears the request. Suppose a write that this hook does not judge (a system write) closes the deal while the request is open. The closed-deal freeze would then refuse the stamp, and the approval would be decided but never recorded. The third ablation below shows exactly that refusal.Two gates, one deal
The new runtime test drives every combination through four acts. Each cell says which gate answers.
apply_status) still lands.stagenorrequested_status, and the freeze lets them through viaAPPROVAL_FIELDS.Acceptance, line by line
pnpm verifygreen. No existing behavioural assertion changed; the three edited test files are the hand-maintained count and rosters (see Guards). Fresh 17.6.0 boot of this branch: a new $5,000 deal was bornnot_requiredwith the request unticked. A stage move,will_bid, ticking the request and a direct close each answered 200, and the deal had 0 approval requests. Positive control on the same boot: raising a deal to $150,000 opened oneflow:opportunity_approvalrequest, and the new verdict stayednot_required.will_bidand a direct close each answered409 RECORD_LOCKED, and the stage did not move. Console (Chromium, Details edit mode): saving Stage = Qualification sent{"stage":"qualification"}, got 409, and the Save bar showed This deal needs qualification approval first: tick Request Qualification Approval. Stage can change once it is approved. The Will Bid toggle also got 409. After approval, the stage move,will_bidand a direct close each answered 200.flow:opportunity_qualification_approvalrequest. There was still one after 3 more seconds. Approve and reject were decided through/api/v1/approvals/requests/{id}/approveand/reject, the inbox's own actions.RECORD_LOCKED/ 409; the start condition tests the transition; a rejected request can be re-requestedrejectedand the request was unticked. A stage move and a direct close each got 409. Re-ticking opened a new pending request beside the rejected one. Transition, measured live: with the term deleted (separate throwaway), one "re-entered for the same record … the guard as authored does not exclude the flow's own write-back" warning per request. With the term in place, 0 across both armed boots.test/opportunity-qualification-approval-gate.test.ts. Boot with both gates armed: arequested_statuswrite before 立项 got 409 with the 立项 sentence (… Requested Status can change once it is approved). A direct close got 409 with the 立项 sentence, and 0 requests existed. After 立项 was approved, a direct close got 409 with the step-14 sentence. Requestingclosed_wonopened oneflow:opportunity_status_change_approvalrequest while the stage stayednegotiation. Approving it moved the stage toclosed_won.b739157d, before any docs: business semantics 55,279 → 56,505 against 59,000. Final: unchanged (see Token ratchet).Verification
8aca1c64, after mergingmaind4a3cafd, which is chore(deps-dev): bump fumadocs-core from 16.9.3 to 16.15.14 #1962, with no conflict):OS_VERIFY_LOCK_SLOT=hotcrm-issue-1995 bash scripts/pm/os-verify-lock.sh -c 'git rev-parse --short HEAD && pnpm verify'printed8aca1c64andos-verify-lock: VERDICT command-exit 0.Validation passed(18 Objects, 363 Fields, 32 Flows) · lint1 warning(s), 18 suggestion(s)·0 i18n/missing-* issues·source hygiene clean·source token ratchet clean·Build complete·Test Files 176 passed (176),Tests 3787 passed | 1 skipped.e6c973c6) also gave command-exit 0.Ablations
Each ablation used
ablation-replace.mjs. The anchor went x1 → x0 and the blob changed. Each restore was proven: blob == HEAD andgit diff HEADempty. The tests importsrc/directly, so no build ordist/leg applies. The direction was predicted before each run, and each went red as predicted.rejectedremoved from the hook's refusalpendingstamp, and the invisible prior rowAPPROVAL_FIELDSBoots (fresh 17.6.0, port 4816)
ablation-replace --holdand built to a scratch path. The source was then restored and proven blob == HEAD.Guards
test/refusal-envelope.test.ts: the hand-maintained site count goes from 21 to 22, edited by hand.test/runtime-coverage.test.ts: the new runtime file is registered.test/automation-docs-coverage.test.ts: the new flow's two Chinese row labels are added.README.mdanddocs/STATUS.mdare recomputed frompnpm validate: 32 flows, 363 fields.Token ratchet (
src/sales)main4e072fdNo ceiling was raised, no prose was compressed, and nothing was moved into an unmetered directory.
Docs
Acceptance notes
@objectstack/plugin-approvalsrefuses a second pending request on a record (DUPLICATE_REQUEST).opportunity_approval's run failed with that error, which was logged, and nothing opened.lockRecord: false. It is recorded in the flow docstring and as an admin tip.{ group }tab (Console 17.6.0: the tabbed Edit/New form dialog renders no tab for a{ group }form section, andrecord:detailsedit mode offers no editor for a markdown field — so group-referenced fields and markdown fields have no editing surface objectstack#21543). Through the UI, Will Bid is therefore set in Details edit mode after creation, and with the gate armed it then waits for 立项.quote_generation(a screen flow, under the rep's session) fast-forwards a pre-proposal deal toproposal.quote_on_acceptedcloses the deal under the accepter's session. On an armed, unqualified deal this gate would refuse both writes. The gate ships off. This is the same class feat(opportunity): REQ-0006 qualification, the customer calendar, narrative and approval on status change #1950 recorded for its own gate.requested_statusbefore 立项. The step-14 flow's start condition does not read the 立项 column, and it was not changed. With both gates armed, a no-session write of a request would open a status-change approval before 立项. The order holds for user writes, which is what the matrix pins.Generated by Claude Code