Merge pull request #75 from Curabis/rule/edison-audit-4-sharpenings-bundled

[BCQuality] 4 Edison-driven sharpenings (bundled): API least-privilege, naming, test-exceptions, table-type edge case
This commit is contained in:
Michael Dieringer 2026-08-13 22:32:45 +02:00 • committed by GitHub
commit 882b963b4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 443 additions and 344 deletions

View file

@ -1,118 +1,119 @@
--- ---
bc-version: [all] bc-version: [all]
domain: architecture domain: architecture
keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table] keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
--- ---
# CURABIS Architecture: BC Table-Type Conventions # CURABIS Architecture: BC Table-Type Conventions
## Description ## Description
Business Central's Base Application follows nine recurring table types — Master, Business Central's Base Application follows nine recurring table types — Master,
Supplemental, Subsidiary, Ledger, Register, Journal, Document, Document History, Supplemental, Subsidiary, Ledger, Register, Journal, Document, Document History,
and Setup. Each type fixes a naming pattern, a primary-key shape, and a set of and Setup. Each type fixes a naming pattern, a primary-key shape, and a set of
associated pages. A new or extended table whose design doesn't match the associated pages. A new or extended table whose design doesn't match the
conventions of its own type is either misclassified or built inconsistently conventions of its own type is either misclassified or built inconsistently
with the rest of the application, and should be flagged in review even if it with the rest of the application, and should be flagged in review even if it
compiles. compiles.
## Key Principle ## Key Principle
"Before assigning a primary key or naming a new table, first ask: which of the "Before assigning a primary key or naming a new table, first ask: which of the
nine standard table types is this? The answer fixes almost every other design nine standard table types is this? The answer fixes almost every other design
decision — key shape, naming suffix, and which pages must exist." decision — key shape, naming suffix, and which pages must exist."
## The Nine Table Types ## The Nine Table Types
### 1. Master ### 1. Master
- **Purpose:** primary focus subject of a functional area (Customer, Vendor, Item). - **Purpose:** primary focus subject of a functional area (Customer, Vendor, Item).
- **Naming:** name of one record in the table, singular (`Customer`, `Item`). - **Naming:** name of one record in the table, singular (`Customer`, `Item`).
- **Primary key:** `Code[20]` named `No.` (occasionally `Code`). - **Primary key:** `Code[20]` named `No.` (occasionally `Code`).
- **Description field:** `Text[80]` named `Name` or `Description`, included in `DataCaptionFields`. - **Description field:** `Text[80]` named `Name` or `Description`, included in `DataCaptionFields`.
- **Pages:** Card (edit), List (view/lookup/drilldown — `LookupPageID`/`DrillDownPageID`), Statistics (calculated info, kept separate for performance). - **Pages:** Card (edit), List (view/lookup/drilldown — `LookupPageID`/`DrillDownPageID`), Statistics (calculated info, kept separate for performance).
### 2. Supplemental ### 2. Supplemental
- **Purpose:** subject used across one or more functional areas (Currency, Language) — not the primary focus of any single area. - **Purpose:** subject used across one or more functional areas (Currency, Language) — not the primary focus of any single area.
- **Naming:** name of one record (`Currency`). - **Naming:** name of one record (`Currency`).
- **Primary key:** `Code[10]` named `Code`. - **Primary key:** `Code[10]` named `Code`.
- **Description field:** `Text[50]` named `Description` (some have none, some use `Name`). - **Description field:** `Text[50]` named `Description` (some have none, some use `Name`).
- **Pages:** one List page, plural of the table name (`Currencies`), set as `LookupPageID`. - **Pages:** one List page, plural of the table name (`Currencies`), set as `LookupPageID`.
### 3. Subsidiary ### 3. Subsidiary
- **Purpose:** information subsidiary to a Master and/or Supplemental table (Item Vendor, FA Depreciation Book). - **Purpose:** information subsidiary to a Master and/or Supplemental table (Item Vendor, FA Depreciation Book).
- **Naming:** built from the table(s) it's subsidiary to (`Item Vendor`). - **Naming:** built from the table(s) it's subsidiary to (`Item Vendor`).
- **Primary key:** one field per table it's subsidiary to, each related to that table, optionally an `Integer` `Line No.` at the end to disambiguate. No description field. - **Primary key:** one field per table it's subsidiary to, each related to that table, optionally an `Integer` `Line No.` at the end to disambiguate. No description field.
- **Pages:** Worksheet page (PK contains an Integer → PK fields excluded/auto-filtered) or Tabular page (PK has no Integer); always linked back to and filtered by the calling page. - **Pages:** Worksheet page (PK contains an Integer → PK fields excluded/auto-filtered) or Tabular page (PK has no Integer); always linked back to and filtered by the calling page.
### 4. Ledger ### 4. Ledger
- **Purpose:** transactional information that is the primary focus of a functional area (Cust. Ledger Entry, Item Ledger Entry). - **Purpose:** transactional information that is the primary focus of a functional area (Cust. Ledger Entry, Item Ledger Entry).
- **Naming:** related Master table name + `Ledger Entry`. - **Naming:** related Master table name + `Ledger Entry`.
- **Primary key:** `Integer` named `Entry No.`, always auto-generated by the posting routine — never user-editable; no add/delete except tightly controlled exceptions. - **Primary key:** `Integer` named `Entry No.`, always auto-generated by the posting routine — never user-editable; no add/delete except tightly controlled exceptions.
- **Secondary keys:** typically carry `SumIndexFields`; at least one has the Master-table-relation field first, paired with FlowFields on the Master table. - **Secondary keys:** typically carry `SumIndexFields`; at least one has the Master-table-relation field first, paired with FlowFields on the Master table.
- **Pages:** List page, plural of table name (`Customer Ledger Entries`), set as `LookupPageID`/`DrillDownPageID`. - **Pages:** List page, plural of table name (`Customer Ledger Entries`), set as `LookupPageID`/`DrillDownPageID`.
### 5. Register ### 5. Register
- **Purpose:** table of contents for its corresponding Ledger table(s), one record per posting process (G/L Register). - **Purpose:** table of contents for its corresponding Ledger table(s), one record per posting process (G/L Register).
- **Naming:** posting function name + `Register`. - **Naming:** posting function name + `Register`.
- **Primary key:** `Integer` named `No.`, auto-generated by the posting routine, never user-editable. - **Primary key:** `Integer` named `No.`, auto-generated by the posting routine, never user-editable.
- **Other standard fields:** `From Entry No.` and `To Entry No.` (Integer, related to the Ledger table). - **Other standard fields:** `From Entry No.` and `To Entry No.` (Integer, related to the Ledger table).
- **Pages:** List page, plural of table name, with a `Register` menu button linking to the Ledger List page(s). - **Pages:** List page, plural of table name, with a `Register` menu button linking to the Ledger List page(s).
### 6. Journal ### 6. Journal
- **Purpose:** primary transactional table where users (or another posting routine) enter data before it is posted to a Ledger table. - **Purpose:** primary transactional table where users (or another posting routine) enter data before it is posted to a Ledger table.
- **Naming:** transaction type + `Journal Line` (`Resource Journal Line`). - **Naming:** transaction type + `Journal Line` (`Resource Journal Line`).
- **Primary key:** Journal Template field + Journal Batch field + `Integer` `Line No.`. - **Primary key:** Journal Template field + Journal Batch field + `Integer` `Line No.`.
- **Related Supplemental tables:** `<X> Journal Template`, `<X> Journal Batch`. - **Related Supplemental tables:** `<X> Journal Template`, `<X> Journal Batch`.
- **Pages:** Worksheet page named like the Journal table minus `Line`, filtered by Template/Batch, `AutoSplitKey` sets `Line No.`; includes a `Posting` menu button and a CTRL+F7 link to the related Ledger. - **Pages:** Worksheet page named like the Journal table minus `Line`, filtered by Template/Batch, `AutoSplitKey` sets `Line No.`; includes a `Posting` menu button and a CTRL+F7 link to the related Ledger.
### 7. Document (Header + Line) ### 7. Document (Header + Line)
- **Purpose:** secondary transactional tables — post to Ledgers through Journal tables, not directly (Sales Header/Line). - **Purpose:** secondary transactional tables — post to Ledgers through Journal tables, not directly (Sales Header/Line).
- **Naming:** transaction/document name + `Header` or `Line`. - **Naming:** transaction/document name + `Header` or `Line`.
- **Primary key (Header):** `Code[20]` `No.`, or — if the table holds multiple document kinds — `Option` `Document Type` + `Code[20]` `No.`. - **Primary key (Header):** `Code[20]` `No.`, or — if the table holds multiple document kinds — `Option` `Document Type` + `Code[20]` `No.`.
- **Primary key (Line):** the Header's key field(s), renamed to `<DocumentName> No.` (without "Header"), + `Integer` `Line No.`. - **Primary key (Line):** the Header's key field(s), renamed to `<DocumentName> No.` (without "Header"), + `Integer` `Line No.`.
- **Pages:** Header uses a Document/Card page with a `Posting` action and a subpage control containing the Document Lines page; Line uses a Worksheet/ListPart page, PK fields hidden via `AutoSplitKey`/link filtering. - **Pages:** Header uses a Document/Card page with a `Posting` action and a subpage control containing the Document Lines page; Line uses a Worksheet/ListPart page, PK fields hidden via `AutoSplitKey`/link filtering.
### 8. Document History ### 8. Document History
- **Purpose:** posted copy of a Document table, populated during posting (Sales Invoice Header/Line). - **Purpose:** posted copy of a Document table, populated during posting (Sales Invoice Header/Line).
- **Naming:** same as the source Document table, with `Posted` or `Issued` inserted. - **Naming:** same as the source Document table, with `Posted` or `Issued` inserted.
- **Primary key / fields:** mirrors the source Document table's field numbers, names, and properties. - **Primary key / fields:** mirrors the source Document table's field numbers, names, and properties.
- **Editability:** never user-editable; deletable only with explicit permission. - **Editability:** never user-editable; deletable only with explicit permission.
- **Pages:** same shape as the Document pages, but the Line-equivalent uses a List page (no editing) instead of a Worksheet page. - **Pages:** same shape as the Document pages, but the Line-equivalent uses a List page (no editing) instead of a Worksheet page.
### 9. Setup ### 9. Setup
- **Purpose:** exactly one record holding options/settings for a functional area or the company as a whole (General Ledger Setup). - **Purpose:** exactly one record holding options/settings for a functional area or the company as a whole (General Ledger Setup).
- **Naming:** functional area name + `Setup` (exception: `Company Information`). - **Naming:** functional area name + `Setup` (exception: `Company Information`).
- **Primary key:** `Code[10]` named `Primary Key`, always left blank — enforces the single-record rule. - **Primary key:** `Code[10]` named `Primary Key`, always left blank — enforces the single-record rule.
- **Pages:** one page, same name as the table, primary key field not shown. - **Pages:** one page, same name as the table, primary key field not shown.
- **Record instantiation:** the page's `OnOpenPage` trigger — not the table — creates the singleton the first time it is opened; it is never assumed to pre-exist. Standard shape: `Rec.Reset(); if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;` (the `Reset()` clears any stale filter before the `Get()`, since the blank `Code` PK would otherwise be vulnerable to one). Verified against Base App W1: `General Ledger Setup` and `Sales & Receivables Setup` both use this exact pattern. A Setup page that omits this and assumes the record exists fails at runtime on first open in a fresh company. - **Record instantiation:** the page's `OnOpenPage` trigger — not the table — creates the singleton the first time it is opened; it is never assumed to pre-exist. Standard shape: `Rec.Reset(); if not Rec.Get() then begin Rec.Init(); Rec.Insert(); end;` (the `Reset()` clears any stale filter before the `Get()`, since the blank `Code` PK would otherwise be vulnerable to one). Verified against Base App W1: `General Ledger Setup` and `Sales & Receivables Setup` both use this exact pattern. A Setup page that omits this and assumes the record exists fails at runtime on first open in a fresh company.
- **Caveat:** a table with "Setup" in its name that holds more than one record follows the Subsidiary-table rules instead — the name alone is not proof of type. - **Caveat:** a table with "Setup" in its name that holds more than one record follows the Subsidiary-table rules instead — the name alone is not proof of type.
- **When the object definition alone can't resolve the caveat:** a "Setup"-named table with a real business-field primary key (not a blank `Code[10]` "Primary Key") and no associated page looks like a violation of both the Setup and Subsidiary shapes at once. Confirming which one it actually is requires checking real row cardinality (does the table ever hold more than one record in practice?) or tracing the table's other call sites — not something the table/page definitions alone settle. When source can't resolve it, say so explicitly rather than forcing a classification (Edison eval 2026-08-13, Wareco @ a2fc8ff8, `ForsendelsesSetup.Table.al`).
## Review Checklist
## Review Checklist
When reviewing or designing a table, ask:
1. Which of the 9 types is this actually — based on its role, not its name? When reviewing or designing a table, ask:
2. Does the primary key shape match that type (Code+`No.` / Integer+`Entry No.` / Code+`Line No.` / blank Code+`Primary Key` / etc.)? 1. Which of the 9 types is this actually — based on its role, not its name?
3. Does the table name carry the expected suffix (`Ledger Entry`, `Journal Line`, `Register`, `Setup`, `Header`/`Line`)? 2. Does the primary key shape match that type (Code+`No.` / Integer+`Entry No.` / Code+`Line No.` / blank Code+`Primary Key` / etc.)?
4. Do the expected pages exist (Card+List(+Statistics) for Master, Worksheet for Journal/Subsidiary-with-Integer-PK, List for Ledger/Register/Document History, Document/Card+subpage for Document Header)? 3. Does the table name carry the expected suffix (`Ledger Entry`, `Journal Line`, `Register`, `Setup`, `Header`/`Line`)?
5. If a Ledger/Register/Document History table's primary key is user-editable, or a Master/Setup table allows duplicate identity, flag it — the design contradicts its own type. 4. Do the expected pages exist (Card+List(+Statistics) for Master, Worksheet for Journal/Subsidiary-with-Integer-PK, List for Ledger/Register/Document History, Document/Card+subpage for Document Header)?
6. For a Setup table's page, does `OnOpenPage` create the singleton record (`Get` → `Init` → `Insert`, guarded by `Reset`) instead of assuming it already exists? 5. If a Ledger/Register/Document History table's primary key is user-editable, or a Master/Setup table allows duplicate identity, flag it — the design contradicts its own type.
6. For a Setup table's page, does `OnOpenPage` create the singleton record (`Get` → `Init` → `Insert`, guarded by `Reset`) instead of assuming it already exists?
A table that mixes conventions from two types (for example, a "Ledger" table that
lets users freely insert or delete rows) is not "flexible" — it is either A table that mixes conventions from two types (for example, a "Ledger" table that
misclassified or has skipped a design step. lets users freely insert or delete rows) is not "flexible" — it is either
misclassified or has skipped a design step.
## Source
## Source
CURABIS Academy course "Your Key to Application Language for Microsoft Business
Central" (rev. July 2022), Chapter 2: Tables, "Table Types and Characteristics" CURABIS Academy course "Your Key to Application Language for Microsoft Business
(p. 73–84). Cross-checked against the current Base Application table structure Central" (rev. July 2022), Chapter 2: Tables, "Table Types and Characteristics"
as of 2026-08-12 — the taxonomy still holds. (p. 73–84). Cross-checked against the current Base Application table structure
as of 2026-08-12 — the taxonomy still holds.
Setup-page record-instantiation pattern (2026-08-13) verified directly against
`microsoft/BCApps` source (W1 layer): `src/Layers/W1/BaseApp/Finance/GeneralLedger/Setup/GeneralLedgerSetup.Page.al` Setup-page record-instantiation pattern (2026-08-13) verified directly against
(`OnOpenPage`, line ~889) and `src/Layers/W1/BaseApp/Sales/Setup/SalesReceivablesSetup.Page.al` `microsoft/BCApps` source (W1 layer): `src/Layers/W1/BaseApp/Finance/GeneralLedger/Setup/GeneralLedgerSetup.Page.al`
(`OnOpenPage`, line ~601) — not inferred from the Academy material, which does (`OnOpenPage`, line ~889) and `src/Layers/W1/BaseApp/Sales/Setup/SalesReceivablesSetup.Page.al`
not document this detail. (`OnOpenPage`, line ~601) — not inferred from the Academy material, which does
not document this detail.

