Skip to content

Track A · crm_opportunity: the 立项 (qualification) approval gate, which blocks stage advancement, bidding and won/lost until approved (REQ-0006 step 11), off by default #1995

Description

@objectstack-fleet

Part of #1904 (Track A). This is a follow-up to #1916.

Filing gate: ③ a task the maintainer ordered directly. In the repo:hotcrm seat's chat (session_01ER8ntXZhYebyQ66aXWdjfT) on 2026-10-03 at about 04:50Z, the seat asked whether REQ-0006's unbuilt step-11 gate needs a follow-up card. The maintainer answered, verbatim: 「立一张后续卡」.

Who acts on it: the repo:hotcrm seat dispatches one dev after PR #1950 merges. The two cards share src/sales/objects/opportunity.{object,hook}.ts, src/sales/flows/** and the four locale packs, and Track A is hard-serial, per #1916's own serial constraint.

Blocked-by: #1950 (shared file surface; Track A is hard-serial)
Restart-when: PR #1950 is merged — done when GET /repos/objectstack-ai/hotcrm/pulls/1950 answers "merged": true

What is missing, and the spec

The authoritative spec is docs/requirements/0006-opportunity-qualification-and-status-approval.md on main. ⛔ It is not re-decided here.

Done when

Duplicate check

All hotcrm issues, open and closed: 747 issues over 19 REST pages, read to the short page. Title and body were grepped for 立项|qualification approval|qualification gate|step 11|step-11: 2 hits, both closed and neither about this gate. #1911 is the Track A triage-filing card; #1185 is notify-node localization. Positive control: REQ-0006 hit 4.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round R71
    Session: session_01ER8ntXZhYebyQ66aXWdjfT
    Account: hotlong (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-1995-qualification-approval-gate
    Worktree: hotcrm-issue-1995
    Domain: repo:hotcrm (single-lane repo, no domain:* taxonomy)
    Seat: repo:hotcrm#1
    File surface: src/sales/objects/opportunity.{object,hook}.ts; one new src/sales/flows/opportunity-qualification-approval.flow.ts plus its index.ts and objectstack.composition.ts registration; src/sales/views/opportunity.view.ts only if a filter requires it; the four src/sales/translations/*/objects.pipeline.ts; the qualification and automation docs pages in 3 locales; README.md / docs/STATUS.md flow counts; the guards under test/ that pin them, plus one new runtime test; one .changeset/1995-*.md (stop on breach; explain in the report)
    Container & model: L, mode:subagent, model: default (dispatch-gates --tier --repo objectstack-ai/hotcrm answers "no path-derived mandate … the tier stays the PM's per-card judgment call"; a gate with design judgement)
    Clause-②: no. This is app metadata on crm_opportunity and adds one flow; it touches no published schema and no accept set.
    Thread-read: none
    Serial constraints cleared: Restart-when: holds. PR #1950 is merged (4e072fd0, "merged": true), so pm:blocked → pm:dispatched in one write. The only open PR is dependabot #1962 (package.json, the lockfiles, apps/docs/**, one test-helper comment), which is disjoint. Track A has nothing else in flight.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 1995,
      "status": "done",
      "branch": "claude/issue-1995-qualification-approval-gate",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/1996",
      "session": "session_01ER8ntXZhYebyQ66aXWdjfT — R71 dev run, dispatched by the repo:hotcrm PM seat (subagent: the parent's id)",
      "premise_still_valid": true,
      "summary": "Built the REQ-0006 step-11 立项 gate on crm_opportunity, OFF by default, on the step-14 pattern without forking it. Two fields in the qualification group: qualification_approval_status (readonly verdict; its defaultValue not_required IS the switch) and qualification_requested (the rep's request, boolean, defaultValue false so the transition is visible on every driver). One flow, opportunity_qualification_approval (record_change after-update; opens one approval on a NEW request while the verdict is pending/rejected; has()-guarded, fail-closed; runAs system; admin_rescue; approvers sales_manager; approve stamps approved; reject stamps rejected and unticks the request). One hook branch in opportunity_lifecycle above the step-14 block: while the verdict reads pending/rejected (input-first), a user write that changes stage, records will_bid or raises a new requested_status is refused RECORD_LOCKED/409; every other edit stays open. Both new columns joined APPROVAL_FIELDS (the flow stamps under the triggering user; a deal closed meanwhile by an unjudged system write would otherwise refuse the stamp). One deliberate term differs from the step-14 gate: lockRecord false, taken from the lead gate on step 11's 「新增商机可跟进」 (and hypothesis 2's every-other-edit-stays-open while pending). Hypotheses 1-6 all held: 立项 comes first with both gates armed, the step-14 gate is unchanged once 立项 is approved, the 立项 write-back trips neither gate nor the freeze, either gate alone leaves the other inert; off by default proven by the suite plus a fresh boot. The three edited existing tests are exactly the hand-maintained count/rosters hypothesis 6 named. New runtime file pins the matrix (off/off, 立项 pending and rejected, 立项 approved, status only, both before and after 立项). Measured live, beyond the dispatch: a user-supplied readonly verdict is stripped BEFORE beforeUpdate hooks (so input-first cannot be fed approved), and the platform holds one pending approval per record, so an amount crossing $100K during an open 立项 request fails the amount flow's run (DUPLICATE_REQUEST) and the amount approval opens on the next write (the 立项 verdict stamp); recorded in the flow docstring and an admin tip.",
      "tests": "FULL VERDICT on HEAD 8aca1c64 (after `git merge origin/main` d4a3cafd/#1962, no conflict): `OS_VERIFY_LOCK_SLOT=hotcrm-issue-1995 bash /home/user/objectstack/scripts/pm/os-verify-lock.sh -c 'git rev-parse --short HEAD && pnpm verify'` printed 8aca1c64 · 'Validation passed' (Data: 18 Objects 363 Fields, Logic: 32 Flows) · lint '1 warning(s), 18 suggestion(s)' (the 18th = the new approval node's approvers-may-resolve-empty note, same class as its siblings; build author-time warnings 9, same note) · '0 i18n/missing-* issues' · 'source hygiene clean' · 'source token ratchet clean' · 'Build complete' · 'Test Files 176 passed (176)' · 'Tests 3787 passed | 1 skipped (3788)' · 'os-verify-lock: VERDICT command-exit 0'. Same command on e6c973c6 (pre-merge): VERDICT command-exit 0, 176 files. New file test/opportunity-qualification-approval-gate.test.ts: 47 tests (75 with the step-14 sibling file run beside it, all green). TOKENS src/sales (business semantics / interaction / authored total): main 4e072fd0 55,279 / 27,996 / 98,761 -> skeleton b739157d (fields+flow+hook, before docs) 56,505 / 27,996 / 99,987 -> final 8aca1c64 56,505 / 27,996 / 99,987; ceilings 59,000 / 31,000 / 107,000 (headroom 2,495 / 3,004 / 7,013); no ceiling raised. ABLATIONS (node /home/user/objectstack/scripts/ablation-replace.mjs; anchor x1->x0, blob changed; restore proven blob == HEAD and git diff HEAD empty; tests import src/ directly so no build/dist leg): (1) rejected removed from the hook refusal -> predicted 6 red, observed 6/75 red (the five held acts on a rejected deal + the matrix rejected row); (2) transition term deleted from the start condition -> predicted 2, observed 2/75 (own pending stamp; invisible prior row); (3) both columns removed from APPROVAL_FIELDS -> predicted 1, observed 1/75 ('Opportunity Big Deal is closed (closed_won); ... Attempted: qualification_approval_status.'). ENGINE PROBE (scratch test, not committed; real ObjectQL + the shipped hook): user update {stage, qualification_approval_status: approved} reached the hooks as keys [id, stage], refused RECORD_LOCKED 409, stage and verdict unchanged. BOOTS (fresh 17.6.0, port 4816, fresh DB each; served object/flow metadata == the artifact booted; armed artifacts built from LOCAL never-pushed throwaways via ablation-replace --hold, built with -o to scratch, source restored blob == HEAD before boot): GATE OFF (branch artifact): new $5,000 deal born not_required / request false; stage move 200, will_bid 200, tick 200, direct close 200; 0 approval requests (positive control: $150,000 raise opened 1 flow:opportunity_approval, 立项 verdict stayed not_required). 立项 ARMED: born pending; stage move / will_bid / direct close 409 RECORD_LOCKED, stage unchanged; amount+next_step+customer_background 200; tick -> exactly 1 flow:opportunity_qualification_approval pending, still 1 after 3 s more; edit while it waits 200 (not locked); stage move while it waits 409; approve -> approved; then stage move 200, will_bid 200, direct close 200 (closed_won). Reject (2nd deal) -> rejected, request false; stage move 409, direct close 409; re-tick -> new pending request beside the rejected one. Console (Chromium /opt/pw-browsers, Details edit mode): Stage=Qualification Save sent {stage: qualification} -> 409 and the Save bar showed 'This deal needs qualification approval first: tick Request Qualification Approval. Stage can change once it is approved.'; Will Bid toggle -> 409; ticking Request Qualification Approval -> 200 + 1 pending request; the console sends only the changed key. Re-entry: 0 're-entered for the same record' warnings across both armed boots; positive control (term deleted, own throwaway) 1 per request. Amount interplay: raise to $150,000 during an open 立项 request -> opportunity_approval run failed DUPLICATE_REQUEST (logged), approval_status stayed not_required; approving 立项 -> the amount request opened (approval_status pending). BOTH ARMED: born pending/pending; requested_status -> 409 with the 立项 sentence ('Requested Status can change once it is approved'); direct close -> 409 立项 sentence; 0 requests; tick+approve -> 立项 approved; direct close -> 409 step-14 sentence; requested_status closed_won -> 200 + 1 flow:opportunity_status_change_approval pending, stage stayed negotiation; approve -> closed_won / status approved. All servers stopped by recorded PID trees. CI on 8aca1c64 at report time: Check Changeset, Quality Checks, Typecheck and Build, Label Pull Request, link-check success; Build and Test (22.x), Playwright, Analyze Code in_progress (not waited for).",
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "3 relay writes as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/hotcrm/pulls via fleet-write/dispatch.mjs pr_create (draft #1996, relay run 37101331713, body read back identical, 14017 bytes); (2) POST /repos/objectstack-ai/hotcrm/issues/1996/assignees via label-write.mjs --assign hotlong (relay run 37101384784, read back MATCHES; zero label writes — the five labels on #1996 are the labeler's); (3) POST /repos/objectstack-ai/hotcrm/issues/1995/comments via post-stamped.mjs (this report). Plus 5 git pushes of the branch (no force, no rebase, no amend). Reads: REST GETs on issue #1995 and its comments, claim 5965843747, PR #1950, comments 5965815876 and 5965611444, PR #1996, check-runs on 8aca1c64.",
      "open_questions": [
        {
          "question": "Will Bid is held until 立项 is approved, as the card's done-when and dispatch hypothesis 2(b) say. Step 8, though, has the rep fill 是否投标 on the new-deal form BEFORE approval (it is input to 立项), and on 17.6.0 the New dialog renders no { group } tab (objectstack-ai/objectstack#21543), so through the UI Will Bid can only be set in Details edit mode after creation — with the gate armed, that waits for 立项. Keep holding it?",
          "options": [
            "A. Keep as built: hold Will Bid on update; insert is not judged, so step 8's new-deal path stays open once the platform renders the group tab (a platform gap, waited for, not routed around).",
            "B. Release Will Bid: hold only stage and the won/lost call, reading 投标 as the act of bidding, which HotCRM does not model."
          ],
          "recommendation": "A, because it is what the card and the customer's literal step 11 list (投标), it is consistent with step 8 by construction (insert is open), and the only friction is the measured platform gap, which AGENTS.md §2 says to wait for. Not a blocker; recorded in the PR's Acceptance notes."
        }
      ],
      "out_of_scope_findings": [
        "carrier: whoever arms the 立项 gate · noted, not filed, NOT MEASURED — quote_generation (screen flow, the rep's session) fast-forwards a pre-proposal deal to proposal, and quote_on_accepted closes the deal under the accepter's session; on an armed, unqualified deal this gate refuses both writes. The gate ships off; same class #1950 recorded for its own gate. In PR #1996 Acceptance notes.",
        "carrier: none · noted, not filed, NOT MEASURED — with both gates armed, a no-session write of requested_status before 立项 would open a status-change approval: the step-14 start condition does not read the 立项 column and was left unchanged by ruling. The order holds for user writes, which the matrix pins. In PR #1996 Acceptance notes."
      ],
      "deviations": [
        "One #1950 term differs on purpose: lockRecord false (the step-14 node has true). Reason written beside the node and in the PR: step 11 「新增商机可跟进」, the lead gate's precedent, and the hook refusing the three acts whether or not a request is open. Pinned in the runtime test.",
        "File surface: content/docs/sales/opportunities{,.zh-Hans,.zh-Hant}.mdx is outside the claim's declared surface (one field-group table row each, adding the two fields; the table says it accounts for every field).",
        "Merged origin/main d4a3cafd (#1962, dependabot; package.json, lockfiles, apps/docs, one test-helper comment) before the final verify; no conflict; pnpm install re-run.",
        "The first both-gates throwaway flip was a usage error (I passed --count; the flag is --expect): exit 2, nothing written, its build output discarded, restore proven blob == HEAD. Redone with two separately anchored --hold flips.",
        "PR footer is the session-URL form the agent contract and the #1950 precedent use, not the harness reminder's 'Generated with' lines; the agent instructions take precedence."
      ],
      "files_changed_vs_main": ".changeset/1995-qualification-approval-gate.md (new), README.md, content/docs/administration/automation{,.zh-Hans,.zh-Hant}.mdx, content/docs/sales/opportunities{,.zh-Hans,.zh-Hant}.mdx, content/docs/sales/opportunity-qualification{,.zh-Hans,.zh-Hant}.mdx, docs/STATUS.md, objectstack.composition.ts, src/sales/flows/index.ts, src/sales/flows/opportunity-qualification-approval.flow.ts (new), src/sales/objects/opportunity.hook.ts, src/sales/objects/opportunity.object.ts, src/sales/translations/{en,es-ES,ja-JP,zh-CN}/objects.pipeline.ts, test/automation-docs-coverage.test.ts, test/opportunity-qualification-approval-gate.test.ts (new), test/refusal-envelope.test.ts, test/runtime-coverage.test.ts (25 files, +864/-22 vs main d4a3cafd)"
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 3, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT: PR #1996 at 8aca1c64. The REQ-0006 step-11 立项 gate on crm_opportunity, off by default. repo:hotcrm seat, session_01ER8ntXZhYebyQ66aXWdjfT, 2026-10-03T06:00Z. Report: the dev's os-dev-report 5966148535.

    Probes taken by the seat itself:

    • Ancestry and checks: main d4a3cafd is an ancestor of 8aca1c64. CI 10/10 success. Governed check: 0 of 25 paths, NOT governed; 886 lines.
    • Read line by line: the hook branch, the two fields and opportunity-qualification-approval.flow.ts.
      • Off by default: qualification_approval_status is readonly, defaultValue: 'not_required', and that is the switch. qualification_requested is a boolean with defaultValue: false, so has(previous.…) holds on every driver.
      • What the hook holds: while the verdict reads pending or rejected (read input-first), the branch refuses three user acts: a stage change, a will_bid change, and a new requested_status. Every other edit is open. It sits above the step-14 block, so 立项 comes first.
      • The flow: its start condition tests the off→on TRANSITION of the request, with has() guards, fail-closed. runAs: 'system', admin_rescue, the sales_manager bench. Approve stamps approved; reject stamps rejected and unticks the request.
      • Columns: both join APPROVAL_FIELDS, with the reason written beside them. Neither reuses another gate's column.
      • lockRecord: false differs from the step-14 node on purpose. The reason is written beside it: 「新增商机可跟进」, and the hook holds the three acts whether or not a request is open.

    The dev's measurements, accepted:

    • Ablations: removing rejected → 6/75 red; deleting the transition term → 2/75; dropping the APPROVAL_FIELDS membership → 1/75. Each restore is proven.
    • Engine probe: a hand-supplied approved verdict is stripped before the hooks run, so the input-first read cannot be fed one.
    • Boots on 17.6.0:
      • gate off: behaviour is as on main;
      • 立项 armed: 409s on the three acts, other edits 200, one approval per request, approve releases, reject re-locks and allows a re-request, 0 re-entries;
      • both armed: 立项 comes first, then the step-14 flow runs unchanged;
      • console: the 409 sentence shows in the Save bar.
    • Gates: pnpm verify VERDICT command-exit 0 at 8aca1c64 (176 files). Tokens: src/sales reads 56,505 / 27,996 / 99,987 against 59,000 / 31,000 / 107,000.

    The dev's open question, answered by the seat: hold Will Bid (option A, as built).

    Noted, not blocking (in the PR's Acceptance notes): on an armed, unqualified deal the gate also refuses quote_generation's stage fast-forward and quote_on_accepted's close. An amount crossing $100K during an open 立项 request opens its approval on the next write, because the platform allows one pending approval per record.

    Landing:

    • Authority: 「立一张后续卡」 (the card), and 「授权你执行pr合并」 at about 04:52Z.
    • Path: ready → auto-merge → queue.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions