Skip to content

feat(opportunity): the 立项 (qualification) approval gate, off by default (REQ-0006 step 11) - #1996

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-1995-qualification-approval-gate
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-1995-qualification-approval-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #1995
Clause-②: no. This is app metadata on crm_opportunity and 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

  • Two fields in the Qualification group.
    • Qualification Approval (qualification_approval_status) is the readonly verdict. Its defaultValue is the switch, shipped as not_required.
    • Request Qualification Approval (qualification_requested) is the rep's request. It is a boolean with defaultValue: false, so every new deal stores the key and the off-to-on transition is visible on every driver.
  • One flow, opportunity_qualification_approval.
    • It opens one approval when the request turns on while the verdict reads pending or rejected.
    • Approve stamps approved. Reject stamps rejected and unticks the request, so ticking it again asks again.
  • One hook branch in opportunity_lifecycle, placed above the step-14 block.
    • While the verdict reads pending or rejected, three user writes are refused RECORD_LOCKED / 409: changing stage, recording will_bid, and raising a new requested_status.
    • Every other edit stays open.
  • Untouched: the step-14 gate's behaviour, opportunity-approval.flow.ts, the opportunity_account_capability hook, and both validations[].

The #1950 pattern, term by term

#1950 term Here
The switch is the verdict field's defaultValue, not the flow's status Same: qualification_approval_status, shipped not_required.
The gate column is read input-first Same. This flow never writes a gated field, so it never needs that reading, but it keeps it. A user cannot hand it an 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.
rejected refuses as pending does Same.
The start condition tests the TRANSITION, guarded with has(), fail-closed Same, on qualification_requested.
runAs: 'system', onEmptyApprovers: 'admin_rescue', approvers on sales_manager Same.
The refusal is RECORD_LOCKED / 409 Same. One sentence names what was held and the way forward.
Its own verdict column, added to APPROVAL_FIELDS Same, with both new columns.
lockRecord: true false, 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.

verdicts (立项 / status change) stage move bid decision won/lost request direct close
off / off ok ok ok ok
pending / off 立项 立项 立项 立项
rejected / off 立项 立项 立项 立项
approved / off ok ok ok ok
off / pending ok ok ok status
pending / pending 立项 立项 立项 立项
approved / pending ok ok ok status
  • 立项 comes first. An unqualified deal cannot raise a status-change request, and its direct close is answered by the 立项 sentence.
  • Once 立项 is approved, the step-14 gate behaves exactly as it does alone. Its own approving close (apply_status) still lands.
  • The 立项 flow's own stamps trip neither gate. They carry neither stage nor requested_status, and the freeze lets them through via APPROVAL_FIELDS.
  • With only one gate armed, the other is inert. Neither flow opens on the other gate's request.

Acceptance, line by line

# #1995 done-when Evidence
1 Off by default, bit-for-bit today's behaviour Suite: pnpm verify green. 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 born not_required with 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 one flow:opportunity_approval request, and the new verdict stayed not_required.
2 Armed: a new deal can be followed up while unapproved Boot with 立项 armed: amount, next step and customer background saved 200 while the deal was pending. An edit also saved 200 while the 立项 request was open, so the deal is not locked. Pinned in the runtime test, including a form save that echoes unchanged values.
3 Armed: stage advancement, the bid decision and closing are refused until approved Same boot: a stage move, will_bid and a direct close each answered 409 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_bid and a direct close each answered 200.
4 The approval lands in the platform inbox Ticking the request, over REST or in Details edit mode, opened exactly one flow:opportunity_qualification_approval request. There was still one after 3 more seconds. Approve and reject were decided through /api/v1/approvals/requests/{id}/approve and /reject, the inbox's own actions.
5 Built on #1950's pattern; RECORD_LOCKED / 409; the start condition tests the transition; a rejected request can be re-requested See the term table above. Reject: the verdict went to rejected and 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.
6 The interaction with the step-14 gate is stated and pinned See the matrix above and its block in test/opportunity-qualification-approval-gate.test.ts. Boot with both gates armed: a requested_status write 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. Requesting closed_won opened one flow:opportunity_status_change_approval request while the stage stayed negotiation. Approving it moved the stage to closed_won.
7 Token budget: skeleton measured first Skeleton (fields, flow, hook branch) at b739157d, before any docs: business semantics 55,279 → 56,505 against 59,000. Final: unchanged (see Token ratchet).