View file

@ -1,46 +1,79 @@
--- ---
bc-version: [all] bc-version: [all]
domain: mcp domain: mcp
keywords: [api-page, least-privilege, write-access, odata, security] keywords: [api-page, least-privilege, write-access, odata, security, external-api, identity-fields]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
--- ---
# CURABIS MCP: API Pages Must Use Least-Privilege Write Access # CURABIS MCP: API Pages Must Use Least-Privilege Write Access
## Description ## Description
A general-purpose API page that exposes many fields should not be widened to allow writes on a single additional field. Instead, create a dedicated minimal API page that exposes only the fields the consumer needs to read and write. This limits the blast radius of any agent or integration mistake. A general-purpose API page that exposes many fields should not be widened to allow writes on a single additional field. Instead, create a dedicated minimal API page that exposes only the fields the consumer needs to read and write. This limits the blast radius of any agent or integration mistake.
## Why This Matters This applies beyond CURABIS's own MCP tooling — it's a general AL API-page design concern. Any `PageType = API` page consumed by an external integration, a Power BI dataset, or a partner system carries the same risk: a write-enabled page with no per-field restriction is a wide-open surface regardless of who or what is calling it.
An MCP agent operates with the permissions of its service identity, not an individual user. A page that allows writing to many fields gives the agent broad power that is hard to audit and easy to misuse. A dedicated page with one writable field makes the intent explicit and the surface area auditable. ## Why This Matters
## Pattern to Avoid An MCP agent operates with the permissions of its service identity, not an individual user — but the same argument holds for any external consumer of an API page. A page that allows writing to many fields gives the caller broad power that is hard to audit and easy to misuse, whether the caller is CURABIS's own agent, a customer's integration, or a Power Platform flow. A dedicated page with one writable field (or explicit `Editable = false` on everything else) makes the intent explicit and the surface area auditable.
// WRONG: General page widened with write access to one field ## Pattern to Avoid
// Now the agent can accidentally (or intentionally) write to all other fields too
field(status; Rec.Status) { } // should be read-only The most common real-world shape of this violation isn't a page that started narrow and got widened — it's a page that was **never restricted at all**. A `PageType = API` page with `InsertAllowed`/`ModifyAllowed`/`DeleteAllowed` left at their defaults, and no `Editable = false` on any field, exposes every field on the source table — including identity fields (`No.`, `Document Type`) and financially significant ones (`VAT Bus. Posting Group`, `Amount Including VAT`) — as fully writable, with nothing marking that as deliberate:
field(gitHubRepository; Rec."GitHub Repository") { } // the one field we want writable
field(estimatedHours; Rec."Estimated Hours") { } // should be read-only // WRONG: no restriction declared anywhere on a write-capable API page —
// ~90 fields, including VAT/posting fields and the record's own key,
## Correct Pattern // are all fully writable by default, with nothing marking that as intentional.
page 50100 "Some Document Header API"
Create a separate, minimal API page: {
PageType = API;
page 6102904 "CUR MCP Project Repository" APIPublisher = 'contoso';
{ APIGroup = 'docs';
// Only two fields: the key and the one writable field APIVersion = 'v1.0';
field(no; Rec."No.") { Editable = false; } SourceTable = "Some Document Header";
field(gitHubRepository; Rec."GitHub Repository") { } // no InsertAllowed/ModifyAllowed/DeleteAllowed override, no Editable = false anywhere
} layout
{
## Requirements area(content)
{
- Each distinct write concern (e.g., setting a GitHub repo, updating a dev status) should have its own API page or be deliberately grouped only with closely related fields repeater(GroupName)
- Read-only fields on write-enabled pages must carry `Editable = false` {
- The page description must document which fields are writable and why field(no; Rec."No.") { }
field(documentType; Rec."Document Type") { }
## Verification field(amountIncludingVAT; Rec."Amount Including VAT") { }
field(vatBusPostingGroup; Rec."VAT Bus. Posting Group") { }
For each API page where `ModifyAllowed = true` (or default), list all fields without `Editable = false`. Confirm that every writable field is intentionally writable for the same consumer use case. If unrelated fields are writable on the same page, split the page. // ... ~85 more fields, none marked Editable = false
}
}
}
}
The narrower "widened by one field" shape below is also real, just less common in practice than the above:
// WRONG: General page widened with write access to one field
// Now the agent can accidentally (or intentionally) write to all other fields too
field(status; Rec.Status) { } // should be read-only
field(gitHubRepository; Rec."GitHub Repository") { } // the one field we want writable
field(estimatedHours; Rec."Estimated Hours") { } // should be read-only
## Correct Pattern
Create a separate, minimal API page:
page 6102904 "CUR MCP Project Repository"
{
// Only two fields: the key and the one writable field
field(no; Rec."No.") { Editable = false; }
field(gitHubRepository; Rec."GitHub Repository") { }
}
## Requirements
- Each distinct write concern (e.g., setting a GitHub repo, updating a dev status) should have its own API page or be deliberately grouped only with closely related fields
- Read-only fields on write-enabled pages must carry `Editable = false`
- The page description must document which fields are writable and why
## Verification
For each API page where `ModifyAllowed = true` (or default), list all fields without `Editable = false`. Confirm that every writable field is intentionally writable for the same consumer use case. If unrelated fields are writable on the same page, split the page.

