# Rolling Forecast (Region Dimension) — Product Requirements Document

- **Version:** v0.4
- **Date:** 2026-09-08 (demo-review decisions folded in; original 2026-08-05)
- **Status:** Draft for review
- **Scope:** NexusCRM feature — monthly rolling revenue/ARR forecast by Region
- **Reference:** current manual process in Google Sheet "APAC_Rolling_Forecast_Main File" / "ROLLING FORECAST" regional workbooks

---

## 1. Background & Goals

### 1.1 Pain points of the current spreadsheet process

- Forecasts are maintained manually in Google Sheets: actuals must be hand-imported, and formulas are fragile.
- Templates and definitions differ across Regions; there is no automatic global rollup.
- No submission/locking mechanism: it is impossible to reconstruct "what was forecast in a given month," so forecast accuracy cannot be measured.

### 1.2 Product goals

1. A monthly rolling forecast cycle per Region (APAC / NA / EMEA / Japan / LATAM): actual revenue is synced from NetSuite and pre-filled by the system; sales enter forecasts for **every remaining month of the fiscal year**.
2. The system performs all ARR conversion and quarter/fiscal-year aggregation, producing Region × Quarter analysis on two dimensions — **Revenue** and **ARR** — each against quarterly targets. Product-type figures are materialized alongside (§5.1) but are not part of the summary surface (§11).
3. Every cycle produces an immutable snapshot version (immutability as defined in §3.1: enforced for every role below the System Administrator, with the audited single-role residual), enabling cycle-over-cycle comparison, entry-time deviation feedback, and forecast-accuracy analysis.

### 1.3 Acceptance standards

1. Production provides a usable forecast surface: sales can create, view, and maintain monthly forecasts for their own accounts; every record identifies the account (a prospect must exist as a CRM Account before it can be forecast — §6.1), the forecast month, the forecast revenue, and the owner.
2. NetSuite-synced voucher lines are the **single source of actual revenue**, aggregated monthly per account in USD.
3. While entering the current month's forecast, sales see recent actual months; when the prior cycle forecast the last boundary month, they also see that forecast, its actual, the delta, and the delta ratio.
4. By **2026-09-15**, every forecast line carries a forecast for **every remaining fiscal month through 2027-03** (the last forecast month of this fiscal year). Coverage is 100%.
5. A read-only coverage & deviation report lists lines, owners, trailing-3-month actuals, per-remaining-month forecasts, missing months, and prior-month deviation; acceptance passes when no line has a missing month.

---

## 2. Terms & Fiscal Calendar

| Term | Definition |
|---|---|
| **FY (fiscal year)** | April 1 – March 31, **named by the ending calendar year**: FY27 = 2026-04-01 – 2027-03-31. Quarters: Q1 = Apr–Jun, Q2 = Jul–Sep, Q3 = Oct–Dec, Q4 = Jan–Mar. |
| **Forecast Cycle** | One per month, **identified by its calendar start month** (`cycle_code` = `2026-08`) and shown to people as **"Aug 2026 Forecast"** everywhere — titles, pickers, selectors, record names. The fiscal-month index (1 = April … 12 = March) is derived from the start month and stays engine-internal; **no `FY-FM` code is ever displayed**. The fiscal-year label (FY27) appears only where annual targets need it, always with its month range ("FY27 (Apr 2026 – Mar 2027)"), and fiscal quarters always carry their months ("Q1 (Apr–Jun)"; ARR: "Q1 (end of Jun)"). The month in which the forecast is made is called **M0**. |
| **Actuals boundary** | The last fiscal month whose NetSuite sync is complete — derived automatically as the end of the contiguous **Complete** prefix of the `NetSuite_Sync_Period__c` sync manifest (§5.3), **not** from the calendar. Months up to and including the boundary are Actuals (read-only); later months are Forecast. |
| **Forecast window** | **From the month immediately after the actuals boundary through FM12 (fiscal March) — a shrinking window.** When the sync is current (boundary = M0−1) this is M0..FM12: the April cycle opens all 12 months, February 2, March 1. If the sync lags, the not-yet-Complete months before M0 stay inside the window — editable, rolled over, and counted in coverage — so no month is ever orphaned between the boundary and M0. The window never crosses fiscal years: FY28 forecasting opens only at the FY28 April cycle. |
| **Region** | APAC / NA / EMEA / Japan / LATAM (top-level org dimension). Japan is an independent single-country Region. |
| **Sub-unit** | Optional sub-division within a Region (formerly called "Pod"). Currently only APAC has Sub-units; the Account field is **`Sub_Unit__c`** with values AI, Hospitality, Web3, Strategic Growth, IN, ANZ, Alliance Sales, CSM, Big2, Other. Regions without Sub-units file one worksheet per Region. |
| **Product Category / Product** | Cloud (Starter, Essential, Premium, Dedicated, BYOC, Lake, Support); OP (Subscription, Professional Service). |
| **Line granularity** | **Account × Sub-unit (nullable) × Product Category × Product × Month.** |
| **Currency** | **USD only.** All amounts are entered and stored in USD. |

---

## 3. Roles & Permissions

| Role | Enters data for | Views | Notes |
|---|---|---|---|
| **Sub-unit Leader** | Own Sub-unit's worksheet | Own Sub-unit | Exists only in Regions with Sub-units (currently APAC). Sole submitter of the Sub-unit worksheet. |
| **Sales (filler)** | Rows assigned to them (`filler` field) | Own rows + own Region worksheet | In Regions **without** Sub-units, multiple salespeople fill the same Region worksheet. The same row-level mechanism is reusable inside Sub-units. |
| **Region Leader** | Any row in own Region (can fill on behalf); can send back (reopen) a Sub-unit worksheet before lock | Entire Region | Accountable for the Region's submission; submits Region worksheets in no-Sub-unit Regions. |
| **Sales Ops** | Configuration: cycles, Targets, leader mapping, row reassignment | Everything | May unlock locked worksheets (§10). |
| **System Administrator** | Platform administration | Everything | May also unlock locked worksheets (§10). |
| **Management (RVP/GVP/Finance)** | — (read only) | All Regions, rollups + drill-down | |
| **Region Viewer** | — (read only) | Own Region's forecast data: worksheets, rollups, drill-down; accounts and actual revenue per the Account floor | Regional management (e.g. a Region GM) who does not file. Forecast objects (worksheets, summaries) are shared to them for **their Region only**; Account visibility — and therefore actual revenue, which follows the Account edge (§3.1) — is the same **additive floor** every forecast role gets: the viewer group adds Read on the Region's accounts on top of the CRM's existing account model, so what they can see of *other* Regions' accounts is exactly what the CRM already lets them see, never widened by this role. Membership in the Region's viewer group only; never the filing group (any filing-group member may add rows) and never Management (all-Region forecast data). |

Row-level security uses NexusCRM's existing RLS/sharing rules: forecast data is isolated by Region/Sub-unit ownership. After submission the filling roles become read-only for that cycle; only Sales Ops / System Administrator can unlock.

Three explicit refinements:

- **Colleague-row visibility.** Within their own worksheet, a filler **sees all rows — including colleagues' — read-only**, and edits only rows where `filler = self`. Full-worksheet visibility is deliberate: fillers need worksheet totals and context to calibrate their own numbers. Cross-worksheet (other Sub-units / other Regions) remains invisible.
- **Delegation (vacation backup).** A filler or Leader can delegate their forecast rights to another user in the same Region for a date range; the delegate gets the same edit/submit rights, every action is audited as "delegate on behalf of", and the delegation auto-expires. Until this ships (P1), the fallback is Leader fill-on-behalf or a Sales Ops filler reassignment.
- **Exports follow RLS.** Any export contains exactly the rows the exporting user can see — enforced at the export layer. Global exports only for Sales Ops / System Administrator / Management.

### 3.1 Permission delivery (stock primitives only)

The entry surfaces (picker, worksheet grid, summary matrix — Appendix B) carry **no permission logic of
their own**: they query with the signed-in user's session, so row-level security is the single authority
and every surface — grid, summaries, record pages, reports, exports — stays consistent by construction.
The delivery is pure configuration on stock platform primitives:

