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]
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]
technologies: [al]
countries: [w1]
application-area: [all]
---
# CURABIS Architecture: BC Table-Type Conventions
## Description
Business Central's Base Application follows nine recurring table types — Master,
Supplemental, Subsidiary, Ledger, Register, Journal, Document, Document History,
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
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
compiles.
## Key Principle
"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
decision — key shape, naming suffix, and which pages must exist."
## The Nine Table Types
### 1. Master
- **Purpose:** primary focus subject of a functional area (Customer, Vendor, Item).
- **Naming:** name of one record in the table, singular (`Customer`, `Item`).
- **Primary key:** `Code[20]` named `No.` (occasionally `Code`).
- **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).
### 2. Supplemental
- **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`).
- **Primary key:** `Code[10]` named `Code`.
- **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`.
### 3. Subsidiary
- **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`).
- **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.
### 4. Ledger
- **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`.
- **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.
- **Pages:** List page, plural of table name (`Customer Ledger Entries`), set as `LookupPageID`/`DrillDownPageID`.
### 5. Register
- **Purpose:** table of contents for its corresponding Ledger table(s), one record per posting process (G/L Register).
- **Naming:** posting function name + `Register`.
- **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).
- **Pages:** List page, plural of table name, with a `Register` menu button linking to the Ledger List page(s).
### 6. Journal
- **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`).
- **Primary key:** Journal Template field + Journal Batch field + `Integer` `Line No.`.
- **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.
### 7. Document (Header + Line)
- **Purpose:** secondary transactional tables — post to Ledgers through Journal tables, not directly (Sales Header/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 (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.
### 8. Document History
- **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.
- **Primary key / fields:** mirrors the source Document table's field numbers, names, and properties.
- **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.
### 9. 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`).
- **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.
- **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.
## 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?
2. Does the primary key shape match that type (Code+`No.` / Integer+`Entry No.` / Code+`Line No.` / blank Code+`Primary Key` / etc.)?
3. Does the table name carry the expected suffix (`Ledger Entry`, `Journal Line`, `Register`, `Setup`, `Header`/`Line`)?
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)?
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
misclassified or has skipped a design step.
## Source
CURABIS Academy course "Your Key to Application Language for Microsoft Business
Central" (rev. July 2022), Chapter 2: Tables, "Table Types and Characteristics"
(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`
(`OnOpenPage`, line ~889) and `src/Layers/W1/BaseApp/Sales/Setup/SalesReceivablesSetup.Page.al`
(`OnOpenPage`, line ~601) — not inferred from the Academy material, which does
not document this detail.
---
bc-version: [all]
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]
technologies: [al]
countries: [w1]
application-area: [all]
---
# CURABIS Architecture: BC Table-Type Conventions
## Description
Business Central's Base Application follows nine recurring table types — Master,
Supplemental, Subsidiary, Ledger, Register, Journal, Document, Document History,
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
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
compiles.
## Key Principle
"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
decision — key shape, naming suffix, and which pages must exist."
## The Nine Table Types
### 1. Master
- **Purpose:** primary focus subject of a functional area (Customer, Vendor, Item).
- **Naming:** name of one record in the table, singular (`Customer`, `Item`).
- **Primary key:** `Code[20]` named `No.` (occasionally `Code`).
- **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).
### 2. Supplemental
- **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`).
- **Primary key:** `Code[10]` named `Code`.
- **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`.
### 3. Subsidiary
- **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`).
- **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.
### 4. Ledger
- **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`.
- **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.
- **Pages:** List page, plural of table name (`Customer Ledger Entries`), set as `LookupPageID`/`DrillDownPageID`.
### 5. Register
- **Purpose:** table of contents for its corresponding Ledger table(s), one record per posting process (G/L Register).
- **Naming:** posting function name + `Register`.
- **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).
- **Pages:** List page, plural of table name, with a `Register` menu button linking to the Ledger List page(s).
### 6. Journal
- **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`).
- **Primary key:** Journal Template field + Journal Batch field + `Integer` `Line No.`.
- **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.
### 7. Document (Header + Line)
- **Purpose:** secondary transactional tables — post to Ledgers through Journal tables, not directly (Sales Header/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 (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.
### 8. Document History
- **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.
- **Primary key / fields:** mirrors the source Document table's field numbers, names, and properties.
- **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.
### 9. 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`).
- **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.
- **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.
- **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
When reviewing or designing a table, ask:
1. Which of the 9 types is this actually — based on its role, not its name?
2. Does the primary key shape match that type (Code+`No.` / Integer+`Entry No.` / Code+`Line No.` / blank Code+`Primary Key` / etc.)?
3. Does the table name carry the expected suffix (`Ledger Entry`, `Journal Line`, `Register`, `Setup`, `Header`/`Line`)?
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)?
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
misclassified or has skipped a design step.
## Source
CURABIS Academy course "Your Key to Application Language for Microsoft Business
Central" (rev. July 2022), Chapter 2: Tables, "Table Types and Characteristics"
(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`
(`OnOpenPage`, line ~889) and `src/Layers/W1/BaseApp/Sales/Setup/SalesReceivablesSetup.Page.al`
(`OnOpenPage`, line ~601) — not inferred from the Academy material, which does
not document this detail.

View file

@ -1,46 +1,79 @@
---
bc-version: [all]
domain: mcp
keywords: [api-page, least-privilege, write-access, odata, security]
technologies: [al]
countries: [w1]
application-area: [all]
---
# CURABIS MCP: API Pages Must Use Least-Privilege Write Access
## 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.
## Why This Matters
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.
## Pattern to Avoid
// 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.
---
bc-version: [all]
domain: mcp
keywords: [api-page, least-privilege, write-access, odata, security, external-api, identity-fields]
technologies: [al]
countries: [w1]
application-area: [all]
---
# CURABIS MCP: API Pages Must Use Least-Privilege Write Access
## 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.
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.
## Why This Matters
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.
## Pattern to Avoid
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:
// WRONG: no restriction declared anywhere on a write-capable API page —
// ~90 fields, including VAT/posting fields and the record's own key,
// are all fully writable by default, with nothing marking that as intentional.
page 50100 "Some Document Header API"
{
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'docs';
APIVersion = 'v1.0';
SourceTable = "Some Document Header";
// no InsertAllowed/ModifyAllowed/DeleteAllowed override, no Editable = false anywhere
layout
{
area(content)
{
repeater(GroupName)
{
field(no; Rec."No.") { }
field(documentType; Rec."Document Type") { }
field(amountIncludingVAT; Rec."Amount Including VAT") { }
field(vatBusPostingGroup; Rec."VAT Bus. Posting Group") { }
// ... ~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]
domain: style
keywords: [variable-naming, semantic-naming, readability, magic-name, self-documenting]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Variable names must describe what the value means, not just its type
## Description
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
from a generic type abbreviation plus a sequence number or letter —
`Amt1`, `Amt2`, `Var1`, `OptA`, `Int3`, `TempX` — fails this test: it tells
the reader the data type, which AL already shows via the declaration, but
nothing about the business meaning. `AmountInclVAT` is immediately
readable; `Amt1` requires the reader to go find out what Amt1 is actually
used for.
The fix is not "add more letters" — it is to name the variable for the
business concept it holds: `AmountInclVAT`, `CustomerDiscountPct`,
`RemainingQuantity`, `IsOverdue`. If two variables genuinely hold the same
kind of value in a comparison or calculation (e.g. two amounts being
subtracted), name them for their distinct roles in that calculation
(`OriginalAmount` / `AdjustedAmount`), not for their shared type
(`Amt1` / `Amt2`).
**Exception:** short-lived variables in a handful of idiomatic, universally
recognized roles are accepted single-letter, because their entire meaning
is visible in the few lines that declare and use them:
- Loop counters and array indices (`i`, `idx`, `x`).
- The progress step counter in a `Dialog`/progress-window idiom — a status
iterator whose only job is tracking how far a long-running process has
gotten (`s`), and the count fed into the update call itself, e.g.
`Window.Update(1, c)` (`c`).
This exception does not extend to variables that live longer than that
tight idiomatic scope, or that carry business meaning beyond "the current
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.
## Best Practice
```al
var
AmountInclVAT: Decimal;
RemainingQuantity: Decimal;
IsOverdue: Boolean;
...
for idx := 1 to ArrayLen(SalesLine) do
TotalAmount += SalesLine[idx];
...
Window.Open('Processing #1#########');
for s := 1 to Item.Count do begin
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.
---
bc-version: [all]
domain: style
keywords: [variable-naming, semantic-naming, readability, magic-name, self-documenting]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Variable names must describe what the value means, not just its type
## Description
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
from a generic type abbreviation plus a sequence number or letter —
`Amt1`, `Amt2`, `Var1`, `OptA`, `Int3`, `TempX` — fails this test: it tells
the reader the data type, which AL already shows via the declaration, but
nothing about the business meaning. `AmountInclVAT` is immediately
readable; `Amt1` requires the reader to go find out what Amt1 is actually
used for.
The fix is not "add more letters" — it is to name the variable for the
business concept it holds: `AmountInclVAT`, `CustomerDiscountPct`,
`RemainingQuantity`, `IsOverdue`. If two variables genuinely hold the same
kind of value in a comparison or calculation (e.g. two amounts being
subtracted), name them for their distinct roles in that calculation
(`OriginalAmount` / `AdjustedAmount`), not for their shared type
(`Amt1` / `Amt2`).
The same failure shows up in a second, more common shape that's easy to
miss because it doesn't look like an abbreviation: a **real record type
name plus a letter suffix** — `ItemA`/`ItemB`/`ItemC`, `VendorA`/`VendorB`.
This is most common in test fixtures, where two records of the same type
play distinct roles the letter suffix erases (e.g. one vendor has a price
configured, the other doesn't and the test expects a zero-price lookup to
fall through to it) — a reader has to go read the test body to learn which
letter means what, exactly the lookup cost this rule exists to avoid. Name
them for the role: `PricedVendor`/`UnpricedVendor`, `ScrapItem`/`RegularItem`,
not for their shared type plus an arbitrary letter.
**Read the sibling declarations before flagging a type+letter name.** A
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
family that happens to use single letters for a real reason (a country or
region code, a variant identifier). `ItemN` sitting next to `ItemDk`,
`ItemSE`, `ItemFI` in the same `var` section isn't an unexplained letter —
it's Norway's country code, following the exact same pattern as its
siblings. Flagging `ItemN` in isolation, without reading what else is
declared alongside it, produces a false positive; the letter/suffix only
counts as unexplained if nothing nearby explains it.
**Exception:** short-lived variables in a handful of idiomatic, universally
recognized roles are accepted single-letter, because their entire meaning
is visible in the few lines that declare and use them:
- Loop counters and array indices (`i`, `idx`, `x`).
- The progress step counter in a `Dialog`/progress-window idiom — a status
iterator whose only job is tracking how far a long-running process has
gotten (`s`), and the count fed into the update call itself, e.g.
`Window.Update(1, c)` (`c`).
This exception does not extend to variables that live longer than that
tight idiomatic scope, or that carry business meaning beyond "the current
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.
## Best Practice
```al
var
AmountInclVAT: Decimal;
RemainingQuantity: Decimal;
IsOverdue: Boolean;
...
for idx := 1 to ArrayLen(SalesLine) do
TotalAmount += SalesLine[idx];
...
Window.Open('Processing #1#########');
for s := 1 to Item.Count do begin
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]
domain: testing
keywords: [test, when, scenario, single-action, bdd, atdd, given-when-then]
technologies: [al]
countries: [w1]
application-area: [all]
---
## Description
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,
then check C") is really two or more tests in disguise. Split them.
This constraint serves two purposes:
1. **Failure isolation** — when the test fails you know which action caused it.
2. **Readable specification** — each test reads as a single, falsifiable claim
about the system's behaviour.
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
belongs in `[WHEN]`.
## Anti Pattern
// WRONG: two actions in one test
[Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
var
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] ...
// [WHEN] first action
FindPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
// [WHEN] second action — this is a second test in disguise
FindPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
// [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), '');
end;
## Best Practice
// CORRECT: split into two focused tests
[Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] a customer with a price list line at 100
WarecoLib.GivenCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
FindPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
end;
[Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
var
TempBuffer: Record "Find Price Tier Buffer" temporary;
begin
// [GIVEN] a customer with a tier price at min qty 10
WarecoLib.GivenCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [WHEN]
FindPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
// [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
end;
## Flow tests — the deliberate exception
A **flow test** verifies the accumulated outcome of a multi-round business
flow (partial receipt then invoicing, multiple posting rounds against one
document). The sequence IS the scenario — splitting it loses the interaction
under test. Multiple `[WHEN]` blocks are permitted when ALL of these hold:
1. The name declares the flow (`SVPartialFlowTests`,
`ReceiveThenInvoice_QuantitiesAreCorrect`).
2. Each `[WHEN]` is labelled as a round of ONE scenario ("Runde 1: kun
modtagelse"), not as an unrelated action.
3. The `[THEN]` asserts the accumulated end-state. If the assertions
decompose cleanly per action, it is two tests in disguise: split.
Unit-level tests keep the strict one-WHEN rule without exception. (Edison
eval 2026-07-02, Jernpladsen @ b7656b1: five deliberate round-labelled flow
procedures in SVPartialFlowTests — the rule previously gave no verdict.)
## 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
---
bc-version: [all]
domain: testing
keywords: [test, when, scenario, single-action, bdd, atdd, given-when-then]
technologies: [al]
countries: [w1]
application-area: [all]
---
## Description
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,
then check C") is really two or more tests in disguise. Split them.
This constraint serves two purposes:
1. **Failure isolation** — when the test fails you know which action caused it.
2. **Readable specification** — each test reads as a single, falsifiable claim
about the system's behaviour.
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
belongs in `[WHEN]`.
## Anti Pattern
// WRONG: two actions in one test
[Test]
procedure GetPrice_ThenGetDiscount_ReturnsCorrectValues()
var
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] ...
// [WHEN] first action
FindPriceMgt.GetSalesPrice(CustomerNo, ItemNo, '', UnitPrice, LineDiscPct);
// [WHEN] second action — this is a second test in disguise
FindPriceMgt.GetSalesPriceTiers(CustomerNo, ItemNo, '', TempBuffer);
// [THEN] asserting two unrelated things
Assert.AreEqual(100, UnitPrice, '');
Assert.IsFalse(TempBuffer.IsEmpty(), '');
end;
## Best Practice
// CORRECT: split into two focused tests
[Test]
procedure GetPrice_CustomerPrice_ReturnsCorrectUnitPrice()
var
UnitPrice, LineDiscPct: Decimal;
begin
// [GIVEN] a customer with a price list line at 100
WarecoLib.GivenCustomerWithPrice(Customer, Item, '', 100);
// [WHEN]
FindPriceMgt.GetSalesPrice(Customer."No.", Item."No.", '', UnitPrice, LineDiscPct);
// [THEN]
Assert.AreEqual(100, UnitPrice, 'Unit price must match price list');
end;
[Test]
procedure GetPriceTiers_CustomerTier_ReturnsOneTierLine()
var
TempBuffer: Record "Find Price Tier Buffer" temporary;
begin
// [GIVEN] a customer with a tier price at min qty 10
WarecoLib.GivenCustomerWithTierPrice(Customer, Item, '', 10, 90);
// [WHEN]
FindPriceMgt.GetSalesPriceTiers(Customer."No.", Item."No.", '', TempBuffer);
// [THEN]
Assert.AreEqual(1, TempBuffer.Count(), 'Exactly one tier line expected');
end;
## Flow tests — the deliberate exception
A **flow test** verifies the accumulated outcome of a multi-round business
flow (partial receipt then invoicing, multiple posting rounds against one
document). The sequence IS the scenario — splitting it loses the interaction
under test. Multiple `[WHEN]` blocks are permitted when ALL of these hold:
1. The name declares the flow (`SVPartialFlowTests`,
`ReceiveThenInvoice_QuantitiesAreCorrect`).
2. Each `[WHEN]` is labelled as a round of ONE scenario ("Runde 1: kun
modtagelse"), not as an unrelated action.
3. The `[THEN]` asserts the accumulated end-state. If the assertions
decompose cleanly per action, it is two tests in disguise: split.
Unit-level tests keep the strict one-WHEN rule without exception. (Edison
eval 2026-07-02, Jernpladsen @ b7656b1: five deliberate round-labelled flow
procedures in SVPartialFlowTests — the rule previously gave no verdict.)
## Defect-then-fix regression tests — a second, narrower exception
A test that reproduces a specific stale/broken state and then verifies a
subsequent action corrects it (`RecalcRestoresStaleDiscountAfterPick`: pick
creates the stale state, recalc is the fix under test) is not the same
shape as an unrelated-action test, even though its `[THEN]` assertions
decompose cleanly per step — decomposing cleanly is expected here, not a
sign of two unrelated tests. The three flow-test conditions above are the
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