View file

@ -1,80 +1,118 @@
--- ---
bc-version: [all] bc-version: [all]
domain: style domain: style
keywords: [variable-naming, semantic-naming, readability, magic-name, self-documenting] keywords: [variable-naming, semantic-naming, readability, magic-name, self-documenting]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
--- ---
# Variable names must describe what the value means, not just its type # Variable names must describe what the value means, not just its type
## Description ## Description
A variable name must let a reader understand what the value represents A variable name must let a reader understand what the value represents
without having to trace every place it is assigned or used. A name built without having to trace every place it is assigned or used. A name built
from a generic type abbreviation plus a sequence number or letter — from a generic type abbreviation plus a sequence number or letter —
`Amt1`, `Amt2`, `Var1`, `OptA`, `Int3`, `TempX` — fails this test: it tells `Amt1`, `Amt2`, `Var1`, `OptA`, `Int3`, `TempX` — fails this test: it tells
the reader the data type, which AL already shows via the declaration, but the reader the data type, which AL already shows via the declaration, but
nothing about the business meaning. `AmountInclVAT` is immediately nothing about the business meaning. `AmountInclVAT` is immediately
readable; `Amt1` requires the reader to go find out what Amt1 is actually readable; `Amt1` requires the reader to go find out what Amt1 is actually
used for. used for.
The fix is not "add more letters" — it is to name the variable for the The fix is not "add more letters" — it is to name the variable for the
business concept it holds: `AmountInclVAT`, `CustomerDiscountPct`, business concept it holds: `AmountInclVAT`, `CustomerDiscountPct`,
`RemainingQuantity`, `IsOverdue`. If two variables genuinely hold the same `RemainingQuantity`, `IsOverdue`. If two variables genuinely hold the same
kind of value in a comparison or calculation (e.g. two amounts being kind of value in a comparison or calculation (e.g. two amounts being
subtracted), name them for their distinct roles in that calculation subtracted), name them for their distinct roles in that calculation
(`OriginalAmount` / `AdjustedAmount`), not for their shared type (`OriginalAmount` / `AdjustedAmount`), not for their shared type
(`Amt1` / `Amt2`). (`Amt1` / `Amt2`).
**Exception:** short-lived variables in a handful of idiomatic, universally The same failure shows up in a second, more common shape that's easy to
recognized roles are accepted single-letter, because their entire meaning miss because it doesn't look like an abbreviation: a **real record type
is visible in the few lines that declare and use them: name plus a letter suffix** — `ItemA`/`ItemB`/`ItemC`, `VendorA`/`VendorB`.
- Loop counters and array indices (`i`, `idx`, `x`). This is most common in test fixtures, where two records of the same type
- The progress step counter in a `Dialog`/progress-window idiom — a status play distinct roles the letter suffix erases (e.g. one vendor has a price
iterator whose only job is tracking how far a long-running process has configured, the other doesn't and the test expects a zero-price lookup to
gotten (`s`), and the count fed into the update call itself, e.g. fall through to it) — a reader has to go read the test body to learn which
`Window.Update(1, c)` (`c`). letter means what, exactly the lookup cost this rule exists to avoid. Name
them for the role: `PricedVendor`/`UnpricedVendor`, `ScrapItem`/`RegularItem`,
This exception does not extend to variables that live longer than that not for their shared type plus an arbitrary letter.
tight idiomatic scope, or that carry business meaning beyond "the current
position" or "the current progress count" — a `Status` field on a table, or **Read the sibling declarations before flagging a type+letter name.** A
a `Counter` that is read elsewhere in the object, still needs a real name. single-letter suffix on a real type name isn't always the anti-pattern
above — it can be one member of a deliberate, self-consistent naming
## Best Practice family that happens to use single letters for a real reason (a country or
region code, a variant identifier). `ItemN` sitting next to `ItemDk`,
```al `ItemSE`, `ItemFI` in the same `var` section isn't an unexplained letter —
var it's Norway's country code, following the exact same pattern as its
AmountInclVAT: Decimal; siblings. Flagging `ItemN` in isolation, without reading what else is
RemainingQuantity: Decimal; declared alongside it, produces a false positive; the letter/suffix only
IsOverdue: Boolean; counts as unexplained if nothing nearby explains it.
...
for idx := 1 to ArrayLen(SalesLine) do **Exception:** short-lived variables in a handful of idiomatic, universally
TotalAmount += SalesLine[idx]; recognized roles are accepted single-letter, because their entire meaning
... is visible in the few lines that declare and use them:
Window.Open('Processing #1#########'); - Loop counters and array indices (`i`, `idx`, `x`).
for s := 1 to Item.Count do begin - The progress step counter in a `Dialog`/progress-window idiom — a status
c += 1; iterator whose only job is tracking how far a long-running process has
Window.Update(1, Round(c / Item.Count * 10000, 1)); gotten (`s`), and the count fed into the update call itself, e.g.
end; `Window.Update(1, c)` (`c`).
Window.Close();
``` This exception does not extend to variables that live longer than that
tight idiomatic scope, or that carry business meaning beyond "the current
## Anti Pattern position" or "the current progress count" — a `Status` field on a table, or
a `Counter` that is read elsewhere in the object, still needs a real name.
```al
var ## Best Practice
Amt1: Decimal;
Amt2: Decimal; ```al
OptA: Option; var
TempX: Integer; AmountInclVAT: Decimal;
... RemainingQuantity: Decimal;
if OptA = 1 then IsOverdue: Boolean;
Amt1 := Amt2 - TempX; ...
``` for idx := 1 to ArrayLen(SalesLine) do
TotalAmount += SalesLine[idx];
A reviewer reading `Amt1 := Amt2 - TempX;` cannot tell what this line is ...
computing without opening the variable declarations and searching for every Window.Open('Processing #1#########');
other assignment to `Amt2` and `TempX` first. The same line as for s := 1 to Item.Count do begin
`AmountInclVAT := AmountExclVAT - DiscountAmount;` needs no further lookup. c += 1;
Window.Update(1, Round(c / Item.Count * 10000, 1));
end;
Window.Close();
```
## Anti Pattern
```al
var
Amt1: Decimal;
Amt2: Decimal;
OptA: Option;
TempX: Integer;
...
if OptA = 1 then
Amt1 := Amt2 - TempX;
```
A reviewer reading `Amt1 := Amt2 - TempX;` cannot tell what this line is
computing without opening the variable declarations and searching for every
other assignment to `Amt2` and `TempX` first. The same line as
`AmountInclVAT := AmountExclVAT - DiscountAmount;` needs no further lookup.
```al
// Same failure, real-type-name shape — common in test fixtures.
var
VendorA: Record Vendor;
VendorB: Record Vendor;
...
LibraryPurchase.CreateVendor(VendorA);
CreateVendorPrice(VendorA, Item, 10);
LibraryPurchase.CreateVendor(VendorB); // no price created for VendorB
Assert.AreEqual(0, PriceMgt.GetVendorPrice(VendorB."No.", Item."No."), '');
```
`VendorA`/`VendorB` tell the reader nothing about why the test needs two
vendors. `PricedVendor`/`UnpricedVendor` would make the assertion make
sense without reading the setup lines above it.