| Layer | Configuration |
|---|---|
| **Object sharing models** (L2 pack) | `Forecast_Worksheet__c`, `Region_Forecast_Summary__c`, `Product_Type_Summary__c` = **Private**. `Forecast_Line__c`, `Forecast_Line_Month__c`, and `Forecast_Filler_Progress__c` (the per-filler completion row, §9) = **ControlledByParent**, with `Forecast_Line__c.Worksheet__c`, `Forecast_Line_Month__c.Line__c`, and `Forecast_Filler_Progress__c.Worksheet__c` declared as **master-detail relationships** — the sharing-parent edge ControlledByParent requires — so rows inherit their worksheet's visibility and no separate line-level rules exist to drift. Configuration objects (Cycle, Org Unit, Product Catalog, Targets) stay platform-readable. `NetSuite_Revenue_Voucher_Line__c` moves from **Private** — what the checked-in L2 pack and its object-contract test (`tests/test_netsuite_revenue_voucher_object_contract.py`) pin today, i.e. the pre-forecast state in which only the integration and administrators see voucher lines — to **ControlledByParent** with `CRM_Account__c` declared as the sharing parent (`managedRelationships` / `isSharingParent`, the same lookup edge Contract/Order/Task use on Account). This is part of the forecast L2 delivery and lands together with the updated contract test; it is not yet on `main`. Voucher-line visibility then follows the per-filing-unit Account Read rule below, so a Leader or filler sees exactly the actual revenue of the accounts they can see and no region-specific voucher rule exists to maintain. `NetSuite_Sync_Period__c` stays platform-readable (the manifest is not account-scoped). |
| **Object permissions (OLS)** | Profile-linked permission sets grant standard users read (and create/edit where the role requires) on the forecast objects; the pre-fill and calculation jobs write Actuals, snapshots, and summaries under system identity only. `NetSuite_Revenue_Voucher_Line__c` is **read-only for every role below the System Administrator**: create and update require the custom permission `ManageNetSuiteRevenue`, which only the ETL integration user's permission set holds among non-admin identities — the System Administrator holds it as the same audited single-role residual as the delete guard below. The gate is a validation rule on the object (validation rules cover create and update, not delete) rather than the platform's default Create/Edit grant, because a ControlledByParent child inherits its parent's write capability and account owners could otherwise create voucher lines. **Delete** is governed separately by the same mechanism as the delete guard: an **objectDelete AccessPolicy that always denies** for every role below the System Administrator — the pipeline never deletes a voucher line (voided lines flip `Is_Source_Active__c`), so no interactive or integration delete path exists. The policy is specified here and not yet exercised on the demo branch. |
| **Filing-unit groups** | Groups mirror the filing unit (§3): in Regions **with** Sub-units one group **per Sub-unit** (fillers + that Sub-unit's Leader) plus a Region-leadership group; Regions **without** Sub-units use a single Region group, which **doubles as that Region's leadership group** (the Region Leader is a member) — every grant below that names "the Region-leadership group" resolves to it there, so rollup and summary visibility is preserved. Each Region additionally has a **viewer group** (Region Viewer role, §3) that receives **Read** on that Region's worksheets and summary rows, plus the same per-Region Account Read floor the filing units get — never Edit — so regional management can read without entering the filing unit; the Account grant is additive like every floor below, so it isolates forecast data, not the CRM account model. |
| **Criteria-based sharing rules (RLS)** | Rules grant by the **worksheet's own filing unit**, never Region-wide where Sub-units exist — with exactly two **Region-wide overlays, both `Region__c`-only criteria and both by design**: the Region-leadership group (**Edit on `Forecast_Worksheet__c` only; Read on both summary objects**, per the grants below and the summaries-read-only invariant in the Global-visibility row) and the Region **viewer group** (Region Viewer role, §3; **Read** on `Forecast_Worksheet__c`, `Region_Forecast_Summary__c`, and `Product_Type_Summary__c`): one rule per Region per object, so an APAC viewer reads every APAC Sub-unit worksheet and summary row exactly as the APAC leadership group does, and neither overlay ever grants Edit on a summary object. Filing-unit grants: worksheets at **Edit** to their Sub-unit's group (`Region__c` + `Sub_Unit__c` criteria; Region-only criteria solely for no-Sub-unit Regions) and to the Region-leadership group (Leaders reopen/fill on behalf across their Region). Summary rows by scope, with leadership overlay: Sub-unit rows at **Read** to their Sub-unit group **and to the Region-leadership group** (a Region Leader reads the entire Region — rollup and every Sub-unit row — per §3); Region-rollup rows to the Region-leadership group. Cross-Sub-unit visibility among fillers therefore never arises from sharing (§3 colleague-visibility contract); it arises only through the two Region-wide overlays, whose members are Leaders and viewers, never fillers. |
| **Account visibility (floor, not ceiling)** | A per-filing-unit `Account` **Read** rule guarantees fillers can resolve their own unit's customers (names in the grid, add-row picker, B.0 resolution). It is a **floor on top of the CRM's existing account model** — Account stays governed by ownership, role hierarchy, and its other sharing (hierarchy sharing is enabled on Account), and criteria rules are additive, so a user may legitimately read accounts beyond their filing unit. That widens only which *accounts* they see; forecast isolation is unaffected because worksheets, lines, and summaries carry their own rules, and a mismatched add-row/link still fails the attribution check (B.0/D23). Because the criteria rule reads **live** account fields while forecast attribution is **cycle-frozen** (§5.2), a mid-cycle transfer could otherwise strand a frozen line whose account the unit can no longer read. The floor therefore includes **in-cycle continuity**: for **every account the cycle's `CycleAccountAttribution` snapshot attributes to a filing unit** — with or without a line yet — the system maintains a record share (written by pre-fill when it stamps the snapshot, and by the entry-resolution service for append-once rows) granting that unit Read **for as long as the cycle's worksheets are editable — the shares are retired at lock and re-armed by the corrective unlock** (the unlock flip that returns a worksheet to Draft restores its unit's shares until re-lock), so the B.0 add-row picker can always resolve an attributed account whenever editing is possible, including post-unlock corrections after a mid-cycle transfer. The **Region-leadership group receives the union of its Region's floors**: the same criteria rules and continuity shares are mirrored to it across all subordinate filing units, so a Region Leader — authorized to fill any row in the Region — can resolve accounts for add-row flows everywhere their worksheet access reaches. For locked history the share is not needed: every line carries an **immutable display name stamped at creation** (part of the line and of the snapshot payload, §10), so locked worksheets and snapshots render names without live Account access — a deep link into an account the unit can no longer read fails closed showing the stamped name. Account visibility follows a transfer only from the next cycle, together with attribution. |
| **Row-level edit gate (beyond sharing)** | Sharing opens a worksheet at Edit for its whole filing unit (needed for read-everything context, totals, and Leader fill-on-behalf), but sharing alone cannot express "colleagues' rows are read-only." A **validation rule / record-mutation AccessPolicy** on `Forecast_Line__c` / `Forecast_Line_Month__c` enforces both the actor and the state machine (§4). Months carry **no authorization fields of their own**: the month gate resolves `filler` and worksheet state **through the master-detail parent chain** (`Line__c` → `Worksheet__c`), and `Forecast_Line_Month__c.Line__c` is in the protected field set (immutable after creation), so the parent resolution cannot be redirected. The gate: writes are accepted only when the acting user is the row's `filler` (or a Leader / Sales Ops / delegate) **and** the hosting worksheet is in **Draft** — the single editable state: submission moves it to Submitted (filling roles read-only; a Leader reopen flips it back to Draft), lock moves it to Locked (LockRule read-only for everyone, §10), and the **corrective unlock likewise returns Status to Draft** before re-locking, so corrections flow through the same gate. The **system-writer exception is a scoped trusted-writer contract, not a marker reuse**: the platform's existing `IsDerivedRecompute` marker is contractually limited to platform-owned rollups, so the forecast jobs carry **named job origins** in system context and the gate whitelists each origin for exactly its write set: **`forecast.prefill`** — create cycles/worksheets/lines/months, write Actual cells and rollover Forecast values, stamp `display_name` and `CycleAccountAttribution`, maintain the in-cycle continuity shares; **`forecast.calc`** — write month `ARR` values, write both summary objects, perform the deadline **Status → Locked** transition (§10), and create snapshots; **`forecast.relocation`** — the D23/link re-parenting writes (`Worksheet__c`, `Account__c`, `Sub_Unit__c`); **`forecast.entry`** — the entry-resolution service's append-once `CycleAccountAttribution` rows and continuity shares for post-pre-fill accounts (B.0), so first-touch resolution neither fails closed nor impersonates pre-fill, **plus the completion reset**: setting `Forecast_Filler_Progress__c.Is_Complete__c` to false (and clearing `Completed_At__c`) on the rows the §5.1 reset invariant names — reset only; this origin can never set a mark to true, create a progress row, or touch any other field. Anything outside an origin's set is rejected. The **committed P0 mechanism is one dedicated integration user per job** — four identities, each granted only its own origin's write set, so identity itself is the origin discriminator and no credential holds the union (a misrouted or leaked credential cannot cross into another job's writes, protected fields included). The accounts are API-only with interactive login disabled. Per the platform-spike notes this gate composition still requires prototyping — it is a named P0 spike/verification item, not assumed. The named job-origin marker is the post-P0 upstream improvement following the derived-recompute precedent. **Create carries its own predicate instead of the filler-match check**: while the worksheet is Draft, a filing-unit member may create a row only for an account **they own** (`Account.OwnerId` = actor, §6.1) **or owned by a user who currently delegates to them** (§3 delegation, once shipped), and a Leader / Sales Ops for any account attributed to the worksheet; the system stamps the new row's `filler` per §6.1 (the creator for a filler; the delegating user for a delegate; for a Leader, the Account Owner when a member of the filing unit, else the Leader — §6.2-7) — the filler-match gate governs subsequent updates. The **worksheet `Status` field carries its own per-transition gate** on `Forecast_Worksheet__c` — worksheet Edit sharing alone must not permit state changes: a Status write is accepted only when it is one of the four §10 transitions performed by its authorized role (Draft → Submitted by the worksheet's submitter; Submitted → Draft by the Region Leader; → Locked by the system at deadline; Locked → Draft by Sales Ops / System Administrator with reason), and every other Status write — any actor, any other transition — is rejected. The `filler` field itself is editable **only by Region/Sub-unit Leaders and Sales Ops** (the roles §3/§9 authorize to reassign rows) and read-only for everyone else — per the platform-spike findings. The same gate covers `Forecast_Filler_Progress__c` with a **self-only** predicate (create/update by the row's own `filler`; Leaders / Sales Ops reopen only; the automatic reset runs under the `forecast.entry` origin) — §5.1. The **UI half of this gate is delivered**: in the grid a row assigned to somebody else renders read-only (month inputs disabled, hover text naming the assignee), the delete affordance appears only on rows the user may edit, and the Account Owner cell is a reassignment dropdown only for Leaders — verified on the demo with a Sub-unit Leader and a filler of the same unit (§6.1, §9). The UI is a courtesy, not the control: the backend gate above remains the P0 spike item and is what actually rejects a colleague's write. The **authorization-driving fields are likewise write-protected**: `Forecast_Worksheet__c.Region__c`/`Sub_Unit__c`/`Cycle__c` and `Forecast_Line__c.Worksheet__c` are written only by the system jobs (pre-fill, D23 relocation) and Sales Ops — a filing-unit member can never re-scope a worksheet or re-parent a line to move ControlledByParent data across RLS boundaries. `Forecast_Line__c.Account__c` and `Forecast_Line__c.Sub_Unit__c` are equally protected: a line's identity is its line_key (D24), so these fields change **only** through D23 relocation — a path that revalidates attribution — never by direct edit, even in Draft; re-pointing a row at a different account is delete-and-re-add (§8), not a field update. These writes additionally carry a **source-and-target state guard**: a re-scope/re-parent is accepted only while the cycle is Open and **both** the source and the target worksheet are Draft — never against a Submitted or Locked worksheet (corrections there go through reopen/unlock first, §10), so frozen totals and locked history cannot be silently re-scoped. D23 relocation obeys the same guard. |
| **Global visibility & correction path** | Non-admin global access is **object-scoped, never org-wide**. Sales Ops do **not** get ModifyAllRecords (the platform contract cascades it to Delete, which would defeat the delete guard below through the master-detail edges): they get **per-object ViewAllRecords** (read everything) plus the **per-filing-unit criteria rules mirrored to a Sales Ops group at Edit on `Forecast_Worksheet__c`** — one rule per Region, a small static set, since blank-criteria rules are owner-based in the platform's validation and cannot express share-all. Lines and months follow via ControlledByParent, so this is record-level edit-everything on the entry data without any delete capability. The **summary objects are deliberately excluded**: `Region_Forecast_Summary__c` / `Product_Type_Summary__c` are precomputed by the calculation job (§5.1) and stay **Read for every human role below the System Administrator** — corrections happen on worksheets/lines and the summaries recompute; the edit gate's system-writer exception is the only write path into them, with the same audited, alerted single-role admin residual as the delete guard (a corrupted summary is additionally self-healing: the next calculation run recomputes it from the worksheet source of truth). Sales Ops duties (corrections, row reassignment, status/unlock flips §10, manifest overrides §5.3) are all edits and status transitions on the entry objects. Management (RVP/GVP/Finance) get **per-object ViewAllRecords** on the forecast objects. Only the System Administrator retains platform-wide ModifyAllData. |
| **Delete guard** | Delete is **restricted on all forecast objects**, delivered via the platform's explicit **objectDelete AccessPolicy** — validation rules do not cover deletes, so the delete predicate is its own policy, not part of the edit gate. Per §8, the policy permits deletion **only of a line that carries no Actual month** — a row added during entry for an account with no NetSuite revenue in the cycle — together with its months, and only when **all** hold: no `value_type = Actual` month exists for the line **and** the hosting worksheet's cycle is Open **and** the worksheet Status = Draft **and** the line appears in no locked snapshot **and** the actor is the row's `filler`, a Leader of the filing unit, or Sales Ops — executed as a **soft delete, audited** (§8). Actual months are written only by the pre-fill job (§5.2) and never by a user, so a row cannot be edited into the deletable branch, and the moment an Actual lands on a row it leaves that branch for good. **Rows carrying actuals are never deleted**: churn and mistaken values are zeroed (or excluded from summary), preserving coverage semantics (§6.2-5). Every other delete, on every forecast object, is denied for all roles. Master-detail cascades by default and the LockRule governs edits, not deletes — so the parent guard is **independent of both**: `Forecast_Worksheet__c`, `Forecast_Cycle__c`, and `CycleSnapshot` carry an **objectDelete policy that always denies** (the object delete-mode enum has no block setting, so the deny lives in the delete policy, same mechanism as the line predicate). This policy binds **every role below the System Administrator**. **The System Administrator is the accepted residual**: platform all-records semantics necessarily include delete (ModifyAll implies Delete in the permission contract), so admin-proof denial is deliberately **not** claimed — consistent with the Salesforce-world convention that Modify All Data is the ultimate trust tier. Mitigations: the admin role is held by a minimal named set; every delete or edit attempt against a worksheet, cycle, or snapshot — allowed or denied — raises an audit event and alerts Sales Ops; and the audit trail records what was destroyed. `CycleSnapshot` additionally carries an **objectEdit deny for all human roles** (only the lock job writes it, at creation) with the same single-role residual. Child deletes are governed solely by the no-actuals predicate above. |

Verified behavior with region-scoped users (on the demo branch's picker, grid, and B.2 summary matrix — the same not-verifiable-from-this-document caveat as Appendix B): the picker shows one worksheet card per cycle, the summary surface returns only the user's Region rows (missing regions are absent from the query result, not hidden), account names resolve only for readable accounts, and navigation shrinks with object permissions. The P0-lite demo (branch `rolling-forecast-p0lite-v2`, two standard-profile leader users)
exercises the **Region granularity** of this configuration end to end, and the two-level completion
flow at Sub-unit granularity (APAC/AI: a Sub-unit Leader plus one filler, §9); Sub-unit-granular
sharing rules and the **backend** row-level edit gate use the same primitives but are **not yet exercised
in the demo** (only the gate's UI rendering is, §6.1) — a claim in any case not verifiable from this
document-only change. The demo's group/rule/user rows are runtime
data to be shipped as pack/seed content with that branch.

---

## 4. Monthly Process & State Machine

```
T-2d   System auto-creates the month's Forecast Cycle, derives the actuals
       boundary from the sync manifest's contiguous Complete prefix, and runs
       the pre-fill job, generating worksheets for every Region/Sub-unit.
T0     Cycle opens (Open). All fillers notified (in-app + email/IM).
T0–T+5 Sales enter forecasts for every remaining fiscal month → save drafts →
       mark own rows complete → worksheet submitted (Submitted).
T+3    Automatic reminders to anyone not yet complete, targeted per filler.
T+5    Deadline: system auto-locks (Locked) and produces the cycle snapshot.
After  Rollup reports finalized; next cycle repeats.
```

State machine: `Scheduled → Open → (Draft / Submitted per worksheet) → Locked`.

- Between submission and lock, the Region Leader may **reopen** a Sub-unit worksheet to request changes.
- After lock, only Sales Ops / System Administrator may initiate a **corrective unlock**; unlock and re-lock are fully audited (§10).

---

## 5. Data Model

### 5.1 Objects

Logical names below map 1:1 to the L2 pack API names delivered on the demo branch `rolling-forecast-p0lite-v2` (not yet on main) — `ForecastWorksheet` → `Forecast_Worksheet__c`, `ForecastLine` → `Forecast_Line__c`, `ForecastLineMonth` → `Forecast_Line_Month__c`, `ForecastFillerProgress` → `Forecast_Filler_Progress__c`, and fields likewise (`region` → `Region__c`, `sub_unit_id` → `Sub_Unit__c`, `worksheet_id` → `Worksheet__c`, `line_id` → `Line__c`, `filler` → `Filler__c`). Sharing rules, master-detail edges, and the §3.1 policies bind to the **API names**.

| Object | Key fields | Notes |
|---|---|---|
| **ForecastCycle** | **cycle_code = calendar start month (`YYYY-MM`, the identity and the only wording shown to people, §2)**, calendar_year_month (same value), fiscal_year, fiscal_month (derived from the start month; engine-internal, never displayed), status, open_date, lock_date, actuals_boundary_month | One per month, globally unique; record Name is "Aug 2026 Forecast". `actuals_boundary_month` is stamped at pre-fill as the end of the sync manifest's contiguous Complete prefix (§5.3). |
| **ForecastWorksheet** | cycle_id, region, sub_unit_id (nullable), owner (submitter), status (Draft/Submitted/Locked), submitted_at | Filing unit: one per Sub-unit in Regions with Sub-units; one per Region otherwise. |
| **ForecastLine** | worksheet_id (**master-detail → ForecastWorksheet**), **display_name** (immutable, stamped at creation — the account label locked history renders from, §3.1), account_id (**required** — a prospect must exist as an Account first, §6.1), sub_unit_id, product_category, product, **status** (picklist: Customer / Pipeline / extensible), **opportunity_id** (lookup, nullable), **opportunity_name_text** (text, nullable), **filler** (user — rendered as the **Account Owner** column, §6.1), is_excluded_from_summary | Detail row = Account × Product combination. Users select **Product only**; `product_category` is derived from the product catalog and read-only. |
| **ForecastLineMonth** | line_id (**master-detail → ForecastLine**), fiscal_year, fiscal_month, amount_usd, value_type (**Actual / Forecast**), source (System / Manual / Rollover) | Narrow storage — one row per month. Actual rows are written by the pre-fill job (from the NetSuite aggregation) and are read-only. |
| **ForecastFillerProgress** | worksheet_id (**master-detail → ForecastWorksheet**), filler (user), progress_key (`{worksheet_id}|{filler}`, unique), is_complete, completed_at | One row per filler per worksheet, created by the filler's own "Mark my rows complete" action (§9) — completion is a persisted fact, not derived from cell contents. **Read** follows the worksheet (ControlledByParent): completion state is deliberately not secret inside the filing unit — every worksheet reader may see who has marked, which is what the Leader's panel and the picker badge render. **Write is self-only**: the §3.1 edit gate accepts a create/update only when the acting user **is** `filler` (marking or reopening their own row), from a Leader / Sales Ops (**reopen only** — they may reset `is_complete`, never set it on someone's behalf), or from the `forecast.entry` system origin performing the automatic reset below; `filler` and `progress_key` are in the protected field set (immutable after creation, `progress_key` derived from `worksheet_id` and `filler`), so a row cannot be re-pointed at another filler. **Reset invariant** (server-side automation on line/month writes, not the UI): a Complete mark describes the filler's row set and values as they stood when marked, so **any** mutation touching that set — a month value written on a row with `filler = F` by anyone, including a Leader filling on behalf; a row entering or leaving F's set through reassignment, add-row, or delete — resets F's `is_complete`; a reassignment resets **both** the previous and the new assignee. Submission reads only the persisted rows. |
| **CycleAccountAttribution** | cycle_id, account_id, region, sub_unit_id | **Per-cycle attribution snapshot** written by the pre-fill job (T-2d) for every active account — the persisted authority behind §5.2 cycle-frozen attribution and the B.0 no-line entry fallback. Written only by two trusted system writers, both server-side in system context: the **pre-fill job** and the **entry-resolution service** (the B.0 append-once path). Interactive users can never write it — the first-touch append executes under system authority as part of resolution, not under the requesting user's permissions (the same trusted-path pattern as the LockRule privileged flip, §10). The initial pre-fill run writes the full set on the cycle × account key; a §5.3 fix-and-re-run repair **only fills or corrects missing/invalid entries** (empty Region, unmappable Sub-unit) and **never overwrites an existing valid row** — so repairs cannot smuggle a mid-cycle transfer into the frozen snapshot (§5.2: transfers take effect next cycle). Existing lines always outrank the snapshot (their hosting worksheet wins — B.0 rule 1). An account created after pre-fill is **appended to the snapshot on first touch**: the first entry-path resolution (or add-row) for it writes its row from the account's fields at that moment — once, atomically — after which the appended row is as frozen as the rest, so every later resolution reads the persisted row and a subsequent transfer cannot re-route it within the cycle. Rows are retained with their cycle. |
| **NetSuite_Revenue_Voucher_Line__c** (source, exists in staging) | CRM_Account__c (lookup Account, required), Posting_Period__c, Transaction_Amount__c, Transaction_Currency__c, Product_Name__c, **Product_Category__c (to be added by the integration owner)**, Is_Source_Active__c, NetSuite_Source_Key__c (required, unique), sync bookkeeping fields (First/Last_Seen, Run IDs, payload hash) | The single source of actual revenue. Written only by the NetSuite integration; UI read-only. ~1,500 lines/month. |
| **NetSuite_Sync_Period__c** (integration-owned) | posting_period, status (**Complete / Partial**), run_id, completed_at | Per-period completeness manifest — the ETL's own **run receipt**, not a NetSuite close flag (the integration has no access to NetSuite accounting-period close status; §5.3). A posting period may only be treated as closed when its manifest row is **Complete**. This is the boundary authority — per-row sync bookkeeping alone cannot prove completeness. The Sales Ops privileged override is asymmetric: to **Partial** freely; to **Complete** only with a recorded reason and a manual-reconciliation attestation — audited, mirroring the worksheet unlock flip (§5.3, §8). |
| **ForecastTarget** | fiscal_year, quarter, region, sub_unit_id, target_type (**ARR / Revenue**), amount_usd | Maintained by Sales Ops per fiscal year at **Sub-unit × Quarter** granularity (no-Sub-unit Regions use an implicit default Sub-unit). Region values are always Σ Sub-units — no manual overrides. |
| **Region_Forecast_Summary__c** | cycle_id, region, sub_unit_id (null = Region rollup row), Q1..Q4 × {ARR, Rev} × {Target, Forecast, Gap} (24 currency fields), yt_forecast_total | **Precomputed summary rows** — one per Sub-unit plus a Region rollup row per cycle, written by the calculation job on save/lock. This is deliberate: the platform report engine has no matrix format, formula columns, or conditional formatting (see the spike notes), so every summary number is computed on the write side and reports merely display this object. |
| **Product_Type_Summary__c** | cycle_id, region, sub_unit_id (null = Region rollup), product, product_category, Q1..Q4 revenue, Q1..Q4 quarter-end ARR, yt_revenue_total | **Precomputed product-type analysis rows** (quarterly granularity), written by the calculation job alongside the Region summary — the report engine cannot pivot, so the product dimension is materialized the same way. **Not shown on the summary surface** (the Forecast Summaries page is the Region matrix only, B.2); reached through its record page and reports (§11). The month-by-month product grid belongs to the post-P0 custom view. |
| **CycleSnapshot** | cycle_id, version (v1, v2, …), created_by, reason, payload/hash | Immutable per-version snapshot of a locked cycle — per the §3.1 delete guard: edit/delete denied for every role below the System Administrator, whose audited access is the accepted residual (§10). Also the data source for entry-time deviation feedback (§6.4). |
| **ForecastOrgUnit** (master data) | unit_type (Region/Sub-unit), code, name, parent_region, **leader** (User lookup), active | **The single place where Region and Sub-unit Leaders are configured.** Maintained by Sales Ops; `code` maps 1:1 to `Region__c` / `Sub_Unit__c` picklist values. Feeds worksheet owner resolution, filler fallback, notifications, and RLS. Fails closed when a unit has no active leader. One user may lead multiple units. |

The **product catalog** (product → category, `arr_eligible`, `arr_method`) remains L2 pack metadata maintained via PR review; the engine reads `arr_method` from the catalog. Changes take effect next cycle; a read-only catalog view is exposed in-app.

### 5.2 Constraints

- **Row uniqueness:** every line carries a `line_key` — `{account_id}|{product}` — unique within a worksheet, which makes `Account × Sub-unit × Product` unique. With multiple opportunities in flight for one Account × Product, the forecast is a combined value on the single row; the Opportunity reference points at the primary deal. Per-opportunity row splitting is a P2 watch item.
- Actual months are written only by the pre-fill aggregation (`value_type=Actual, source=System`); never editable in the UI.
- An Account's Region/Sub-unit attribution is read from **`Account.Region__c` / `Account.Sub_Unit__c`** and fixed as of pre-fill time for each cycle; mid-cycle transfers take effect next cycle, never retroactively.
- All amounts are USD.

### 5.3 Data ingestion

**Actual revenue — NetSuite integration (authoritative).**

- Voucher lines sync from NetSuite into `NetSuite_Revenue_Voucher_Line__c` via the integration interface; idempotency keys on the unique `NetSuite_Source_Key__c`. `CRM_Account__c` is a required Account lookup, so no separate customer mapping is needed.
- A monthly **aggregation step** derives Account × Product × Month actuals for pre-fill:
  - **Month** = parsed `Posting_Period__c` (format "July,2026" → 2026-07) — the posting period is authoritative, not the transaction date.
  - **Amount** = Σ `Transaction_Amount__c` over active rows (`Is_Source_Active__c = true`); negative lines (credits/reversals) net out naturally. Rows with `Transaction_Currency__c` ≠ USD are **excluded from aggregation and reported on the exception list** — never converted and never mixed in (`Base_Amount__c` / `Exchange_Rate__c` are not used).
  - **Product** = `Product_Name__c` (must match the 9 catalog products; unmatched values go to an exception list, never silently bucketed) with `Product_Category__c` as the category source.
  - **Attribution** (Region/Sub-unit) comes from the CRM Account fields at aggregation time, not from NetSuite-side fields.
- The **actuals boundary** auto-derives as the **end of the contiguous Complete prefix** of `NetSuite_Sync_Period__c` manifest rows counted from fiscal April — not the maximum Complete period. A Partial or missing period halts the boundary before it (so a later Complete period can never freeze an earlier incomplete one into read-only Actuals), a partially-synced period never becomes Actuals, and a genuinely zero-revenue Complete period still advances the boundary.
- **Completeness semantics — the manifest is the ETL's own run receipt, not a NetSuite close flag.** The integration cannot read NetSuite's accounting-period close status; what it can attest is its own run. Because the ETL pulls only already-finished months, a run that has written a period's voucher lines **in full** — verified by **payload-set identity** against the source extract: the synced set of unique `NetSuite_Source_Key__c` values and their per-row payload hashes must match exactly (aggregate row-count/amount checks alone cannot prove completeness — missing and substituted lines can preserve both) — stamps that period **Complete** (with `run_id` / `completed_at`); a failed or interrupted run leaves it **Partial**, halting the boundary until a rerun repairs it. In the normal path the boundary therefore equals the latest successfully synced month; Partial exists to stop half-written runs from freezing into Actuals. The Sales Ops privileged override is **asymmetric and fail-closed**: demoting a period to **Partial** — halting the boundary, e.g. for a source restatement or a temporary freeze — is always allowed; promoting a period to **Complete without an ETL receipt** is exceptional and requires a recorded reason plus an explicit attestation that the period was manually reconciled against the source, audited like the worksheet unlock flip (§8). There is no routine manual Finance control.
- At ~1,500 voucher lines/month, aggregation runs comfortably within scheduled-flow limits; no materialized monthly table is required initially.

**Targets — CSV initial load + UI maintenance.**

- Initial FY load via CSV (`fiscal_year, region, sub_unit, quarter, target_type, amount_usd`) with `target_type ∈ {ARR, Revenue}`; upsert key = **FY × Region × Sub-unit × Quarter × Type** (Region is part of the key — no-Sub-unit regions all carry a blank Sub-unit and would otherwise overwrite one another). Subsequent corrections through the standard record UI by Sales Ops.

**Account attribution governance.**

| Account field state | Handling |
|---|---|
| `Region__c` empty | Rows cannot be routed to any worksheet → pre-fill exception list for Sales Ops to fix and re-run. |
| `Region__c = APAC`, `Sub_Unit__c` empty | Routed to the APAC **"Other"** worksheet, flagged as pending attribution. |
| `Region__c ≠ APAC`, `Sub_Unit__c` set | Sub-unit value ignored; flagged in the data-hygiene list. |
| `Sub_Unit__c` value set | Must map 1:1 to the ForecastOrgUnit master list; both live in the L2 pack and change together. |

---

## 6. Worksheet / Form Design

### 6.1 Layout (example: the Aug 2026 cycle, actuals through Jul 2026)

The grid is reached from the **Forecast Entry** navigation item (cycle/worksheet picker) — the **single entry point** (Appendix B.0); there is no button on the Account record page. The title reads "APAC/AI · Aug 2026 Forecast" and the subtitle states the forecast window, the actuals boundary and the status in calendar months (§2). Fixed columns are **Account · Account Owner · Forecast Type · Product · Product Category**, followed by the twelve months **April through March**. Months at or before the actuals boundary render as read-only Actuals; **every month after the boundary is an editable Forecast cell** — including not-yet-Complete months before M0 when the sync lags (§2, Forecast window). Each row shows its live Monthly ARR for every month. **Row order:** rows that carry NetSuite actuals keep their pre-fill order; rows added during entry follow, in the order they were added. Every amount — actual, editable forecast, total — renders with thousands separators, and the twelve month columns share one fixed width so figures align vertically. A fixed footer carries worksheet totals, quarterly totals, and FY totals. ASCII prototypes: **Appendix B**.

- **Forecast Type** column (field `Status__c`): picklist per §6.5. **Opportunity Name** column: linked Opportunity's name when set, otherwise free text.
- **Account Owner** column (field `filler`, §5.1) — the person responsible for the row, shown right after Account. Pre-fill stamps it from the CRM Account's Owner when that user is a member of the filing-unit group, falling back to the filing unit's Leader when the account has no owner or its owner is outside the unit (§6.2-7). For the filing unit's Leader (and Sales Ops) the cell is a **dropdown of the filing-unit group's members** plus a "— (Leader)" choice, so reassignment happens **in-sheet** with no separate screen; for everyone else it is plain text. Rows assigned to somebody else are **visible but read-only** — month inputs disabled, hover text naming the assignee, no ✕ — while the user's own rows are editable; this is the grid's rendering of the §3.1 row-level edit gate (the backend gate is the §3.1 spike item). A Leader edits every row of the unit.
- **Row-creation rights follow account ownership.** A filler may add rows only for accounts **they own** (CRM `Account.OwnerId` = self) that are attributed to the hosting worksheet (B.0): the add-row Account picker shows a filler exactly those accounts, and the new row's `filler` is therefore always the creator. The filing unit's **Leader and Sales Ops** may add rows for **any** account attributed to the worksheet; their new rows get `filler` from the standard rule (§6.2-7: the Account Owner when a member of the filing unit, otherwise the Leader). An account with no owner, or whose owner is outside the unit, can thus be added only by the Leader or Sales Ops (the same two roles throughout this rule). The panel takes **Account × Product** only — **a prospect must already exist as a CRM Account** (there are no placeholder rows; a deal without an account is entered after the account is created); a colleague's account is added by that colleague or by the Leader, never on their behalf by another filler — except under an active **§3 delegation**: a delegate's picker additionally lists the accounts owned by the user who delegated to them, and a row created that way is stamped `filler = the delegating user` (the delegate acts on their behalf; the audit records "delegate on behalf of"), so ownership-scoping and delegation compose without the delegate ever owning the account. Until delegation ships (P1), a colleague on leave is covered by the Leader or Sales Ops. A row added during entry carries no actuals and stays deletable until one lands (§8).

### 6.2 Pre-fill rules

1. Aggregate NetSuite voucher lines (§5.3) and write Actual cells for fiscal April through the boundary. The aggregation additionally derives the **prior fiscal year's final two months** (February and March) from voucher lines for **ARR computation only** — the Cloud trailing window spans fiscal years, so April/May ARR of existing customers must see that history rather than zero-fill it. Where the integration has no prior-FY data (initial adoption), those months count as 0 per D8 — an explicitly accepted understatement limited to the first two months of adoption.
2. Row set = accounts × products with any actual revenue this FY **∪** lines forecast in the previous cycle.
3. **Rollover (P0):** all months where the previous and current forecast windows overlap carry the previous cycle's values forward as draft initial values (`source=Rollover`, overwritable). The month that just matured to Actual is not carried — its old forecast stays in the previous cycle's locked snapshot and feeds deviation feedback (§6.4) and accuracy analysis. With windows up to 12 months long, rollover is mandatory, not a convenience.
4. Fillers can add rows for new customers/products they own (Account × Product; the account must already exist in CRM and be owned by the filler — §6.1; the Leader adds rows for any account of the unit).
5. Churn: enter 0 for the remaining months; rows carrying actuals are never deleted (a row with no actual month may be deleted — §8).
6. FY transition: the March cycle covers only March; FY28 worksheets first open at the FY28 April cycle.
7. **Filler stamp:** every pre-filled row gets `filler = Owner` of its Account as of pre-fill **provided that user is a member of the hosting worksheet's filing-unit group** (§3.1) — the same set the reassignment dropdown offers, so a filler can always open the worksheet their row lives in; an account whose owner is not a member (an owner in another Region, a non-sales user, an inactive user), like an account with no owner, falls back to the filing unit's Leader (the Sub-unit Leader where Sub-units exist, the Region Leader otherwise) from `ForecastOrgUnit`; a filing unit without an active Leader makes pre-fill **fail closed for that unit** per D15 — there is no administrator fallback, so no row is ever assigned outside its filing unit. The stamp is re-derived each cycle from the Account — an in-sheet reassignment (§9) applies to that worksheet only and does not change the CRM Account Owner.

### 6.3 Validation rules

Hard (blocking) at submission:

- **Every month in the forecast window must be filled** (0 is allowed, blank is not) — this is the 100% coverage guarantee (§1.3-4).
- Amounts ≥ 0.

Soft (warning, non-blocking):

- Row-level month-over-month change > ±50% → require a comment.
- This cycle's M0 forecast deviates > ±30% from the previous cycle's forecast for the same month → prompt.
- `Status = Pipeline` rows should carry an Opportunity reference.

### 6.4 Entry-time deviation feedback

While a filler works in the worksheet, the row (or a side panel) shows:

- the recent actual months (always visible as read-only columns), and
- when the **previous cycle** forecast the **last boundary month** (the newest month inside the contiguous Complete prefix): that forecast, the actual that landed, the delta, and the delta ratio.

The forecast side of the comparison reads from the **most recent prior cycle whose locked snapshot actually contains a forecast for that boundary month** — normally the immediately preceding cycle, but the selection rule matters when the sync lags or a cycle was skipped; if no prior snapshot forecast the month, the panel states so instead of showing an empty comparison. Running data never retains a matured month's old forecast (§6.2-3), which is precisely why snapshots exist.

### 6.5 Forecast Type column (Customer / Pipeline / extensible)

| Field | Type | Values / behavior |
|---|---|---|
| **Forecast Type** (`Status__c`) | Picklist | `Customer` (existing customer, backed by actual revenue), `Pipeline` (new business based on an open deal). Shown as **Forecast Type** in the grid. Metadata-managed value set, extensible without code changes. |
| **Opportunity** | Lookup(Opportunity), nullable | Candidates limited to the row's Account. |
| **Opportunity Name (Text)** | Text, nullable | Free-text fallback for deals not yet in CRM; kept in history when upgraded to a lookup. |

Defaults and transitions: pre-fill-generated rows (with FY actuals) default to `Customer`; manually added rows default to `Pipeline` (changeable). Each pre-fill auto-flips a Pipeline row to Customer on its first FY actual revenue (badged, audited; no automatic reverse).

---

## 7. Calculation Logic

### 7.1 Monthly ARR (computed per Account × Product row group, aggregated upward)

| Product | Formula |
|---|---|
| **Cloud** (all products) | `ARR(m) = ( Rev(m−2) + Rev(m−1) + Rev(m) ) / 3 × 12`. Elapsed months use Actuals; future months use Forecast values; the trailing window may span fiscal years. **New-customer rule: any missing month counts as $0 — the denominator is always 3.** Example: first revenue $10,000 in Dec → ARR(Dec) = ($0+$0+$10,000)/3 × 12 = $40,000. |
| **OP / Subscription** | `ARR(m) = Rev(m) × 12`. |
| **OP / Professional Service** | **Excluded from ARR** (`arr_eligible = false`); counts only toward Revenue metrics. |

- The engine is catalog-driven (`arr_method`): no hardcoded category→formula mapping.
- ARR is computed for **every month through fiscal March** (the window covers the whole remaining FY); there is no beyond-horizon case within the fiscal year, and forward zero-fill never applies — zero-fill is backwards-only for genuinely missing history. For April/May the trailing window reads the prior FY's February/March actuals supplied by the aggregation (§6.2); zero-fill applies there only when the integration truly has no prior-FY data.
- UX note: a new customer's ARR ramps 1/3 → 2/3 → full over its first two months by design; affected cells carry an explanatory badge.

### 7.2 Quarter / FY Revenue rollup

- **Quarterly Revenue** = Σ Actual months + Σ Forecast months in the quarter (mixed quarters splice automatically). All four quarters are always fully computable.
- **FY Total** = Σ all 12 fiscal months (actuals to the boundary + forecasts to March). Reports may additionally show a YT Forecast Total.

### 7.3 ARR dimension (Region/Sub-unit × Quarter)

| Metric | Calculation |
|---|---|
| Actual/Forecast ARR | **The Monthly ARR of the quarter-end month** (Jun / Sep / Dec / Mar). Quarters fully behind the actuals boundary yield an Actual value; later quarters blend forecasts through the trailing window. |
| ARR Target | ForecastTarget(ARR, sub-unit, quarter) — Sales Ops maintained. |
| **GAP** | `Actual/Forecast ARR − ARR Target`. Negative values render red. |

### 7.4 Revenue dimension (Region/Sub-unit × Quarter)

| Metric | Calculation |
|---|---|
| Actual/Forecast Revenue | §7.2 quarterly rollup. |
| Revenue Target | ForecastTarget(Revenue, sub-unit, quarter). |
| **GAP** | `Actual/Forecast Revenue − Revenue Target`. |

### 7.5 Target rollup rules

- Targets are entered at **Sub-unit × Quarter × {ARR, Revenue}**; Regions without Sub-units use the implicit default Sub-unit.
- Region GAP = Σ Sub-unit GAPs; Global = Σ Regions. No manual overrides of rollups.
- A missing Target renders "—" with a warning — never treated as 0.

### 7.6 Worked example (the Aug 2026 cycle, actuals through Jun 2026, one Cloud row)

Actuals: Apr = 100, May = 110, Jun = 120. Forecasts: Jul = 130, Aug = 140, Sep = 150, then 150/month through March.

- ARR(Jun) = (100+110+120)/3 × 12 = **1,320** (Q1 quarter-end, all Actual)
- ARR(Sep) = (130+140+150)/3 × 12 = **1,680** (Q2 quarter-end)
- ARR(Mar) = (150+150+150)/3 × 12 = **1,800** (Q4 quarter-end)
- Q1 Revenue = 330 (Actual); Q2 = 420; Q3 = 450; Q4 = 450; **FY Total = 1,650**

---

## 8. Row Deletion (no placeholder rows)

**Decision (2026-09-08, demo review):** there are **no placeholder rows**. A prospective deal is forecast only once its **Account exists in CRM**; the add-row panel offers Account × Product and nothing else (§6.1). This retires the former placeholder lifecycle — creation, link/convert, merge preview, duplicate-pair checks — and with it the `is_placeholder`, `placeholder_name`, `converted_from_placeholder_id`, and `merged_into_line_id` fields and the `placeholder:` form of `line_key` (§5.2).

### 8.1 Deletable rows

A line is deletable **iff it carries no Actual month** — in practice a row added during entry for an account that has no NetSuite revenue in the cycle. Deleting it soft-deletes the line and its months, audited. The full predicate (cycle Open, worksheet Draft, not in a locked snapshot, actor = the row's `filler`, a Leader of the filing unit, or Sales Ops) is the §3.1 delete guard; the grid shows the delete control only on rows that satisfy it.

### 8.2 Rows that are never deleted

A line with **any** Actual month is never deleted — by anyone below the System Administrator (§3.1 residual). Churn, lost deals and mistaken values are expressed by entering 0 for the remaining months (§6.2-5), so coverage semantics and locked history stay intact. Because Actual months are written only by the pre-fill job, the deletable set can only shrink over a row's life.

---

## 9. Multi-Filler Collaboration (Regions without Sub-units)

- The worksheet remains **one per Region**; rows carry a **`filler`** (responsible person) field, rendered as the **Account Owner** column (§6.1).
- **Row assignment:** pre-fill stamps `filler = Account Owner` when that user belongs to the Region's filing-unit group, falling back to the Region Leader for unowned accounts and for owners outside the group (§6.2-7; no Leader configured → pre-fill fails closed, D15); a filler's add-row always yields `filler = self` because the picker offers only their own accounts, and a Leader's add-row follows the pre-fill rule (§6.1). Invariant: `filler` is always a member of the worksheet's filing unit (or its Leader), which is also what the reassignment dropdown enforces. The Region Leader (and Sales Ops) reassigns any row **in-sheet** through the Account Owner dropdown, whose choices are the members of the Region's filing-unit group; reassignment reopens the previous assignee's completion mark (below).
- **Edit rights:** salespeople edit only rows where `filler = self` — colleagues' rows stay visible for context but read-only; the Region Leader can edit any row in the Region (§3.1 edit gate, rendered in the grid per §6.1).
- **Row-creation rights** follow account ownership: a filler adds rows only for accounts they own (a §3 delegate also for the delegating user's accounts, stamped to that user); the Region Leader / Sales Ops add rows for any account of the Region (§6.1).
- **Two-level completion.** Level 1 — the filler: a **"Mark my rows complete (filled/total)"** button on the worksheet, where *total* is the number of rows assigned to the user and *filled* the number of those rows with every forecast-window month non-blank. The button is enabled only when filled = total (churn is entered as 0, §6.2-5) and there are no unsaved changes; clicking it writes the user's `ForecastFillerProgress` row (§5.1). The mark is **reopened automatically** — `is_complete` reset — by **any** change to the rows it describes (§5.1 reset invariant): a month value saved on one of the filler's rows by the filler *or* by a Leader filling on behalf, a row reassigned to or away from the filler, or a row added to or deleted from their set; a reassignment therefore reopens **both** the previous and the new assignee, so an assignee who was already Complete must re-mark after inheriting a row. The filler can also reopen by hand; a Leader / Sales Ops may reopen but never mark on someone's behalf (§5.1). A stale mark cannot stand. Level 2 — the Leader: a **Team progress** panel on the worksheet lists every filler with rows in it (rows · missing months · status · last edit), where status is **Not started** (no manually entered month yet), **In progress**, or **Complete** with its timestamp; rows with no filler count as the Leader's own. The Leader submits once everyone is complete; submitting while fillers are still open **asks for confirmation naming them** rather than blocking — the Leader may fill or submit on behalf. The Forecast Entry picker (B.0) shows a **complete/total fillers** badge on every Draft worksheet so a Leader sees the state before opening it.
- Reminders (§4, T+3) target individual fillers whose progress row is absent or not complete.
- Regions with Sub-units: the Sub-unit Leader is the sole submitter; the same `filler` / completion mechanism applies inside a Sub-unit — this is the granularity the demo exercises (APAC/AI).

---

## 10. Locking, Corrections & Snapshot Versioning

1. Cycle deadline auto-locks all worksheets → **snapshot v1** (immutable and permanent for every role below the System Administrator; admin operations against snapshots are audited and alerted — §3.1 delete guard).
2. **Lock mechanics:** record read-only-ness is enforced by a `LockRule` formula on the line/month records that keys off the worksheet's `Status = Locked` — under a LockRule, records are read-only for **everyone including administrators**, which is exactly the guarantee wanted. **Unlocking is therefore not an edit of locked records but a privileged transition of the driving field.** `Status` authority is scoped **per transition**: Draft → Submitted belongs to the worksheet's submitter (§3); Submitted → Draft (pre-lock reopen) belongs to the Region Leader (§4); the deadline lock to Locked is the system's; and **Locked → Draft — the corrective unlock — belongs to Sales Ops or System Administrator only**, with a mandatory reason and scope; the LockRule then evaluates false and the worksheet's records become editable again. Unlock opens only the targeted worksheet.
3. Correction → re-lock (Status back to Locked) → **snapshot v2**. Reports read the latest version by default; deviation feedback (§6.4) and accuracy analytics default to **v1** ("what was reported at the time"), switchable.
4. Version history (who, why, diff) is visible on the cycle detail page; unlock events notify the Region Leader.

---

## 11. Reports & Analytics

1. **Region Summary:** Sub-units as rows plus a Region rollup row; two blocks — **Revenue** (Q1–Q4 Actual/Forecast vs Target vs GAP + YT Forecast Total) and **ARR** (quarter-end ARR vs Target vs GAP). Account-level exclude flag supports "Total excl. <major accounts>" carve-outs. **P0 delivery mechanism:** all numbers are precomputed into `Region_Forecast_Summary__c` (§5.1) and displayed through standard list views/reports over that object — nothing depends on report-engine matrix/formula features the platform lacks. Negative-GAP red formatting and cell-level drill-down belong to the custom summary view that follows P0 (the numbers themselves are complete at P0; only the visual treatment is deferred).
2. **Product-type analysis:** Revenue and ARR by product type with OP/Cloud subtotals, at Region and Sub-unit scope. **P0 delivery mechanism:** quarterly figures precomputed into `Product_Type_Summary__c` (§5.1), reached through its record page and standard reports — **not shown on the Forecast Summaries page**, which is the Region matrix only (B.2, demo review 2026-09-08); the month-by-month product grid is part of the post-P0 custom view.
3. **Coverage & deviation report (read-only):** every forecast line with owner, trailing-3-month actuals, forecast for each remaining fiscal month, **missing-month flags**, and prior-month deviation (forecast vs actual, delta, ratio). Acceptance passes when no line has a missing month (§1.3-5).
4. **Global Summary:** the five Regions side by side + Global Total.
5. **Drill-down:** any rollup number drills to Account × Product × Month.
6. **Cycle comparison / forecast accuracy (P1):** forecasts across cycles vs eventual actuals; MAPE / bias by Region/Sub-unit/filler.
7. **Forecast composition by Status:** Customer vs Pipeline layers.
8. **Export:** CSV/Excel; export scope always equals the exporting user's RLS scope.

---

## 12. Edge Cases

| Case | Handling |
|---|---|
| NetSuite restatement (voucher lines corrected/resynced) | Locked snapshots never change; the next cycle's aggregation picks up the latest lines; reports offer "snapshot basis / latest basis" toggle. |
| Filler leaves / changes | The Leader or Sales Ops reassigns the rows in-sheet (§9); the leaver's completion mark is reopened and the new assignee marks again; edit history preserved. |
| Account transferred across Region/Sub-unit | Effective from the next cycle (§5.2); never retroactive. |
| Region misses the deadline | Locked as-is from the last draft, flagged `auto-locked-incomplete`; prominent warning. |
| New Sub-unit created mid-year | Worksheet appears next cycle after the master record is active; Sales Ops backfills Targets. |
| Unmatched `Product_Name__c` in voucher lines | Aggregation exception list; the amount is never silently bucketed. |
| Concurrent editing | Optimistic locking at worksheet level + cell-level save-conflict prompts. |

---

## 13. Non-Functional Requirements & Implementation Mapping

- **Audit:** every forecast write records who/when/old→new; unlocks require a reason; row deletions (§8) log the deleted line and months.
- **Performance:** ≤ ~2,000 detail rows per worksheet expected; ~1,500 voucher lines/month; row-level ARR computed client-side; quarter/Region aggregates materialized server-side and frozen at lock.
- **Layering (per the repo's L0/L1/L2 architecture):** generic mechanisms — forecast object model, cycle state machine, aggregation engine — belong in the L1 app pack (or upstream platform capability). PingCAP-specific configuration — Region/Sub-unit lists, product catalog, FY convention, Targets — belongs in the L2 pack.
- **Ownership classification:** Rolling Forecast is **PingCAP-specific business functionality built entirely from stock platform primitives** — tenant-namespaced custom objects, picklists, layouts, and flows delivered through the L2 pack, exactly what the L2 charter covers. It modifies **no platform behavior, no standard-object behavior, and no L0/L1 code**; the platform-primitive spike (docs/rolling-forecast-spike-notes.md) confirmed zero upstream changes are needed, and no Phase 2 behavior migration is started or implied. Should other tenants later want the capability, promoting the object model and flows into an L1 official pack is the productization path — a future product decision, not a deferred obligation of this PRD. Runtime artifacts created by deploy scripts are environment data (L3). The custom entry grid is the one item that is upstream/L1 frontend work by design.

---

## 14. Rollout Plan

| Phase | Scope |
|---|---|
| **P0 — production by 2026-09-15** (§1.3) | NetSuite voucher aggregation + automatic actuals boundary; pre-fill with full-window rollover; remaining-FY-months entry with 100%-coverage hard validation; Monthly ARR + quarterly Revenue/ARR vs Sub-unit × Quarter targets; Region/Sub-unit Summary matrix (product-type rows materialized, not surfaced) + coverage & deviation report; entry-time deviation feedback; submit-lock-snapshot; base permissions; Account-only rows with no-actuals deletion; Targets CSV import (ARR + Revenue); Forecast Type & Opportunity columns. |
| **P1** | Forecast-accuracy reports (MAPE); escalating reminders; delegation; Global comparison board; RLS-scoped exports hardening. |
| **P2** | Smart soft-validation hints; Opportunity-pipeline-driven suggested values and amount×probability cross-checks; per-opportunity row splitting (watch item). |

---

## 15. Decision Log

| # | Decision | Resolution |
|---|---|---|
| Q1 | OP / Professional Service in ARR? | **Excluded from ARR** (revenue metrics only); `arr_eligible` product flag. |
| Q2 | Target granularity | **Sub-unit × Quarter** for both **ARR and Revenue** targets, maintained by Sales Ops; Region = Σ Sub-units, no overrides; no-Sub-unit Regions use an implicit default Sub-unit. |
| Q3 | Placeholder rows | **Not allowed (revised 2026-09-08 after the demo review; previously allowed).** A prospect must exist as a CRM Account before it can be forecast. Rows with no actual month are deletable (§8). |
| Q4 | Post-lock corrections | **Sales Ops and System Administrator** may unlock; corrections produce snapshot v2; v1 immutable per §3.1 (all roles below System Administrator; admin residual audited and alerted). |
| Q5 | Japan vs APAC boundary | Japan is an independent single-country Region. |
| Q6 | Currency | **USD only.** |
| D7 | FY naming | Named by ending year: **FY27 = 2026-04 … 2027-03** (regional sheets showing "FY26" for this span are a labeling error). |
| D8 | Cloud ARR for new customers | Missing months count as **$0** in the 3-month average (denominator always 3), uniformly applied. |
| D9 | Multi-filler in no-Sub-unit Regions | One Region worksheet, row-level `filler` ownership (the Account Owner column), two-level completion, Region Leader submits. |
| D10 | Row granularity with multiple opportunities | One row per Account × Sub-unit × Product; combined value; Opportunity reference = primary deal. Per-opportunity splitting deferred to P2. |
| D11 | Placeholder merge conflicts | **Superseded (2026-09-08):** placeholders retired with Q3, so no link/merge flow exists. |
| D12 | Actual revenue ingestion | **NetSuite integration** into `NetSuite_Revenue_Voucher_Line__c` (UI read-only, unique source key, required CRM Account lookup) + monthly aggregation to Account × Product × Month. Month = `Posting_Period__c`; amount = `Transaction_Amount__c` (non-USD rows excluded and reported, never converted); product = `Product_Name__c` + `Product_Category__c` (field to be added). ~1,500 lines/month. Supersedes the earlier CSV-import approach. |
| D13 | ARR analysis basis | **Net ARR and Starting ARR are removed.** The ARR dimension compares the **quarter-end month's Monthly ARR** against a quarterly **ARR Target**; no fiscal-year opening ARR import exists. |
| D14 | Region/Sub-unit attribution source | Existing Account fields **`Region__c`** and **`Sub_Unit__c`** (the field formerly planned as `apac_pod__c`; "Pod" is now called **Sub-unit** throughout). Exception lists for unattributed/inconsistent accounts (§5.3). |
| D15 | Leader configuration | **ForecastOrgUnit** master data maintained by Sales Ops; feeds worksheet owner, filler fallback, notifications, RLS. Fails closed without an active leader. |
| D16 | Forecast window | **Shrinking window: from the month after the actuals boundary through fiscal March.** With a current sync this is M0..FM12 (April cycle = 12 months … March cycle = 1); when the sync lags, not-yet-Complete pre-M0 months stay editable inside the window (no orphaned months). Never crosses fiscal years; FY28 opens at the FY28 April cycle. Supersedes the earlier fixed 3-month window. |
| D17 | Product catalog maintenance | **No standalone admin module.** L2 pack metadata via PR review; engine reads `arr_method` from the catalog; changes effective next cycle; read-only in-app view. |
| D18 | Permission refinements | (a) Colleague rows visible read-only within own worksheet; (b) date-ranged delegation with audit (P1); (c) exports follow RLS. |
| D19 | Coverage guarantee | Submission requires **every remaining fiscal month filled** on every line (0 allowed, blank not) — the 100% coverage acceptance standard, verified by the coverage & deviation report. |
| D20 | Ownership classification | Rolling Forecast is tenant business functionality built from stock platform primitives and delivered via the L2 pack (no platform/standard-object behavior changes, no L0/L1 code). L1 productization is a future product decision (§13). |
| D21 | Entry-time deviation feedback | The worksheet shows recent actuals plus, when applicable, the prior forecast for the last boundary month vs its actual with delta and ratio — sourced from the most recent locked snapshot that actually forecast that month. |
| D22 | Actuals-boundary completeness authority | The boundary = end of the **contiguous Complete prefix** of `NetSuite_Sync_Period__c` manifest rows from fiscal April — a Partial or missing period halts it (partial periods never freeze as Actuals; zero-revenue Complete periods still advance; a later Complete period cannot leapfrog an earlier incomplete one). **Complete is the ETL's own verified run receipt** (full-period write verified by payload-set identity — source-key set + per-row hashes — against the source extract; aggregate checksums alone cannot prove completeness), not NetSuite's close flag, which the integration cannot see. The Sales Ops override is asymmetric and fail-closed: to Partial freely; to Complete only with a recorded reason + manual-reconciliation attestation, audited (§5.3). |
| D23 | Cross-unit rows | The add-row picker offers only accounts attributed to the hosting worksheet — and, for a filler, only those they own (§6.1) — and a mismatched add fails closed (B.0). Relocating an existing line to its attributed worksheet after an attribution correction is a Sales Ops action under the `forecast.relocation` origin (fail closed unless the cycle is Open and both source and target worksheets are Draft); audited, both fillers notified. |
| D24 | Row identity | Line identity = `line_key` (`{account}|{product}`), unique within a worksheet (§5.2); the placeholder form is retired. |
| D25 | Completion tracking | Per-filler completion is a persisted `ForecastFillerProgress` row (§5.1), not a flag on lines: marking requires every forecast month of the filler's rows to be filled, any later edit or reassignment reopens the mark, and a Leader submitting with open fillers is asked to confirm, not blocked (§9). Filler defaults to the CRM Account Owner when that user is in the filing-unit group, with a Leader fallback (unowned or out-of-unit owner) and no administrator fallback (§6.2-7, D15); a filler can add rows only for accounts they own (a §3 delegate also for the delegating user's, stamped to that user), so their added rows are always their own, while the Leader adds for any account of the unit (§6.1); reassignment is in-sheet and per worksheet. Progress rows are self-write-only; any change to a filler's row set or values resets their mark server-side, including Leader fill-on-behalf and reassignment in either direction (§5.1). |

---

## Appendix A — Delivery Notes

- A P0-lite demo of the earlier v0.3 model (10 objects, layouts, navigation, seeded July 2026 scenario) runs locally and validated the platform primitives end to end (see docs/rolling-forecast-spike-notes.md). The v0.4 deltas over that demo: shrinking window, ARR-vs-target dimension replacing Net ARR (retiring the ARRSnapshot object and Starting ARR import), NetSuite voucher aggregation replacing the CSV revenue import, `Sub_Unit__c` terminology, coverage validation, deviation feedback, and the coverage & product-type reports.
- **Milestone:** production-ready with 100% coverage by **2026-09-15** (§1.3).

## Appendix B — ASCII UI Prototypes

Status of these prototypes: the entry surface (B.0/B.1 — worksheet picker, worksheet grid, add-row panel) and the summary matrix (B.2) are implemented and were reviewed with users on the P0-lite demo on the downstream branch `rolling-forecast-p0lite-v2` (with its carried L1 frontend patch, `docs/rolling-forecast-frontend-grid.patch` on that branch) — a claim **not verifiable from this document-only change** and to be re-verified when that branch lands; the `CycleAccountAttribution` snapshot and the attribution-filtered add-row picker specified in B.0 are spec-first and not yet in that demo; it becomes the delivered UI when that branch and the corresponding app-packs frontend change land — until then it remains pre-release. The 2026-09-08 demo review shaped these prototypes: a single navigation entry point, no Account-page button; Account-only rows with no placeholders; the Forecast Type column second; calendar-month cycle wording; a Region-only summary. A second review round added the two-level completion flow (§9) to the demo: the Account Owner column with in-sheet reassignment, colleagues' rows read-only, the gated "Mark my rows complete" button, the Leader's Team progress panel, and the picker's complete/total badge. The coverage & deviation report (B.3) remains target form, initially rendered through standard list views and reports (the report engine itself has no matrix/formula/conditional formatting — §5.1, which is why every number is precomputed on the write side). Unit: USD '000. Example: the Aug 2026 cycle with a current sync — actuals through Jul 2026 (Apr–Jul actual, Aug–Mar forecast window).

### B.0 Entry path into the worksheet grid

Sales reach the grid through **one** path: the **Forecast Entry** navigation item, a picker listing the
cycles and, under each, the worksheets the user can read. There is **no entry from the Account record
page** (removed after the demo review — the navigation item is the only entry point). The picker and the
add-row panel respect the existing contracts:

- **RLS scope (§3).** The picker surfaces only worksheets readable under the current user's row-level
  security; a filler typically sees exactly one card per cycle. The multi-worksheet listing below depicts a
  Sales Ops / admin view. Cards are labelled by filing unit ("APAC/AI"); the cycle heading is the
  calendar month ("August 2026 Forecast · Open · actuals through Jul 2026", §2). Every **Draft** card
  carries a **complete/total fillers** badge (§9) so a Leader sees the team's state before opening it.
- **Cycle-frozen attribution (§5.2) in the add-row picker.** The Account picker offers only accounts
  attributed to the hosting worksheet by **`CycleAccountAttribution`** (§5.1) — the per-cycle attribution
  snapshot the pre-fill job writes for every active account; a §5.3 repair re-run only fills
  missing/invalid entries and never overwrites a valid row. An account **created after pre-fill** (absent
  from the snapshot) is appended to it on first touch by the **entry-resolution service** (server-side,
  system context — §5.1), which writes its row once, atomically, from the account's fields at that moment;
  the requesting user never writes the snapshot, and the appended row is then frozen like the rest. A
  mismatched add fails closed with guidance instead of guessing; an account that is not in CRM cannot be
  added at all (§6.1, §8). On top of attribution the picker is **ownership-scoped**: a filler sees only the
  attributed accounts they own — plus, under an active §3 delegation, those owned by the user who
  delegated to them (rows stamped to that user) — so their new row is always their own or their
  delegator's; the unit Leader and Sales Ops see every attributed account (§6.1).

```text
┌─ Nav ▸ Forecast Entry (picker, RLS-scoped) ──────────────────────────────┐
│ August 2026 Forecast   Open · actuals through Jul 2026                  │
│   [APAC/AI · 1/2 complete]   [APAC/Web3 · 0/3 complete]  …              │
│ July 2026 Forecast     Locked · actuals through Jun 2026                │
│   [APAC/AI] (read-only)  …                                              │
│ (a filler sees only their own worksheet; admin view depicted)           │
└───────────────┬─────────────────────────────────────────────────────────┘
                │ pick a worksheet
                ▼
   /forecast/worksheet/{id}
```

### B.1 Worksheet entry form (Sub-unit worksheet)

Shown as the filler `apac-sales` sees it: own rows editable, a colleague's row read-only.

```text
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ APAC/AI · Aug 2026 Forecast                                                                  [ Save ] [ Submit ▸ ]   │
│ Forecast window: Aug 2026 – Mar 2027 (FY27) · Actuals through: Jul 2026 · Status: Draft                              │
├─────────────┬─────────────┬──────────┬──────────────┬──────────┬────────┬────────┬────────┬───────┬───────┬───────┬───┤
│ Account     │ Account     │ Forecast │ Product      │ Product  │ Apr 26 │ May 26 │ Jun 26 │Jul 26 │Aug 26 │Sep 26 │ … │
│             │ Owner       │ Type     │              │ Category │ Actual │ Actual │ Actual │Actual │Fcst   │Fcst   │   │
├─────────────┼─────────────┼──────────┼──────────────┼──────────┼────────┼────────┼────────┼───────┼───────┼───────┼───┤
│ Acme Corp   │ apac-sales  │ Customer │ Premium      │ Cloud    │100,000 │110,000 │120,000 │125,000│[145,000]│[150,000]│…│
│ Acme Corp   │ apac-sales  │ Customer │ Subscription │ OP       │ 20,000 │ 20,000 │ 20,000 │ 20,000│[ 20,000]│[ 20,000]│…│
│ Delta Inc   │ apac-leader │ Customer │ Premium      │ Cloud    │ 30,000 │ 30,000 │ 30,000 │ 30,000│ 30,000 │ 30,000 │…│
│ Beta Ltd  ✕ │ apac-sales  │ Pipeline │ BYOC         │ Cloud    │        │        │        │       │[       ]│[       ]│…│
├─────────────┴─────────────┴──────────┴──────────────┴──────────┼────────┼────────┼────────┼───────┼───────┼───────┼───┤
│ Total (USD)                                                    │150,000 │160,000 │170,000 │175,000│195,000│200,000│ … │
└────────────────────────────────────────────────────────────────┴────────┴────────┴────────┴───────┴───────┴───────┴───┘
  Rows with actuals first (pre-fill order); rows added during entry follow in the order added.
  [ … ] = editable cell on the user's own rows; the Delta Inc row belongs to apac-leader and renders
  read-only (hover: "Read-only — assigned to apac-leader"). ✕ appears only on the user's own rows with
  no Actual month (deletable, §8). Account Owner is plain text for fillers; the unit Leader sees a
  dropdown (unit members + "— (Leader)") and reassigns in place. Every amount carries thousands
  separators; month columns share one width.
┌─ Deviation (prev cycle → Jul) ────────────────────────┐  ┌─ Auto summary ───────────────────────────────────┐
│ Jul forecast 130,000 → Jul actual 125,000             │  │ Revenue  Q1 330,000A · Q2 415,000 · Q3 … · Q4 … │
│ Δ -5,000  (-3.8%)                                     │  │ ARR      Jun 1,320 · Sep 1,660 · Dec … · Mar …   │
└───────────────────────────────────────────────────────┘  │ vs Target: per-quarter GAP (red when negative)   │
  [ + Add Row ▸ Account ▾ × Product ▾ (category derived;   └──────────────────────────────────────────────────┘
    duplicate line_key rejected; account must exist in CRM;
    a filler's picker lists only accounts they own (+ a delegator's, §3);
    the Leader's / Sales Ops' lists the whole unit) ]                                        [ Save Draft ]
  [ Mark my rows complete (2/3 filled) ]  ← disabled until every forecast month of Beta Ltd is filled
  Validation on submit: every Forecast month non-blank (100% coverage)                       [ Submit ▸ ]
```

The same worksheet as the Sub-unit Leader `apac-leader` sees it — every row editable, plus the panel:

```text
┌─ Team progress · 1/2 complete ───────────────────────────────────────────────────────────────────────┐
│ Filler        Rows   Missing months   Status                          Last edit                      │
│ apac-sales      3    1                In progress                     2026-09-08 14:02               │
│ apac-leader     1    0                ✓ Complete · 2026-09-08 11:30   2026-09-08 11:30               │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
  [ Submit ▸ ] with an open filler → "1 of 2 fillers have not marked their rows complete
  (apac-sales). Submit anyway?"  — confirmation, not a block (§9).
  Picker (B.0): each Draft worksheet card carries the badge "1/2 complete".
```

### B.2 Region Summary (Revenue block + ARR block)

```text
Forecast Summary ▸ [Aug 2026 Forecast · Open ▾]   [Revenue | ARR]   Unit USD
┌── Revenue ──────────────┬─── Q1 (Apr–Jun) ───────┬─── Q2 (Jul–Sep) ───────┬─── Q3 (Oct–Dec) ───────┬─── Q4 (Jan–Mar) ───────┬─ YT (FY) ─┐
│ Region / Sub-unit       │ Target   Fcst    GAP   │ Target   Fcst    GAP   │ Target   Fcst    GAP   │ Target   Fcst    GAP   │ …        │
│ APAC/AI · APAC/Web3 · … │  …        …       …    │  …        …       …    │  …        …       …    │  …        …       …    │   …      │
│ APAC (Region Total)     │  …        …       …    │  …        …       …    │  …        …       …    │  …        …       …    │   …      │
├── ARR (quarter-end) ────┼─ Q1 (end of Jun) ──────┼─ Q2 (end of Sep) ──────┼─ Q3 (end of Dec) ──────┼─ Q4 (end of Mar) ──────┤
│ Region / Sub-unit       │ Target   ARR     GAP   │ Target   ARR     GAP   │ Target   ARR     GAP   │ Target   ARR     GAP   │
└─────────────────────────┴────────────────────────┴────────────────────────┴────────────────────────┴────────────────────────┘
The summary surface is the Region matrix only. Product-type rows (`Product_Type_Summary__c`) are materialized
by the same calculation run but are not shown here (§11).
```

### B.3 Coverage & deviation report (read-only)

```text
┌ Line                        │ Owner  │ Last-3-mo Actuals │ Aug … Mar forecasts │ Missing │ Prev-month deviation ┐
│ Acme Corp / Cloud/Premium   │ Lux Li │ 110 / 120 / 125   │ 140 … 150           │  none   │ -5.0 (-3.8%)         │
│ Beta Ltd / OP/Subscription  │ Lux Li │ 20 / 20 / 20      │ 20 … [BLANK Nov]    │ ⚠ Nov   │ 0.0 (0.0%)           │
└ Acceptance: report shows no missing months across all lines (2026-09-15)                                        ┘
```

### B.4 Legend

```text
(A)      Actual month: read-only; written by the NetSuite aggregation only
(F) [ ]  Forecast month: editable; blanks not allowed at submit (100% coverage)
✕        delete control: shown only on rows with no Actual month (§8)
▲        ARR badge: trailing window contains zero-filled months (new-customer ramp)
n/a      OP/Professional Service: excluded from ARR, Revenue only
( ) red  negative GAP rendered red
⚠        missing coverage flag
```
