Skip to content

fix(quotes,hooks): refuse an acceptance an approval holds; draft a rep's contract; the capability gate and activity bubbles cross the caller's grants (runAs: 'system') - #2035

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-2014-round-2
Oct 9, 2026

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #2014
Clause-②: no

Second round of #2014, on the maintainer's ruling recorded in comment 6076828219 (batch hotcrm-R74b): F1 → C, F11 → B, plus the task_activity_bubble / event_activity_bubble pair. F9 is not in this PR: it waits on objectstack-ai/objectstack#22437 (AGENTS.md §2), and lead_auto_assign, case_auto_assign and lead_duplicate_check are untouched. This card stays open for F9.

What was measured before the fix (17.7.0, through the engine's write door)

Every reading below is a person's write through @objectstack/verify's hooks.run (the REST data ingress's path) on branch base 08cfa209.

F1, a sales rep accepts a quote on a deal under approval. The rep opens a 150,000 deal, so opportunity_approval_on_create opens a request whose approval node locks the deal (lockRecord: true). Presented → accepted is admitted. The close-won write is then refused RECORD_LOCKED ("Opportunity 'Big 2' is locked while an approval is in progress …"), and onError: 'log' swallows it. The contract insert is refused PERMISSION_DENIED (a rep holds crm_contract.allowCreate: false). Result: 0 contracts, the deal stays proposal / pending, and the quote reads Accepted, which freezes it.

On an unlocked deal, the rep's acceptance closes the deal closed_won and still drafts 0 contracts (the same PERMISSION_DENIED).

What holds the deal. A rep's own direct close-won, measured per state:

Deal state Rep's close-won
Large Deal Approval request pending (approval_status pending) refused RECORD_LOCKED (platform lock)
approval_status approved / rejected (a recall also leaves rejected) admitted
status_change_approval_status pending / rejected (gate armed) refused RECORD_LOCKED (opportunity_lifecycle); with a request open the platform lock holds too
status_change_approval_status approved admitted
qualification_approval_status pending / rejected (立项 armed) refused RECORD_LOCKED (opportunity_lifecycle). Its request does not lock the record (lockRecord: false); rep edits other than the stage land.
qualification_approval_status approved admitted
a system close-won on a deal pending its Large Deal Approval admitted (the lock exempts isSystem)

A rep cannot read sys_approval_request (PERMISSION_DENIED). So the gate reads the deal's own mirror columns as the caller, not the request table.

F11. An outsider rep cannot read a Settlement Only account (0 rows). Their new deal on it is STORED: under the caller's record access findOne answers null, and the gate stands down. The owner's identical deal is refused, and the refusal names the account (ACC-9001 - Tender Agency).

Activity bubble. A service agent completes a task on their own case. The account write is PERMISSION_DENIED, swallowed, and last_activity_date stays empty; the admin's identical completion stamps it. event_activity_bubble was measured the same way (round 1 did not): an agent's held call on their own case leaves the account empty while the admin's stamps it. A rep's completed task on their own deal, whose account and contact a manager owns, stamps neither.

The fix, and why each part is the minimal one

  • F1, ruled C. The acceptance is refused at the moment it is made (quote_workflow, beforeUpdate, user writes only). It throws RECORD_LOCKED / 409, an existing code; no code is minted. Example message: "Quote QTE-0007 - Q-3 cannot be accepted while its opportunity is held by an approval (Approval Status is Pending): accepting it would close that deal as won. Accept it once the approval is granted."
    • "Held" mirrors the table above and goes no wider: approval_status pending, or either REQ-0006 verdict pending / rejected, on a deal that is not already closed.
    • It reads the deal as the caller, so nothing is elevated. A deal the caller cannot read is a non-approval failure and stands down.
    • A system acceptance is not judged, because neither the lock nor the app gates judge a system close-won.
  • The contract draft moves out of quote_on_accepted into its own hook, quote_accepted_contract_draft (runAs: 'system'), with its rule-9 reason beside it. The rep's acceptance gets its draft, and the rep still cannot create a contract by hand.
    • Close-won stays the caller's write in quote_on_accepted and is not elevated. Elevating it would let a system write close a locked deal.
    • Rule 10: the draft issues no read. Its insert is stamped from the trigger's tenant, which withRunAs('system') carries through, and test/hook-org-inheritance.test.ts pins that on a walled deployment.
  • F11, ruled B. opportunity_account_capability declares runAs: 'system' and reads only commercial_capability. Its refusal says "this account" and names no number, name or id. The read is pinned to the new deal's organization_id when it carries one.
  • Bubble. task_activity_bubble and event_activity_bubble declare runAs: 'system'. Every walk-up read is pinned to the triggering record's organization_id.
    • A by-id update cannot carry an extra where key (the engine refuses it), so each write target is first confirmed inside the organization by one pinned read per object.
    • The case's first-response stamp is written only when its pinned read found the case.
  • Token ratchet. src/revenue authored total is 18,990 against a ceiling of 19,000, paid by compacting quote.hook.ts code (comments are not counted). No ceiling moved. The src/sales headline (~57k / ~28k / ~101k) is unchanged.

The tests that pin each business fact

  • test/hooks-runtime-sales.test.ts
    • The refused acceptance. A rep, a real Large Deal Approval: RECORD_LOCKED/409, the message names the hold and the quote's title and never an id. The quote stays presented, the deal stays proposal/pending, 0 contracts, and no contract insert or opportunity update reaches the engine.
    • "No wider." Nine states, each measured both ways on twin deals: the rep's own close-won is the control, and the acceptance is refused in exactly the states where it is refused.
    • The contract drafted for a rep's acceptance on an unlocked deal (created_by = the rep). The rep's own contract insert is still PERMISSION_DENIED.
  • test/opportunity-account-capability-gate.test.ts: the outsider is refused, and nothing it read under elevation (number, name, title, id) is echoed. The raw-id-in-prose class survives in five more places after #1208 — 15 of 31 tasks in a demo org still name a record by its primary key #1243's naming case is re-pinned to "this account". The message has one locale; the three docs locales are updated.
  • test/hooks-runtime-service.test.ts, test/activity-recency.test.ts: an agent's completed task and held call on their own case stamp the case account (updated_by = the agent).
  • test/case-first-response.test.ts: a writer who cannot read the case still stamps its first response. The two best-effort cases are now staged faults on the real engine, since the caller's grants no longer refuse anything.

Ablation. One-off, through scripts/ablation-replace.mjs: the anchor hit x1 → x0, every restore was proven by blob == HEAD with an empty git diff HEAD, and no permanent file was kept.

Mutation Result
Gate off the 4 held rows and the Large Deal case go red; the 5 free rows stay green
runAs → inherit on the draft the rep-draft case goes red
runAs → inherit on the gate the outsider case goes red
runAs → inherit on the task bubble the agent task case goes red
runAs → inherit on the event bubble the agent call and first-response cases go red

The organization pins were proven on a two-organization boot with a tenant-less (system) trigger. With the pins, a task in org A naming B's account stamps only A, and a held call in A naming B's case stamps neither B's account nor B's case. Removing each pin turns its case red (B stamped).

File surface beyond the claim, and why

  • src/revenue/objects/contract.object.ts and src/sales/objects/_picklists.ts (comments only): they name the hook that performs the payment-terms carry-over, which is now quote_accepted_contract_draft. test/quote-accepted-payment-terms.test.ts pins that name.
  • content/docs/sales/accounts*.mdx: the dispatch names the Settlement Only refusal docs, and those pages said the refusal names the account.
  • Comment renames only, where a comment named the old drafter: test/contract-write-depth.test.ts, test/record-id-not-in-prose.test.ts, test/quote-contact-required-when.test.ts.
  • test/case-first-response.test.ts: it pinned the caller-context behaviour this fix removes.
  • docs/feature-inventory.md: QUO-005 and QUO-006 name the split hooks.

Acceptance notes

  • Boundary, F1. The mirror covers the three approval flows this app ships on crm_opportunity. An install adding another locking approval there is not mirrored (narrower, not wider).
  • Boundary, F11. A tenant-less system insert of a deal in org A on org B's Settlement Only account was refused before (unscoped read). With the pin it is admitted, because the account sits outside the deal's organization. That is the same answer a person's tenant-scoped read already gave.
  • Not changed. A tenant-less system acceptance in a two-organization install drafts no contract: the platform refuses an organization-less insert. This was measured before and after, and nothing in the app accepts quotes as the system.
  • Stale comment. scripts/check-source-hygiene.mjs's comment says quote.hook.ts keeps two failures.push diagnostics. It keeps none now: both cascade throws name the quote by its label or number. Not edited here (a gate script).
  • test/hooks-runtime-service.test.ts is at 82% of the 100KB file cap (hygiene advisory).

Gates

  • pnpm verify on a83103b5: OS_VERIFY_LOCK_SLOT=hotcrm-2014 bash os-verify-lock.sh -c 'pnpm verify' → VERDICT command-exit 0 (held 477s).
    • ✓ Validation passed; tsc --noEmit clean; lint --strict 18 suggestions, no error; i18n gate 0 issues; ✓ source hygiene clean; ✓ source token ratchet clean; ✓ Build complete.
    • Test Files 172 passed (172), Tests 3589 passed | 1 skipped (3590).
  • An earlier verify on 73c712a3 was red on source hygiene: two record ids interpolated into the cascade diagnostics. Fixed in a83103b5.
  • NOT MEASURED locally, declared to CI: test:coverage thresholds, docs-app types:check, e2e, link-check.

Generated by Claude Code

claude added 3 commits October 9, 2026 08:41
…e contract draft, the capability gate and the activity bubbles

Accepting a quote closes its deal as won, and a rep's acceptance on a deal
held by an approval was admitted while its close-won met the lock inside an
async hook that swallowed it. quote_workflow now refuses that acceptance at
the moment it is made (RECORD_LOCKED / 409), naming the approval that holds
the deal, and exactly when the rep's own close-won would be refused.

The contract draft moves out of quote_on_accepted into its own elevated
hook, quote_accepted_contract_draft (runAs: 'system'), so a sales rep's
acceptance gets its contract; the close-won stays the caller's write.

opportunity_account_capability reads the account's verdict elevated and
refuses with "this account", naming none; task_activity_bubble and
event_activity_bubble write the recency clocks elevated. Every elevated read
is pinned to the triggering record's organization.

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

The split moved the two bare cascade throws out of the `failures.push` sink
the source hygiene gate recognises, and both interpolated a record id. They
now name the quote the way every screen does: the contract draft by its
`quote_number - name` label, the close-won by its quote number.

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:18am 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 backend Server-side behaviour — hooks, flows, actions labels Oct 9, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 09:29
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit a2f2a40 Oct 9, 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 documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants