# Object Proposal: Replace legacy `PoC__c` with unified `Credits__c`

**Proposer:** jieni-droid (PM)
**Admin lead:** TBD
**Status:** approved
**Created:** 2026-05-08 (original); 2026-05-16 (v3 reframe); 2026-05-16 (unified-credits redesign)
**Implementation PR:** #2055
**Source design:** condensed from 8 detailed docs at `/Users/jieni/Applications/claude/poc-refactor/docs/`. The technical execution details (Apex retirement plan, flow-by-flow mapping, migration scripts) stay there and will move into the implementation PR when work begins.

> This is a **refactor**, not a greenfield object. We're replacing the bloated legacy `PoC__c` (labeled "Cloud User Request" in the UI — the name is already wrong; covers three unrelated businesses) with **two clean, generic objects**: `Credits__c` (the unified credit-engagement object) and `Credits_Topup__c` (the master-detail child). PoC is the first and only **Credit_Type** active at launch, but the schema is designed so additional types (Trial / Customer Success / Marketing / Partner / ...) can be added as future PingCAP business lines need them — without renaming the object or re-doing the approval pattern.

> **Zero-Apex invariant**: this refactor retires the only two legacy Apex classes that touched PoC (`POCUpdateLeaderHandler` — approver routing; the Cloud billing callout class — post-approval webhook) and introduces no new ones. The end state is 100% declarative pack content (object schema, layouts, ProcessDefinitions, Flows, permissions, validation rules). The refactor inherits no Apex from the legacy SF implementation.

> **Naming clarification**: the proposal filename and PR title still carry "PoC" because the refactor is *triggered* by the PoC__c cleanup. The *result* is the generic `Credits__c` object. PoC is just one Credit_Type value out of N possible future values.

---

## 0. Phase status & roadmap (updated 2026-08-04; status notes §0.1 added 2026-07-13)

The body below is the original approved design. This section records what has actually **shipped** and what **Phase 3 / Phase 4** cover. **Where the body still calls a top-up behavior "Phase 2 / deferred", read it as shipped 2026-06-02** — the NX#947 rollup-summary + active-formula substrate gap is closed.

> **Terminology note (2026-08-04).** Body text written before 2026-06-02 uses "Phase 2" in an older sense — *"the later automation phase"* — because Phase 2 was redefined on 2026-06-02 as the top-up phase that shipped. Where the body assigns the **Cloud-billing webhook** to "Phase 2" or "Phase 2/3", that work is now **Phase 4**; those specific lines have been relabelled. Where the body assigns **approval notifications** to a later phase, that work shipped in Phase 3 on 2026-07-31. The phase list in this section is authoritative; body prose is design narrative and is not rewritten beyond phase labels.

- **Phase 1 — shipped.** `Credits__c` create (two entry points) → snapshot Flow → create-time approver stamping from `Region_All__c` (NeedsTriage fallback) → gated ProcessDefinition (Presales → Finance **AND-gate** → BG Leader) → lifecycle Flow (Approved→Active / Rejected→Cancelled) → manual Sales-Ops Active→Provisioned→Completed. Zero Apex. **Note (2026-06-03, §7.0):** the **Finance step is DORMANT at launch** — the per-record budget flag is set by nobody and hidden from all layouts, so the effective chain is **Presales → BG Leader**. Finance becomes a per-region annual-budget gate when that gate is reached (A-blueprint in §7.0).

- **Phase 2 — shipped (2026-06-02). No further feature work in Phase 2.** `Credits_Topup__c` master-detail child with:
  - its **own required validity window** — `Effective_Date__c` + `Expiration_Date__c`, AE-filled, **not** pre-filled (a top-up may extend the PoC time, so it carries dates that can differ from the parent; Phase 2 captures the dates only — no auto-propagation to the parent window, no cross-window validation);
  - auto-sequence + display name `Top-up #<n>`;
  - **cumulative-disbursement gating live** — `Approved_Topups__c` (rollup) + `Total_Disbursed__c` (formula) + `Cumulative_Disbursed__c` now populate (NX#947 closed), so the top-up's 3-step approval gates on the **running total** (parent.Total_Disbursed + this top-up);
  - "Add Top-up" action (lifecycle-gated to Active) + Top-ups related list on the parent;
  - Account / Opportunity / Org "Credits" related-list Quick Links; Opportunity picker Account-scoped (`runtime_scoped_lookup` + `Credits__VR_Opp_Matches_Account`); Credits list-view leads with `Name`; record-page layout grouped Initial Grant / Totals (incl. Top-ups) / Status / Snapshot.
  - Landed via #2272 #2275 #2277 #2292 #2296 #2305 #2317.
  - _Reverted in Phase 2:_ the #2265 Cloud_Org hint Flow + `Available_Cloud_Orgs__c` field (a RecordBeforeCreate Flow can't drive a pre-save picker UX) — reverted by #2273. **Disregard the 2026-06-01 "Cloud_Org picker hint" History entry as a shipped feature** (and any `Available_Cloud_Orgs__c` reference in §3 / §5).

- **Phase 3 — notifications shipped and verified in production; gating fix implemented and awaiting deployment. (Provisioning integration moved to Phase 4.)**
  1. **Approval-decision notifications — SHIPPED, verified in production 2026-07-31.** Step-assignment and decision notifications now deliver on **both** channels. Delivered as: the four notify Flows bound to `Submit` / `StepApproval` / `FinalApproval` / `FinalRejection` at the process level (#4342 templates + bindings), the in-app leg's required `link` (#4464), and the regional-ops recipient stamped from `Region_All__c.Region_Ops__c`. Recipients are approver (assignment), submitter (decision), and regional ops (final approval → reset the balance in the external billing system). Production live-fire on 2026-07-31: four terminal approvals produced **8 in-app + 8 email notifications, zero misses**. One upstream defect surfaced and was closed on the tenant side during rollout — a terminal-event notification snapshot is read as the acting approver, so an approver without object-level `Credits__c` Read silently produced an empty `$Record` and dropped every final-approval notification (upstream NexusCRM#4148; roster grants corrected, and `staging-inspection/scripts/check-approver-object-read.mjs` now guards the roster against recurrence).
  2. **Cross-Credits gating loophole fix — implemented, awaiting deployment (#4733).** The approval gate aggregates only *within one engagement* (a single `Credits__c`, or a parent plus its top-ups via `Cumulative_Disbursed__c`); it does **not** aggregate across *separate* `Credits__c` records for the same customer, so a fresh record instead of a top-up has each request gated on its own smaller amount. Production confirmed this is the **normal usage pattern, not an occasional dodge**: duplicate same-Org/same-Opportunity engagements exist and were each approved, and `Credits_Topup__c` adoption across the whole org was **zero** — the cumulative mechanism had never been exercised. Finalized rule: **a Cloud Org may hold at most one non-Cancelled `Credits__c` ledger**; while one exists, additional credit must be a top-up on it; only when every record for the org is Cancelled may a new ledger be created. This reframes `Credits__c` as the org's lifetime ledger, which makes `Cumulative_Disbursed__c` the org's lifetime total and satisfies the §12 aggregation requirement without a new cross-record mechanism. Landed on `main` via #4747 (stamp Flow + VR + guard test) and #4791 (keep "Add Top-up" reachable for the whole life of an open ledger); awaiting deployment.

- **Phase 4 — planned (not yet scoped):**
  1. **Cloud Platform provisioning integration** *(moved from Phase 3, 2026-08-04)*. Wire NexusCRM → Cloud billing so granted credits actually load onto the Cloud Org. **Ops-triggered, not auto-on-approve**: after approval (Lifecycle=Active), Sales Ops clicks a **"Ship" / provision action** that fires `CallWebhook` to Cloud billing and flips Lifecycle→Provisioned on success. (Fully-automatic on-approve provisioning remains a later phase.) Depends on the Cloud-billing endpoint + payload contract + the `CallWebhook` substrate (§7, §14). Moved because it is gated on an external contract that is not scheduled, while the Phase 3 ops notification covers the manual workflow in the meantime. **Live evidence that the manual leg is weak:** the lifecycle's back half went unused for months (every approved record sat at Active; `Provisioned`/`Completed` had never been reached) until the ops notification started asking for it explicitly — the write-back depends entirely on ops discipline, which is precisely what this integration removes. _(Qualified 2026-08-06: this evidence is about the **write-back**, and the record state cannot distinguish "credits were loaded late" from "credits were loaded promptly and the record was updated late". Both are removed by this integration, so the item stands, but its **urgency** depends on which one is happening, and that has not been asked of ops. Do not cite the 25-day approval→`Provisioned` figure as a customer-facing delay until it is.)_
  2. **Feishu (Lark) as a delivery and action surface.** Approval reaches people where they already work, and — more importantly — becomes *actionable* in place. **Architectural decision: NexusCRM remains the approval engine and the system of record; Feishu is a delivery + action surface, not a workflow owner.** This deliberately differs from the internal IT platform's (Helix) integration model, where Feishu owns the workflow and each application type maps to a Feishu approval template. Credits cannot adopt that model without re-expressing region-based approver stamping, the cumulative gate, and the one-ledger-per-org rule inside Feishu templates, and without creating a second source of truth for approval state. Sketch of scope (details deferred to Phase 4 planning):
     - interactive cards for the four notification points, carrying the org's cumulative disbursed total so the approver sees what the gate saw;
     - **approve / reject in place** via card buttons that deep-link back into NexusCRM with a one-time action token (the pattern Helix already uses — note their card buttons are deep links precisely because there is no Feishu interactive-card callback entry; their only inbound Feishu webhook is the approval callback);
     - the regional-ops card carries a **"provisioning done"** button that writes `Lifecycle→Provisioned`, closing the write-back gap described in item 1 at a fraction of its cost;
     - reminder / escalation for stalled approvals rides this channel (tenant backlog #4723; the authoritative pending-work source is `ProcessInstanceWorkitem`, which exists while and only while a task is open).
     - **Blocking question to answer first:** whether NexusCRM routes through the existing internal Feishu gateway or connects to Feishu directly. That gateway already owns the credential store, the process-wide `tenant_access_token` cache, the systemic-error circuit breaker, a durable outbox with backoff/lease/dedup, the interactive-card builder, email→`open_id` resolution, and one-time action tokens. Connecting directly means rebuilding all of it and duplicating the single-tenant app credential across a second system. Our side already has the matching rails: `_System_ExternalDeliveryAttempt` is channel-generic with replay support, `CallWebhook` carries an allowlist/SSRF guard, and `_System_NamedCredential` exists. Whether that gateway exposes an inbound "send card" API is not documented and must be settled with its owners before this item can be scoped.

### 0.1 Status notes (2026-07-13) — pre-Phase-3 verification pass

Claims below were verified against merged PRs, live issue state, and `origin/main` tree state before Phase 3 kickoff. The body was written before these changes; where it disagrees, this section wins. **Precedence is directional, not absolute (clarified 2026-08-04):** this section supersedes the *original body*, but it is itself a dated snapshot — where a **later** dated entry in §0 or in History disagrees with a note here, the later entry wins. Notes below carry their own supersession markers where events have overtaken them.

**Numbering note (2026-08-04):** these dated notes use the pre-2026-08-04 Phase 3 numbering (1 = provisioning, 2 = notifications, 3 = gating). Provisioning has since moved to Phase 4, so current Phase 3 is 1 = notifications, 2 = gating. Item references below are left as written.

1. **Submission UX is now explicit "Save & Submit" (shipped 2026-07-09).** app-packs#337 (L1) added an opt-in **Save & Submit** primary button on the create form — create + submit-for-approval as two sequential API calls, landing once on the settled detail page — and removed the transitional detail-page approval state machine. pack#3763 (L2) declares the `SaveAndSubmit` ui-action for `Account`, `Credits__c`, and `Credits_Topup__c` (`packs/Salesforce-PingCAP-L2/actions/ui_actions.json`) and **retired the interim `Credits_Auto_Submit_Approval` Flow**, an auto-submit experiment that never reached prod (and was never recorded in this doc — noted here for the archaeology). The manual Submit action on the record page is unchanged, so the §6 "AE submits for approval" narrative and the §7 create-time-stamping / engine-ordering analysis remain valid; the create-form path is an addition, not a replacement.
2. **Phase 3 item 2 splits into two halves with different readiness (corrected 2026-07-13 — an earlier same-day version of this note overstated the blockage as covering the whole item).** The item requires **both email and in-app** notification at each step assignment and at the final decision.

   > **SUPERSEDED 2026-08-04 — both halves are live in production; read this note as history.** The readiness split below was accurate on 2026-07-13 and has since been overtaken by events. Production delivery-attempt rows show the email transport flipping over: **13 `failed` attempts between 2026-07-14 and 2026-07-17, then 72 `success` from 2026-07-16 onward** (latest 2026-08-04) — so the "prod has no SMTP transport" caveat below is no longer true. The in-app half unblocked when the upstream `SendNotification` Flow action shipped (NexusCRM#3627 for NX#930); production `Notification` rows begin **2026-07-27** and stand at 18. End-to-end verification on **2026-07-31**: four terminal approvals produced 8 in-app + 8 email notifications with zero misses. See the Phase 3 entry in §0 and the 2026-07-31 / 2026-08-04 History entries for current state.
   - **Email half — expressible today, no upstream dependency.** The approval engine's `ProcessAction ActionType='Flow'` bridge fires on all lifecycle events (`Submit / StepApproval / StepRejection / FinalApproval / FinalRejection / Recall / Reassign`), post-commit best-effort. Crucially for gated chains: on step advance the engine executes the definition-level `StepApproval` batch with the **post-gating `nextActorIDs`** injected as the `Approval.ApproverUserIds` Flow variable (`nexuscrm-automation/approval_action_process.go`), so a notification Flow can email **exactly the newly-assigned approver(s)** even when steps are conditionally skipped; `Approval.SubmitterUserId` covers the decision email back to the submitter; `resolveEmailRecipients` accepts `userIds` inputs. **Scaffold caveat**: the Setup UI scaffold (app-packs#298, merged 2026-07-02, generates `ApprovalEmail_*` Flows) only offers static/field-based recipient kinds (`user` / `role` / `group` / `emailAddress` / `lookupEmailField` / `lookupOwnerEmail` / `userLookupField` — `emailAlertRecipientTypeRegistry.ts`), so it **cannot** emit a `userIds: Approval.ApproverUserIds` input; a scaffolded field-based recipient on a gated chain can notify a skipped/wrong approver. **Root-fix decision (2026-07-13): app-packs#366 filed** — add approval-context recipient kinds ("assigned approvers" → `Approval.ApproverUserIds`, "submitter" → `Approval.SubmitterUserId`) to the scaffold registry/serialization. Phase 3 will **not** hand-author per-tenant workaround Flows to compensate for the scaffold gap; Credits assignment/decision email configuration waits on #366 (unless the item-3 ownership decision lands on pack-seeded Flow JSON, in which case the pack authors the Flow directly against the runtime contract and #366 remains the admin-UI parity fix). The non-admin-approver metadata fix landed upstream 2026-07-06 (NX PR #3474, root fix of pack#3661). The §0 item-2 dependency line ("Depends on … NX#930 + an email channel") is stale for this half: the email channel exists (`sendEmail` + SMTP env transport) and NX#930 is irrelevant to email. **Delivery caveats**: staging is permanently mailpit capture-only (decision 2026-07-13 — no real mail ever leaves staging; pack#3610 closed the same day without enabling real delivery), and prod has **no SMTP transport configured**, so prod email delivery needs its own transport decision before this half ships to prod (undelivered sends are recorded as failed `_System_ExternalDeliveryAttempt` rows).
   - **In-app half — still blocked upstream.** The approval engine writes no `Notification` rows; the notification-Flow sandbox rejects all record mutation; `Notification` is a system table no Flow can target; the only production writer is the comment @mention path. NX#930 (`SendNotification` Flow action) remains open with zero traction; **NX#3619 (filed 2026-07-13)** proposes engine-native assignment/decision notifications instead, without opening the sandbox to record mutation. Prod bell delivery pipe was separately fixed by pack#3773 (merged 2026-07-10), effective with the next deploy. NX#947 re-verified closed — the §0 Phase 2 claims stand.
3. **Approval-chain ownership caveat for Phase 3 implementers** (especially item 3, which changes gating). In staging/prod, the live approval chain — `ProcessDefinition` `PoC_Credits_Approval_Process` + its `ProcessNode` / `ProcessAction` rows — and the Credits permission matrix exist **only via hand-applied patch bundles**: `docs/migration/patch-bundles/20260531_staging_green_customsetting_column/007_credits_approval_and_permissions.sql` (parent chain + permsets), `docs/migration/patch-bundles/20260602_credits_topup_approval/001_credits_topup_approval.sql` (top-up chain), and `docs/migration/patch-bundles/20260603_credits_1466_followups/001_credits_finance_salesops_viewall.sql` (the Story #1466 R16 follow-up that **corrects 007's permission matrix** — Finance Ops `ViewAll`, Sales Ops `ViewAll`/`ModifyAll`; applied to staging and prod). Anyone inspecting or replaying the bundles must include the 20260603 correction — 007 alone carries the stale `ViewAll=0` values that broke cross-team approval-queue and Sales Ops access. They are **not pack content**: `packs/Salesforce-PingCAP-L2` mentions ProcessDefinition only in field descriptions, the deploy baseline seed carries zero ProcessDefinition rows. Consequences: a fresh local bootstrap does **not** reproduce the staging/prod approval chain (the Story #1466 R16 lesson: `local COUNT(ProcessAction)=0 vs staging=8`); where §7 / §14 say "the seed", read "the staging/prod patch bundle" for environment state; and any Phase 3 change to step criteria or the permission matrix must start with a read-only probe of the target environment, plus a decision on whether Phase 3 finally moves the chain into seedable pack/deploy content or ships as another patch bundle.

---

## 1. Business purpose

PingCAP grants credits to customers (and prospects) to run engagements on PingCAP Cloud. **PoC credits is one such program**; other credit programs (Customer Success credits for onboarding / training, Trial credits for free-trial conversions, Marketing credits, Partner credits) are either already active in shadow workflows or planned for the next year. The future-state goal is **a single home in NexusCRM for all credit grants**, with consistent approval / audit / billing-integration patterns.

### Today

Sales-issued PoC credits live in a 4-year-old `PoC__c` object that has grown 8 RecordTypes covering three unrelated businesses:

- **Sales-issued PoC credits** (real volume — 75 new in 2026)
- **Customer self-serve from the website** (almost dead — 5 new in 2026, down from hundreds)
- **Marketing / SDR intake** (demos, feedback forms — dead, 0 new in 2026)

Mess that followed:
- 36 cloned approval processes (only 5 active; 31 abandoned but not cleaned)
- 18 overlapping flows doing pieces of the same job
- 165 reports, most unused
- Cloud Portal's API is welded directly to this object
- Admin can't tell which RecordType to touch when they want to add a picklist value

The other Credit programs today live entirely offline — spreadsheets, emails, ad-hoc Slack approvals. No system of record.

### What the refactor delivers

Replace the legacy `PoC__c` mess with **one generic credit-engagement object** that the PoC program runs on **today** and that the other credit programs can adopt **as they need a system of record**:

- **`Credits__c`** (parent) — any credit engagement, distinguished by a `Credit_Type__c` field
- **`Credits_Topup__c`** (master-detail child) — incremental top-ups to any credit engagement
- **`Credit_Type__c` picklist** — starts with the single value `PoC` at launch. New values added via Pack PR as future programs adopt the system (per RFC v3 / #1402 workflow)

The two "other businesses" currently bundled into legacy PoC__c (customer self-serve from website, marketing intake) move to **standard Lead + Campaign**, not to `Credits__c` — they're not credit grants, they're intake records that look like credits but aren't.

`Credits__c` lookups properly to **PingCAP Cloud Org records** (`Org__c`, existing in pack) — replacing the legacy string `Cloud_Account_ID__c` field. This gives downstream systems a real relationship to follow, and the Cloud-billing webhook becomes "update Org.remaining_points" rather than "look up an org by string ID".

### End state

**2 clean objects, 1 unified approval pattern** (one ProcessDefinition per active Credit_Type — at launch, one PD for PoC), **no Cloud Portal dependency, 90-day grace for old reports**, 6 weeks of work, no NexusCRM platform changes, zero Apex, **schema ready for future credit types without renaming**.

---

## 2. Data ownership

The AE (or program owner — for non-PoC types, this might be a CSM or marketing manager rather than an AE) who initiated the engagement owns the record. Their manager sees it through normal role-based access. Finance can read everything (they're an approval step). Sales Ops can edit across teams when they need to clean up batch data. Marketing and CSM don't need access by default (read-only context where relevant).

Region matters: a credit grant in APAC is approved by APAC leadership, EMEA by EMEA, etc. We need a clean way to route approvals by region without cloning the whole approval process per region (which is what the legacy did wrong). This applies across **all** Credit_Types — region is a structural concern, not a PoC-specific one.

---

## 3. Fields

Two objects, parent and child. The parent (`Credits__c`) records the credit engagement; the child (`Credits_Topup__c`) records each top-up request.

### Parent — credit engagement

- **`Credit_Type__c`** picklist — at launch one value: `PoC`. Add values via Pack PR as future programs adopt the system. **`updateable: false`** (locked after create — see snapshot rule below).
- **`Account__c`** — required lookup. The master context for any credit engagement.
- **`Opportunity__c`** — optional lookup at schema level. Business rule (validation rule, §6): required when `Credit_Type__c = 'PoC'`. Other future types may not have an Opportunity.
- **`Cloud_Org__c`** — **schema-level required across all Credit_Types** (every credit grant lands on a Cloud Org — Trial, Marketing, CS, Partner all share the Cloud-billing target model; no Credit_Type opts out). Lookup to existing `Org__c` (the PingCAP Cloud org). **Lookup is filtered**: the dropdown picker only shows Orgs whose `Account__c` matches the current Credits record's `Account__c` — AE never sees Orgs from other customers, and a validation rule (§6) blocks the cross-Account mismatch case (e.g., AE changing Account after picking an Org). **Replaces the legacy `Cloud_Account_ID__c` text field** — gives the system a real, scoped relationship and lets the Cloud-billing webhook (Phase 4) work against `Org.remaining_points__c`.
- **Snapshot fields** stamped at create time, all `updateable: false`: `Account_Name__c`, `Account_Owner__c` (User lookup — the Account's owner at grant time; renamed from the earlier "AE_Owner" framing because the canonical source is Account.OwnerId, not the submitting user), `Opportunity_Name__c`, `Region__c`, `Industry__c`, `Vertical__c`. The transitive Account → snapshot-fields lookup is performed by a before-save Create Flow (see §6), not by the entry-point ui_action, so both entries produce identical snapshots regardless of where the user started. Customer_Segment and Currency are intentionally not snapshotted at Phase 1 — see §3 USD-only note and §12 Open Questions.
- **Initial credit amount** — USD. PingCAP's PoC program is USD-only at Phase 1; no native/USD-equivalent dual-field machinery ships. If a non-USD program is added under `Credit_Type__c` later, currency handling re-enters then.
- **Three pre-resolved approver lookups** (`Presales_Manager_User__c` + `Finance_Approver_User__c` + `BG_Leader_User__c`) — stamped by a Flow **at create time** so the approval engine resolves approvers by reading these directly when AE clicks Submit. (Stamping moved from submit-time to create-time after substrate code-review revealed the engine reads approver fields BEFORE the Submit FieldUpdate fires — see §7.)
- **`Requires_Finance_Approval__c`** boolean — **DORMANT at launch (§7.0): set by nobody, hidden from all layouts.** Intended as the hook for the future per-region annual-budget Finance gate (offline-tracked); when re-enabled it is stamped from a `Region_All__c` toggle at submit time, not armed per record. See §7.0.
- **Running totals** — total disbursed (Initial + sum of approved top-ups) and approved-top-up sum (rollup). All amounts in USD. **Phase 1 substrate caveat**: `Approved_Topups__c` (rollupsummary) and `Total_Disbursed__c` (active formula) are stripped from the merged pack by the build script today (see §14 + upstream NX#947). Phase 1 ships forward-compatible declarations; the values come online once the substrate gap closes.
- **Status fields** — `Credit_Lifecycle__c` (Draft → Active → Provisioned → Completed / Cancelled) and `Approval_Status__c` (NotSubmitted / Pending / Approved / Rejected / NeedsTriage). Two distinct fields: the first tracks the credit's business lifecycle (what the AE / list view / report cares about); the second tracks the approval engine's workflow state (what the approval inbox cares about). Both are generic across Credit_Types — neither value set is PoC-specific. Transitions: Active is Flow-stamped on `Approval_Status='Approved'`; Cancelled is Flow-stamped on `Approval_Status='Rejected'`; Active → Provisioned (credits live on Cloud) and Provisioned → Completed (credits used up / expired) are manual flips by Sales Ops in Phase 1, both planned for later automation (Cloud-billing webhook for Provisioned — now Phase 4; scheduled Flow for Completed). FLS-locked to Sales Ops Edit only — AE / Sales Manager / Finance Ops read-only.
- **`Effective_Date__c`** (required, date) — when the credit grant becomes effective on the Cloud Org. AE fills on Create. Replaces the prior `Start_Date__c` which was auto-stamped at approval time and conflated "approved on" with "effective on".
- **`Expiration_Date__c`** (required, date) — when the credit grant expires; after this date the Cloud Org should reject further consumption. AE fills on Create. Phase 1 has no scheduled job to auto-Complete; Sales Ops monitors and flips manually.
- **`Completed_Date__c`** (date) — set when Sales Ops flips Credit_Lifecycle to Completed. Renamed from `Concluded_Date__c`. `createable: false` so it does not appear on the Create form (a brand-new Draft credit is never Completed); Sales Ops fills via Edit when flipping the lifecycle.
- **Approver lookups** (`Presales_Manager_User__c`, `Finance_Approver_User__c`, `BG_Leader_User__c`) and **`Requires_Finance_Approval__c`** — all `createable: false` so they are hidden on the Create form. The three approver lookups are stamped by the `Credits_Stamp_Approvers` before-save Flow at create time and render read-only on the detail view. The budget flag is **dormant and hidden from all layouts** (§7.0) — no one sets it at launch.

### Snapshot rule

**Everything except the Account / Opportunity / Cloud_Org lookups themselves is snapshotted at create time.** Account renames, Opportunity close-date moves, AE territory shifts after submission don't drift the record, and in-flight approvals don't reroute mid-stream. **`Credit_Type__c` is also locked after create** — switching type post-creation would invalidate the approver stamping and business rules.

Account-Owner snapshot means a re-territorialization (Account ownership transferred to a new AE) does not retroactively change the Account_Owner stamped on historical Credits. Sales Ops fixes via API on the affected records when needed (acceptable, one-off).

### Child — top-up request

Which parent, how much, **its own required validity window** (`Effective_Date__c` + `Expiration_Date__c`, AE-filled at create, not pre-filled — a top-up may extend the PoC time so it carries dates that can differ from the parent engagement; Phase 2 captures the dates only — auto-propagation to the parent `Expiration_Date__c` and cross-window validation are deferred), what sequence number (with display name `Top-up #<n>`, auto-assigned), why it's needed, its own approval status, and **its own three approver User-lookup fields** (`Presales_Manager_User__c` + `Finance_Approver_User__c` + `BG_Leader_User__c` on the child — stamped **at create time**, NOT read from the parent). This means each top-up captures the approver chain at the moment of creation, so successive top-ups across an approver-rotation window each route to the correct person and in-flight top-ups don't reroute mid-stream. Same snapshot rule as the parent. (Stamping moved from submit-time to create-time per the §7 engine-ordering rationale.)

Two derived fields on the parent — total disbursed and approved-top-up sum — come for free from the platform (rollup-summary + formula auto-computed from approved children). Concluding the parent doesn't trigger anything automatic; it's just an audit flag.

A full field-by-field spec with types and constraints goes into the implementation PR. At this stage we're aligning on shape, not bit-level detail.

---

## 4. Record Types

One default RecordType per object. Differentiation across credit types and regions is handled by **fields, not RecordTypes**:

- **Credit_Type__c picklist** discriminates type (PoC, future Trial / Marketing / CS / etc.)
- **Region__c picklist** discriminates region

Why no RecordTypes:

- #1401 spike recommends RecordType × Layout **only when UX differs**. The form looks the same across types — same fields, same sections (some are conditionally required per type, enforced via validation rule). Only approval routing differs.
- RecordType-per-type would force a layout-per-type, with each layout drifting independently and needing parallel maintenance. The fields-as-discriminator approach keeps one layout in sync across types.

This is a deliberate trade-off favoring **schema flexibility** over **per-type UI variation**. If a future credit type genuinely needs a different form (different fields visible, different section order), we revisit and add a RecordType then.

---

## 5. Layouts

One layout per object. Sections on the parent: **Context** (Credit_Type, Account, Opportunity, Cloud_Org), **Initial Grant** (initial amount + Effective/Expiration dates), **Totals (incl. Top-ups)**, **Status** (state + approval state + dates), **Snapshot (auto-filled on save)** (Account_Name, Opportunity_Name, Account_Owner, Region, Industry, Vertical), **Notes** (free-text). Related list at the bottom shows the child top-ups, sorted by sequence. _The former **Finance Override** section (which held only `Requires_Finance_Approval__c`) was removed 2026-06-03 with the §7.0 dormant-Finance decision — the flag is now hidden from all layouts._

Child layout: parent reference + amount + sequence + reason in one section, status in another.

Layouts don't vary by Profile — sales rep, manager, finance, ops all see the same form. Layouts also don't vary by Credit_Type — fields that don't apply to a given type (e.g., Opportunity for a non-PoC credit) just show as blank for that record. Finance Ops works off the standard approval inbox and a folded "Audit" subsection on the layout when they need deeper context, not a separate variant.

---

## 6. Business flows

### Two entry points (both create the same `Credits__c` record)

Two design decisions shape both entries:

1. **The entry-point ui_action only prefills what's directly addressable on the source record's context.** Everything else — `Account_Name`, `Account_Owner`, `Region`, `Industry`, `Vertical`, plus `Opportunity_Name` when an Opportunity is linked — is stamped by a single before-save Create Flow (`Credits_Prefill_Snapshot_Fields`) that runs on `Credits__c`. The Flow always looks up the linked Account; it additionally looks up the Opportunity when one is present. This keeps the entry-point prefill logic minimal and guarantees both entries produce identical snapshots regardless of where the user started.
2. **Cloud_Org and Opportunity stay user-selected, not auto-derived,** because both are 1-to-many to Account (one Account can have many Orgs and many Opportunities). Both lookups are Account-filtered in the picker so the AE only sees options scoped to the Account they're working from.

**Account-page entry: "Apply for Credits" button.** The generic entry point. Used for any Credit_Type — PoC today, future types as they come online. AE / program owner clicks the button from an Account record:

- **`Account__c`** — auto-set (the Account they were on; passed via the ui_action `lookupField`)
- AE then fills in the new-record form: **`Credit_Type__c`** (picklist; defaults to PoC at launch), **`Opportunity__c`** (Account-filtered picker; optional at schema level but required for PoC by validation rule), **`Cloud_Org__c`** (Account-filtered picker; required at schema level for all Credit_Types), **`Initial_Credits__c`**, **`Effective_Date__c`**, **`Expiration_Date__c`**, **`Notes__c`**
- On save: the Create Flow stamps `Account_Name`, `Account_Owner`, `Region`, `Industry`, `Vertical` from the Account; if `Opportunity__c` is set, also stamps `Opportunity_Name`.

**Opportunity-page entry: "Apply for PoC" button.** The high-frequency PoC shortcut. AE on an Opportunity clicks the button; the ui_action prefills the deal-specific bits so the AE doesn't re-enter what's already on the Opp:

- **`Account__c`** — `Opportunity.AccountId` (via ui_action `prefillMappings`)
- **`Credit_Type__c`** — literal `PoC` (locked at this entry)
- **`Opportunity_Name__c`** — `Opportunity.Name` (since this entry has the Opp in hand, prefill it directly; the Account-entry path leaves this blank until the Flow stamps it from the chosen Opp's name on save)
- AE then fills: **`Opportunity__c`** (still selectable; the link can be confirmed or swapped — defaults will be reinforced by the Flow at save time), **`Cloud_Org__c`** (Account-filtered picker; required), **`Initial_Credits__c`**, **`Effective_Date__c`**, **`Expiration_Date__c`**, **`Notes__c`**
- On save: the Create Flow stamps `Account_Name`, `Account_Owner`, `Region`, `Industry`, `Vertical` from the Account (looked up via the prefilled `Account__c`); `Opportunity_Name` is already set from the prefill, but the Flow re-stamps it from the linked Opportunity to keep both entries consistent.

After save: `Credit_Lifecycle__c = Draft`, `Approval_Status = NotSubmitted`, `Credit_Type` locked, all snapshot fields locked (`updateable: false`).

### Validation rules

Three validation rules enforce business requirements at the API / form layer (not at the schema level — schema keeps Opportunity and Cloud_Org as plain optional Lookups for future-type flexibility):

| Rule | Condition | Error |
|---|---|---|
| `Credits__VR_PoC_Requires_Opp` | `Credit_Type__c = 'PoC' AND ISBLANK(Opportunity__c)` | "An Opportunity is required for PoC credit grants." |
| `Credits__VR_CloudOrg_Matches_Account` | `Cloud_Org__c != null AND Cloud_Org__r.Account__c != Account__c` | "The selected Cloud Org must belong to the same Account as this Credits record." |
| `Credits__VR_Opp_Matches_Account` _(added Phase 2, #2292)_ | `Opportunity__c != null AND Opportunity__r.AccountId != Account__c` | "The selected Opportunity must belong to the same Account as this Credits record." — server-side backstop for the Opportunity picker's `runtime_scoped_lookup` Account scoping (same defense-in-depth pattern as the Cloud_Org rule). |

`Cloud_Org__c` itself is required at schema (across all Credit_Types), so the `Credits__VR_PoC_Requires_CloudOrg` validation rule that existed in earlier drafts is retired — schema-level required:true covers it. The account-match VR above stays — different concern.

The third rule is the server-side companion to the Cloud_Org lookup's filtered dropdown — the form picker hides Orgs from other Accounts, but if the AE changes the Account after picking an Org (or if an API caller bypasses the picker), the validation rule blocks the inconsistent save.

When a future Credit_Type (Trial, Marketing, …) is added, similar rules can be appended if those types have their own required-when-X fields. Adding a Credit_Type doesn't require schema changes — only a picklist value addition + new validation rules as needed.

**AE submits for approval.** `Approval_Status__c` moves to `Pending`, the approval engine fires (see §7 — one ProcessDefinition per Credit_Type; at launch only the PoC ProcessDefinition is active). `Credit_Lifecycle__c` stays at `Draft` during the approval window. AE gets notified when the engine lands a decision. _(Update 2026-07-09: the create form additionally offers an explicit **Save & Submit** primary button — declared for `Credits__c` / `Credits_Topup__c` via pack#3763 on the app-packs#337 mechanism — so AE can create + submit in one step; plain Save shows a not-yet-submitted nudge. The record-page manual Submit path described here is unchanged. See §0.1 note 1.)_

**Approval lands.** When the ProcessDefinition's final step approves, the **Credits_Lifecycle_On_Approval_Decision Flow** flips `Credit_Lifecycle__c` to `Active` (Effective_Date was AE-filled at create). AE gets a notification. If the engine flips `Approval_Status__c` to `Rejected` instead, the same Flow flips `Credit_Lifecycle__c` to `Cancelled`.

> **Cloud-billing webhook is Phase 4** _(reassigned 2026-08-04; this line previously read "Phase 2/3")_ — the actual POST to Cloud billing's REST endpoint (so `Org.remaining_points__c` reflects the granted credits) is the deferred Flow 2 (see §7). In Phase 1 the activation Flow only flips local fields; AE notifies their Cloud-account contact manually, or sales ops bridges the gap until the webhook lands.

> **Phase 1 Sales Ops discovery mechanism** _(partially overtaken 2026-08-04: the NX#930 dependency cleared and approval-event notifications shipped 2026-07-31, **but this discovery gap is not closed** — the shipped Flows fire on approval events only, with no Active-age or ops-latency trigger, so Sales Ops still polls the list view. WA-004 stays active; its remaining exits are the Cloud-billing webhook (Phase 4) and a latency-triggered ping. Production bears this out: records have sat at `Active` for days after their notification fired.)_: Without push notification (NX#930 SendNotification action) or the Cloud-billing webhook, Sales Ops needs an explicit way to find records waiting for provisioning. Same YAGNI pattern as the NeedsTriage list view (§7): Sales Ops checks a saved list view filter `Credit_Lifecycle__c = 'Active'` per the ops handbook's daily-check procedure, picks records, provisions credits on the Cloud Org, then manually flips `Credit_Lifecycle__c` to `Provisioned`. Both ends close later, in different phases: the Cloud-billing webhook auto-stamps Provisioned after the Cloud-side load (**Phase 4**), and the notification half shipped in Phase 3 (2026-07-31) — NX#930 lets a Flow push a notification on `Lifecycle='Active'` for any record where ops latency exceeds threshold. Tracked as WA-004 in `docs/workarounds.md`.

**AE creates a top-up.** "Add Top-up" button on the parent. They enter the amount and reason. Sequence number auto-assigned. The eventual target is the same 3-step approval pattern as the parent (see §7), with the credit-amount comparison against **cumulative disbursement** (parent's running total + this top-up) — so a $500 top-up on a $4000 cumulative engagement still hits the BG Leader step. **Phase 1 ships top-ups with the 1-step approval spike only** (Presales Manager); cumulative-threshold gating depends on the `Total_Disbursed__c` / `Approved_Topups__c` substrate gap closing (§14, NX#947). The forward-compatible approver + cumulative fields are pre-declared so the Phase 2 upgrade is a seed-only change.

**Provisioning + completion.** After approval, **Sales Ops** flips `Credit_Lifecycle__c` from `Active` to `Provisioned` once credits are actually loaded onto the Cloud Org, and from `Provisioned` to `Completed` once credits are used up or `Expiration_Date__c` has passed. Phase 1: manual via the Edit form (Sales Ops has FLS Edit on the lifecycle picklist, AE does not). Later automation (**Phase 4** for the webhook): the Cloud-billing webhook auto-stamps `Provisioned`, and a scheduled Flow auto-flips records past `Expiration_Date__c` to `Completed` (stamps `Completed_Date__c`).

The two entry buttons ("Issue Credits" on Account, "Apply for PoC" on Opportunity) are the sales-rep / program-owner path. **Sales Ops batch operations** (importing 421 legacy records during migration, periodic bulk cleanups) don't use this UI — they go through the agent with a dry-run + idempotent import.

---

## 7. Approval needs

### 7.0 — LAUNCH DECISION (2026-06-03): the Finance step is DORMANT at launch

> **Decision (Option B).** The Finance approval step does **not run** at launch and
> the per-record `Requires_Finance_Approval__c` flag is **set by nobody and hidden
> from every layout** (it was already `createable: false`; it is now removed from the
> parent "Finance Override" section and the child "Running Total" section). Effective
> launch behavior is a **2-step chain: Presales Manager → BG Leader (>3000)**. The
> mechanism described in the rest of §7 stays defined in the ProcessDefinition but is
> inert because the flag is never true.
>
> **Why the per-record flag was the wrong primitive.** Finance review is not a
> per-deal decision. It is a **per-region annual-budget gate**: a region's Credits
> budget is tracked **offline**; once that region's execution rate crosses the
> threshold, **every subsequent approval in that region** must include Finance — a
> one-time, region-level, once-on-stays-on switch. A per-record boolean armed deal by
> deal cannot express that, and shipping it now (a) is premature for a gate that has
> not been reached and is computed offline, and (b) was actively misleading (people
> could set a flag that did nothing). It also carried a real evaluation bug — see the
> `"true,1"` note below.
>
> **A-blueprint — what to build the day the budget gate is actually reached** (not
> before; keep it written so that day is a mechanical change, not a scramble):
> 1. Add a **region-level toggle**, e.g. `Region_All__c.Finance_Approval_Required__c`
>    (boolean), flipped on by an admin when the offline tally crosses the threshold.
> 2. Add a **submit-time Flow** that stamps each Credits's mirror field
>    `Requires_Finance_Approval__c` from that region toggle (chain
>    `Credits.Account__c → Account.Region__c → Region_All__c`). Stamping at **submit**
>    (not create) means the gate applies to every approval *after* the switch,
>    regardless of when the record was created.
> 3. **Re-enable** the Finance step (it is already in the ProcessDefinition).
> 4. When re-enabling, fix the step-criteria boolean match: the flag is stored as
>    `tinyint 1/0`, so `equals "true"` never matches — use the comma ANY-match
>    `"true,1"` (the same fix #2305 applied to the Topup gate; the parent never got
>    it). This is why the gate must be **verified live** when switched on.
>
> Consequences of this decision, already applied: nobody arms the flag → the prior
> "who can edit the flag" permission question is **moot** (FinanceOps/SalesOps need no
> edit on it); the Topup mirror follows the parent automatically (no separate change);
> the `"true,1"` fix is deferred to re-enable time, not shipped now. The paragraphs
> below describe the **dormant** mechanism for when it is switched back on.

### Per Credit_Type, one ProcessDefinition

Each active Credit_Type gets its own ProcessDefinition with `EntryFilter` filtering on `Credit_Type__c = '<type>'`. At launch, only **PoC** is active, so only the PoC ProcessDefinition ships. When a future credit program adopts the object (Trial, Marketing, …), its own PD is added with its own step structure + EntryFilter. The substrate guidance from #159/161 confirms this is the right pattern — separate PDs filtered by `Credit_Type` is cleaner than a single PD with per-type branching inside.

### PoC ProcessDefinition

PoC approval is a **3-step gated flow** driven by the credit amount:

- **Step 1 — Presales Manager**: validates the PoC and confirms budget context. Always runs.
- **Step 2 — Finance**: reviews only when **both** conditions hold: credits > 1000 USD **and** ops has flagged the engagement with `Requires_Finance_Approval__c = true`. The flag is the primary trigger — Finance never sees a record solely because of amount. (Earlier drafts framed this as `OR`, which would have escalated every >1000 request to Finance; PM confirmed the AND-gate is the intended rule.)
- **Step 3 — Regional Leader (= BG Leader)**: reviews when credits cross 3000 and Step 2 has approved. (BG and region are the same scope at PingCAP, so the regional leader and BG leader are the same person.)

Routing matrix (`flag` = `Requires_Finance_Approval__c`):

| Credit amount (USD) | flag = false (default) | flag = true |
|---|---|---|
| ≤ 1000 | Presales | Presales |
| 1001 – 3000 | Presales | Presales → Finance |
| > 3000 | Presales → BG Leader | Presales → Finance → BG Leader |

Step conditions stated explicitly:

| Step | Fires when |
|---|---|
| 1 (Presales) | always |
| 2 (Finance) | `credits > 1000` **AND** `flag = true` |
| 3 (BG Leader) | `credits > 3000` (independent of flag) |

At credits > 3000 with flag = false, Step 2's AND-gate fails so the engine skips Finance and routes directly to Step 3 (Step 2's `IfCriteriaNotMet = GotoNextStep`). Step 3 evaluates its own independent amount-gate.

**Thresholds are USD.** PingCAP PoC credits are USD-only at Phase 1 (see §3) so no currency-conversion machinery ships — the credit amount stored on the record is the value compared against the thresholds directly. If a non-USD program is added under `Credit_Type__c` later, currency conversion re-enters then.

For **top-up requests**, the same 3-step chain runs, but the credit amount checked against the thresholds is **cumulative** — `parent.Total_Disbursed__c + this.Topup_Amount__c`. The cumulative field includes only approved children (in-flight pending top-ups are excluded to avoid race conditions during simultaneous submissions). This prevents successive small top-ups from escaping the larger-grant gating.

> **Phase 1 vs Phase 2 split** _(both now shipped — see §0)_: the **parent `Credits__c` 3-step gated flow ships at Phase 1** — its step criteria reference `Initial_Credits__c` (plain currency, not stripped) and `Requires_Finance_Approval__c` (boolean, not stripped), so the gating works on day one. The **top-up `Credits_Topup__c` cumulative-threshold gating shipped 2026-06-02** (NX#947 closed — see §0 / §14); it depended on `Approved_Topups__c` (rollupsummary) and `Total_Disbursed__c` (active formula), which the build script previously stripped from the merged pack via its `unsupported_field_types` + `allowed_active_formula_fields` filters (see §14 substrate-gap entry). Phase 1 retains forward-compatible declarations of the cumulative-gating fields so layout, FLS, and approval-engine wiring are already in place when the substrate gap closes.

**Finance gate is ops-controlled, not AE-controlled**: the boolean field `Requires_Finance_Approval__c` on `Credits__c` is the hook ops uses when offline-tracked budget exec rate exceeds the 80% threshold (the threshold currently lives in ops spreadsheets, not in the system). When ops flips it true on a record with credits > 1000, Step 2 (Finance) fires; otherwise Step 2 stays skipped regardless of amount. **The AE never sets this flag** — field-level security gives AE and Sales Manager read-only access (so they can see why their request did or didn't escalate), while Finance Ops and Sales Ops PermSets have edit. Field stays on the page layout so the value is visible everywhere; only ops can flip it. Default value is `false`. Future improvement: when account-level budget data is in-system, a Flow can auto-set this flag.

**Implementation (NexusCRM-native, zero Apex)**

The approval routing uses only existing NexusCRM Approval primitives — `ProcessDefinition` + `ApprovalStep` with `ApproverRelatedField`. The mechanism:

1. `Credits__c` carries three User-lookup fields: `Presales_Manager_User__c`, `Finance_Approver_User__c`, `BG_Leader_User__c`. Blank in Draft.
2. **At Credits record creation**, a before-save Create Flow (`Credits_Stamp_Approvers`) fires. It derives Region via Account.Region__c (self-sufficient — independent of Prefill Flow ordering), looks up the corresponding row in `Region_All__c`, and stamps the three approver User IDs onto the Credits record. **Stamping happens at create, not on Submit**: the upstream approval engine (`approval_service.go:487 selectInitialApprovalStep`) resolves approvers BEFORE the Submit FieldUpdate writes `Approval_Status='Pending'` (line 567), so a before-save Flow listening for the Pending flip would fire too late — the engine would read NULL and fall back to the submitter as their own approver (line 318). Stamping at create guarantees the field is populated by the time the engine reads it on Submit.
3. The `ProcessDefinition` has three `ApprovalStep`s, each using `ApproverType = User` + `ApproverRelatedField` pointing to one of the three stamped fields, gated by `StepCriteria`:
   - Step 1: no criteria (always runs)
   - Step 2: `StepCriteria = "Initial_Credits__c > 1000 AND Requires_Finance_Approval__c == true"` (AND-gate per the matrix above); `IfCriteriaNotMet = GotoNextStep` (skip Finance and evaluate Step 3's amount-gate when AND fails — correctly routes the credits > 3000, flag = false case to BG Leader)
   - Step 3: `StepCriteria = "Initial_Credits__c > 3000"`; `IfCriteriaNotMet = ApproveRecord` (auto-approve and finalize if amount-gate not met)
4. The top-up `ProcessDefinition` mirrors this. `Credits_Topup__c` carries its own three approver User-lookup fields (see §3 child description) stamped **at create time** by `Credits_Topup_Stamp_Approvers` Flow — the Flow follows the chain `$Record.Credits__c → Credits.Account__c → Account.Region__c → Region_All__c` and writes the three approver IDs to the child record. Step criteria reference `Cumulative_Disbursed__c` on the child (evaluated live; while the top-up is un-approved that is `parent.Total_Disbursed__c` plus this top-up's own amount) so the same thresholds (1000 / 3000) apply against the running total. **The cumulative-disbursed active formula + step-criteria activation shipped 2026-06-02** (NX#947 rollup-summary + active-formula substrate gap closed; see §0 / §14). Note: `Cumulative_Disbursed__c` is an **active formula** (`Credits__r.Total_Disbursed__c + IF(ISPICKVAL(Approval_Status__c, 'Approved'), 0, Topup_Amount__c)`) computed at request time — there is no stamping Flow and no stored value for it on the record. The self-addition is guarded on approval state because the parent rollup filters on `Approval_Status = 'Approved'`: before approval this top-up is absent from the parent total and must be added, after approval it is already in there and must not be (#5154). Step criteria are evaluated while the record is `Pending`, i.e. the unguarded branch, so the gating value is unchanged; and the gating-relevant `Requires_Finance_Approval__c` is mirrored onto the child as a boolean active formula from the parent so the AND-gate evaluates per the same rule.
5. To rotate an approver (someone leaves, someone gets promoted), update the relevant `Region_All__c` row. The change takes effect on the next submission. **In-flight approvals keep their original approver** because the User ID was already stamped onto the record — the snapshot rule from §3 working as intended.
6. **Lookup-failure fallback.** If the create-time Flow cannot stamp one or more approver fields — e.g., the Account's Region has no matching `Region_All__c` row, the row is missing one of the three approver columns, or the looked-up User is inactive / deleted — the record creation does not silently produce an unsubmittable record. The Flow sets `Approval_Status__c = NeedsTriage` (leaves OwnerId on the AE; record stays in the AE's view as a "stuck" state). Sales ops uses a saved list view filter on `Approval_Status__c = 'NeedsTriage'` to find and process these records; the ops handbook carries the daily-check procedure. Sales ops fixes the `Region_All__c` row (or activates the User), then either re-runs the Flow via a record update or recreates the Credits — the new attempt finds the approver chain. The Submit ui_action is conditioned on `Approval_Status__c = 'NotSubmitted'`, so a NeedsTriage record can't be submitted by the AE accidentally. A native in-app notification on triage entry is deferred to upstream NX#930 (SendNotification Flow action type); until then, daily-list-view check from ops handbook is the contract. Queue-style ownership routing is not used — for a refactor with ~1-2 triage records per year, the YAGNI choice is "status + list view + ops handbook", not Queue infrastructure (Group + GroupMember + QueueSobject seed).

**Region_All__c column mapping**:

| Approver role | Region_All__c column | Stamped onto Credits as | Status |
|---|---|---|---|
| Presales Manager (step 1) | `Region_Pre_Sales_Manager__c` | `Presales_Manager_User__c` | ✅ Confirmed (PR #2085 schema cleanup) |
| Finance (step 2) | `Regional_Finance__c` | `Finance_Approver_User__c` | ✅ Confirmed |
| BG Leader / Regional Leader (step 3) | `Region_BG_Leader__c` | `BG_Leader_User__c` | ✅ Confirmed (PR #2085 schema cleanup) |

These mappings are locked by PR #2085 (Region_All__c rename for naming consistency: `Region_Pre_Sales__c` → `Region_Pre_Sales_Manager__c`, new `Region_BG_Leader__c` field replacing the legacy `User__c`, `Regional_Finance__c` unchanged). Identity bindings (which person fills each role per region) live in an ops-controlled surface, loaded into prod once #2087 closes — see `docs/object-proposals/poc-credits-region-all-approver-matrix.md`.

Everything above is declarative pack content (ProcessDefinition JSON, ApprovalStep config, Flow definition, the three custom User-lookup fields, the boolean flag). **No Apex handler, no new substrate primitives.** Region_All__c remains a normal lookup-table object; the lookup itself happens in a Flow, not in code.

**Post-approval lifecycle routing (Phase 1)** — a declarative Flow on `Credits__c` (`Credits_Lifecycle_On_Approval_Decision`) triggered by `Approval_Status__c` IsChanged. A Decision node routes the outcome: `Approved` → `Credit_Lifecycle__c = Active`; `Rejected` → `Credit_Lifecycle__c = Cancelled`; other values → no change. No date stamp here — `Effective_Date__c` and `Expiration_Date__c` were AE-filled at Create. The corresponding Topup Flow stamps `Approved_Date__c` on Credits_Topup. Zero Apex, fully local field updates, no external calls. Ships in Phase 1.

**Post-approval Cloud-billing callout (Phase 4 — deferred)** — when the billing integration contract is ready, a second standard RecordUpdated Flow invokes the NexusCRM native `CallWebhook` action with the Cloud billing endpoint, auth header, and the credit-grant JSON payload (the target is `Org.remaining_points__c` via the new `Cloud_Org__c` lookup, not a string ID like the legacy callout used). This replaces the legacy Apex callout class. Fire-and-forget semantics (no response-body capture) match what the legacy callout already did. **Phase 1 ships without this Flow**; sales ops manually bridges the local activation to Cloud billing in the interim. Tracked separately once billing team aligns on endpoint + payload contract.

The two legacy Apex classes — `POCUpdateLeaderHandler` (approver lookup) and the Cloud-billing callout — are **both retired** as part of this refactor. The new Credits engagement has zero Apex from day one.

**Budget exec rate auto-bypass deferred**: the flowchart in the legacy SF flow had a "budget exec rate ≤ 80%" auto-skip branch that bypassed Finance entirely. Because budget exec rate is currently tracked offline (spreadsheets, not in CRM), we do not replicate that auto-bypass — instead the `Requires_Finance_Approval__c` flag gives ops manual control. If/when budget data moves in-system, a small Flow can compute the rate and auto-set the flag.

---

## 8. Permissions

### Record-level: `sharingModel: Private` + role hierarchy + targeted ViewAll bypass

`Credits__c` ships with **`sharingModel: Private`** + `enableHierarchySharing: true`. Restores parity with legacy `PoC__c` (also `Private`) and with the surrounding parent records (`Account=Private`, `Opportunity=Private`). The brief window where the new object shipped as `PublicReadWrite` is recorded in History 2026-05-30 / 2026-06-01 — the public default was an accidental loosening from the legacy posture, not a deliberate design choice. Under public RW the proposal's per-field FLS story (`Approval_Status` lock, `Credit_Lifecycle` lock to Sales Ops, `Requires_Finance_Approval` lock to Finance/Sales Ops) was effectively decorative: any user reads every record regardless of field-level rules.

Visibility derives from four orthogonal mechanisms:

| Persona | Mechanism | Scope |
|---|---|---|
| AE / record owner | default Private (owner auto-visible) | own records only |
| Sales Manager | `enableHierarchySharing: true` (role hierarchy) | own + records owned by users below in the role tree (team scope) |
| Finance Ops | `PermissionsViewAllRecords: true` on the `Credits_Finance_Ops` permset | all records (read-only). Required because Finance Ops plans the Step 2 approval queue across teams; per-record approval auto-share covers the single record they are routed to approve, but cross-team queue visibility needs ViewAll. |
| Sales Ops | `PermissionsViewAllRecords: true` + `PermissionsModifyAllRecords: true` on the `Credits_Sales_Ops` permset | all records, full CRUD **including delete**. Required for §6 Phase 1 Sales Ops list-view discovery (Active→Provisioned manual flips, NeedsTriage triage), §10 cross-team batch cleanup, and the 421-record legacy migration. (Delete included per decision 2026-07-20: `ModifyAllRecords` grants delete-all in Salesforce/the platform — "Modify All but no delete" is not a valid state, so the earlier "CRUD ex delete" framing was unachievable and Sales Ops does delete.) |
| Marketing | none beyond default Private | own records only (= zero records in practice; correct outcome). The earlier scaffold draft granted Marketing `ViewAllRecords`; revised on 2026-06-01 after challenge from PM. The proposal text "Marketing reads context fields only" is a field-level restriction, not a record-level "see all" mandate — and there is no concrete business use case for Marketing reading other teams' credit grants. If a real Marketing use case emerges, ViewAll can be added then; until then, narrower is safer. |
| Pending approver | upstream approval engine's auto-share primitive ([NX#1510](https://github.com/pingcap-inc/NexusCRM/issues/1510) / [PR #1544](https://github.com/pingcap-inc/NexusCRM/pull/1544)) — `_System_RecordShare` with `Reason='Approval'`, `AccessLevel=Read`, auto-revoked on approve/reject/recall | the single record they are routed to approve, for the duration of the workitem |

The tighten landed only after NX#1510 fixed the read-paths so non-owning approvers can open the record they are routed to approve. The upstream fix is on `main`; pack now defaults to upstream `main` since PR [#2239](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/2239) removed the pinned `nexuscrm_ref` from `stack.lock.json`, so the dependency is satisfied implicitly going forward.

**One-paragraph operational summary**: AE creates + edits their own; Sales Manager reads team records via role hierarchy and approves when in the chain; Finance Ops reads every record and approves the Finance step; Sales Ops reads/edits/creates/deletes across teams for batch cleanup and Active→Provisioned flips; Admin has full access; Marketing has Read on the object but Private sharing means they see only records they own (= effectively none). Only Admin and Sales Ops can delete (Sales Ops via `ModifyAllRecords`; decision 2026-07-20).

**Audit notification on cross-team writes (Phase 2 — deferred)**: when Sales Ops (or Admin) edits a Credits record they don't own, the original owner (AE / program owner) should get an in-app notification with a diff of what changed. The Flow itself is straightforward (after-update + editor ≠ owner check + Diff), **but the in-app delivery mechanism doesn't exist yet** — NexusCRM Flow has no native `SendNotification` action type. Upstream NX#930 requests it. Phase 1 ships the permission model (Sales Ops can write across teams) but **without the notification Flow**. When NX#930 lands, the audit Flow is a small Phase 2 add. Email-fallback was rejected as the in-app intent of §8 — the system should signal owner-visible, not flood inboxes.

The detailed Profile × Field matrix lives in the implementation PR.

---

## 9. Reports and dashboards

No default reports or dashboards ship with this object. Sales Manager + FP&A folks do their analysis through conversational analytics (per RFC v3) and ad-hoc queries — pre-built reports aren't where the value is for this object's volume (~80-100 records/year).

The 165 legacy reports tied to the old object get flagged "deprecated" with a 90-day grace window. Data shows only 16/165 ran in the last 90 days, mostly scheduled batches with low human consumption. If someone really uses one, they can rebuild on the new object. We won't pre-emptively migrate.

---

## 10. Lifecycle

- **Volume**: ~80-100 new PoC engagements per year, ~10-15 top-ups per year (based on 2026 trajectory). Future Credit_Type volumes (Trial / Marketing / CS) add to this as they adopt — likely 100-300 records/year/type for high-volume programs like Trial.
- **Retention**: indefinite — audit-relevant for revenue recognition.
- **Archival**: none for new records; legacy `PoC__c` rows that aren't being migrated get an `Archived__c=true` flag and stay in place for audit.
- **Decommission**: only if PingCAP fundamentally changes the credit-grant model across all programs. Not foreseen.

### Multi-Credit_Type growth path

Adding a new Credit_Type (e.g., Trial) is a small Pack PR. The Phase 1 design intentionally pre-builds everything needed for this transition to be **no-schema-change, no-data-migration**:

**1. Schema additions (additive only):**
- Append the new value to the `Credit_Type__c` picklist (e.g., add `Trial`)
- Add any type-specific custom fields (e.g., `Trial_Expiration_Date__c`); existing records get null, no migration needed

**2. Business-rule additions (additive only):**
- Add a new ProcessDefinition + ProcessNode + ProcessAction set (via deploy-seed YAML) with `EntryFilter` filtering on `Credit_Type__c = 'Trial'`
- Add validation rules for any new conditional-required fields per the new type

**3. UX transition (single-step, well-scoped):**
- **Remove** the `default: "PoC"` from `Credit_Type__c` field schema (or change to conditional default) — AE must now actively pick the type when creating from Account
- **Upgrade** the Account-entry ui_action ("Issue Credits") from direct-form to picker-style: either a small dialog "What type?" before opening the form, or the layout makes Credit_Type a top-of-form required-with-no-default field so AE picks it as their first action
- (Optionally) add a new type-specific entry button (e.g., a "Apply for Trial" button on Account or Lead, similar to how "Apply for PoC" works on Opportunity today)

**Doesn't change:**
- The Opportunity-page "Apply for PoC" button stays as a PoC-locked shortcut — it works for the PoC type forever
- All existing PoC records keep their `Credit_Type__c = PoC` (the field is `updateable: false`)
- Layouts, Flows (stamp_approvers, auto_sequence, activation), permissions, audit, snapshot rule — all type-agnostic, unchanged
- The PoC ProcessDefinition keeps running for new PoC records

**Phase 2 transition checklist** (when 2nd type goes live):

| Step | Change | Effort |
|---|---|---|
| Picklist | Append `Trial` to Credit_Type__c.options | 1 line |
| Schema default | Remove `default: "PoC"` from Credit_Type__c | 1 line |
| ProcessDefinition | New PD + Nodes + Actions for Trial via seed YAML | 1 new file |
| Validation rules | Trial-specific required fields | 1-2 new VR per type |
| ui_action | Upgrade IssueCredits from direct-form to picker | 1 ui_action config edit |
| Layout (optional) | Make Credit_Type field more prominent / required-without-default | 1 layout edit |
| Tests | Add Trial-flow e2e tests | per usual test process |

The `Credits__c` object is the single home; each new type adopts the existing pattern. No object renaming, no record migration, no Flow rewrite.

The legacy `PoC__c` itself: Week 5 of the refactor, we close the legacy approval processes, hide the old RecordTypes from the UI, and delete the dead `POC_Result__c` table (1391 rows of dumped Cloud Portal form fields with no PoC content — last real entry 2022). Read-only access to the legacy object remains until end of fiscal year, then we evaluate hard-delete.

---

## 11. UX — who uses what

This is where the v3 reframe matters most. The original design assumed admins click around Setup UI when they need to make changes. Under v3 that's no longer the default path.

**Sales reps (AE, SDR, CSM)** keep using the UI as today — "Apply for PoC" button on Opportunity (PoC shortcut) and "Issue Credits" button on Account (generic), status visible on the record header, mobile support for status checks on the road. This is L3 record work and the UI stays first-class. Agent helps for composite tasks ("submit this credit grant and draft a slack note to my manager") but doesn't replace the UI.

**Sales Ops** stops doing CSV roundtrips and Data Loader imports. The Phase 2 migration of 421 legacy records is the canonical first batch — done through the agent with dry-run, idempotency, and an audit trail. Same pattern for monthly cleanups and ad-hoc corrections.

**Sales Manager + Finance Ops** approve through the standard approval inbox + UI. Rotating regional approvers (when someone changes role) is **not** a Setup-UI click — it's an agent-mediated Pack PR that the agent can generate from a one-line request ("move Lily to APAC leader effective next Monday"). This is the bit of UX that's most different from the legacy framing.

**Admin** does governance, not daily editing. They review the codified config (the `Region_All__c` lookup, the approval routing), they sign off on Pack PRs, they audit. Setup UI is for read + audit + emergencies, not for daily authoring.

**Cloud Portal team** no longer integrates with this object. The website self-serve flow moves to standard Lead + Campaign REST, which they already know how to use. `Credits__c` becomes a CRM-internal object only.

A line from the original design that needs updating: it said "after the refactor, business ops can add new regions / change picklists / rotate approvers — just click around in Setup UI for a minute, no engineering ticket needed." Under v3, the spirit of that promise is right (no engineering ticket) but the medium changes: it's an agent conversation that produces a Pack PR, not a Setup UI click trail. Worth saying explicitly in the Phase 0 alignment so no one expects a Setup-UI-led world after launch.

---

## 12. Open questions

All design-shape questions are resolved (see History for the 9 PM decisions on 2026-05-16, plus the Q10 webhook resolution listed below). One coordination question is parked as a non-blocking GitHub issue:

**Parked (not blocking)**: Cloud Portal cutover target date — tracked in #1446, assigned to @jiwei2021. The refactor can proceed in parallel; default assumption is Q3 FY27 if no commit lands earlier.

### Known gap — cross-Credits gating loophole (Phase 3 fix)

**Surfaced 2026-06-02 (PM). Confirmed structural in the shipped design.** The approval gate aggregates credit amounts **only within a single engagement**:

- a standalone `Credits__c` record gates on its own `Initial_Credits__c`;
- a top-up gates on `Cumulative_Disbursed__c` = parent `Total_Disbursed__c` + this top-up.

There is **no aggregation across separate `Credits__c` records for the same customer** (same Account / same Cloud Org). So a sales rep who wants additional credit for a customer who already has an active engagement can **bypass the top-up path and open a brand-new PoC Credits record instead** — each fresh request is then gated on its own smaller amount, dodging the cumulative thresholds (Finance at >1000, BG Leader at >3000) that a top-up against the running total would have triggered. Example: customer already has $1500 disbursed; rep wants $2000 more → a **top-up** gates at cumulative $3500 (→ BG Leader, 3-step), but a **new Credits record** for $2000 gates at $2000 alone (→ Presales, or Presales+Finance only if the flag is set — never BG Leader).

This is a real escalation-avoidance loophole, not a corner case. **Resolved 2026-07-31 — option 2 was chosen and implemented** (#4747 + #4791 on `main`, awaiting deployment; see the Phase 3 entry in §0 and the 2026-07-31 History entry for the decision record, the production evidence, and the accepted residual gaps). The candidates below are kept as the design record of what was considered:

1. **Account- / Cloud-Org-level outstanding-credit aggregation feeding the gate** — the step criteria compare against the customer's total active+pending credit across all `Credits__c` for the Account (or Cloud Org), not just the single record. Needs a cross-record rollup/aggregation the engine can read at submit time.
2. **Route same-customer requests through the existing engagement** — if an active Credits already exists for the Account/Cloud Org, the "Apply for Credits/PoC" entry nudges (or forces) the rep into "Add Top-up" on that engagement instead of a fresh record.
3. **Hybrid** — allow new records but gate them on the aggregated outstanding total (option 1's criterion applied to new-record creation too).

Open design questions for Phase 3: aggregate at Account or Cloud Org granularity? Include `Pending`/`NeedsTriage` records or only `Active`/`Provisioned`? How to treat `Cancelled`/`Completed` (expired) engagements? Does aggregation cross `Credit_Type__c` (PoC vs future Trial) or stay within type?

**Resolved 2026-07-31 (PM) — closed by option 2, one ledger per Cloud Org.** Production
confirmed the loophole is the normal usage pattern rather than a hypothesis: two same-org
duplicate groups (combined 3000 and 2000), and `Credits_Topup__c` live row count **0** across
the whole org — the cumulative mechanism the gate depends on has never been exercised once.
Neither group has actually dodged a gate yet (both sit at or below the live BG Leader
threshold of `Initial_Credits__c > 3000`), so this is still risk-control work, not incident
response.

The rule: **a Cloud Org may have at most one `Credits__c` ledger whose
`Credit_Lifecycle__c` is not `Cancelled`.** While one exists, creating another is blocked and
additional credit must be a `Credits_Topup__c` on it. This reframes `Credits__c` from "an
engagement you open and close" into **the org's lifetime credit ledger**, which is what makes
it a full answer to the four questions above rather than a partial one:

- **Granularity — Cloud Org.** Credits are loaded onto the org, so the threshold being
  protected accrues per org. Account is too coarse (one customer may legitimately run parallel
  engagements on different orgs, and forcing them into one ledger gives wrong approver stamps
  and wrong attribution); Opportunity is trivially gamed, since reps create opportunities
  freely. Both production duplicate groups share org *and* opportunity, so the org anchor
  catches every observed case.
- **Which states count — all of them except `Cancelled`.** Deliberately state-independent.
  The lifecycle's back half is dead in production: no record has ever reached `Completed`, and
  none has ever been `Cancelled`; nothing transitions records on `Expiration_Date__c` and the
  manual ops leg has been exercised only twice (2 `Provisioned` of 22 records as of
  2026-07-31). Any rule requiring records to be *closed* would therefore silently block
  legitimate future requests. This one depends on no lifecycle hygiene at all.
- **All-Cancelled escape.** An org whose every record is Cancelled is rejected-only history,
  not a dead end — a fresh ledger there re-enters the full approval chain, so nothing is
  bypassed.
- **Aggregation scope — lifetime, and free.** Because top-ups now carry every follow-on grant,
  the existing top-up criteria (`Cumulative_Disbursed__c` = parent `Total_Disbursed__c` +
  this top-up) already aggregate the org's whole history. **No new cross-record aggregate is
  needed**, which is what made option 2 preferable to options 1 and 3. If approval fatigue
  emerges on long-lived orgs, the lever is a time-windowed criteria change — parameters, not
  structure. `Credit_Type__c` does not partition the ledger; PoC is the only live type.

Delivered as pack content, no engine change: a `RecordBeforeSave`/`Create` Flow
(`Credits_Stamp_Open_Org_Ledger`) stamps `Has_Open_Org_Ledger__c`, and
`Credits__VR_One_Open_Ledger_Per_Org` blocks on it in the same save (`ISNEW()`-guarded, admin
bypass via `$Permission.ModifyAllData`). A companion inspection guard was built and used to gather the
production evidence above, then **deliberately not shipped**: roughly 420 lines of scheduled
checking against a table holding 22 production rows that grows by a handful of records a quarter,
whose own review cost had already exceeded the value it protected. The create-time rule is therefore
the only enforcement, and the write paths it cannot see have no detection behind them — recorded
under residual gaps below rather than papered over.

The rule fires on the **complete set of three events** by which a record can newly occupy, or move
between, a Cloud Org's single ledger slot. A record occupies a slot exactly when
`Credit_Lifecycle__c` is not `Cancelled`, and *which* slot is `Cloud_Org__c`. Therefore: **create**
takes a slot; **changing `Cloud_Org__c`** moves to another slot; **leaving `Cancelled`** retakes one.
No other field can affect whether or where it occupies. Changes on the *other* ledger are covered by
that record's own three events — which is what makes the invariant closed with no cross-record
trigger. Create alone was not enough (`Cloud_Org__c` is updateable and on the detail layout, so an
ordinary edit could repoint a ledger at an occupied org), and create-plus-anchor was still not enough
(a Cancelled ledger could simply be reactivated alongside a fresh one). Two asymmetries are
deliberate: transitions **into** `Cancelled` are never blocked, because they release a slot and are
the recommended cleanup for the grandfathered groups; and the lifecycle gate keys on `PRIORVALUE`, so
ordinary progression such as `Active -> Provisioned` on a grandfathered duplicate stays allowed while
only `Cancelled -> anything` counts as a retake. The Flow re-stamps on update and excludes the record
from its own lookup — on update the row is already persisted and would otherwise match itself and
block itself.

**Accepted residual gaps** (recorded, not solved): two same-org ledgers can still be created by two
saves committing in the same instant — a before-save Flow's same-object lookup runs inside the save
transaction and cannot see another transaction's uncommitted row, so both stamp false and both pass.
That is a millisecond window on a hand-entered form, not the days-long "two sub-threshold requests in
flight at once" variant the rule does close. With no inspection guard shipped, such an occurrence
would not surface on its own. (An earlier draft also listed reactivating a Cancelled ledger as
unavoidable, on the reasoning that it is "a change to a different record". That was wrong — from the
reactivated record's own point of view it is an ordinary update, so it is gated like the other two
events.) The concurrent window is not worth a locking primitive the Flow layer does not have. Neither
it, nor a `ModifyAllData` holder, nor a bulk-loader / direct-API write that never evaluates
validation rules, has any detection behind it — that was the guard's job, and the guard was judged
disproportionate to a 22-row table. These are accepted blind spots, not monitored risks. Beyond
those: a determined rep can still open a **new
Cloud Org** — a genuinely new wallet, heavyweight and auditable; if ops manually cancels an
Active ledger its disbursed history stops counting toward a future ledger's cumulative (rare,
leaves an audit trail); top-ups carry no Opportunity attribution, and if that becomes painful
the fix is an optional reference field on `Credits_Topup__c` rather than reopening
multi-ledger. Reporting note: "engagements run this period" becomes a count of top-ups, not
parent records. The two existing duplicate groups are **grandfathered** — the rule is
create-time and does not retro-block; converting each group's second record into a top-up on
the first is recommended cleanup, PM's call, non-blocking.

> **Implementation-PR follow-ups** (not blocking proposal approval):
> - Region_All__c column for Presales Manager (step 1) and BG Leader (step 3) — ops will know once real region data is loaded; mapping locked in implementation PR.
> - When account-level budget data moves in-system, replace the manual `Requires_Finance_Approval__c` flag with a Flow that auto-computes budget exec rate and sets the flag.

### Resolved on 2026-05-16 (PM decisions)

- ✅ **Region differentiation**: single RecordType, all regions use the same form. Region differences live in approval routing only (different approvers, same steps).
- ✅ **Approval routing**: NexusCRM-native `ProcessDefinition` + `ApprovalStep` with `ApproverRelatedField`; zero Apex. Pre-submission Flow stamps approver User IDs from `Region_All__c`. Legacy `POCUpdateLeaderHandler` retired. See §7.
- ✅ **Approval structure**: 3-step gated flow (Presales Manager → Finance → Regional Leader / BG Leader), with credits thresholds at 1000 and 3000 auto-skipping later steps. BG = Region (same scope at PingCAP), so BG Leader is the regional leader. Top-up uses the same 3-step chain but compares cumulative disbursement against thresholds. See §7.
- ✅ **Budget exec rate handling**: not modeled as auto-bypass (budget is offline-tracked today). Ops uses a manual `Requires_Finance_Approval__c` boolean flag to force the Finance step when budget exceeds 80%. See §7.
- ✅ **Cross-team write audit**: Sales Ops / Admin edits on records they don't own trigger an in-app notification to the original owner. See §8.
- ✅ **Per-cohort layouts**: not needed. Single layout for all profiles; Finance gets a folded "Audit" subsection on the same form when they need deeper context.
- ✅ **Field snapshot**: everything except the Opportunity / Account lookups themselves is snapshotted at create time. See §3 snapshot rule.
- ✅ **`Region_All__c` exists** in `packs/Salesforce-PingCAP-L2/objects/Region_All__c.json` (label "Region Allocation"). Finance approver column confirmed: `Regional_Finance__c`. Presales Manager and BG Leader column mappings are implementation-PR-time follow-ups (ops will know once real region rows are loaded). No new lookup table needed.
- ✅ **Enterprise PoC / OfflinePoC RT retired**: confirmed by PM. 2026 new-record volume on this RT was 0; legacy data stays in place as `Archived__c=true` per §10. No migration to a new object.
- ✅ **Cloud billing webhook (deferred to Phase 2/3)**: option B (declarative Flow `CallWebhook` action) confirmed feasible after substrate investigation — NexusCRM has a native CallWebhook action type with full HTTP method support, headers, payload, SSRF protection, 30s timeout. The CallWebhook Flow itself is deferred until Cloud billing team aligns on endpoint + payload contract. Phase 1 ships the **Credits_Lifecycle_On_Approval_Decision Flow** (Approval_Status changed → Decision: Approved sets Credit_Lifecycle=Active, Rejected sets Cancelled) but NOT the CallWebhook step that would auto-stamp Provisioned. Both legacy Apex classes — POCUpdateLeaderHandler and the Cloud-billing callout — are retired; refactor lands with zero Apex from day one. See §7.

---

## 13. Launch criteria

The refactor is "done" when the following observable conditions hold for at least **4 consecutive weeks** after Phase 4 (legacy retirement).

### Phase 1 launch criteria

- **No stuck approvals.** Zero records sit at `Approval_Status__c = 'Pending'` for > 7 days unless explicitly held by ops. (Stuck = no approver action and no `Approval_Status__c = 'NeedsTriage'` flag.)
- **Activation runs cleanly.** Every record reaching `Approval_Status__c = Approved` also has `Credit_Lifecycle__c = Active` within 1 minute (the Phase 1 lifecycle Flow's latency window). Symmetric for Rejected → Cancelled. Audit by querying records where (`Approval_Status = Approved AND Credit_Lifecycle__c NOT IN ('Active', 'Provisioned', 'Completed')`) OR (`Approval_Status = Rejected AND Credit_Lifecycle__c != Cancelled`).
- **Migration integrity.** All 421 legacy active records migrated to `Credits__c` (with `Credit_Type__c = 'PoC'`) + children on `Credits_Topup__c` carry correctly stamped approver fields and a matching cumulative-disbursement value. Verified by a one-off cross-check report: legacy disbursement total == new-object disbursement total (per Account).
- **Legacy write cutoff.** `PoC__c` shows zero new INSERTs after Phase 4 start. Any incoming write is treated as a bug (Cloud Portal still on old API, or a runaway flow).
- **No silent triage backlog.** Records in `Approval_Status__c = 'NeedsTriage'` (lookup-failure fallback, see §7) count < 5 pending at any time during launch — keeps Region_All__c data quality honest. Sales ops checks the saved list view daily per ops handbook procedure.
- **No silent provisioning backlog.** Records in `Credit_Lifecycle__c = 'Active'` (post-approval, awaiting Cloud-side provisioning) hold for < 3 business days on average over any rolling 4-week window. Zero records stay in `Active` for > 7 days without explicit Sales Ops hold. Sales ops checks the saved list view daily per ops handbook procedure (same pattern as NeedsTriage, see §6 Phase 1 Sales Ops discovery mechanism). **Phase 4** closes this with the Cloud-billing webhook auto-stamping `Provisioned` _(relabelled 2026-08-04; previously "Phase 2")_. Tracked as WA-004.
- **AE friction.** No more than 3 distinct AEs file workflow complaints during the first 2 weeks post-launch. (Sales rep UX should be essentially the same as legacy; if more complaints come in, something broke that the proposal didn't anticipate.)

If any of these slip for 2 consecutive weeks, refactor is "in launch", not "launched" — ops handbook entry stays as `in-launch`, and Phase 5 (90-day report cleanup) does not start.

### Phase 2 launch criteria — shipped 2026-06-02 (see §0)

Top-up cumulative-disbursement gating is live. Criteria to confirm during the staging re-verification of the 2026-06-02 batch:

- A top-up whose cumulative disbursement (`parent.Total_Disbursed__c + Topup_Amount__c`) crosses a threshold routes to the corresponding step (>1000 AND flag → Finance; >3000 → BG Leader) — i.e. successive small top-ups can't escape large-grant gating.
- `Approved_Topups__c` / `Total_Disbursed__c` / `Cumulative_Disbursed__c` populate correctly (NX#947 substrate closed) and recompute on each approval.
- Top-up create requires both `Effective_Date__c` and `Expiration_Date__c`; the parent's Top-ups related list renders the rows.

### Phase 3 / Phase 4 launch criteria

_Status refreshed 2026-08-04; the criteria themselves are unchanged. Item order below is the original pre-2026-08-04 Phase 3 numbering; §0 carries the current phase assignment._

- **Cloud Platform provisioning ("Ship") — now Phase 4, not built.** Criterion as written: the ops-triggered provision action fires `CallWebhook` returning 2xx on ≥ 95% of invocations after billing-team alignment; on success the record flips Lifecycle → `Provisioned`. Failures are visible (logged with the Credits record ID) and retried before alerting ops. (Phase 4 is ops-clicks-Ship, not auto-on-approve.)
- **Approval-decision notifications — MET (verified in production 2026-07-31).** Criterion as written: once NX#930 (SendNotification Flow action) + an email channel land, on `Approval_Status__c = Approved` (and on each step assignment) the AE / approver receives an email + in-app notification within 30s. Both dependencies landed; four terminal approvals produced 8 in-app + 8 email notifications with zero misses, each delivered within seconds of the approval. Recipients also extended beyond the criterion to regional ops on final approval.
- **Cross-Credits gating loophole closed — implemented, awaiting deployment** (§12; #4747 + #4791 on `main`). Criterion as written: a new same-customer `Credits__c` request is gated against the customer's aggregated outstanding credit (not just its own amount), so opening a fresh record no longer dodges the cumulative thresholds a top-up would trigger.

---

## 14. Dependencies

These need to land or be verified before implementation work starts:

- **Object-proposal workflow (#1402)** — merged or merge-imminent. This proposal references that workflow's template + checklist; implementation PR will use the same checklist. If #1402 hasn't merged when implementation PR opens, the implementation PR can still proceed but should link the unmerged #1402 to anchor the workflow reference.
- **`Region_All__c` data completeness** — sales ops audits every active region row and confirms the three approver columns (Finance, plus whichever columns end up mapped to Presales Manager and BG Leader per the implementation-PR follow-up). Any region missing a column means the lookup-failure fallback (§7) will fire — acceptable as a safety net but not as the default state. Owner: Sales Ops.
- **NexusCRM substrate readiness (Phase 1)** — at the upstream ref pinned in `stack.lock.json`, verify these primitives behave as the proposal assumes:
  - `ProcessDefinition` + `ApprovalStep` with relatedUserField approver resolution via the `Metadata` JSON blob path (`approval_runtime.go:48-89` → `metadataStepForNode` → `resolveApproverIdentifierWithContext`). The seed sets `Metadata` so the engine takes this primary path; the ProcessNode row's `ApproverType` / `ApproverRelatedField` columns are defensive secondary markers. _(2026-07-13: in staging/prod these rows are applied by hand patch bundles, not by pack install or deploy seed — see §0.1 note 3.)_ (Earlier "verify the engine reads after the Flow writes" spike-test question was answered from upstream code: engine reads at `approval_service.go:487` BEFORE Submit FieldUpdate at `:567`. Stamping Flow now runs at Credits Create, not on Submit — see §7.)
  - `StepCriteria` with `IfCriteriaNotMet = approve` (auto-skip-and-approve when criteria not met)
  - RecordUpdated Flow trigger firing on `Approval_Status__c` field-value changes, with Decision-node branching to route Approved/Rejected outcomes (used by Credits_Lifecycle_On_Approval_Decision)
  - Validation rules with formula expressions referencing picklist values (used by the PoC Credit_Type required-fields rules). **Spike adds: verify VR formula supports the `<>` operator and `__r` cross-object dot traversal** (`Credits__VR_CloudOrg_Matches_Account` uses `Cloud_Org__r.Account__c <> Account__c`). If unsupported, replace the VR with a before-save Flow that fails-closed on mismatch.
  - Owner: admin lead + substrate engineer signoff during Phase 0 alignment.

- **NexusCRM substrate gap: rollup-summary + active-formula support — ✅ CLOSED 2026-06-02 (NX#947).** _Resolved: the build script now allow-lists the three Credits fields via `wave6_root_rollupsummary_fields` / `wave6_active_formula_fields`, and Phase 2 (#2296) verified `Approved_Topups__c` (rollup, filter excludes non-Approved), `Total_Disbursed__c` (parent formula), and `Cumulative_Disbursed__c` (child formula via `__r`) compute end-to-end against the runtime; the top-up cumulative-threshold gating is live._ Original limitation (retained for context): the pack build script (`scripts/build_pingcap_salesforce_sample_pack.sh`) stripped `rollupsummary`-typed fields entirely and stripped active-formula expressions (`formula` / `formulaMode` / `returnType`) from the merged spec, because *"Most source-authored active formulas still require broader runtime/view support that this downstream stack does not yet validate end-to-end"* (build script comment). This affects three Credits fields:
  - `Credits__c.Approved_Topups__c` (rollupsummary) — does not ship at Phase 1
  - `Credits__c.Total_Disbursed__c` (active formula `Initial_Credits + Approved_Topups`) — ships as plain currency, always NULL
  - `Credits_Topup__c.Cumulative_Disbursed__c` — declared but no stamping Flow at Phase 1 (would produce semantically-wrong values since `parent.Total_Disbursed__c` is NULL)
  
  Phase 1 retains the source declarations + layout + FLS coverage as **forward-compatible** so the substrate-gap closure is a no-op metadata refresh, not a re-shape. The Phase 1 parent `Credits__c` 3-step gated flow reads only `Initial_Credits__c` (plain currency) and `Requires_Finance_Approval__c` (boolean), neither stripped — it ships and works at Phase 1. Only the top-up `Credits_Topup__c` cumulative-threshold gating is Phase 2 because it needs `Total_Disbursed__c` (the stripped active-formula field) to be populated.
  - Tracked upstream as [NX#947](https://github.com/pingcap-inc/NexusCRM/issues/947). Owner: substrate engineer.

- **Deferred (not Phase 1 dependencies)** — tracked separately:
  - `CallWebhook` action with custom headers + JSON payload — needed for Phase 2/3 Cloud billing integration once billing team aligns on endpoint contract
  - `SendNotification` Flow action type — upstream NX#930, needed for Phase 2 cross-team audit notification

These three Phase 1 items are the "Phase 0 deliverables". The refactor cannot move from Phase 0 to Phase 1 until they're confirmed.

---

## History

- 2026-05-04 — initial probe + snapshot of legacy `PoC__c`
- 2026-05-08 — `proposal-readable.md` authored at `/Users/jieni/Applications/claude/poc-refactor/docs/`
- 2026-05-16 — condensed and reframed against RFC v3 (#1403) into this single proposal following the object-proposal workflow (#1402); cohort × medium reasoning added (§11); approver routing source flagged as an open decision (§7); original 8-doc design retained at the local path for technical execution reference
- 2026-05-16 (later) — first batch of PM decisions resolved 6/10 open questions: single RecordType confirmed (Q1), NexusCRM-native ProcessDefinition + ApprovalStep + ApproverRelatedField for routing with zero Apex (Q2; §7 rewritten), cross-team write audit notification added to §8 (Q8), no per-cohort layouts (Q9; §5), all-snapshot rule for non-relationship fields (Q10; §3 snapshot rule), and Region_All__c existence verified in pack (Q7 — `Regional_Finance__c` covers step 2; `Region_DM__c` proposed for step 1 pending one-line semantics confirmation). Four questions remained pending external input at end of session.
- 2026-05-16 (Q3 resolved + §7 restructured) — PM shared the real legacy PoC Application Process flowchart (4 actors: Presales Manager → Credits-gated Finance → Credits-gated BG Leader, with a budget-exec-rate auto-skip branch that's offline-tracked). §7 rewritten to match: 3-step gated flow with credits thresholds at 1000 (Finance gate) and 3000 (BG Leader gate); BG = Region clarified, so BG Leader is the regional leader and Region_All__c stays the single source; budget exec rate auto-bypass NOT modeled (offline data) — replaced by manual `Requires_Finance_Approval__c` boolean. Three new approver fields on PoC_Credits__c (Presales_Manager_User, Finance_Approver_User, BG_Leader_User) replace the previous two. Region_All__c column mappings for Presales Manager and BG Leader deferred to data-migration time (implementation-PR follow-up, not blocking proposal). Three open questions remain.
- 2026-05-16 (Q4 resolved) — PM confirmed Enterprise PoC / OfflinePoC RT is retired. No migration to a new object; legacy rows stay in place under §10's archival policy.
- 2026-05-16 (Q6 parked, not blocking) — Cloud Portal cutover target date parked as coordination issue #1446, assigned to @jiwei2021. Refactor proceeds in parallel; Q3 FY27 default assumption if no earlier commit.
- 2026-05-16 (Q10 resolved) — Cloud billing webhook moves to option B (declarative Flow `CallWebhook` action). Substrate investigation confirmed NexusCRM has a native CallWebhook action type with full HTTP method support, headers, payload, SSRF protection, and 30s timeout. Trigger pattern: ApprovalService writes `Approval_Status__c = Approved` → standard RecordUpdated Flow fires → CallWebhook posts the credit-grant payload to Cloud billing. Fire-and-forget (no response capture) matches the legacy Apex callout's behavior, so no functional regression. Both legacy Apex classes (POCUpdateLeaderHandler for approver lookup, plus the billing callout class) are retired — refactor lands with zero Apex.
- 2026-05-16 (senior-PM review pass) — closed four gaps from a holistic re-read: (a) §7 thresholds explicitly USD-denominated with currency conversion at submission time; cumulative excludes in-flight pending top-ups; (b) §7 lookup-failure fallback added — incomplete Region_All__c data sets `Approval_Status__c = 'NeedsTriage'` instead of silent stuck approvals; (c) new §13 Launch criteria with six observable conditions that must hold for 4 weeks post-Phase-4 before the refactor is "launched"; (d) new §14 Dependencies listing the three Phase 0 deliverables (#1402 merge, Region_All__c data completeness, NexusCRM substrate verification).
- 2026-05-16 (queue → list view simplification) — investigated NexusCRM substrate queue/group primitive (verified: full L0 support via Group + GroupMember + _System_GroupMembershipClosure + QueueSobject tables) and concluded YAGNI-style: at ~1-2 triage records per year, formal Queue ownership routing is over-engineering. §7 lookup-failure fallback now relies on `Approval_Status__c = 'NeedsTriage'` + a saved list view + an ops handbook daily-check procedure. Queue infrastructure can be added later if volume grows. Native in-app notification on triage entry deferred to upstream NX#930 (SendNotification Flow action type).
- 2026-05-16 (unified-credits redesign — major) — PM raised two structural concerns: (a) PingCAP has multiple credit programs (PoC + future Trial / Marketing / CS / Partner) that should all share one system of record, not separate per-program objects; (b) credits are an Account-level concern (Account is the long-term entity; Opportunity is one of many sales artifacts that may or may not exist for a given credit grant). Rewrote the proposal to: rename `PoC_Credits__c` → `Credits__c` and `PoC_Credits_Topup__c` → `Credits_Topup__c`; add `Credit_Type__c` picklist (`PoC` only at launch, future values added as new programs adopt the object); replace text-field `Cloud_Account_ID__c` with proper `Cloud_Org__c` lookup to the existing `Org__c` (the PingCAP Cloud org — confirmed via pack audit: schema includes TiDB_Cloud_User boolean, Plan, remaining_points currency); move Opportunity + Cloud_Org from schema-level required to validation-rule-enforced required when `Credit_Type='PoC'`; add a second entry button on Account pages ("Issue Credits" — generic, any type) alongside the existing Opportunity-page button ("Apply for PoC" — PoC shortcut); lock `Credit_Type__c` after create (snapshot-rule extension). §1, §3, §4, §5, §6, §7, §10 rewritten. Picklist starts at one value (PoC) — no premature enumeration of future types. The implementation PR (#1450) will need a corresponding rename + new fields + validation rules + second entry button.
- 2026-05-16 (status → approved) — proposal moves from `draft` to `approved`. Implementation PR can be opened by the admin lead following the #1402 New Object Implementation Checklist. Phase 0 alignment kickoff: confirm the three §14 dependencies, then move to Phase 1.
- 2026-05-16 (prefill round — sync doc to shipped Phase 1) — PM review of pre-fills on PR #1450 changed the snapshot field set. §3, §5, §6 updated to reflect what shipped: (a) snapshot field list is now `Account_Name`, `Account_Owner` (renamed from `AE_Owner` — canonical source is Account.OwnerId), `Opportunity_Name`, `Region`, `Industry`, `Vertical`; `Customer_Segment` and `Expected_End_Date` dropped from Phase 1; `Currency` field dropped (USD-only). (b) §6 entry-point bullets rewritten — ui_action prefill is intentionally minimal (Account-entry: just `Account__c`; Opportunity-entry: `Account__c` + `Credit_Type__c=PoC` + `Opportunity_Name__c`); a single before-save Create Flow `Credits_Prefill_Snapshot_Fields` does the transitive Account → snapshot-fields stamping for both entries, so both produce identical snapshots. Cloud_Org and Opportunity stay user-selected (1-to-many to Account) with Account-filtered pickers. (c) §7 USD-conversion paragraph simplified to "USD-only at Phase 1"; cumulative-disbursed reference dropped the `_USD` suffix to match the renamed fields. (d) §5 layout Context section field list synced.
- 2026-05-16 (global review — substrate gap surfaced) — cross-file review of impl PR #1450 surfaced a L0 substrate gap that materially scopes Phase 1: the pack build script strips `rollupsummary`-typed fields and active-formula expressions (see new §14 entry). `Approved_Topups__c` does not ship at Phase 1; `Total_Disbursed__c` ships as plain currency always NULL; `Cumulative_Disbursed__c` has no stamping Flow because the value would be semantically wrong. §7 cumulative-threshold gating is therefore Phase 2 + substrate-dependent. The 1-step approval spike (Presales Manager only, no threshold reads) is unaffected and ships at Phase 1. §7, §14 updated. Other findings landed in code: required:false on Account_Owner / Region / Topup_Sequence (Flow-populated, non-UI create paths shouldn't be blocked); Account null guard in Prefill Flow; stale comment cleanups. Spike test plan in §14 expanded with two additional asserts: (a) Stamp_Approvers / ProcessDefinition ordering verification, (b) VR formula `<>` operator + `__r` traversal verification.
- 2026-05-16 (post-unified-redesign cleanup) — full re-read after the unified-Credits redesign caught 7 P0 inconsistencies and 1 design gap. Fixes landed in this commit: (a) `PoC_Status__c` field renamed to `Engagement_Status__c` (was flagged as "future rename" — done now while changes are still cheap); the value set was always generic-lifecycle anyway. (b) Phase 1 vs Phase 2/3 split made explicit on the post-approval flow: a **new small Phase 1 activation Flow** flips Engagement_Status__c to Active + stamps Start_Date when Approval_Status flips to Approved — closing a real gap where Phase 1 ship would otherwise have left approved records sitting in InApproval forever. The Cloud-billing CallWebhook Flow stays deferred to Phase 2/3. (c) Cross-team audit notification (§8) explicitly marked Phase 2 deferred — depends on NX#930 SendNotification action. (d) §13 Launch criteria split into Phase 1 criteria (must hold for refactor "launched" status) and Phase 2/3 criteria (added when deferred features land). (e) §14 Dependencies trimmed to Phase 1 essentials; CallWebhook + SendNotification flagged as deferred substrate items, not Phase 0 deliverables. (f) Various stale "Add PoC" / "PoC Credits" / "PoC record" references updated to the unified naming.
- 2026-05-17 (substrate deep-dive — approver-stamp ordering root-fixed) — followup to the global review's H2 question. Deep-read upstream `nexuscrm/backend/internal/features/automation/approval_service.go` + `approval_runtime.go` confirmed: the engine resolves approvers at `approval_service.go:487 selectInitialApprovalStep` BEFORE the Submit FieldUpdate fires at `:567`. The original design (stamping Flow on `Approval_Status='Pending' IsChanged`) would have meant the engine reads NULL approver fields and falls back to `submittedByID` (line 318) — submitter becomes own approver, security hole. Root fix: `Credits_Stamp_Approvers` + `Credits_Topup_Stamp_Approvers` Flows moved from Submit-time to **Create-time** triggers. Self-sufficient lookup chain (derive Region via Account.Region__c, not from $Record.Region__c). NeedsTriage fallback fires at Create if Region_All__c is incomplete. §3 (both parent + child stamping wording) and §7 (steps 2/4/6) updated. Also: ProcessDefinition seed now uses the engine's Metadata-resolution path (a JSON blob describing the approvalStep) instead of relying on the legacy ProcessNode-column path that has an early-return bug at `approval_service.go:1409` (filed upstream as [NX#1122](https://github.com/pingcap-inc/NexusCRM/issues/1122)). EntryFilter format corrected from SQL `=` to simple-token `equals`. §14 spike-test addition for ordering is now resolved from source code — only the VR formula support test remains.
- 2026-05-18 (whole-picture review — NeedsTriage retry gap closed + InApproval cleanup) — followup to the substrate deep-dive. Spotted that Credits_Stamp_Approvers becoming Create-only left a NeedsTriage retry gap: after ops fixed Region_All__c and AE flipped Approval_Status from NeedsTriage back to NotSubmitted, no Flow re-ran → approver fields stayed NULL → Submit hit the submittedByID fallback again. Closed in code by adding `Credits_Stamp_Approvers_Retry` (Update trigger, NotSubmitted match) which re-runs the same lookup chain. Idempotent: bounces back to NeedsTriage if Region_All__c is still incomplete. Topup retry path deferred to Phase 2 (rare in practice — requires Region_All__c row change between parent.Create and topup.Create). Also cleaned stale `InApproval` references in §6 (AE submit narrative) and §13 launch criterion (now references `Approval_Status='Pending'` instead of the never-written `Engagement_Status='InApproval'`). The `InApproval` picklist option itself is left in the schema as a harmless dead value (Phase 2 cleanup).
- 2026-05-28 (Phase 1 parent flow upgraded SPIKE → 3-step + Step 2 AND-gate rule correction) — local substrate verification on `Credits__c` (PR #2055) confirmed the `relatedUserField` engine path works end-to-end on a non-admin AE → non-admin Presales Manager submit→approve, so the seed was upgraded from the 1-step SPIKE to the full 3-step gated design. While reviewing the routing rule, PM clarified that the earlier `OR`-gate framing in §7 ("Step 2 fires when credits > 1000 OR flag = true") was incorrect — the real business rule is the **AND**-gate: Finance fires only when **both** credits > 1000 **and** the manual flag is true. Without the flag, even large requests stay Presales-only. §7 updated: tier table replaced with a 2×3 matrix (flag × amount), step-condition table added, StepCriteria expressions corrected from `OR` to `AND` with `IfCriteriaNotMet = GotoNextStep` so that `credits > 3000, flag = false` routes correctly to BG Leader (skipping Finance). The Manual budget bypass paragraph also rewritten to clarify ops controls the flag (not AE), and §14 substrate-gap note updated to say the parent 3-step flow ships at Phase 1 (only the top-up cumulative-threshold gating remains Phase 2). FLS lock for `Requires_Finance_Approval__c` added in the implementation PR: AE / Sales Manager read-only, Finance Ops / Sales Ops edit, Marketing hidden.
- 2026-05-29 (form-review cleanup — Engagement_Status__c renamed + dead picklist value retired) — local Credits demo surfaced two AE-comprehension gaps. (a) "Engagement Status" did not communicate intent on the form — AE looking at the field couldn't tell what "engagement" meant or how it differed from `Approval_Status__c`. Renamed `Engagement_Status__c` → **`Credit_Lifecycle__c`** (label "Credit Lifecycle") since PR #2055 has not landed yet and no production data exists under the old api name; all in-repo call sites updated (object schema, layout compact + Status section + headerAction visibilityCondition, ui_actions visibilityCondition, Credits_Activation_On_Approval Flow assignToReference + description + step label, Credits_Stamp_Approvers Flow description, watchdog seed business_purpose). (b) The `InApproval` picklist value was the dead value flagged on 2026-05-18 — nothing writes it, redundant with `Approval_Status='Pending'`. Removed from the picklist entirely (previous "Phase 2 cleanup" tag retired with this commit). The field description was rewritten to explicitly distinguish from `Approval_Status__c` and document who writes each value (Draft = schema default; Active = Credits_Activation_On_Approval Flow; Concluded / Cancelled = AE manual, Phase 1 has no automation). Also two form-layout fixes from the same review: removed a layout-embedded `SubmitCreditsForApproval` headerAction that bypassed the approval engine via `type:"UpdateStatus"` (Bug A pattern from `actions/ui_actions.json` had a twin here; NexusCRM auto-renders a correct Submit button when an ApprovalProcess exists, see `MetadataRecordDetail.tsx:505-515`), and auto-hid the Snapshot section on the Create form via `createable: false` on the five snapshot fields (verified safe against the before-save Flow: `pruneNonCreateableFields` at `persistence_insert.go:103/143` runs BEFORE `RecordBeforeCreate` event fires at line 235, so Flow stamps to createable:false fields survive to INSERT at line 298). §3, §6, §13 updated.
- 2026-05-29 (lifecycle redesign — Provisioned / Completed states + AE-filled dates + Reject auto-Cancel) — followup to the Engagement_Status → Credit_Lifecycle rename. PM walked through the form again and surfaced that the previous 4-value lifecycle (Draft / Active / Concluded / Cancelled) missed the critical real-world gap: between "approved" and "credits actually live on the customer's Cloud Org" — currently a manual Cloud-billing handoff, soon a webhook. New 5-value lifecycle: **Draft → Active (approved, not yet on Cloud) → Provisioned (credits live on Cloud) → Completed (used up / expired) / Cancelled (rejected or recalled)**. Phase 1 Sales Ops flips Active↔Provisioned↔Completed manually; Phase 2 the Cloud-billing webhook auto-stamps Provisioned and a scheduled Flow auto-flips records past Expiration_Date to Completed. Dates: dropped Start_Date__c (was auto-stamped at approval, conflated "approved on" with "effective on"); added required AE-filled **Effective_Date__c** + **Expiration_Date__c**; renamed Concluded_Date__c → **Completed_Date__c**. Flow renamed Credits_Activation_On_Approval → **Credits_Lifecycle_On_Approval_Decision**, now triggers on Approval_Status IsChanged (not just Approved) and uses a Decision node to route: Approved → Credit_Lifecycle=Active; Rejected → Credit_Lifecycle=Cancelled (no more "AE manually marks Cancelled" — the previous gap where Rejected records sat in Draft is closed). FLS: Credit_Lifecycle__c locked to Sales Ops Edit only — AE / Sales Manager / Finance Ops read-only, Marketing hidden. Rationale: if AE could flip lifecycle they'd skip the real "credits on Cloud" check and the field would stop reflecting Cloud state. Flow writes are unaffected (Flow runs in system context, gated by intrinsic createable/updateable not FLS). Layout: Effective_Date + Expiration_Date placed in the Credits section, not Context. §3 / §6 / §7 / §13 updated. Implementation in PR #2055 commit 8b66dd9; stack.lock.json flow_whitelist updated to the new Flow name.
- 2026-05-29 (form-review cleanup round 2 — Cloud_Org required + Audit / Completed_Date hidden on Create) — third pass over the New Credits form. Three tightenings: (a) `Cloud_Org__c` promoted from schema-optional + PoC-required-by-VR to **schema-level required: true across all Credit_Types** — every credit grant lands on a Cloud Org regardless of type, no exception PM could think of (Trial / Marketing / CS / Partner all share the Cloud-billing target model). The redundant validation rule `Credits__VR_PoC_Requires_CloudOrg` was dropped (the account-match VR stays — different concern). (b) Audit section auto-hidden on the New form: the three approver lookups (`Presales_Manager_User__c`, `Finance_Approver_User__c`, `BG_Leader_User__c`) flipped to `createable: false` since they are stamped by the `Credits_Stamp_Approvers` before-save Flow at RecordBeforeCreate (Flow writes survive the prune pass at `pruneNonCreateableFields`). The fourth Audit field `Requires_Finance_Approval__c` also flipped to `createable: false` so admin-perspective Create form does not show a one-checkbox section either — the budget-bypass flag is a Sales-Ops-controlled gate set via Edit before AE submits, the rare-event extra-step cost is worth the cleaner New form. (c) `Completed_Date__c` flipped to `createable: false` — a brand-new Draft credit is never Completed; the date is stamped by Sales Ops via Edit when they flip Credit_Lifecycle to Completed. §3, §6 updated. Implementation in PR #2055 commit c4b2aa8.
- 2026-06-01 (security tighten — sharingModel ReadWrite → Private + targeted ViewAll grants + Credits seed restoration) — post-NX#1510 verification on staging surfaced two coupled gaps. (1) `Credits__c.sharingModel` had silently shipped as `ReadWrite` since the first POC scaffold (commit `358fc27`, 2026-05-16) — a regression from legacy `PoC__c` (which was `Private`) that made the per-field FLS story decorative and that happened to mask NX#1510 entirely from PR #2055 local verification (under public RW, approvers can read records regardless of share-row existence). Tightened to `Private`, `enableHierarchySharing: true`. (2) PR [#2231](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/2231) "shrink watchdog deploy baseline seed" had moved business-graph content out of `watchdog/seeds/deploy-baseline.seed.yaml` per the new architecture (business graph belongs in `default.seed.yaml` under bucket scope; deploy-baseline keeps only environment identities), but the Credits seed (5 PermSets + 5 ObjectPermissions + 15 FieldPermissions + 1 ProcessDefinition + 3 ProcessNodes + 4 ProcessActions) had been removed without being re-authored at the new location. Restored the full Credits seed to `watchdog/seeds/default.seed.yaml` with `buckets: ['approval-core']` (matching the Quote-approval seed pattern). ObjectPermissions matrix corrected during the restore: Finance Ops gets `PermissionsViewAllRecords` (queue visibility across teams), Sales Ops gets `PermissionsViewAllRecords + PermissionsModifyAllRecords` (cross-team batch + §6 Active→Provisioned flips), Marketing gets **no** ViewAll grant. The earlier scaffold draft (in the closed #2159) had included Marketing ViewAll on the (fabricated) rationale of "org-wide context visibility for campaign correlation" — there is no concrete Marketing use case for cross-Account credit visibility, and the proposal §8 phrase "Marketing reads context fields only" is a field-level restriction not a record-level mandate. Narrower is safer; can be expanded later if a real Marketing use case appears. §8 rewritten with the explicit visibility matrix.
- 2026-06-01 (UX polish — Cloud_Org picker hint + N=1 auto-fill) — staging UI walkthrough surfaced that the Cloud_Org__c picker shows an empty `Start typing to search...` state by default, even when the picker has the `runtime_scoped_lookup` filter that scopes candidates to the current Account's Orgs (#2246). AE has no signal whether the Account has 0 / 1 / many Orgs, and no way to discover Org names without typing. Investigated SF parity options (App Launcher All Items / Recent Items / per-field display mode / Related List entry) and the broader question of whether `SearchableLookup` is even the right component for filtered + small-cardinality cases — all addressable but blocked on upstream substrate (filed NX#1698 for App Launcher All Items; NX#1685 covers display-name resolution; closed NX#1703 because its "show all on filter active" framing put UX policy in the data layer and conflated 'filter active' with 'small N'). Settled on a pure-pack-side solution that doesn't require any upstream change: combine **(Y) Flow auto-fill of Cloud_Org__c when the Account has exactly one Org** with **(hint) a read-only `Available_Cloud_Orgs__c` text field next to the picker** that signals the count state. Staging Account→Org cardinality probe 2026-06-01 showed 76% of Accounts have exactly one Org, 24% have 2-5, <1% have 6+ — so the N=1 auto-fill case is dominant. New Flow `Credits_Hint_Available_Cloud_Orgs` runs at RecordBeforeCreate, queries Org__c WHERE Account__c=$Record.Account__c, branches on count: N=0 → hint "No Cloud Orgs found for this Account — please create an Org first"; N=1 → auto-fill Cloud_Org__c + hint "Cloud Org auto-filled — 1 available for this Account"; N≥2 → leave Cloud_Org__c empty + hint "Multiple Cloud Orgs available for this Account — type any character in the picker below to filter". Count is accumulated via Loop + Increment_Counter (number Add operator) following the existing BN_Account_Lead Loop+Assignment precedent; string accumulation would be the ideal hint (listing Org names) but the upstream Flow `Add` operator only supports number+collection, not string concatenation, so the hint stays count-aware not name-listing. Cloud_Org__c gets the auto-fill via N=1 branch; the existing `validator: runtime_scoped_lookup` (#2246) keeps the picker scoped when AE picks manually in the N≥2 case. Layout `Context` section field list reordered to `[Credit_Type, Account_Name, Opportunity, Available_Cloud_Orgs, Cloud_Org]` — under columns:2 layout this puts the hint at row-2-col-2 directly above the Cloud_Org picker at row-3-col-1, so AE sees the count state before/while interacting with the picker. §3 adds `Available_Cloud_Orgs__c` field. Pure pack metadata + one new Flow; no upstream substrate change required.
- 2026-06-02 (reconcile to shipped Phase 2 + define Phase 3) — added §0 Phase status & roadmap. **Phase 2 shipped**: top-up cumulative-disbursement gating is now live (NX#947 rollup-summary + active-formula substrate gap **closed** — §14 entry flipped to ✅; #2296 allow-listed the three fields and verified them end-to-end); `Credits_Topup__c` got its own **required `Effective_Date__c` + `Expiration_Date__c` validity window** (§3 child) and a `Top-up #<n>` display name; Opportunity picker Account-scoped via `runtime_scoped_lookup` + new `Credits__VR_Opp_Matches_Account` (§6); Top-ups related list + "Add Top-up" + list-view/layout polish. Landed via #2272 #2275 #2277 #2292 #2296 #2305 #2317. The #2265 Cloud_Org hint Flow + `Available_Cloud_Orgs__c` was **reverted** (#2273) — the prior 2026-06-01 History entry no longer reflects shipped state (noted in §0). **Phase 2 takes no further feature work.** **Phase 3 defined** (§0 + §13 + §12): (1) Cloud Platform provisioning integration via an **ops-triggered "Ship" action** (not auto-on-approve) that fires CallWebhook → Lifecycle=Provisioned; (2) **email + in-app notification** on approval decision (NX#930 SendNotification + email channel); (3) **cross-Credits gating loophole fix** — new §12 known gap: the gate aggregates only within one engagement (single record, or parent+top-ups via `Cumulative_Disbursed__c`), so a rep can open a fresh `Credits__c` instead of a top-up to dodge cumulative thresholds; confirmed structural, Phase 3 closes it with account/Cloud-Org-level outstanding-credit aggregation.
- 2026-06-01 (related-list visibility — Credits child relationship registration + legacy PoC label) — UI screenshot from prod admin walked through Account detail page and Related List Quick Links showed "Cloud User Request" (PoC__c, the legacy object) but NOT "Credits". Root cause: Credits__c's reference fields (`Account__c`, `Opportunity__c`, `Cloud_Org__c`, plus 4 User lookups) had `relationshipName` but no `childRelationshipName`. The platform registers a child relationship on the parent's schema only when `childRelationshipName` is set on the FK field; without it, the parent's Related List has no entry for the child. PoC__c had `childRelationshipName: "POC"` on its Account__c field, which is why "Cloud User Request" (PoC__c.Label) was visible. Fix: added `childRelationshipName` to Credits.Account__c / Opportunity__c / Cloud_Org__c (the three business-meaningful parents — Account/Opp/Org details should surface a Credits related list). Deliberately did NOT add it to the four User-lookup fields (`Account_Owner__c`, `Presales_Manager_User__c`, `Finance_Approver_User__c`, `BG_Leader_User__c`) — surfacing "all Credits where I'm BG Leader" on a User detail page is UX noise, not value. Also suffixed PoC__c.Label and pluralLabel with " (Legacy)" so AE / Sales Ops who see both "Credits" and "Cloud User Request (Legacy)" in an Account's related lists do not mistake one for the other (the Cloud User Request label was the original PoC product naming and shipped here because PoC__c was probe-imported with its existing label). §3 unaffected (no schema-shape change beyond the relationship-registration metadata field).
- 2026-07-13 (pre-Phase-3 verification pass — §0.1 status notes added) — verified §0 claims against merged PRs, live issue state, and `origin/main` tree state ahead of Phase 3 kickoff. Three corrections recorded in the new §0.1: (a) **submission UX** is now explicit **Save & Submit** on the create form (app-packs#337 mechanism + pack#3763 opt-in for Account / Credits__c / Credits_Topup__c, both merged 2026-07-09); the interim `Credits_Auto_Submit_Approval` auto-submit Flow was retired by #3763 without ever reaching prod — that experiment and its retirement were never recorded here, so §0.1 note 1 carries the archaeology; §6 submit narrative annotated, record-page manual Submit unchanged. (b) **NX#930 still open** → Phase 3 item 2 remains blocked upstream; prod in-app bell delivery additionally required the nginx `/product/*` gateway fix (pack#3773, merged 2026-07-10, effective next deploy); NX#947 re-verified closed. (c) **Approval-chain ownership documented**: the staging/prod `PoC_Credits_Approval_Process` chain + permission matrix live only in hand-applied patch bundles (20260531/007 + 20260602/001, permission matrix corrected by 20260603_credits_1466_followups/001 — flagged by Codex review on #3876), not in the L2 pack, so local bootstrap cannot reproduce the live chain (Story #1466 R16 lesson); §7/§14 "seed" wording annotated, and Phase 3 must decide whether the chain finally moves into seedable pack/deploy content.
- 2026-07-13 (item-2 dual-channel verification — §0.1 note 2 corrected) — follow-up to the same-day status notes after item 2 was picked as the first Phase 3 workstream. The earlier note 2 wording ("item 2 is still blocked upstream") overstated the blockage: only the **in-app half** is upstream-blocked. Verified from upstream source that the **email half is fully expressible today**, including the subtle gated-chain case — on step advance the engine runs the definition-level `StepApproval` action batch with the post-gating `nextActorIDs` as `Approval.ApproverUserIds` (`approval_action_process.go`), so "email exactly the newly-assigned approver" works even when Finance is conditionally skipped; `Approval.SubmitterUserId` + `resolveEmailRecipients` `userIds` inputs cover the decision email. §0 item-2 dependency line annotated (email channel exists; NX#930 irrelevant to email). Recorded the in-app path forward: **NX#3619 filed** (engine-native assignment/decision Notification rows; cross-linked on NX#930 as the complementary general-purpose Flow-action case). Also recorded the staging email decision: staging stays mailpit capture-only permanently (pack#3610 closed 2026-07-13 without enabling real delivery); prod has no SMTP transport — a prod transport decision gates real delivery of the email half.

- 2026-07-31 (Phase 3 item 3 — cross-Credits gating loophole closed; §12 resolved) — production read-only evidence turned §12 from a hypothesis into observed behavior: two same-`Cloud_Org__c` duplicate groups (combined 3000 and 2000) and **zero** `Credits_Topup__c` rows org-wide, i.e. every follow-on grant ever made went through a fresh parent record rather than the top-up path the cumulative gate depends on. No approval has actually been dodged yet — both combined totals sit at or below the live BG Leader gate (`Initial_Credits__c > 3000`, read from the prod ProcessDefinition metadata, with the Finance step dormant because its flag is set by nobody) — so this landed as risk control, not incident response. PM picked §12 option 2: **at most one non-`Cancelled` `Credits__c` ledger per Cloud Org**, which reframes the object as the org's lifetime ledger and therefore satisfies the aggregation requirement with **no new cross-record mechanism** (the existing top-up criteria already sum the full history). Anchor is Cloud Org, not Account (too coarse) or Opportunity (trivially gamed); blocking is state-independent except an all-`Cancelled` escape, deliberately so, because production has never produced a `Completed` or `Cancelled` record and any close-dependent rule would silently block legitimate requests. Shipped as pack content only: `Credits_Stamp_Open_Org_Ledger` (`RecordBeforeSave`/`Create`) stamps `Has_Open_Org_Ledger__c`, `Credits__VR_One_Open_Ledger_Per_Org` blocks on it in the same save (`ISNEW()`-guarded; bypass via `$Permission.ModifyAllData`, **not** `$Profile.Name`, which is empty for the environment admin and would make the gate always-true). A companion inspection guard was built and used to gather the production evidence above, then deliberately not shipped as disproportionate to a 22-row table; the write paths the create-time rule cannot see are therefore accepted blind spots rather than monitored risks. The two existing duplicate groups are grandfathered. §12 carries the full decision record and the accepted residual gaps.
- 2026-08-04 (Phase 3 / Phase 4 split; Feishu delivery surface added) — **Phase 3 reduced to two items** now that notifications shipped and verified in production on 2026-07-31 (8 in-app + 8 email across four terminal approvals, zero misses) and the cross-Credits gating fix landed on `main` (#4747 + #4791, awaiting deployment). **The Cloud-billing provisioning integration moved from Phase 3 to Phase 4** — it is gated on an external endpoint/payload contract that is not scheduled, and the shipped ops notification covers the manual workflow meanwhile; the move is recorded rather than silent because production showed the manual leg is the weak link (the lifecycle back half was never used until the ops notification asked for it explicitly). **Phase 4 opened** with that item plus a new one: **Feishu as a delivery and action surface**, with the architectural decision recorded up front — NexusCRM stays the approval engine and system of record, and Feishu does not own the workflow (deliberately unlike the internal IT platform's model, where each application type maps to a Feishu approval template; adopting that here would mean re-expressing approver stamping, the cumulative gate, and the one-ledger-per-org rule inside Feishu and running a second source of truth). Scope is a sketch only: cards for the four notification points, approve/reject in place via one-time-token deep links, a regional-ops "provisioning done" button that closes the Phase 4 item 1 write-back gap cheaply, and stalled-approval reminders (#4723). The blocking question is recorded with it: route through the existing internal Feishu gateway (which already owns credentials, token cache, systemic-error circuit breaker, durable outbox, card builder, and one-time action tokens) or connect directly and rebuild all of it while duplicating a single-tenant app credential — unresolved because it is not documented whether that gateway exposes an inbound send-card API.

- 2026-08-06 (Phase 3 gating fix accepted on staging; a production-state inference about *user behaviour* was falsified by asking the users) — **§12's fix verified end to end on staging** (build `v17859578`): a first ledger on a fresh Cloud Org saves, a second on the same org is blocked by `Credits__VR_One_Open_Ledger_Per_Org`, and an ordinary edit on an existing ledger is not caught; #4791's `Add Top-up` visibility verified across `Active` / `Provisioned` (shown) and `Draft` (hidden). Deliberately **not** re-run in production — the check requires creating a ledger and then attempting a duplicate, which would leave test records in the live credit ledger, and the behaviour is environment-independent (same pack content, same engine). Two testing facts worth carrying: the rule's `$Permission.ModifyAllData` bypass means **running this check as an administrator yields a false negative that reads exactly like "the rule does not work"**, so a non-admin identity with the `Credits_AE` permission set is required; and `Credit_Lifecycle__c` has **no `Rejected` value** (rejection lives in `Approval_Status__c`), so the only states hiding the top-up action are `Draft` and `Cancelled`. #4733 closed on this acceptance.
  **The larger correction is methodological.** A read-only production sweep had been used to argue that the approval control was never being exercised — 24 approvals, 21 single-step, zero rejections ever, BG Leader fired once, Finance never — and that reading was filed as #4851. It does not survive asking the people who run the process: in the APAC region an approver who spots a wrong credit amount resolves it **offline**, the submitter fixes and resubmits, and rejecting is simply not the habit. So "zero rejections" is evidence that review happens and catches things, not that it does not; the workflow's fingerprint was in the data all along as the **2 recalled** requests, which had been read as noise. Likewise "BG Leader fired once" is explained by the same sweep's own finding that only **1 of 24** requests exceeded the 3000 threshold — a tier that rarely fires because requests are rarely large is a threshold working, not a dead gate. #4851 closed as not planned. **Standing lesson for anyone reading production state here: the absence of a row is not the absence of the behaviour — it can mean the behaviour uses a mechanism you did not query. Where a finding is about what people do, ask them before building anything that measures them.** The one finding not explained by this is the approval→`Provisioned` latency, and it carries the same defect (see the qualifier on the Phase 4 provisioning item): the data cannot separate slow provisioning from slow write-back, and the next step is an ops question, not a report.