View file

@ -1,100 +1,127 @@
--- ---
bc-version: [all] bc-version: [all]
domain: testing domain: testing
keywords: [test, when, scenario, single-action, bdd, atdd, given-when-then] keywords: [test, when, scenario, single-action, bdd, atdd, given-when-then]
technologies: [al] technologies: [al]
countries: [w1] countries: [w1]
application-area: [all] application-area: [all]
--- ---
## Description ## Description
Each test procedure must contain exactly **one** `[WHEN]` block — one action that Each test procedure must contain exactly **one** `[WHEN]` block — one action that
triggers the behaviour under test. A test with multiple WHENs ("do A, then do B, triggers the behaviour under test. A test with multiple WHENs ("do A, then do B,
then check C") is really two or more tests in disguise. Split them. then check C") is really two or more tests in disguise. Split them.
This constraint serves two purposes: This constraint serves two purposes:
1. **Failure isolation** — when the test fails you know which action caused it. 1. **Failure isolation** — when the test fails you know which action caused it.
2. **Readable specification** — each test reads as a single, falsifiable claim 2. **Readable specification** — each test reads as a single, falsifiable claim
about the system's behaviour. about the system's behaviour.
A scenario that genuinely requires a precondition action (e.g. "post an order so A scenario that genuinely requires a precondition action (e.g. "post an order so
that a ledger entry exists") belongs in `[GIVEN]`. Only the action being asserted that a ledger entry exists") belongs in `[GIVEN]`. Only the action being asserted
belongs in `[WHEN]`. belongs in `[WHEN]`.
## Anti Pattern ## Anti Pattern
// WRONG: two actions in one test // WRONG: two actions in one test
[Test] [Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues() procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
var var
UnitPrice, LineDiscPct: Decimal; UnitPrice, LineDiscPct: Decimal;
begin begin
// [GIVEN] ... // [GIVEN] ...
// [WHEN] first action // [WHEN] first action
FindPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct); FindPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
// [WHEN] second action — this is a second test in disguise // [WHEN] second action — this is a second test in disguise
FindPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer); FindPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
// [THEN] asserting two unrelated things // [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, ''); Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), ''); Assert.IsFalse(TempBuffer.IsEmpty(), '');
end; end;
## Best Practice ## Best Practice
// CORRECT: split into two focused tests // CORRECT: split into two focused tests
[Test] [Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice() procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var var
UnitPrice, LineDiscPct: Decimal; UnitPrice, LineDiscPct: Decimal;
begin begin
// [GIVEN] a customer with a price list line at 100 // [GIVEN] a customer with a price list line at 100
WarecoLib.GivenCustomerWithPrice(Customer, Item, '', 100); WarecoLib.GivenCustomerWithPrice(Customer, Item, '', 100);
// [WHEN] // [WHEN]
FindPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct); FindPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN] // [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list'); Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
end; end;
[Test] [Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine() procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
var var
TempBuffer: Record "Find Price Tier Buffer" temporary; TempBuffer: Record "Find Price Tier Buffer" temporary;
begin begin
// [GIVEN] a customer with a tier price at min qty 10 // [GIVEN] a customer with a tier price at min qty 10
WarecoLib.GivenCustomerWithTierPrice(Customer, Item, '', 10, 90); WarecoLib.GivenCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [WHEN] // [WHEN]
FindPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer); FindPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
// [THEN] // [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected'); Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
end; end;
## Flow tests — the deliberate exception ## Flow tests — the deliberate exception
A **flow test** verifies the accumulated outcome of a multi-round business A **flow test** verifies the accumulated outcome of a multi-round business
flow (partial receipt then invoicing, multiple posting rounds against one flow (partial receipt then invoicing, multiple posting rounds against one
document). The sequence IS the scenario — splitting it loses the interaction document). The sequence IS the scenario — splitting it loses the interaction
under test. Multiple `[WHEN]` blocks are permitted when ALL of these hold: under test. Multiple `[WHEN]` blocks are permitted when ALL of these hold:
1. The name declares the flow (`SVPartialFlowTests`, 1. The name declares the flow (`SVPartialFlowTests`,
`ReceiveThenInvoice_QuantitiesAreCorrect`). `ReceiveThenInvoice_QuantitiesAreCorrect`).
2. Each `[WHEN]` is labelled as a round of ONE scenario ("Runde 1: kun 2. Each `[WHEN]` is labelled as a round of ONE scenario ("Runde 1: kun
modtagelse"), not as an unrelated action. modtagelse"), not as an unrelated action.
3. The `[THEN]` asserts the accumulated end-state. If the assertions 3. The `[THEN]` asserts the accumulated end-state. If the assertions
decompose cleanly per action, it is two tests in disguise: split. decompose cleanly per action, it is two tests in disguise: split.
Unit-level tests keep the strict one-WHEN rule without exception. (Edison Unit-level tests keep the strict one-WHEN rule without exception. (Edison
eval 2026-07-02, Jernpladsen @ b7656b1: five deliberate round-labelled flow eval 2026-07-02, Jernpladsen @ b7656b1: five deliberate round-labelled flow
procedures in SVPartialFlowTests — the rule previously gave no verdict.) procedures in SVPartialFlowTests — the rule previously gave no verdict.)
## Naming implication ## Defect-then-fix regression tests — a second, narrower exception
The procedure name should make the single WHEN self-evident. A test that reproduces a specific stale/broken state and then verifies a
A name with "And" or "Then" in the middle is a strong signal to split: subsequent action corrects it (`RecalcRestoresStaleDiscountAfterPick`: pick
creates the stale state, recalc is the fix under test) is not the same
- `GetPrice_AndDiscount_ReturnsValues` → split shape as an unrelated-action test, even though its `[THEN]` assertions
- `GetPrice_CustomerPrice_ReturnsUnitPrice` → good decompose cleanly per step — decomposing cleanly is expected here, not a
- `ReceiveThenInvoice_QuantitiesAreCorrect` → legitimate flow test IF the sign of two unrelated tests. The three flow-test conditions above are the
flow-test conditions above are met wrong fit for this case: the name doesn't need to declare a multi-round
"flow," and there is no natural "Runde 1/2" framing for "create the broken
state, then fix it." This shape is permitted when:
1. The second `[WHEN]` cannot be meaningfully tested without the first —
the fix being verified only has an effect on the specific stale state
the first action produced, so splitting would require re-running the
first action inside a second test's `[GIVEN]` anyway, testing nothing
new.
2. The procedure name communicates the before/after relationship (a
defect symptom and its correction), even without the word "flow."
Applying flow-test criterion 3 ("assertions decompose cleanly → split") to
this shape would have been a false positive (Edison eval 2026-08-13,
Wareco @ a2fc8ff8, `SalesOrderAmountAfterPickTest.RecalcRestoresStaleDiscountAfterPick`)
— clean decomposition is exactly what a defect-then-fix test's assertions
are supposed to do at each step, not evidence the steps belong in separate
tests.
## Naming implication
The procedure name should make the single WHEN self-evident.
A name with "And" or "Then" in the middle is a strong signal to split:
- `GetPrice_AndDiscount_ReturnsValues` → split
- `GetPrice_CustomerPrice_ReturnsUnitPrice` → good
- `ReceiveThenInvoice_QuantitiesAreCorrect` → legitimate flow test IF the
flow-test conditions above are met