Verification

  • This branch (8aca1c64, after merging main d4a3cafd, 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' printed 8aca1c64 and os-verify-lock: VERDICT command-exit 0.
    • The chain: Validation passed (18 Objects, 363 Fields, 32 Flows) · lint 1 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.
    • The 18th lint suggestion, and the build's 9th author-time warning, is the new approval node's "approvers may resolve empty" note. It is the same class as its siblings, without their lockRecord clause.
  • The same command before the merge (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 and git diff HEAD empty. The tests import src/ directly, so no build or dist/ leg applies. The direction was predicted before each run, and each went red as predicted.

term predicted observed
rejected removed from the hook's refusal 6 red: the five held acts on a rejected deal plus the matrix's rejected row 6 of 75 red, exactly those
transition term deleted from the start condition 2 red: the node's own pending stamp, and the invisible prior row 2 of 75 red, exactly those
both columns removed from APPROVAL_FIELDS 1 red: the flow's stamps on a closed deal 1 of 75 red: Opportunity Big Deal is closed (closed_won); … Attempted: qualification_approval_status.

Boots (fresh 17.6.0, port 4816)

  • Each boot used a fresh database, and the served object and flow metadata were checked equal to the artifact booted.
  • The armed artifacts were built from LOCAL, never-pushed throwaways. Each default was flipped through ablation-replace --hold and built to a scratch path. The source was then restored and proven blob == HEAD.
  • Every server was stopped by its recorded PID tree.

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.md and docs/STATUS.md are recomputed from pnpm validate: 32 flows, 363 fields.
  • The four locale packs carry both fields: label, help and option labels.
  • No guard was skipped, disabled or loosened.

Token ratchet (src/sales)

scope main 4e072fd this branch 8aca1c6 ceiling
business semantics 55,279 56,505 59,000 (headroom 2,495)
interaction layer 27,996 27,996 31,000
authored total 98,761 99,987 107,000 (headroom 7,013)

No ceiling was raised, no prose was compressed, and nothing was moved into an unmetered directory.

Docs

  • The qualification page (3 locales) gets a Qualification approval section. It covers what is held and what stays open, the verdict table, request, approve and reject, and "qualification comes first".
  • The same page's admin tips say how to arm the gate and that the two gates arm independently. They also note that a deal carries one open approval at a time.
  • The automation page (3 locales) gets the new row, a count of 32 and a sentence under Approvals.
  • The opportunities page's field-group table (3 locales) names the two new fields. That table says it accounts for every field.

Acceptance notes

  • One open approval per record (measured). @objectstack/plugin-approvals refuses a second pending request on a record (DUPLICATE_REQUEST).
    • On the armed boot, a deal was raised to $150,000 while its 立项 request waited. opportunity_approval's run failed with that error, which was logged, and nothing opened.
    • The amount approval opened on the next write, which was the 立项 verdict stamp itself, because the amount flow's entry tests the current value. It was deferred, not lost.
    • This follows from lockRecord: false. It is recorded in the flow docstring and as an admin tip.
  • Will Bid and step 8. Will Bid is held, as the card's done-when asks.
  • Boundary, as both sibling gates draw it: insert is not judged, and system writes are not judged. A deal created directly at a later stage, or a stage written by an integration with nobody signed in, is not stopped.
  • NOT MEASURED: an armed, unqualified deal and the CPQ path. quote_generation (a screen flow, under the rep's session) fast-forwards a pre-proposal deal to proposal. quote_on_accepted closes 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.
  • NOT MEASURED: a system-written requested_status before 立项. 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.
  • Outside the claim's file surface: the opportunities pages (3 files, one table row each). Without the row, the table's "accounts for every field" sentence would have been false.

Generated by Claude Code

claude added 4 commits October 3, 2026 05:24
…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
@vercel

vercel Bot commented Oct 3, 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 3, 2026 5:54am UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces configuration Build and app configuration files backend Server-side behaviour — hooks, flows, actions labels Oct 3, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 06:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 4807ed9 Oct 3, 2026
11 checks passed
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 configuration Build and app configuration files documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants