Skip to content

feat(account): registration identifier, a capability gate, business profile and approval (REQ-0003) - #1948

Merged
os-elon-musk merged 3 commits into
mainfrom
claude/issue-1915-account-registration-category-approval-v2
Sep 16, 2026
Merged

os-elon-musk merged 3 commits into
mainfrom
claude/issue-1915-account-registration-category-approval-v2

Conversation

@os-elon-musk

Copy link
Copy Markdown
Collaborator

Part of #1915 — REQ-0003, the crm_account half of REQ-0002's steps 1, 3, 4 and 5.

Supersedes #1931, whose gate construct was overturned by the maintainer ruling below. This branch is re-cut from origin/main (a3fe341); #1931 is closed.

The ruling this PR carries

Maintainer, 2026-09-16 — reply 「其他同意」 to the PM's five-card analysis, of which this card's proposal read, verbatim:

闸门走 opportunity.hook.ts 的 beforeInsert,用 hook 的 ctx.api 读客户类别。REQ-0003 第 128 行的 validations[] 与该记录自己的验收第 2 条冲突,以验收为准。

REQ-0003 line 128 named a validations[] entry on crm_opportunity. That construct contradicts the same record's acceptance 2, which requires that "opportunities that already point at it keep working and keep being editable": a validation is evaluated against {...previous, ...data} on every write, so "already linked to a restricted account" and "linking now" are the same state, and every later edit to every historical opportunity would be refused. beforeInsert fires only on creation, so the transition gate is the construct rather than a claim about it — which is what the record's own line 126 asks for ("a transition gate, not an invariant"). lead.hook.ts carries the identical finding for the lead conversion gate.

A second, independent measurement points the same way: a validation predicate cannot read the account at all. It is evaluated over the opportunity's own stored columns, record.crm_account.<field> does not traverse the lookup (No such key), and os.lookup(...) has no overload because @objectstack/formula's buildScope() never binds ctx.api (objectstack#18318) — while an unevaluable predicate rejects the write.

⚠️ The hook's ctx.api is a different surface from that formula EvalContext.api. It is live on the pinned platform, used ~20 times across src/sales/objects/*.hook.ts, and pinned against a real engine by test/hook-query-predicate.test.ts / test/hook-write-shape.test.ts. objectstack#18318 does not block this card.

docs/requirements/0003-account-registration-category-and-approval.md's Product response transcribes that ruling, quoting it and the reasoning. No other section of the record is touched.

What landed

registration_number The counterparty's registered identity, country-neutral in name and label — the requirement arrived as 社会信用代码, but a Japanese install writes 法人番号 and a US one an EIN, and a field named after one registry cannot be renamed by any of them. Searchable + indexed (acceptance 1). ⛔ Not unique, following the soft shape this repo already chose for crm_lead.email.
commercial_capability + its gate Full / Settlement Only. A new opportunity may not be opened against a Settlement Only account; the refusal names the account and the remedy (acceptance 2). Generic values only — the customer's own vocabulary (招标代理公司 …) is ruled C.
Business Profile group incumbent_vendor, annual_purchasing_budget, payment_cycle in a new fieldGroups entry, so the layout stays derived — no record:details authored anywhere (acceptance 3).
approval_status + account-approval.flow.ts Ships armed (defaultValue: 'pending'), the inverse of its lead sibling, because acceptance 4 asks for it in as many words. The off switch is that one default, ⛔ not the flow's status.
i18n + docs Four locale packs; content/docs/sales/accounts and content/docs/administration/automation in en / zh-Hans / zh-Hant (acceptance 5).

Why the gate sits beside type and not inside it

type is a relationship-stage axis the system itself advances: opportunity.hook.ts flips a won deal's account to customer. An agency option on type would be overwritten by the first won deal booked against it, silently un-restricting the account. The two axes are independent — a Settlement Only account can be a prospect, a customer or a former customer.

The boundary, stated rather than left to be found

An UPDATE that re-points an existing opportunity onto a restricted account is not refused. That is the ruled construct, and it is what acceptance 2 asks for (the refusal is scoped to "linked to a new opportunity"). It is pinned as a deliberate non-refusal, not left silent.

Tests

pnpm verify — exit 0, the whole chain (validate → typecheck → lint → lint:i18n-gate → hygiene → hygiene:tokens → build → test). Exit codes were redirected to a file before any pipe.

✓ Validation passed        ✓ i18n lint gate: 0 `i18n/missing-*` issues
✓ source hygiene clean     ✓ source token ratchet clean
Test Files  172 passed (172)
     Tests  3685 passed | 1 skipped (3686)

Measured at fcccebd.

Reverse verification — both legs predicted, both measured

Each leg: mutate → prove the mutation reached disk (grep counts before/after + git hash-object) → run → restore → prove the restore (blob equals HEAD's, git diff HEAD empty).

Leg A — the construct the ruling rejected. events: ['beforeInsert'] → ['beforeInsert', 'beforeUpdate'], i.e. the gate re-evaluated on every write.

ON-DISK PROOF A: one-event spelling 1 -> 0 ; two-event spelling now 2 ; blob now b680157f
  × is declared beforeInsert-only — the transition gate IS the event list
  × lets an EXISTING opportunity on a restricted account be updated
  × re-pointing an existing opportunity is OUT of the gate, and that is stated
Tests  3 failed | 8 passed (11)
RESTORED ok: blob a20c2556, git diff HEAD empty

⚠️ The first run of leg A reddened only two of those three, and the fix is in this PR. The editability leg was passing under the widened gate because its payload was a sparse { amount } that never mentioned the account — green against the very construct it exists to rule out. A detail form posts every field it rendered, so the ordinary edit on a record sitting on a restricted account carries the restricted link in its own body; that is now the fixture, and it is what reddens. Reported rather than quietly re-run.

Leg B — the gate itself neutered (the settlement_only verdict can never match):

ON-DISK PROOF B: old spelling before=1 ; new spelling after=1 ; blob now dcc05c8b
  × refuses an opportunity opened against a Settlement Only account
  × names the account the way the UI names it, and never by its id (#1243)
  × refuses a SYSTEM write too — a gate only users trip is not a gate
Tests  3 failed | 8 passed (11)
RESTORED ok: blob a20c2556, git diff HEAD empty

Token ratchet

⛔ No ceiling was raised, no unrelated prose compressed, nothing hidden in an unmeasured directory.

✓ src/sales business semantics ~54,285 tokens (ceiling ~55,000; headroom ~715).
✓ src/sales interaction layer  ~29,477 tokens (ceiling ~31,000; headroom ~1,523).
✓ src/sales authored total     ~96,351 tokens (ceiling ~100,000; headroom ~3,649).

Baseline a3fe341 was ~52,379 with ~2,621 of headroom; this card spends ~1,906. Measured on the skeleton before the prose was written, as the dispatch required.

README banner restated — a measurement, not a budget change

The banner said business semantics ~50k; the gate now measures 54,285, outside the band 47,500–52,500. Per #1601 (「解耦:banner 钉实测,ceiling 独立」) the banner pins the measured reading and the ceiling stands on its own, so the banner is restated from the gate's own Headline line — ~54k, band 51,300–56,700 — together with the worked row in test/docs-readme-token-figures.test.ts's docstring that derives from it. ⛔ The guard itself was not touched, and the ceiling did not move.

docs/STATUS.md and the README rosters are re-copied at the source from this branch's own pnpm validate summary (343 fields, 31 flows), per the #1917 precedent — ⛔ not by editing test/docs-declared-versions.test.ts.

test/refusal-envelope.test.ts's swept-call-site count moves 18 → 19, the gate's refusal being the new one. That count is hand-maintained by design.

Changeset

minor, ⛔ not skip-changeset: six new fields, a new form section, a new approval flow and a new refusal are all user-visible metadata, shipped in every locale pack.

Known advisory, deliberate

pnpm validate reports the three Business Profile fields as "carrier-only — declared but nothing in this stack reads or displays it". The liveness diagnostic counts view columns, form sections, page bindings, flow nodes, formulas, hooks and actions as sites; it does not count fieldGroups membership — which is exactly the derived layout acceptance 3 demands. ⛔ Answering it with a record:details section would trade acceptance 3 for a quieter log. crm_article_feedback.comment carries the same verdict on main. No guard reads this warning; the reasoning is recorded beside the fields so the next agent does not tidy it away.

Acceptance notes


Generated by Claude Code

claude Bot and others added 3 commits September 16, 2026 08:38
…rofile and approval

REQ-0003 — the crm_account half of REQ-0002's steps 1, 3, 4 and 5.

- registration_number: the counterparty's registered identity, country-neutral
  by name and label, searchable and indexed. Not account_number, which is our
  own sequence and already half the record title.
- commercial_capability + the rule that reads it: a NEW opportunity may not be
  opened against a Settlement Only account. Authored as a beforeInsert hook in
  opportunity.hook.ts reading the account through ctx.api, per the maintainer
  ruling of 2026-09-16 — the event list IS the transition gate, so historical
  opportunities keep working and keep being editable (acceptance 2).
- Business Profile: incumbent_vendor, annual_purchasing_budget, payment_cycle,
  in a new fieldGroups entry so the layout stays derived (acceptance 3).
- approval_status + account-approval.flow.ts, shipping ARMED because acceptance
  4 asks for it — the inverse of its lead sibling, switchable from the field's
  own defaultValue rather than the flow's status.
- Four locale packs, the accounts and automation documentation in en/zh-Hans/
  zh-Hant, STATUS.md and README figures re-copied at the source.
- REQ-0003's Product response transcribes the ruling that replaced the
  validations[] construct it named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
…ve from

- opportunity-account-capability-gate: the re-point and absent-api legs were
  describing dispatches the engine never performs. Both now drive the write
  (the registered beforeUpdate chain, a ctx with no api) rather than calling
  the handler with a ctx the engine would not hand it.
- refusal-envelope: 18 -> 19 swept call sites, the gate's refusal being the new
  one. The count is hand-maintained on purpose.
- docs-readme-token-figures: the docstring band row follows the restated
  banner (~50k -> ~54k, band 51,300-56,700). Per #1601 the banner pins the
  measurement and the ceiling stands on its own, so this is a restatement of
  what the gate reports, not a budget change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
… form sends

Reverse verification found the leg green against the construct it exists to
rule out: widening the gate's events to beforeUpdate left it passing, because
its payload never mentioned the account. A detail form posts every field it
rendered, so the ordinary edit on a record sitting on a restricted account
carries the restricted link in its own body — that is now the fixture, and it
is the case that reddens under the widened events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
@vercel

vercel Bot commented Sep 16, 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 Sep 16, 2026 8:49am 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 Sep 16, 2026
@os-elon-musk
os-elon-musk marked this pull request as ready for review September 16, 2026 08:59
@os-elon-musk
os-elon-musk added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit 9531cde Sep 16, 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

Development

Successfully merging this pull request may close these issues.

1 participant